import { IConnection } from "./IConnection" ; import { Appservice } from "matrix-bot-sdk" ; import LogWrapper from "../LogWrapper" ; import { JiraIssueEvent , JiraIssueUpdatedEvent } from "../Jira/WebhookTypes" ; import { FormatUtil } from "../FormatUtil" ; import markdownit from "markdown-it" ; import { generateJiraWebLinkFromIssue } from "../Jira" ; import { JiraProject } from "../Jira/Types" ; import { botCommand , BotCommands , compileBotCommands } from "../BotCommands" ; import { MatrixEvent , MatrixMessageContent } from "../MatrixEvent" ; import { CommandConnection } from "./CommandConnection" ; import { UserTokenStore } from "../UserTokenStore" ; import { CommandError , NotLoggedInError } from "../errors" ; import { ApiError , ErrCode } from "../provisioning/api" ; import JiraApi from "jira-client" ; type JiraAllowedEventsNames = "issue.created" ; const JiraAllowedEvents = [ "issue.created" ] ; export interface JiraProjectConnectionState { id ? : string ; url ? : string ; events ? : JiraAllowedEventsNames [ ] ; commandPrefix ? : string ; } function validateJiraConnectionState ( state ) { const { url , commandPrefix , events } = state as JiraProjectConnectionState ; if ( url === undefined ) { throw new ApiError ( "Expected a 'url' property" , ErrCode . BadValue ) ; } if ( commandPrefix ) { if ( typeof commandPrefix !== "string" ) { throw new ApiError ( "Expected 'commandPrefix' to be a string" , ErrCode . BadValue ) ; } if ( commandPrefix . length < 2 || commandPrefix . length > 24 ) { throw new ApiError ( "Expected 'commandPrefix' to be between 2-24 characters" , ErrCode . BadValue ) ; } } if ( events ?. find ( ( ev ) => ! JiraAllowedEvents . includes ( ev ) ) ?. length ) { throw new ApiError ( `'events' can only contain ${ JiraAllowedEvents . join ( ", " ) }` , ErrCode . BadValue ) ; } return { url , commandPrefix , events } ; } const log = new LogWrapper ( "JiraProjectConnection" ) ; const md = new markdownit ( ) ; /**
 * Handles rooms connected to a github repo.
 */ export class JiraProjectConnection extends CommandConnection implements IConnection { static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.jira.project" ; static readonly LegacyCanonicalEventType = "uk.half-shot.matrix-github.jira.project" ; static readonly EventTypes = [ JiraProjectConnection . CanonicalEventType , JiraProjectConnection . LegacyCanonicalEventType , ] ; static botCommands : BotCommands ; static helpMessage : ( cmdPrefix ? : string ) => MatrixMessageContent ; static async provisionConnection ( roomId , userId , data , as , tokenStore ) { const validData = validateJiraConnectionState ( data ) ; log . info ( `Attempting to provisionConnection for ${ roomId } ${ validData . url } on behalf of ${ userId }` ) ; const jiraClient = await tokenStore . getJiraForUser ( userId ) ; if ( ! jiraClient ) { throw new ApiError ( "User is not authenticated with JIRA" , ErrCode . ForbiddenUser ) ; } const jiraResourceClient = await jiraClient . getClientForUrl ( new URL ( validData . url ) ) ; if ( ! jiraResourceClient ) { throw new ApiError ( "User is not authenticated with this JIRA instance" , ErrCode . ForbiddenUser ) ; } const connection = new JiraProjectConnection ( roomId , as , data , validData . url , tokenStore ) ; log . debug ( `projectKey for ${ validData . url } is ${ connection . projectKey }` ) ; if ( ! connection . projectKey ) { throw Error ( 'Expected projectKey to be defined' ) ; } try { await jiraResourceClient . getProject ( connection . projectKey ) ; } catch ( ex ) { throw new ApiError ( "Requested project was not found" , ErrCode . ForbiddenUser ) ; } log . info ( `Created connection via provisionConnection ${ connection . toString ( ) }` ) ; return { stateEventContent : validData , connection } ; } public get projectId ( ) { return this . state . id ; } public get instanceOrigin ( ) { return this . projectUrl ?. origin ; } public get projectKey ( ) { const parts = this . projectUrl ?. pathname . split ( '/' ) ; return parts ? parts [ parts . length - 1 ] ?. toUpperCase ( ) : undefined ; } public toString ( ) { return `JiraProjectConnection ${ this . projectId || this . projectUrl }` ; } public isInterestedInHookEvent ( eventName ) { return ! this . state . events || this . state . events ?. includes ( eventName as JiraAllowedEventsNames ) ; } public interestedInProject ( project ) { if ( this . projectId === project . id ) { return true ; } if ( this . instanceOrigin ) { const url = new URL ( project . self ) ; return this . instanceOrigin === url . origin && this . projectKey === project . key . toUpperCase ( ) ; } return false ; } example private projectUrl ? : URL ; constructor ( roomId , private readonly as , private state , stateKey , private readonly tokenStore ) { super ( roomId , stateKey , JiraProjectConnection . CanonicalEventType , as . botClient , JiraProjectConnection . botCommands , JiraProjectConnection . helpMessage , state . commandPrefix || "!jira" , "jira" ) ; if ( state . url ) { this . projectUrl = new URL ( state . url ) ; } else if ( state . id ) { log . warn ( `Legacy ID option in use, needs to be switched to 'url'` ) ; } else { throw Error ( 'State is missing both id and url, cannot create connection' ) ; } } public isInterestedInStateEvent ( eventType , stateKey ) { return JiraProjectConnection . EventTypes . includes ( eventType ) && this . stateKey === stateKey ; } public async onStateUpdate ( event ) { const validatedConfig = validateJiraConnectionState ( event . content as JiraProjectConnectionState ) ; this . state = validatedConfig ; } public async onJiraIssueCreated ( data ) { log . info ( `onIssueCreated ${ this . roomId } ${ this . projectId } ${ data . issue . id }` ) ; const creator = data . issue . fields . creator ; if ( ! creator ) { throw Error ( 'No creator field' ) ; } const url = generateJiraWebLinkFromIssue ( data . issue ) ; const content = `${ creator . displayName } created a new JIRA issue [${ data . issue . key }](${ url }): "${ data . issue . fields . summary }"` ; await this . as . botIntent . sendEvent ( this . roomId , { msgtype : "m.notice" , body : content , formatted_body : md . renderInline ( content ) , format : "org.matrix.custom.html" , ... FormatUtil . getPartialBodyForJiraIssue ( data . issue ) } ) ; } public static getProvisionerDetails ( botUserId ) { return { service : "jira" , eventType : JiraProjectConnection . CanonicalEventType , type : "JiraProject" , botUserId : botUserId , } ; } public getProvisionerDetails ( ) { return { ... JiraProjectConnection . getProvisionerDetails ( this . as . botUserId ) , id : this . connectionId , config : { ... this . state , } , } ; } public async onJiraIssueUpdated ( data ) { log . info ( `onJiraIssueUpdated ${ this . roomId } ${ this . projectId } ${ data . issue . id }` ) ; const url = generateJiraWebLinkFromIssue ( data . issue ) ; let content = `${ data . user . displayName } updated JIRA [${ data . issue . key }](${ url }): ` ; const changes = data . changelog . items . map ( ( change ) => `**${ change . field }** changed from '${ change . fromString || "not set" }' to '${ change . toString || "not set" }'` ) ; if ( changes . length < 0 ) { log . warn ( `Empty changeset, not sending message` ) ; return ; } else if ( changes . length === 1 ) { content += changes [ 0 ] ; } else { content += `\n - ` + changes . join ( `\n  - ` ) ; } await this . as . botIntent . sendEvent ( this . roomId , { msgtype : "m.notice" , body : content , formatted_body : md . renderInline ( content ) , format : "org.matrix.custom.html" , ... FormatUtil . getPartialBodyForJiraIssue ( data . issue ) } ) ; } private async getUserClientForProject ( userId ) { const jiraClient = await this . tokenStore . getJiraForUser ( userId ) ; if ( ! jiraClient ) { throw new NotLoggedInError ( ) ; } if ( ! this . projectUrl ) { throw new CommandError ( "No-resource-origin" , "Room is configured with an ID and not a URL, cannot determine correct JIRA client" ) ; } const jiraProjectClient = await jiraClient . getClientForUrl ( this . projectUrl ) ; if ( ! jiraProjectClient ) { throw new CommandError ( "No-resource" , "You do not have permission to manage issues for this JIRA org" ) ; } return jiraProjectClient ; } @ botCommand ( "create" , "Create an issue for this project" , [ "type" , "title" ] , [ "description" , "labels" ] , true ) public async onCreateIssue ( userId , type , title , description ? , labels ? ) { const api = await this . getUserClientForProject ( userId ) ; const keyOrId = this . projectKey || this . projectId ; if ( ! keyOrId ) { throw Error ( 'Neither Key or ID are specified' ) ; } const project = await api . getProject ( keyOrId ) ; if ( ! project . issueTypes || project . issueTypes . length === 0 ) { throw new CommandError ( "project has no issue types" , "Cannot create issue, project has no issue types" ) ; } const issueTypeId = project . issueTypes . find ( ( issueType ) => issueType . name . toLowerCase ( ) === type . toLowerCase ( ) ) ?. id ; if ( ! issueTypeId ) { const content = project . issueTypes . map ( ( t ) => t . name ) . join ( ', ' ) ; throw new CommandError ( "invalid-issuetype" , `You must specify a valid issue type (one of ${ content }). E.g. ${ this . commandPrefix } create ${ project . issueTypes [ 0 ] . name }` ) ; } log . info ( `Creating new issue on behalf of ${ userId }` ) ; let result ; try { result = await api . addNewIssue ( { fields : { "summary" : title , "project" : { "key" : this . projectKey , } , "issuetype" : { id : issueTypeId , } , ... ( description ? { "description" : { "type" : "doc" , "version" : 1 , "content" : [ { "type" : "paragraph" , "content" : [ { "text" : description , "type" : "text" } ] } ] } } : undefined ) , ... ( labels ? { "labels" : labels . split ( "," ) } : undefined ) , } } ) ; } catch ( ex ) { log . warn ( "Failed to create JIRA issue:" , ex ) ; throw new CommandError ( ex . message , "Failed to create JIRA issue" ) ; } const link = generateJiraWebLinkFromIssue ( { self : this . projectUrl ?. toString ( ) || result . self , key : result . key as string } ) ; const content = `Created JIRA issue ${ result . key }: [${ link }](${ link })` ; return this . as . botIntent . sendEvent ( this . roomId , { msgtype : "m.notice" , body : content , formatted_body : md . render ( content ) , format : "org.matrix.custom.html" } ) ; } @ botCommand ( "issue-types" , "Get issue types for this project" , [ ] , [ ] , true ) public async getIssueTypes ( userId ) { const api = await this . getUserClientForProject ( userId ) ; log . info ( `Creating new issue on behalf of ${ userId }` ) ; let result ; try { const keyOrId = this . projectKey || this . projectId ; if ( ! keyOrId ) { throw Error ( 'Neither Key or ID are specified' ) ; } result = await api . getProject ( keyOrId ) ; } catch ( ex ) { log . warn ( "Failed to get issue types:" , ex ) ; throw new CommandError ( ex . message , "Failed to get issue types" ) ; } const content = `Issue types: ${ ( result . issueTypes || [ ] ) . map ( ( t ) => t . name ) . join ( ', ' ) }` ; return this . as . botIntent . sendEvent ( this . roomId , { msgtype : "m.notice" , body : content , formatted_body : md . render ( content ) , format : "org.matrix.custom.html" } ) ; } @ botCommand ( "assign" , "Assign an issue to a user" , [ "issueKey" , "user" ] , [ ] , true ) public async onAssignIssue ( userId , issueKey , user ) { const api = await this . getUserClientForProject ( userId ) ; try { await api . getIssue ( issueKey ) ; } catch ( ex ) { log . warn ( `Failed to find issue` , ex ) ; throw new CommandError ( ex . message , "Failed to find issue" ) ; } log . info ( `Assinging issue on behalf of ${ userId }` ) ; const searchForUser = await api . searchUsers ( { query : user , maxResults : 1 , includeInactive : false , includeActive : true , username : "" } ) ; if ( searchForUser . length === 0 ) { throw new CommandError ( "not-found" , `Could not find a user matching '${ user }'` ) ; } await api . updateAssigneeWithId ( issueKey , searchForUser [ 0 ] . accountId ) ; } public async onRemove ( ) { log . info ( `Removing ${ this . toString ( ) } for ${ this . roomId }` ) ; try { await this . as . botClient . getRoomStateEvent ( this . roomId , JiraProjectConnection . CanonicalEventType , this . stateKey ) ; await this . as . botClient . sendStateEvent ( this . roomId , JiraProjectConnection . CanonicalEventType , this . stateKey , { disabled : true } ) ; } catch ( ex ) { await this . as . botClient . getRoomStateEvent ( this . roomId , JiraProjectConnection . LegacyCanonicalEventType , this . stateKey ) ; await this . as . botClient . sendStateEvent ( this . roomId , JiraProjectConnection . LegacyCanonicalEventType , this . stateKey , { disabled : true } ) ; } } public async provisionerUpdateConfig ( userId , config ) { const validatedConfig = validateJiraConnectionState ( config ) ; await this . as . botClient . sendStateEvent ( this . roomId , JiraProjectConnection . CanonicalEventType , this . stateKey , validatedConfig ) ; } } const res = compileBotCommands ( JiraProjectConnection . prototype as any , CommandConnection . prototype as any ) ; JiraProjectConnection . helpMessage = res . helpMessage ; JiraProjectConnection . botCommands = res . botCommands ;