diff --git a/changelog.d/534.feature b/changelog.d/534.feature new file mode 100644 index 00000000..a2e39dde --- /dev/null +++ b/changelog.d/534.feature @@ -0,0 +1 @@ +Support Jira version events. diff --git a/docs/usage/room_configuration/jira_project.md b/docs/usage/room_configuration/jira_project.md index 12f3a904..61ea0d03 100644 --- a/docs/usage/room_configuration/jira_project.md +++ b/docs/usage/room_configuration/jira_project.md @@ -40,3 +40,7 @@ This connection supports sending messages when the following actions happen on t - issue - issue_created - issue_updated +- version + - version_created + - version_updated + - version_released \ No newline at end of file diff --git a/src/Bridge.ts b/src/Bridge.ts index 58e03cb0..1bfb4995 100644 --- a/src/Bridge.ts +++ b/src/Bridge.ts @@ -11,7 +11,7 @@ import { IBridgeStorageProvider } from "./Stores/StorageProvider"; import { IConnection, GitHubDiscussionSpace, GitHubDiscussionConnection, GitHubUserSpace, JiraProjectConnection, GitLabRepoConnection, GitHubIssueConnection, GitHubProjectConnection, GitHubRepoConnection, GitLabIssueConnection, FigmaFileConnection, FeedConnection, GenericHookConnection } from "./Connections"; import { IGitLabWebhookIssueStateEvent, IGitLabWebhookMREvent, IGitLabWebhookNoteEvent, IGitLabWebhookPushEvent, IGitLabWebhookReleaseEvent, IGitLabWebhookTagPushEvent, IGitLabWebhookWikiPageEvent } from "./Gitlab/WebhookTypes"; -import { JiraIssueEvent, JiraIssueUpdatedEvent } from "./Jira/WebhookTypes"; +import { JiraIssueEvent, JiraIssueUpdatedEvent, JiraVersionEvent } from "./Jira/WebhookTypes"; import { JiraOAuthResult } from "./Jira/Types"; import { MatrixEvent, MatrixMemberContent, MatrixMessageContent } from "./MatrixEvent"; import { MemoryStorageProvider } from "./Stores/MemoryStorageProvider"; @@ -516,6 +516,14 @@ export class Bridge { (c, data) => c.onJiraIssueUpdated(data), ); + for (const event of ["created", "updated", "released"]) { + this.bindHandlerToQueue( + `jira.version_${event}`, + (data) => connManager.getConnectionsForJiraVersion(data.version), + (c, data) => c.onJiraVersionEvent(data), + ); + } + this.queue.on("jira.oauth.response", async (msg) => { if (!this.config.jira || !this.tokenStore.jiraOAuth) { throw Error('Cannot handle, JIRA oauth support not enabled'); diff --git a/src/ConnectionManager.ts b/src/ConnectionManager.ts index 51075a2a..b053c97b 100644 --- a/src/ConnectionManager.ts +++ b/src/ConnectionManager.ts @@ -10,7 +10,7 @@ import { BridgeConfig, BridgePermissionLevel, GitLabInstance } from "./Config/Co import { ConnectionDeclarations, GenericHookConnection, GitHubDiscussionConnection, GitHubDiscussionSpace, GitHubIssueConnection, GitHubProjectConnection, GitHubRepoConnection, GitHubUserSpace, GitLabIssueConnection, GitLabRepoConnection, IConnection, JiraProjectConnection } from "./Connections"; import { GithubInstance } from "./Github/GithubInstance"; import { GitLabClient } from "./Gitlab/Client"; -import { JiraProject } from "./Jira/Types"; +import { JiraProject, JiraVersion } from "./Jira/Types"; import { Logger } from "matrix-appservice-bridge"; import { MessageSenderClient } from "./MatrixSender"; import { GetConnectionTypeResponseItem } from "./provisioning/api"; @@ -213,6 +213,10 @@ export class ConnectionManager extends EventEmitter { return this.connections.filter((c) => (c instanceof JiraProjectConnection && c.interestedInProject(project))) as JiraProjectConnection[]; } + public getConnectionsForJiraVersion(version: JiraVersion): JiraProjectConnection[] { + return this.connections.filter((c) => (c instanceof JiraProjectConnection && c.interestedInVersion(version))) as JiraProjectConnection[]; + } + public getConnectionsForGenericWebhook(hookId: string): GenericHookConnection[] { return this.connections.filter((c) => (c instanceof GenericHookConnection && c.hookId === hookId)) as GenericHookConnection[]; } diff --git a/src/Connections/JiraProject.ts b/src/Connections/JiraProject.ts index 006aef15..9edf008f 100644 --- a/src/Connections/JiraProject.ts +++ b/src/Connections/JiraProject.ts @@ -1,11 +1,11 @@ import { Connection, IConnection, IConnectionState, InstantiateConnectionOpts, ProvisionConnectionOpts } from "./IConnection"; import { Appservice, StateEvent } from "matrix-bot-sdk"; import { Logger } from "matrix-appservice-bridge"; -import { JiraIssueEvent, JiraIssueUpdatedEvent } from "../Jira/WebhookTypes"; +import { JiraIssueEvent, JiraIssueUpdatedEvent, JiraVersionEvent } from "../Jira/WebhookTypes"; import { FormatUtil } from "../FormatUtil"; import markdownit from "markdown-it"; -import { generateJiraWebLinkFromIssue } from "../Jira"; -import { JiraProject } from "../Jira/Types"; +import { generateJiraWebLinkFromIssue, generateJiraWebLinkFromVersion } from "../Jira"; +import { JiraProject, JiraVersion } from "../Jira/Types"; import { botCommand, BotCommands, compileBotCommands } from "../BotCommands"; import { MatrixMessageContent } from "../MatrixEvent"; import { CommandConnection } from "./CommandConnection"; @@ -19,15 +19,21 @@ import { HookshotJiraApi } from "../Jira/Client"; type JiraAllowedEventsNames = "issue_created" | - "issue_updated"; + "issue_updated" | + "version_created" | + "version_updated" | + "version_released"; const JiraAllowedEvents: JiraAllowedEventsNames[] = [ "issue_created" , "issue_updated" , + "version_created" , + "version_updated" , + "version_released", ]; export interface JiraProjectConnectionState extends IConnectionState { - // legacy field, prefer url + // prefer url, but some events identify projects by id id?: string; url: string; events?: JiraAllowedEventsNames[], @@ -55,7 +61,10 @@ export type JiraProjectResponseItem = GetConnectionsResponseItem; + const {id, url, commandPrefix, priority} = state as Partial; + if (id !== undefined && typeof id !== "string") { + throw new ApiError("Expected 'id' to be a string", ErrCode.BadValue); + } if (url === undefined) { throw new ApiError("Expected a 'url' property", ErrCode.BadValue); } @@ -73,7 +82,7 @@ function validateJiraConnectionState(state: unknown): JiraProjectConnectionState } else if (events.find((ev) => !JiraAllowedEvents.includes(ev))?.length) { throw new ApiError(`'events' can only contain ${JiraAllowedEvents.join(", ")}`, ErrCode.BadValue); } - return {url, commandPrefix, events, priority}; + return {id, url, commandPrefix, events, priority}; } const log = new Logger("JiraProjectConnection"); @@ -122,8 +131,13 @@ export class JiraProjectConnection extends CommandConnection) { const validatedConfig = validateJiraConnectionState(config); + if (!validatedConfig.id) { + await this.updateProjectId(validatedConfig, userId); + } await this.as.botClient.sendStateEvent(this.roomId, JiraProjectConnection.CanonicalEventType, this.stateKey, validatedConfig); this.state = validatedConfig; } + + private async updateProjectId(validatedConfig: JiraProjectConnectionState, userIdForAuth: string) { + const jiraClient = await this.tokenStore.getJiraForUser(userIdForAuth); + if (!jiraClient) { + log.warn(`Cannot update JIRA project ID via user ${userIdForAuth} who is not authenticted with JIRA`); + return; + } + const url = new URL(validatedConfig.url); + const jiraResourceClient = await jiraClient.getClientForUrl(url); + if (!jiraResourceClient) { + log.warn(`Cannot update JIRA project ID via user ${userIdForAuth} who is not authenticated with this JIRA instance`); + return; + } + const projectKey = JiraProjectConnection.getProjectKeyForUrl(url); + if (projectKey) { + const project = await jiraResourceClient.getProject(projectKey); + validatedConfig.id = project.id; + } + } } // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/Jira/Router.ts b/src/Jira/Router.ts index a40fe39f..76191948 100644 --- a/src/Jira/Router.ts +++ b/src/Jira/Router.ts @@ -118,6 +118,7 @@ interface JiraAccountStatus { interface JiraProjectsListing { name: string; key: string; + id: string; url: string; } @@ -183,12 +184,13 @@ export class JiraProvisionerRouter { return next( new ApiError("Instance not known or not accessible to this user.", ErrCode.ForbiddenUser)); } - const projects = []; + const projects: JiraProjectsListing[] = []; try { for await (const project of resClient.getAllProjects()) { projects.push({ key: project.key, name: project.name, + id: project.id, // Technically not the real URL, but good enough for hookshot! url: `${resClient.resource.url}/projects/${project.key}`, }); diff --git a/src/Jira/Types.ts b/src/Jira/Types.ts index 0604339a..d7b926c0 100644 --- a/src/Jira/Types.ts +++ b/src/Jira/Types.ts @@ -68,6 +68,25 @@ export interface JiraIssue { } } +export interface JiraVersion { + /** + * URL + */ + self: string; + id: string; + description: string; + name: string; + archived: boolean; + released: boolean; + startDate?: string; + releaseDate?: string; + overdue: boolean; + userStartDate?: string; + userReleaseDate?: string; + project?: string; + projectId: number; +} + export interface JiraStoredToken { expires_in?: number; access_token: string; diff --git a/src/Jira/WebhookTypes.ts b/src/Jira/WebhookTypes.ts index a2a51af2..a04980ac 100644 --- a/src/Jira/WebhookTypes.ts +++ b/src/Jira/WebhookTypes.ts @@ -1,4 +1,4 @@ -import { JiraAccount, JiraComment, JiraIssue } from "./Types"; +import { JiraAccount, JiraComment, JiraIssue, JiraVersion } from "./Types"; export interface IJiraWebhookEvent { timestamp: number; @@ -32,4 +32,9 @@ export interface JiraIssueUpdatedEvent extends JiraIssueEvent { toString: null; }[]; } +} + +export interface JiraVersionEvent extends IJiraWebhookEvent { + webhookEvent: "version_created"|"version_updated"|"version_released"; + version: JiraVersion; } \ No newline at end of file diff --git a/src/Jira/index.ts b/src/Jira/index.ts index 150cd904..c5e4bda6 100644 --- a/src/Jira/index.ts +++ b/src/Jira/index.ts @@ -1,3 +1,4 @@ import * as libRs from "../libRs"; export const generateJiraWebLinkFromIssue = libRs.generateJiraWeblinkFromIssue; +export const generateJiraWebLinkFromVersion = libRs.generateJiraWeblinkFromVersion; diff --git a/src/Jira/types.rs b/src/Jira/types.rs index 68df4652..26f43ee5 100644 --- a/src/Jira/types.rs +++ b/src/Jira/types.rs @@ -52,3 +52,14 @@ pub struct JiraIssueMessageBody { #[napi(js_name = "external_url")] pub external_url: String, } + +#[derive(Serialize, Debug, Deserialize)] +#[napi(object)] +pub struct JiraVersion { + #[serde(rename = "self")] + pub _self: String, + pub id: String, + pub description: String, + pub name: String, + pub projectId: String, +} diff --git a/src/Jira/utils.rs b/src/Jira/utils.rs index 4d7963e5..7aa1e2cb 100644 --- a/src/Jira/utils.rs +++ b/src/Jira/utils.rs @@ -1,4 +1,4 @@ -use super::types::JiraIssueLight; +use super::types::{JiraIssueLight, JiraVersion}; use napi::bindgen_prelude::*; use napi_derive::napi; use url::Url; @@ -23,3 +23,25 @@ pub fn generate_jira_web_link_from_issue(jira_issue: &JiraIssueLight) -> Result< Err(err) => Err(Error::new(Status::Unknown, err.to_string())), } } + +/// Generate a URL for a given Jira Version object. +#[napi(js_name = "generateJiraWeblinkFromVersion")] +pub fn js_generate_jira_web_link_from_version(jira_version: JiraVersion) -> Result { + return generate_jira_web_link_from_version(&jira_version); +} + +pub fn generate_jira_web_link_from_version(jira_version: &JiraVersion) -> Result { + let result = Url::parse(&jira_version._self); + match result { + Ok(url) => Ok(format!( + "{}://{}{}/projects/{}/versions/{}", + url.scheme(), + url.host_str().unwrap(), + url.port() + .map_or(String::new(), |port| format!(":{}", port)), + jira_version.projectId, + jira_version.id + )), + Err(err) => Err(Error::new(Status::Unknown, err.to_string())), + } +} diff --git a/web/components/roomConfig/JiraProjectConfig.tsx b/web/components/roomConfig/JiraProjectConfig.tsx index cd452122..15471de9 100644 --- a/web/components/roomConfig/JiraProjectConfig.tsx +++ b/web/components/roomConfig/JiraProjectConfig.tsx @@ -176,6 +176,12 @@ const ConnectionConfiguration: FunctionComponentCreated Updated + Versions +
    + Created + Updated + Released +