From fa85dc070b838462fa8290c5efdb7ef95fbd1471 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Mon, 3 Oct 2022 09:54:14 -0400 Subject: [PATCH] Add commands to remove/list Jira connections (#503) * Add commands to remove/list Jira connections * Add docs page for Jira connections * Clarify "instance" and webhook reqs in Jira docs - Jira has "instances" instead of "organizations", so use the former term - Don't suggest that webhook support can work for multiple instances - Mention that webhooks require special access - Make some minor grammar changes --- changelog.d/503.feature | 1 + docs/SUMMARY.md | 1 + docs/setup/jira.md | 9 +- docs/usage/room_configuration/jira_project.md | 38 ++++++++ src/Connections/SetupConnection.ts | 88 ++++++++++++++++--- 5 files changed, 121 insertions(+), 16 deletions(-) create mode 100644 changelog.d/503.feature create mode 100644 docs/usage/room_configuration/jira_project.md diff --git a/changelog.d/503.feature b/changelog.d/503.feature new file mode 100644 index 00000000..63737b2a --- /dev/null +++ b/changelog.d/503.feature @@ -0,0 +1 @@ +Add bot commands to list and remove Jira connections. diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 3dc890c0..94cbcde8 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -15,6 +15,7 @@ - [Room Configuration](./usage/room_configuration.md) - [GitHub Repo](./usage/room_configuration/github_repo.md) - [GitLab Project](./usage/room_configuration/gitlab_project.md) + - [JIRA Project](./usage/room_configuration/jira_project.md) - [📊 Metrics](./metrics.md) # 🧑‍💻 Development diff --git a/docs/setup/jira.md b/docs/setup/jira.md index 9e8c7c59..ceb91f1c 100644 --- a/docs/setup/jira.md +++ b/docs/setup/jira.md @@ -1,21 +1,22 @@ # JIRA -## Adding a webhook to a JIRA Organisation +## Adding a webhook to a JIRA Instance -This should be done for all JIRA organisations you wish to bridge. The setup steps are the same for both On-Prem and Cloud. +This should be done for the JIRA instance you wish to bridge. The setup steps are the same for both On-Prem and Cloud. You need to go to the `WebHooks` configuration page under Settings > System. +Note that this may require administrative access to the JIRA instance. Next, add a webhook that points to `/` on the public webhooks address for hookshot. You should also include a secret value by appending `?secret=your-webhook-secret`. The secret value can be anything, but should be reasonably secure and should also be stored in the `config.yml` file. -Ensure that you enable all the events that you wish to be bridge. +Ensure that you enable all the events that you wish to be bridged. ## Configuration -You can now set some configuration in the bridge `config.yml` +You can now set some configuration in the bridge `config.yml`: ```yaml jira: diff --git a/docs/usage/room_configuration/jira_project.md b/docs/usage/room_configuration/jira_project.md new file mode 100644 index 00000000..386378ea --- /dev/null +++ b/docs/usage/room_configuration/jira_project.md @@ -0,0 +1,38 @@ +JIRA Project +================= + +This connection type connects a JIRA project to a room. + +You can run commands to create and assign issues, and receive notifications when issues are created. + +## Setting up + +To set up a connection to a JIRA project in a new room: + +(NB you must have permission to bridge JIRA projects before you can use this command, see [auth](../auth.html#jira).) + +1. Create a new, unencrypted room. It can be public or private. +1. Invite the bridge bot (e.g. `@hookshot:example.com`). +1. Give the bridge bot moderator permissions or higher (power level 50) (or otherwise configure the room so the bot can edit room state). +1. Send the command `!hookshot jira project https://jira-instance/.../projects/PROJECTKEY/...`. +1. If you have permission to bridge this repo, the bridge will respond with a confirmation message. + +## Managing connections + +Send the command `!hookshot jira list project` to list all of a room's connections to JIRA projects. + +Send the command `!hookshot jira remove project ` to remove a room's connection to a JIRA project at a given URL. + +## Configuration + +This connection supports a few options which can be defined in the room state: + +| Option | Description | Allowed values | Default | +|--------|-------------|----------------|---------| +|events|Choose to include notifications for some event types|Array of: [Supported event types](#supported-event-types) |*empty*| +|commandPrefix|Choose the prefix to use when sending commands to the bot|A string, ideally starts with "!"|`!jira`| + + +### Supported event types + +This connection currently supports sending messages only when a `issue.created` action happens on the project. diff --git a/src/Connections/SetupConnection.ts b/src/Connections/SetupConnection.ts index 1007f790..74e0dbc5 100644 --- a/src/Connections/SetupConnection.ts +++ b/src/Connections/SetupConnection.ts @@ -1,7 +1,7 @@ // We need to instantiate some functions which are not directly called, which confuses typescript. import { BotCommands, botCommand, compileBotCommands, HelpFunction } from "../BotCommands"; import { CommandConnection } from "./CommandConnection"; -import { GenericHookConnection, GitHubRepoConnection, JiraProjectConnection } from "."; +import { GenericHookConnection, GitHubRepoConnection, JiraProjectConnection, JiraProjectConnectionState } from "."; import { CommandError } from "../errors"; import { v4 as uuid } from "uuid"; import { BridgePermissionLevel } from "../Config/Config"; @@ -112,29 +112,93 @@ export class SetupConnection extends CommandConnection { await this.as.botClient.sendNotice(this.roomId, `Room configured to bridge ${connection.path}`); } + private async checkJiraLogin(userId: string, urlStr: string) { + const jiraClient = await this.provisionOpts.tokenStore.getJiraForUser(userId, urlStr); + if (!jiraClient) { + throw new CommandError("User not logged in", "You are not logged into Jira. Start a DM with this bot and use the command `jira login`."); + } + } + + private async getJiraProjectSafeUrl(userId: string, urlStr: string) { + const url = new URL(urlStr); + const urlParts = /\/projects\/(\w+)\/?(\w+\/?)*$/.exec(url.pathname); + const projectKey = urlParts?.[1] || url.searchParams.get('projectKey'); + if (!projectKey) { + throw new CommandError("Invalid Jira url", "The JIRA project url you entered was not valid. It should be in the format of `https://jira-instance/.../projects/PROJECTKEY/...` or `.../RapidBoard.jspa?projectKey=TEST`."); + } + return `https://${url.host}/projects/${projectKey}`; + } + @botCommand("jira project", { help: "Create a connection for a JIRA project. (You must be logged in with JIRA to do this.)", requiredArgs: ["url"], includeUserId: true, category: "jira"}) public async onJiraProject(userId: string, urlStr: string) { - const url = new URL(urlStr); if (!this.config.jira) { throw new CommandError("not-configured", "The bridge is not configured to support Jira."); } await this.checkUserPermissions(userId, "jira", JiraProjectConnection.CanonicalEventType); + await this.checkJiraLogin(userId, urlStr); + const safeUrl = await this.getJiraProjectSafeUrl(userId, urlStr); - const jiraClient = await this.provisionOpts.tokenStore.getJiraForUser(userId, urlStr); - if (!jiraClient) { - throw new CommandError("User not logged in", "You are not logged into Jira. Start a DM with this bot and use the command `jira login`."); - } - const urlParts = /.+\/projects\/(\w+)\/?(\w+\/?)*$/.exec(url.pathname.toLowerCase()); - const projectKey = urlParts?.[1] || url.searchParams.get('projectKey'); - if (!projectKey) { - throw new CommandError("Invalid Jira url", "The JIRA project url you entered was not valid. It should be in the format of `https://jira-instance/.../projects/PROJECTKEY/...` or `.../RapidBoard.jspa?projectKey=TEST`."); - } - const safeUrl = `https://${url.host}/projects/${projectKey}`; const res = await JiraProjectConnection.provisionConnection(this.roomId, userId, { url: safeUrl }, this.provisionOpts); await this.as.botClient.sendNotice(this.roomId, `Room configured to bridge Jira project ${res.connection.projectKey}.`); } + @botCommand("jira list project", { help: "Show JIRA projects currently connected to.", category: "jira"}) + public async onJiraListProject() { + const projects: JiraProjectConnectionState[] = await this.as.botClient.getRoomState(this.roomId).catch((err: any) => { + if (err.body.errcode === 'M_NOT_FOUND') { + return []; // not an error to us + } + throw err; + }).then(events => + events.filter( + (ev: any) => ( + ev.type === JiraProjectConnection.CanonicalEventType || + ev.type === JiraProjectConnection.LegacyCanonicalEventType + ) && ev.content.url + ).map(ev => ev.content) + ); + + if (projects.length === 0) { + return this.as.botClient.sendHtmlNotice(this.roomId, md.renderInline('Not connected to any JIRA projects')); + } else { + return this.as.botClient.sendHtmlNotice(this.roomId, md.render( + 'Currently connected to these JIRA projects:\n\n' + + projects.map(project => ` - ${project.url}`).join('\n') + )); + } + } + + @botCommand("jira remove project", { help: "Remove a connection for a JIRA project.", requiredArgs: ["url"], includeUserId: true, category: "jira"}) + public async onJiraRemoveProject(userId: string, urlStr: string) { + await this.checkUserPermissions(userId, "jira", JiraProjectConnection.CanonicalEventType); + await this.checkJiraLogin(userId, urlStr); + const safeUrl = await this.getJiraProjectSafeUrl(userId, urlStr); + + const eventTypes = [ + JiraProjectConnection.CanonicalEventType, + JiraProjectConnection.LegacyCanonicalEventType, + ]; + let event = null; + let eventType = ""; + for (eventType of eventTypes) { + try { + event = await this.as.botClient.getRoomStateEvent(this.roomId, eventType, safeUrl); + break; + } catch (err: any) { + if (err.body.errcode !== 'M_NOT_FOUND') { + throw err; + } + } + } + if (!event || Object.keys(event).length === 0) { + throw new CommandError("Invalid Jira project URL", `Feed "${urlStr}" is not currently bridged to this room`); + } + + await this.as.botClient.sendStateEvent(this.roomId, eventType, safeUrl, {}); + return this.as.botClient.sendHtmlNotice(this.roomId, md.renderInline(`Room no longer bridged to Jira project \`${safeUrl}\`.`)); + } + @botCommand("webhook", { help: "Create an inbound webhook.", requiredArgs: ["name"], includeUserId: true, category: "webhook"}) public async onWebhook(userId: string, name: string) { if (!this.config.generic?.enabled) {