mirror of
https://github.com/matrix-org/matrix-hookshot.git
synced 2025-03-10 21:19:13 +00:00
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:
parent
d82e0d7d91
commit
505c083f5f
1
changelog.d/534.feature
Normal file
1
changelog.d/534.feature
Normal file
@ -0,0 +1 @@
|
||||
Support Jira version events.
|
@ -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
|
@ -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');
|
||||
|
@ -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[];
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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}`,
|
||||
});
|
||||
|
@ -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;
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { JiraAccount, JiraComment, JiraIssue } from "./Types";
|
||||
import { JiraAccount, JiraComment, JiraIssue, JiraVersion } from "./Types";
|
||||
|
||||
export interface IJiraWebhookEvent {
|
||||
timestamp: number;
|
||||
@ -33,3 +33,8 @@ export interface JiraIssueUpdatedEvent extends JiraIssueEvent {
|
||||
}[];
|
||||
}
|
||||
}
|
||||
|
||||
export interface JiraVersionEvent extends IJiraWebhookEvent {
|
||||
webhookEvent: "version_created"|"version_updated"|"version_released";
|
||||
version: JiraVersion;
|
||||
}
|
@ -1,3 +1,4 @@
|
||||
import * as libRs from "../libRs";
|
||||
|
||||
export const generateJiraWebLinkFromIssue = libRs.generateJiraWeblinkFromIssue;
|
||||
export const generateJiraWebLinkFromVersion = libRs.generateJiraWeblinkFromVersion;
|
||||
|
@ -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,
|
||||
}
|
||||
|
@ -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())),
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
|
Loading…
x
Reference in New Issue
Block a user