diff --git a/src/Bridge.ts b/src/Bridge.ts index 3ccc4622..270eeedb 100644 --- a/src/Bridge.ts +++ b/src/Bridge.ts @@ -435,8 +435,8 @@ export class Bridge { ); connManager.push(discussionConnection); } catch (ex) { - log.error(ex); - throw Error('Failed to create discussion room'); + log.error("Failed to create discussion room", ex); + return; } } diff --git a/src/ConnectionManager.ts b/src/ConnectionManager.ts index 8f3fa414..21b1d0ca 100644 --- a/src/ConnectionManager.ts +++ b/src/ConnectionManager.ts @@ -351,11 +351,12 @@ export class ConnectionManager { return this.connections.filter((c) => (c instanceof GitLabRepoConnection && c.path === pathWithNamespace)) as GitLabRepoConnection[]; } - public getConnectionsForJiraProject(project: JiraProject, eventName: string): JiraProjectConnection[] { + public getConnectionsForJiraProject(project: {id: string, self: string, key: string}, eventName?: string): JiraProjectConnection[] { return this.connections.filter((c) => (c instanceof JiraProjectConnection && c.interestedInProject(project) && - c.isInterestedInHookEvent(eventName))) as JiraProjectConnection[]; + (!eventName || c.isInterestedInHookEvent(eventName)) + )) as JiraProjectConnection[]; } @@ -388,12 +389,12 @@ export class ConnectionManager { return this.connections.find((c) => c.connectionId === connectionId && c.roomId === roomId); } - public async purgeConnection(roomId: string, connectionId: string, requireNoRemoveHandler = true) { + public async purgeConnection(roomId: string, connectionId: string, requireRemoveHandler = true) { const connection = this.connections.find((c) => c.connectionId === connectionId && c.roomId == roomId); if (!connection) { throw Error("Connection not found"); } - if (requireNoRemoveHandler && !connection.onRemove) { + if (requireRemoveHandler && !connection.onRemove) { throw Error("Connection doesn't support removal, and so cannot be safely removed"); } await connection.onRemove?.(); diff --git a/src/Connections/GithubRepo.ts b/src/Connections/GithubRepo.ts index 22b9c24e..0667154d 100644 --- a/src/Connections/GithubRepo.ts +++ b/src/Connections/GithubRepo.ts @@ -487,7 +487,13 @@ export class GitHubRepoConnection extends CommandConnection implements IConnecti } const orgRepoName = event.repository.full_name; - let message = `**${event.issue.user.login}** created new issue [${orgRepoName}#${event.issue.number}](${event.issue.html_url}): "${event.issue.title}"`; + let actionText = "created new issue" + if (event.issue.comments > 0) { + // This was tranferred + actionText = "transferred issue" + } + + let message = `**${event.issue.user.login}** ${actionText} [${orgRepoName}#${event.issue.number}](${event.issue.html_url}): "${event.issue.title}"`; message += (event.issue.assignee ? ` assigned to ${event.issue.assignee.login}` : ''); if (this.showIssueRoomLink) { const appInstance = await this.githubInstance.getSafeOctokitForRepo(this.org, this.repo); diff --git a/src/Connections/JiraProject.ts b/src/Connections/JiraProject.ts index 297bfb36..4c30e50b 100644 --- a/src/Connections/JiraProject.ts +++ b/src/Connections/JiraProject.ts @@ -109,7 +109,7 @@ export class JiraProjectConnection extends CommandConnection implements IConnect return !this.state.events || this.state.events?.includes(eventName as JiraAllowedEventsNames); } - public interestedInProject(project: JiraProject) { + public interestedInProject(project: {id: string, self: string, key: string}) { if (this.projectId === project.id) { return true; } diff --git a/src/Connections/SetupConnection.ts b/src/Connections/SetupConnection.ts index 9e585a3b..90dceb4d 100644 --- a/src/Connections/SetupConnection.ts +++ b/src/Connections/SetupConnection.ts @@ -13,8 +13,11 @@ import markdown from "markdown-it"; import { FigmaFileConnection } from "./FigmaFileConnection"; import { URL } from "url"; import { AdminRoom } from "../AdminRoom"; +import { ConnectionManager } from "../ConnectionManager"; const md = new markdown(); +const GITHUB_REGEX = /^https:\/\/github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/; +const JIRA_REGEX = /.+\/projects\/(\w+)\/?(\w+\/?)*$/; /** * Handles setting up a room with connections. This connection is "virtual" in that it has * no state, and is only invoked when messages from other clients fall through. @@ -28,6 +31,7 @@ export class SetupConnection extends CommandConnection { private readonly as: Appservice, private readonly tokenStore: UserTokenStore, private readonly config: BridgeConfig, + private readonly connectionManager: ConnectionManager, private readonly getOrCreateAdminRoom: (userId: string) => Promise, private readonly githubInstance?: GithubInstance,) { super( @@ -59,7 +63,7 @@ export class SetupConnection extends CommandConnection { if (!octokit) { throw new CommandError("User not logged in", "You are not logged into GitHub. Start a DM with this bot and use the command `github login`."); } - const urlParts = /^https:\/\/github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/.exec(url.trim().toLowerCase()); + const urlParts = GITHUB_REGEX.exec(url.trim().toLowerCase()); if (!urlParts) { throw new CommandError("Invalid GitHub url", "The GitHub url you entered was not valid"); } @@ -69,6 +73,38 @@ export class SetupConnection extends CommandConnection { await this.as.botClient.sendNotice(this.roomId, `Room configured to bridge ${org}/${repo}`); } + @botCommand("remove github repo", "Remove a GitHub repository connection.", ["url"], [], true) + public async onRemoveGitHubRepo(userId: string, url: string) { + if (!this.githubInstance || !this.config.github) { + throw new CommandError("not-configured", "The bridge is not configured to support GitHub"); + } + if (!this.config.checkPermission(userId, "github", BridgePermissionLevel.manageConnections)) { + throw new CommandError('You are not permitted to remove connections for GitHub'); + } + if (!await this.as.botClient.userHasPowerLevelFor(userId, this.roomId, "", true)) { + throw new CommandError("not-configured", "You must be able to set state in a room ('Change settings') in order to remove integrations."); + } + if (!await this.as.botClient.userHasPowerLevelFor(this.as.botUserId, this.roomId, GitHubRepoConnection.CanonicalEventType, true)) { + throw new CommandError("Bot lacks power level to set room state", "I do not have permission to remove a bridge in this room. Please promote me to an Admin/Moderator"); + } + const urlParts = GITHUB_REGEX.exec(url.trim().toLowerCase()); + if (!urlParts) { + throw new CommandError("Invalid GitHub url", "The GitHub url you entered was not valid"); + } + const [, org, repo] = urlParts; + const connections = await this.connectionManager.getConnectionsForGithubRepo(org, repo); + for (const connection of connections) { + await this.connectionManager.purgeConnection(connection.roomId, connection.connectionId, false); + } + if (connections.length === 0) { + throw new CommandError("no-connections-found", "No connections found matching that url"); + } else if (connections.length === 1) { + await this.as.botClient.sendNotice(this.roomId, `Removed connection from this room`); + } else { + await this.as.botClient.sendNotice(this.roomId, `Removed ${connections.length} connections from this room`); + } + } + @botCommand("jira project", "Create a connection for a JIRA project. (You must be logged in with JIRA to do this)", ["url"], [], true) public async onJiraProject(userId: string, urlStr: string) { const url = new URL(urlStr); @@ -88,7 +124,7 @@ export class SetupConnection extends CommandConnection { 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 urlParts = JIRA_REGEX.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`"); @@ -99,6 +135,44 @@ export class SetupConnection extends CommandConnection { await this.as.botClient.sendNotice(this.roomId, `Room configured to bridge Jira project ${res.connection.projectKey}`); } + @botCommand("remove jira project", "Remove a GitHub repository connection.", ["url"], [], true) + public async onRemoveJiraProject(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"); + } + if (!this.config.checkPermission(userId, "jira", BridgePermissionLevel.manageConnections)) { + throw new CommandError('You are not permitted to remove connections for Jira'); + } + if (!await this.as.botClient.userHasPowerLevelFor(userId, this.roomId, "", true)) { + throw new CommandError("not-configured", "You must be able to set state in a room ('Change settings') in order to remove connections."); + } + if (!await this.as.botClient.userHasPowerLevelFor(this.as.botUserId, this.roomId, GitHubRepoConnection.CanonicalEventType, true)) { + throw new CommandError("Bot lacks power level to set room state", "I do not have permission to remove a connection in this room. Please promote me to an Admin/Moderator"); + } + const urlParts = JIRA_REGEX.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 connections = await this.connectionManager.getConnectionsForJiraProject({ + id: "none", + self: safeUrl, + key: projectKey, + }); + for (const connection of connections) { + await this.connectionManager.purgeConnection(connection.roomId, connection.connectionId, false); + } + if (connections.length === 0) { + throw new CommandError("no-connections-found", "No connections found matching that url"); + } else if (connections.length === 1) { + await this.as.botClient.sendNotice(this.roomId, `Removed connection from this room`); + } else { + await this.as.botClient.sendNotice(this.roomId, `Removed ${connections.length} connections from this room`); + } + } + @botCommand("webhook", "Create an inbound webhook", ["name"], [], true) public async onWebhook(userId: string, name: string) { if (!this.config.generic?.enabled) {