Add support for Jira version events (#534)

- Support version created/updated/released events
- Look up project ID if missing when subscribing to version events
- Properly format version event notices
- Prioritize project URL over ID in debug strings
This commit is contained in:
Andrew Ferrazzutti 2022-10-21 09:24:35 -04:00 committed by GitHub
parent d82e0d7d91
commit 505c083f5f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 169 additions and 18 deletions

1
changelog.d/534.feature Normal file
View File

@ -0,0 +1 @@
Support Jira version events.

View File

@ -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

View File

@ -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<JiraVersionEvent, JiraProjectConnection>(
`jira.version_${event}`,
(data) => connManager.getConnectionsForJiraVersion(data.version),
(c, data) => c.onJiraVersionEvent(data),
);
}
this.queue.on<JiraOAuthRequestCloud|JiraOAuthRequestOnPrem>("jira.oauth.response", async (msg) => {
if (!this.config.jira || !this.tokenStore.jiraOAuth) {
throw Error('Cannot handle, JIRA oauth support not enabled');

View File

@ -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[];
}

View File

@ -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<JiraProjectConn
function validateJiraConnectionState(state: unknown): JiraProjectConnectionState {
const {url, commandPrefix, priority} = state as Partial<JiraProjectConnectionState>;
const {id, url, commandPrefix, priority} = state as Partial<JiraProjectConnectionState>;
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<JiraProjectConnecti
throw Error('Expected projectKey to be defined');
}
try {
// Just need to check that the user can access this.
await jiraResourceClient.getProject(connection.projectKey);
// Need to check that the user can access this.
const project = await jiraResourceClient.getProject(connection.projectKey);
// Fetch the project's id now, to support events that identify projects by id instead of url
if (connection.state.id !== undefined && connection.state.id !== project.id) {
log.warn(`Updating ID of project ${connection.projectKey} from ${connection.state.id} to ${project.id}`);
connection.state.id = project.id;
}
} catch (ex) {
throw new ApiError("Requested project was not found", ErrCode.ForbiddenUser);
}
@ -149,7 +163,11 @@ export class JiraProjectConnection extends CommandConnection<JiraProjectConnecti
}
public get projectKey() {
const parts = this.projectUrl?.pathname.split('/');
return this.projectUrl ? JiraProjectConnection.getProjectKeyForUrl(this.projectUrl) : undefined;
}
public static getProjectKeyForUrl(projectUrl: URL) {
const parts = projectUrl?.pathname.split('/');
return parts ? parts[parts.length - 1]?.toUpperCase() : undefined;
}
@ -158,7 +176,7 @@ export class JiraProjectConnection extends CommandConnection<JiraProjectConnecti
}
public toString() {
return `JiraProjectConnection ${this.projectId || this.projectUrl}`;
return `JiraProjectConnection ${this.projectUrl || this.projectId}`;
}
public isInterestedInHookEvent(eventName: JiraAllowedEventsNames, interestedByDefault = false) {
@ -176,6 +194,10 @@ export class JiraProjectConnection extends CommandConnection<JiraProjectConnecti
return false;
}
public interestedInVersion(version: JiraVersion) {
return this.projectId === version.projectId.toString();
}
/**
* The URL of the project
* @example https://test.atlassian.net/jira/software/c/projects/PLAY
@ -223,7 +245,7 @@ export class JiraProjectConnection extends CommandConnection<JiraProjectConnecti
if (!this.isInterestedInHookEvent('issue_created', true)) {
return;
}
log.info(`onIssueCreated ${this.roomId} ${this.projectId} ${data.issue.id}`);
log.info(`onIssueCreated ${this.roomId} ${this.projectUrl || this.projectId} ${data.issue.id}`);
const creator = data.issue.fields.creator;
if (!creator) {
@ -299,6 +321,7 @@ export class JiraProjectConnection extends CommandConnection<JiraProjectConnecti
for await (const project of resClient.getAllProjects()) {
allProjects.push({
state: {
id: project.id,
// Technically not the real URL, but good enough for hookshot!
url: `${resClient.resource.url}/projects/${project.key}`,
},
@ -317,7 +340,7 @@ export class JiraProjectConnection extends CommandConnection<JiraProjectConnecti
if (!this.isInterestedInHookEvent('issue_updated')) {
return;
}
log.info(`onJiraIssueUpdated ${this.roomId} ${this.projectId} ${data.issue.id}`);
log.info(`onJiraIssueUpdated ${this.roomId} ${this.projectUrl || this.projectId} ${data.issue.id}`);
const url = generateJiraWebLinkFromIssue(data.issue);
let content = `${data.user.displayName} updated JIRA [${data.issue.key}](${url}): `;
@ -342,6 +365,29 @@ export class JiraProjectConnection extends CommandConnection<JiraProjectConnecti
});
}
public async onJiraVersionEvent(data: JiraVersionEvent) {
if (!this.isInterestedInHookEvent(data.webhookEvent)) {
return;
}
log.info(`onJiraVersionEvent ${this.roomId} ${this.projectUrl || this.projectId} ${data.webhookEvent}`);
const url = generateJiraWebLinkFromVersion({
...data.version,
projectId: data.version.projectId.toString(),
});
const action = data.webhookEvent.substring("version_".length);
const content =
`Version **${action}**` +
(this.projectKey && this.projectUrl ? ` for project [${this.projectKey}](${this.projectUrl})` : "") +
`: [${data.version.name}](${url}) (_${data.version.description}_)`;
await this.as.botIntent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content,
formatted_body: md.renderInline(content),
format: "org.matrix.custom.html",
});
}
private async getUserClientForProject(userId: string) {
if (!this.projectUrl) {
throw new CommandError("No-resource-origin", "Room is configured with an ID and not a URL, cannot determine correct JIRA client");
@ -467,9 +513,31 @@ export class JiraProjectConnection extends CommandConnection<JiraProjectConnecti
public async provisionerUpdateConfig(userId: string, config: Record<string, unknown>) {
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

View File

@ -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}`,
});

View File

@ -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;

View File

@ -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;
}

View File

@ -1,3 +1,4 @@
import * as libRs from "../libRs";
export const generateJiraWebLinkFromIssue = libRs.generateJiraWeblinkFromIssue;
export const generateJiraWebLinkFromVersion = libRs.generateJiraWeblinkFromVersion;

View File

@ -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,
}

View File

@ -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<String> {
return generate_jira_web_link_from_version(&jira_version);
}
pub fn generate_jira_web_link_from_version(jira_version: &JiraVersion) -> Result<String> {
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())),
}
}

View File

@ -176,6 +176,12 @@ const ConnectionConfiguration: FunctionComponent<ConnectionConfigurationProps<ne
<EventCheckbox allowedEvents={allowedEvents} eventName="issue_created" onChange={toggleEvent}>Created</EventCheckbox>
<EventCheckbox allowedEvents={allowedEvents} eventName="issue_updated" onChange={toggleEvent}>Updated</EventCheckbox>
</ul>
Versions
<ul>
<EventCheckbox allowedEvents={allowedEvents} eventName="version_created" onChange={toggleEvent}>Created</EventCheckbox>
<EventCheckbox allowedEvents={allowedEvents} eventName="version_updated" onChange={toggleEvent}>Updated</EventCheckbox>
<EventCheckbox allowedEvents={allowedEvents} eventName="version_released" onChange={toggleEvent}>Released</EventCheckbox>
</ul>
</ul>
</InputField>
<ButtonSet>