Add bits needed for Jira OAuth and commands

This commit is contained in:
Will Hunt 2021-11-24 18:34:29 +00:00
parent d0c936bd12
commit b2c711fb47
21 changed files with 904 additions and 169 deletions

View File

@ -29,6 +29,7 @@
"generate-default-config": "node lib/Config/Defaults.js --config > config.sample.yml" "generate-default-config": "node lib/Config/Defaults.js --config > config.sample.yml"
}, },
"dependencies": { "dependencies": {
"@alloc/quick-lru": "^5.2.0",
"@octokit/auth-app": "^3.3.0", "@octokit/auth-app": "^3.3.0",
"@octokit/auth-token": "^2.4.5", "@octokit/auth-token": "^2.4.5",
"@octokit/rest": "^18.10.0", "@octokit/rest": "^18.10.0",
@ -37,6 +38,7 @@
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.17.1", "express": "^4.17.1",
"ioredis": "^4.28.0", "ioredis": "^4.28.0",
"jira-client": "^8.0.0",
"markdown-it": "^12.2.0", "markdown-it": "^12.2.0",
"matrix-bot-sdk": "^0.5.19", "matrix-bot-sdk": "^0.5.19",
"matrix-widget-api": "^0.1.0-beta.17", "matrix-widget-api": "^0.1.0-beta.17",
@ -66,6 +68,7 @@
"@types/node": "^12", "@types/node": "^12",
"@types/node-emoji": "^1.8.1", "@types/node-emoji": "^1.8.1",
"@types/uuid": "^8.3.3", "@types/uuid": "^8.3.3",
"@types/jira-client": "^7.1.0",
"@typescript-eslint/eslint-plugin": "^5.4.0", "@typescript-eslint/eslint-plugin": "^5.4.0",
"@typescript-eslint/parser": "^5.4.0", "@typescript-eslint/parser": "^5.4.0",
"chai": "^4.3.4", "chai": "^4.3.4",

View File

@ -4,7 +4,6 @@ import { UserTokenStore } from "./UserTokenStore";
import { BridgeConfig } from "./Config/Config"; import { BridgeConfig } from "./Config/Config";
import {v4 as uuid} from "uuid"; import {v4 as uuid} from "uuid";
import qs from "querystring"; import qs from "querystring";
import { EventEmitter } from "events";
import LogWrapper from "./LogWrapper"; import LogWrapper from "./LogWrapper";
import "reflect-metadata"; import "reflect-metadata";
import markdown from "markdown-it"; import markdown from "markdown-it";
@ -18,6 +17,8 @@ import { BridgeRoomState, BridgeRoomStateGitHub } from "./Widgets/BridgeWidgetIn
import { Endpoints } from "@octokit/types"; import { Endpoints } from "@octokit/types";
import { ProjectsListResponseData } from "./Github/Types"; import { ProjectsListResponseData } from "./Github/Types";
import { NotifFilter, NotificationFilterStateContent } from "./NotificationFilters"; import { NotifFilter, NotificationFilterStateContent } from "./NotificationFilters";
import { JiraBotCommands } from "./Jira/AdminCommands";
import { AdminAccountData, AdminRoomCommandHandler } from "./AdminRoomCommandHandler";
type ProjectsListForRepoResponseData = Endpoints["GET /repos/{owner}/{repo}/projects"]["response"]; type ProjectsListForRepoResponseData = Endpoints["GET /repos/{owner}/{repo}/projects"]["response"];
type ProjectsListForUserResponseData = Endpoints["GET /users/{username}/projects"]["response"]; type ProjectsListForUserResponseData = Endpoints["GET /users/{username}/projects"]["response"];
@ -33,52 +34,26 @@ export const LEGACY_BRIDGE_GITLAB_NOTIF_TYPE = "uk.half-shot.matrix-github.gitla
export const BRIDGE_ROOM_TYPE = "uk.half-shot.matrix-hookshot.github.room"; export const BRIDGE_ROOM_TYPE = "uk.half-shot.matrix-hookshot.github.room";
export const BRIDGE_NOTIF_TYPE = "uk.half-shot.matrix-hookshot.github.notif_state"; export const BRIDGE_NOTIF_TYPE = "uk.half-shot.matrix-hookshot.github.notif_state";
export const BRIDGE_GITLAB_NOTIF_TYPE = "uk.half-shot.matrix-hookshot.gitlab.notif_state"; export const BRIDGE_GITLAB_NOTIF_TYPE = "uk.half-shot.matrix-hookshot.gitlab.notif_state";
export interface AdminAccountData { export class AdminRoom extends AdminRoomCommandHandler {
// eslint-disable-next-line camelcase
admin_user: string;
github?: {
notifications?: {
enabled: boolean;
participating?: boolean;
};
};
gitlab?: {
[instanceUrl: string]: {
notifications: {
enabled: boolean;
}
}
}
}
export class AdminRoom extends EventEmitter {
public static helpMessage: () => MatrixMessageContent; public static helpMessage: () => MatrixMessageContent;
private widgetAccessToken = `abcdef`; protected widgetAccessToken = `abcdef`;
static botCommands: BotCommands; static botCommands: BotCommands;
private pendingOAuthState: string|null = null; protected pendingOAuthState: string|null = null;
public readonly notifFilter: NotifFilter; public readonly notifFilter: NotifFilter;
constructor(public readonly roomId: string, constructor(roomId: string,
private data: AdminAccountData, data: AdminAccountData,
notifContent: NotificationFilterStateContent, notifContent: NotificationFilterStateContent,
private botIntent: Intent, botIntent: Intent,
private tokenStore: UserTokenStore, tokenStore: UserTokenStore,
private config: BridgeConfig) { config: BridgeConfig) {
super(); super(botIntent, roomId, tokenStore, config, data);
this.notifFilter = new NotifFilter(notifContent); this.notifFilter = new NotifFilter(notifContent);
// TODO: Move this // TODO: Move this
this.backfillAccessToken(); this.backfillAccessToken();
} }
public get accountData() {
return {...this.data};
}
public get userId() {
return this.data.admin_user;
}
public get oauthState() { public get oauthState() {
return this.pendingOAuthState; return this.pendingOAuthState;
} }
@ -156,10 +131,6 @@ export class AdminRoom extends EventEmitter {
}); });
} }
public async sendNotice(noticeText: string) {
return this.botIntent.sendText(this.roomId, noticeText, "m.notice");
}
@botCommand("help", "This help text") @botCommand("help", "This help text")
public async helpCommand() { public async helpCommand() {
return this.botIntent.sendEvent(this.roomId, AdminRoom.helpMessage()); return this.botIntent.sendEvent(this.roomId, AdminRoom.helpMessage());
@ -654,6 +625,6 @@ export class AdminRoom extends EventEmitter {
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const res = compileBotCommands(AdminRoom.prototype as any); const res = compileBotCommands(AdminRoom.prototype as any, JiraBotCommands.prototype as any);
AdminRoom.helpMessage = res.helpMessage; AdminRoom.helpMessage = res.helpMessage;
AdminRoom.botCommands = res.botCommands; AdminRoom.botCommands = res.botCommands;

View File

@ -0,0 +1,59 @@
import EventEmitter from "events";
import { Intent } from "matrix-bot-sdk";
import { BridgeConfig } from "./Config/Config";
import { UserTokenStore } from "./UserTokenStore";
export interface AdminAccountData {
// eslint-disable-next-line camelcase
admin_user: string;
github?: {
notifications?: {
enabled: boolean;
participating?: boolean;
};
};
gitlab?: {
[instanceUrl: string]: {
notifications: {
enabled: boolean;
}
}
}
}
export abstract class AdminRoomCommandHandler extends EventEmitter {
// This needs moving to the JIRA specific impl.
protected pendingJiraOAuthState: string|null = null;
public get jiraOAuthState() {
return this.pendingJiraOAuthState;
}
public clearJiraOauthState() {
this.pendingJiraOAuthState = null;
}
public get accountData() {
return {...this.data};
}
public get userId() {
return this.data.admin_user;
}
constructor(
protected readonly botIntent: Intent,
public readonly roomId: string,
protected tokenStore: UserTokenStore,
protected readonly config: BridgeConfig,
protected data: AdminAccountData,
) {
super();
}
public async sendNotice(noticeText: string) {
return this.botIntent.sendText(this.roomId, noticeText, "m.notice");
}
}

View File

@ -5,6 +5,7 @@ import { BridgeConfig, parseRegistrationFile } from "../Config/Config";
import { Webhooks } from "../Webhooks"; import { Webhooks } from "../Webhooks";
import { MatrixSender } from "../MatrixSender"; import { MatrixSender } from "../MatrixSender";
import { UserNotificationWatcher } from "../Notifications/UserNotificationWatcher"; import { UserNotificationWatcher } from "../Notifications/UserNotificationWatcher";
import { LogLevel, LogService } from "matrix-bot-sdk";
LogWrapper.configureLogging("debug"); LogWrapper.configureLogging("debug");
const log = new LogWrapper("App"); const log = new LogWrapper("App");

View File

@ -25,24 +25,26 @@ export type BotCommands = {[prefix: string]: {
includeUserId: boolean, includeUserId: boolean,
}}; }};
export function compileBotCommands(prototype: Record<string, BotCommandFunction>): {helpMessage: (cmdPrefix?: string) => MatrixMessageContent, botCommands: BotCommands} { export function compileBotCommands(...prototypes: Record<string, BotCommandFunction>[]): {helpMessage: (cmdPrefix?: string) => MatrixMessageContent, botCommands: BotCommands} {
let content = "Commands:\n"; let content = "Commands:\n";
const botCommands: BotCommands = {}; const botCommands: BotCommands = {};
Object.getOwnPropertyNames(prototype).forEach(propetyKey => { prototypes.forEach(prototype => {
const b = Reflect.getMetadata(botCommandSymbol, prototype, propetyKey); Object.getOwnPropertyNames(prototype).forEach(propetyKey => {
if (b) { const b = Reflect.getMetadata(botCommandSymbol, prototype, propetyKey);
const requiredArgs = b.requiredArgs.join(" "); if (b) {
const optionalArgs = b.optionalArgs.map((arg: string) => `[${arg}]`).join(" "); const requiredArgs = b.requiredArgs.join(" ");
content += ` - \`££PREFIX££${b.prefix}\` ${requiredArgs} ${optionalArgs} - ${b.help}\n`; const optionalArgs = b.optionalArgs.map((arg: string) => `[${arg}]`).join(" ");
// We know that this is safe. content += ` - \`££PREFIX££${b.prefix}\` ${requiredArgs} ${optionalArgs} - ${b.help}\n`;
botCommands[b.prefix as string] = { // We know that this is safe.
fn: prototype[propetyKey], botCommands[b.prefix as string] = {
requiredArgs: b.requiredArgs, fn: prototype[propetyKey],
optionalArgs: b.optionalArgs, requiredArgs: b.requiredArgs,
includeUserId: b.includeUserId, optionalArgs: b.optionalArgs,
}; includeUserId: b.includeUserId,
} };
}); }
});
})
return { return {
helpMessage: (cmdPrefix?: string) => ({ helpMessage: (cmdPrefix?: string) => ({
msgtype: "m.notice", msgtype: "m.notice",

View File

@ -1,4 +1,4 @@
import { AdminRoom, BRIDGE_ROOM_TYPE, AdminAccountData } from "./AdminRoom"; import { AdminRoom, BRIDGE_ROOM_TYPE, LEGACY_BRIDGE_ROOM_TYPE } from "./AdminRoom";
import { Appservice, IAppserviceRegistration, RichRepliesPreprocessor, IRichReplyMetadata, StateEvent, PantalaimonClient, MatrixClient } from "matrix-bot-sdk"; import { Appservice, IAppserviceRegistration, RichRepliesPreprocessor, IRichReplyMetadata, StateEvent, PantalaimonClient, MatrixClient } from "matrix-bot-sdk";
import { BridgeConfig, GitLabInstance } from "./Config/Config"; import { BridgeConfig, GitLabInstance } from "./Config/Config";
import { BridgeWidgetApi } from "./Widgets/BridgeWidgetApi"; import { BridgeWidgetApi } from "./Widgets/BridgeWidgetApi";
@ -13,14 +13,14 @@ import { GitLabIssueConnection } from "./Connections/GitlabIssue";
import { IBridgeStorageProvider } from "./Stores/StorageProvider"; import { IBridgeStorageProvider } from "./Stores/StorageProvider";
import { IConnection, GitHubDiscussionSpace, GitHubDiscussionConnection, GitHubUserSpace } from "./Connections"; import { IConnection, GitHubDiscussionSpace, GitHubDiscussionConnection, GitHubUserSpace } from "./Connections";
import { IGitLabWebhookIssueStateEvent, IGitLabWebhookMREvent, IGitLabWebhookNoteEvent } from "./Gitlab/WebhookTypes"; import { IGitLabWebhookIssueStateEvent, IGitLabWebhookMREvent, IGitLabWebhookNoteEvent } from "./Gitlab/WebhookTypes";
import { JiraIssueEvent } from "./Jira/WebhookTypes"; import { JiraIssueEvent, JiraIssueUpdatedEvent } from "./Jira/WebhookTypes";
import { MatrixEvent, MatrixMemberContent, MatrixMessageContent } from "./MatrixEvent"; import { MatrixEvent, MatrixMemberContent, MatrixMessageContent } from "./MatrixEvent";
import { MemoryStorageProvider } from "./Stores/MemoryStorageProvider"; import { MemoryStorageProvider } from "./Stores/MemoryStorageProvider";
import { MessageQueue, createMessageQueue } from "./MessageQueue/MessageQueue"; import { MessageQueue, createMessageQueue } from "./MessageQueue/MessageQueue";
import { MessageSenderClient } from "./MatrixSender"; import { MessageSenderClient } from "./MatrixSender";
import { NotifFilter, NotificationFilterStateContent } from "./NotificationFilters"; import { NotifFilter, NotificationFilterStateContent } from "./NotificationFilters";
import { NotificationProcessor } from "./NotificationsProcessor"; import { NotificationProcessor } from "./NotificationsProcessor";
import { OAuthRequest, OAuthTokens, NotificationsEnableEvent, NotificationsDisableEvent, GenericWebhookEvent,} from "./Webhooks"; import { OAuthRequest, GitHubOAuthTokens, NotificationsEnableEvent, NotificationsDisableEvent, GenericWebhookEvent,} from "./Webhooks";
import { ProjectsGetResponseData } from "./Github/Types"; import { ProjectsGetResponseData } from "./Github/Types";
import { RedisStorageProvider } from "./Stores/RedisStorageProvider"; import { RedisStorageProvider } from "./Stores/RedisStorageProvider";
import { retry } from "./PromiseUtil"; import { retry } from "./PromiseUtil";
@ -28,6 +28,8 @@ import { UserNotificationsEvent } from "./Notifications/UserNotificationWatcher"
import { UserTokenStore } from "./UserTokenStore"; import { UserTokenStore } from "./UserTokenStore";
import * as GitHubWebhookTypes from "@octokit/webhooks-types"; import * as GitHubWebhookTypes from "@octokit/webhooks-types";
import LogWrapper from "./LogWrapper"; import LogWrapper from "./LogWrapper";
import { JiraOAuthResult } from "./Jira/Types";
import { AdminAccountData } from "./AdminRoomCommandHandler";
const log = new LogWrapper("Bridge"); const log = new LogWrapper("Bridge");
export class Bridge { export class Bridge {
@ -66,7 +68,7 @@ export class Bridge {
this.messageClient = new MessageSenderClient(this.queue); this.messageClient = new MessageSenderClient(this.queue);
this.commentProcessor = new CommentProcessor(this.as, this.config.bridge.mediaUrl || this.config.bridge.url); this.commentProcessor = new CommentProcessor(this.as, this.config.bridge.mediaUrl || this.config.bridge.url);
this.notifProcessor = new NotificationProcessor(this.storage, this.messageClient); this.notifProcessor = new NotificationProcessor(this.storage, this.messageClient);
this.tokenStore = new UserTokenStore(this.config.passFile || "./passkey.pem", this.as.botIntent); this.tokenStore = new UserTokenStore(this.config.passFile || "./passkey.pem", this.as.botIntent, this.config);
} }
public stop() { public stop() {
@ -142,6 +144,7 @@ export class Bridge {
this.queue.subscribe("notifications.user.events"); this.queue.subscribe("notifications.user.events");
this.queue.subscribe("github.*"); this.queue.subscribe("github.*");
this.queue.subscribe("gitlab.*"); this.queue.subscribe("gitlab.*");
this.queue.subscribe("jira.*");
const validateRepoIssue = (data: GitHubWebhookTypes.IssuesEvent|GitHubWebhookTypes.IssueCommentEvent) => { const validateRepoIssue = (data: GitHubWebhookTypes.IssuesEvent|GitHubWebhookTypes.IssueCommentEvent) => {
if (!data.repository || !data.issue) { if (!data.repository || !data.issue) {
@ -331,7 +334,7 @@ export class Bridge {
}); });
}); });
this.queue.on<OAuthTokens>("oauth.tokens", async (msg) => { this.queue.on<GitHubOAuthTokens>("oauth.tokens", async (msg) => {
const adminRoom = [...this.adminRooms.values()].find((r) => r.oauthState === msg.data.state); const adminRoom = [...this.adminRooms.values()].find((r) => r.oauthState === msg.data.state);
if (!adminRoom) { if (!adminRoom) {
log.warn("Could not find admin room for successful tokens request. This shouldn't happen!"); log.warn("Could not find admin room for successful tokens request. This shouldn't happen!");
@ -428,8 +431,7 @@ export class Bridge {
this.queue.on<JiraIssueEvent>("jira.issue_created", async ({data}) => { this.queue.on<JiraIssueEvent>("jira.issue_created", async ({data}) => {
log.info(`JIRA issue created for project ${data.issue.fields.project.id}, issue id ${data.issue.id}`); log.info(`JIRA issue created for project ${data.issue.fields.project.id}, issue id ${data.issue.id}`);
const projectId = data.issue.fields.project.id; const connections = connManager.getConnectionsForJiraProject(data.issue.fields.project, "jira.issue_created");
const connections = connManager.getConnectionsForJiraProject(projectId, "jira.issue_created");
connections.forEach(async (c) => { connections.forEach(async (c) => {
try { try {
@ -439,7 +441,42 @@ export class Bridge {
} }
}); });
}); });
this.queue.on<JiraIssueEvent>("jira.issue_updated", async ({data}) => {
log.info(`JIRA issue created for project ${data.issue.fields.project.id}, issue id ${data.issue.id}`);
const connections = connManager.getConnectionsForJiraProject(data.issue.fields.project, "jira.issue_updated");
connections.forEach(async (c) => {
try {
await c.onJiraIssueUpdated(data as JiraIssueUpdatedEvent);
} catch (ex) {
log.warn(`Failed to handle jira.issue_updated:`, ex);
}
});
});
this.queue.on<OAuthRequest>("jira.oauth.response", async (msg) => {
const adminRoom = [...this.adminRooms.values()].find((r) => r.jiraOAuthState === msg.data.state);
await this.queue.push<boolean>({
data: !!(adminRoom),
sender: "Bridge",
messageId: msg.messageId,
eventName: "response.jira.oauth.response",
});
});
this.queue.on<JiraOAuthResult>("jira.oauth.tokens", async (msg) => {
const adminRoom = [...this.adminRooms.values()].find((r) => r.jiraOAuthState === msg.data.state);
if (!adminRoom) {
log.warn("Could not find admin room for successful tokens request. This shouldn't happen!");
return;
}
adminRoom.clearJiraOauthState();
await this.tokenStore.storeUserToken("jira", adminRoom.userId, JSON.stringify(msg.data));
await adminRoom.sendNotice(`Logged into Jira`);
});
this.queue.on<GenericWebhookEvent>("generic-webhook.event", async ({data}) => { this.queue.on<GenericWebhookEvent>("generic-webhook.event", async ({data}) => {
log.info(`Incoming generic hook ${data.hookId}`); log.info(`Incoming generic hook ${data.hookId}`);
const connections = connManager.getConnectionsForGenericWebhook(data.hookId); const connections = connManager.getConnectionsForGenericWebhook(data.hookId);
@ -504,12 +541,20 @@ export class Bridge {
// TODO: Refactor this to be a connection // TODO: Refactor this to be a connection
try { try {
const accountData = await this.as.botIntent.underlyingClient.getSafeRoomAccountData<AdminAccountData>( let accountData = await this.as.botIntent.underlyingClient.getSafeRoomAccountData<AdminAccountData>(
BRIDGE_ROOM_TYPE, roomId, BRIDGE_ROOM_TYPE, roomId,
); );
if (!accountData) { if (!accountData) {
log.debug(`Room ${roomId} has no connections and is not an admin room`); accountData = await this.as.botIntent.underlyingClient.getSafeRoomAccountData<AdminAccountData>(
continue; LEGACY_BRIDGE_ROOM_TYPE, roomId,
);
if (!accountData) {
log.debug(`Room ${roomId} has no connections and is not an admin room`);
continue;
} else {
// Upgrade the room
await this.as.botClient.setRoomAccountData(BRIDGE_ROOM_TYPE, roomId, accountData);
}
} }
let notifContent; let notifContent;

View File

@ -97,18 +97,19 @@ interface BridgeConfigBot {
avatar?: string; avatar?: string;
} }
interface BridgeConfigRoot { interface BridgeConfigRoot {
bot?: BridgeConfigBot;
bridge: BridgeConfigBridge; bridge: BridgeConfigBridge;
webhook: BridgeConfigWebhook; generic?: BridgeGenericWebhooksConfig;
queue: BridgeConfigQueue;
logging: BridgeConfigLogging;
passFile: string;
github?: BridgeConfigGitHub; github?: BridgeConfigGitHub;
gitlab?: BridgeConfigGitLab; gitlab?: BridgeConfigGitLab;
jira?: BridgeConfigJira; jira?: BridgeConfigJira;
bot?: BridgeConfigBot; logging: BridgeConfigLogging;
passFile: string;
queue: BridgeConfigQueue;
webhook: BridgeConfigWebhook;
widgets?: BridgeWidgetConfig; widgets?: BridgeWidgetConfig;
generic?: BridgeGenericWebhooksConfig;
} }
export class BridgeConfig { export class BridgeConfig {

View File

@ -62,7 +62,12 @@ const DefaultConfig = new BridgeConfig({
jira: { jira: {
webhook: { webhook: {
secret: 'secrettoken' secret: 'secrettoken'
} },
oauth: {
client_id: "foo",
client_secret: "bar",
redirect_uri: "https://example.com/bridge_oauth/",
},
}, },
generic: { generic: {
enabled: false, enabled: false,

View File

@ -12,6 +12,7 @@ import { GenericHookConnection } from "./Connections/GenericHook";
import { JiraProjectConnection } from "./Connections/JiraProject"; import { JiraProjectConnection } from "./Connections/JiraProject";
import { GithubInstance } from "./Github/GithubInstance"; import { GithubInstance } from "./Github/GithubInstance";
import { GitLabClient } from "./Gitlab/Client"; import { GitLabClient } from "./Gitlab/Client";
import { JiraProject } from "./Jira/Types";
import LogWrapper from "./LogWrapper"; import LogWrapper from "./LogWrapper";
import { MessageSenderClient } from "./MatrixSender"; import { MessageSenderClient } from "./MatrixSender";
import { UserTokenStore } from "./UserTokenStore"; import { UserTokenStore } from "./UserTokenStore";
@ -41,7 +42,8 @@ export class ConnectionManager {
public push(...connections: IConnection[]) { public push(...connections: IConnection[]) {
// NOTE: Double loop // NOTE: Double loop
for (const connection of connections) { for (const connection of connections) {
if (this.connections.find((c) => c !== connection)) { if (!this.connections.find((c) => c === connection)) {
console.log("PUSH!");
this.connections.push(connection); this.connections.push(connection);
} }
} }
@ -49,8 +51,8 @@ export class ConnectionManager {
} }
public async createConnectionForState(roomId: string, state: StateEvent<any>) { public async createConnectionForState(roomId: string, state: StateEvent<any>) {
log.debug(`Looking to create connection for ${roomId}`); log.debug(`Looking to create connection for ${roomId} ${state.type}`);
if (state.content.disabled === false) { if (state.content.disabled === true) {
log.debug(`${roomId} has disabled state for ${state.type}`); log.debug(`${roomId} has disabled state for ${state.type}`);
return; return;
} }
@ -128,10 +130,11 @@ export class ConnectionManager {
} }
if (JiraProjectConnection.EventTypes.includes(state.type)) { if (JiraProjectConnection.EventTypes.includes(state.type)) {
console.log("WOOF", state);
if (!this.config.jira) { if (!this.config.jira) {
throw Error('JIRA is not configured'); throw Error('JIRA is not configured');
} }
return new JiraProjectConnection(roomId, this.as, state.content, state.stateKey, this.commentProcessor, this.messageClient); return new JiraProjectConnection(roomId, this.as, state.content, state.stateKey, this.commentProcessor, this.messageClient, this.tokenStore);
} }
if (GenericHookConnection.EventTypes.includes(state.type) && this.config.generic?.enabled) { if (GenericHookConnection.EventTypes.includes(state.type) && this.config.generic?.enabled) {
@ -227,10 +230,15 @@ export class ConnectionManager {
return this.connections.filter((c) => (c instanceof GitLabRepoConnection && c.path === pathWithNamespace)) as GitLabRepoConnection[]; return this.connections.filter((c) => (c instanceof GitLabRepoConnection && c.path === pathWithNamespace)) as GitLabRepoConnection[];
} }
public getConnectionsForJiraProject(projectId: string, eventName: string): JiraProjectConnection[] { public getConnectionsForJiraProject(project: JiraProject, eventName: string): JiraProjectConnection[] {
return this.connections.filter((c) => (c instanceof JiraProjectConnection && c.projectId === projectId && c.isInterestedInHookEvent(eventName))) as JiraProjectConnection[]; console.log(this.connections);
return this.connections.filter((c) =>
(c instanceof JiraProjectConnection &&
c.interestedInProject(project) &&
c.isInterestedInHookEvent(eventName))) as JiraProjectConnection[];
} }
public getConnectionsForGenericWebhook(hookId: string): GenericHookConnection[] { public getConnectionsForGenericWebhook(hookId: string): GenericHookConnection[] {
return this.connections.filter((c) => (c instanceof GenericHookConnection && c.hookId === hookId)) as GenericHookConnection[]; return this.connections.filter((c) => (c instanceof GenericHookConnection && c.hookId === hookId)) as GenericHookConnection[];
} }

View File

@ -0,0 +1,54 @@
import { botCommand, BotCommands, handleCommand } from "../BotCommands";
import LogWrapper from "../LogWrapper";
import { MatrixClient } from "matrix-bot-sdk";
import { MatrixMessageContent, MatrixEvent } from "../MatrixEvent";
const log = new LogWrapper("CommandConnection");
export abstract class CommandConnection {
constructor(
public readonly roomId: string,
private readonly botClient: MatrixClient,
private readonly botCommands: BotCommands,
private readonly helpMessage: (prefix: string) => MatrixMessageContent,
protected readonly stateCommandPrefix: string,
) { }
protected get commandPrefix() {
return this.stateCommandPrefix + " ";
}
public async onMessageEvent(ev: MatrixEvent<MatrixMessageContent>) {
const { error, handled, humanError } = await handleCommand(ev.sender, ev.content.body, this.botCommands, this, this.commandPrefix);
if (!handled) {
// Not for us.
return;
}
if (error) {
await this.botClient.sendEvent(this.roomId, "m.reaction", {
"m.relates_to": {
rel_type: "m.annotation",
event_id: ev.event_id,
key: "⛔",
}
});
await this.botClient.sendEvent(this.roomId, 'm.room.message', {
msgtype: "m.notice",
body: humanError ? `Failed to handle command: ${humanError}` : "Failed to handle command",
});
log.warn(`Failed to handle command:`, error);
return;
}
await this.botClient.sendEvent(this.roomId, "m.reaction", {
"m.relates_to": {
rel_type: "m.annotation",
event_id: ev.event_id,
key: "✅",
}
});
}
@botCommand("help", "This help text")
public async helpCommand() {
return this.botClient.sendEvent(this.roomId, 'm.room.message', this.helpMessage(this.commandPrefix));
}
}

View File

@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */ /* eslint-disable @typescript-eslint/ban-ts-comment */
import { Appservice } from "matrix-bot-sdk"; import { Appservice } from "matrix-bot-sdk";
import { BotCommands, handleCommand, botCommand, compileBotCommands } from "../BotCommands"; import { BotCommands, botCommand, compileBotCommands } from "../BotCommands";
import { CommentProcessor } from "../CommentProcessor"; import { CommentProcessor } from "../CommentProcessor";
import { FormatUtil } from "../FormatUtil"; import { FormatUtil } from "../FormatUtil";
import { IConnection } from "./IConnection"; import { IConnection } from "./IConnection";
@ -15,6 +15,7 @@ import axios from "axios";
import emoji from "node-emoji"; import emoji from "node-emoji";
import LogWrapper from "../LogWrapper"; import LogWrapper from "../LogWrapper";
import markdown from "markdown-it"; import markdown from "markdown-it";
import { CommandConnection } from "./CommandConnection";
const log = new LogWrapper("GitHubRepoConnection"); const log = new LogWrapper("GitHubRepoConnection");
const md = new markdown(); const md = new markdown();
@ -57,7 +58,7 @@ function compareEmojiStrings(e0: string, e1: string, e0Index = 0) {
/** /**
* Handles rooms connected to a github repo. * Handles rooms connected to a github repo.
*/ */
export class GitHubRepoConnection implements IConnection { export class GitHubRepoConnection extends CommandConnection implements IConnection {
static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.github.repository"; static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.github.repository";
static readonly LegacyCanonicalEventType = "uk.half-shot.matrix-github.repository"; static readonly LegacyCanonicalEventType = "uk.half-shot.matrix-github.repository";
@ -145,12 +146,18 @@ export class GitHubRepoConnection implements IConnection {
static helpMessage: (cmdPrefix: string) => MatrixMessageContent; static helpMessage: (cmdPrefix: string) => MatrixMessageContent;
static botCommands: BotCommands; static botCommands: BotCommands;
constructor(public readonly roomId: string, constructor(roomId: string,
private readonly as: Appservice, private readonly as: Appservice,
private readonly state: GitHubRepoConnectionState, private readonly state: GitHubRepoConnectionState,
private readonly tokenStore: UserTokenStore, private readonly tokenStore: UserTokenStore,
private readonly stateKey: string) { private readonly stateKey: string) {
super(
roomId,
as.botClient,
GitHubRepoConnection.botCommands,
GitHubRepoConnection.helpMessage,
state.commandPrefix || "!gh"
);
} }
public get org() { public get org() {
@ -161,49 +168,11 @@ export class GitHubRepoConnection implements IConnection {
return this.state.repo.toLowerCase(); return this.state.repo.toLowerCase();
} }
private get commandPrefix() {
return (this.state.commandPrefix || "!gh") + " ";
}
public isInterestedInStateEvent(eventType: string, stateKey: string) { public isInterestedInStateEvent(eventType: string, stateKey: string) {
return GitHubRepoConnection.EventTypes.includes(eventType) && this.stateKey === stateKey; return GitHubRepoConnection.EventTypes.includes(eventType) && this.stateKey === stateKey;
} }
public async onMessageEvent(ev: MatrixEvent<MatrixMessageContent>) {
const { error, handled, humanError } = await handleCommand(ev.sender, ev.content.body, GitHubRepoConnection.botCommands, this, this.commandPrefix);
if (!handled) {
// Not for us.
return;
}
if (error) {
await this.as.botClient.sendEvent(this.roomId, "m.reaction", {
"m.relates_to": {
rel_type: "m.annotation",
event_id: ev.event_id,
key: "⛔",
}
});
await this.as.botIntent.sendEvent(this.roomId,{
msgtype: "m.notice",
body: humanError ? `Failed to handle command: ${humanError}` : "Failed to handle command",
});
log.warn(`Failed to handle command:`, error);
return;
}
await this.as.botClient.sendEvent(this.roomId, "m.reaction", {
"m.relates_to": {
rel_type: "m.annotation",
event_id: ev.event_id,
key: "✅",
}
});
}
@botCommand("help", "This help text")
public async helpCommand() {
return this.as.botIntent.sendEvent(this.roomId, GitHubRepoConnection.helpMessage(this.commandPrefix));
}
@botCommand("create", "Create an issue for this repo", ["title"], ["description", "labels"], true) @botCommand("create", "Create an issue for this repo", ["title"], ["description", "labels"], true)
// @ts-ignore // @ts-ignore
private async onCreateIssue(userId: string, title: string, description?: string, labels?: string) { private async onCreateIssue(userId: string, title: string, description?: string, labels?: string) {
@ -539,6 +508,6 @@ ${event.release.body}`;
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const res = compileBotCommands(GitHubRepoConnection.prototype as any); const res = compileBotCommands(GitHubRepoConnection.prototype as any, CommandConnection.prototype as any);
GitHubRepoConnection.helpMessage = res.helpMessage; GitHubRepoConnection.helpMessage = res.helpMessage;
GitHubRepoConnection.botCommands = res.botCommands; GitHubRepoConnection.botCommands = res.botCommands;

View File

@ -3,16 +3,26 @@ import { Appservice } from "matrix-bot-sdk";
import LogWrapper from "../LogWrapper"; import LogWrapper from "../LogWrapper";
import { CommentProcessor } from "../CommentProcessor"; import { CommentProcessor } from "../CommentProcessor";
import { MessageSenderClient } from "../MatrixSender" import { MessageSenderClient } from "../MatrixSender"
import { JiraIssueEvent } from "../Jira/WebhookTypes"; import { JiraIssueEvent, JiraIssueUpdatedEvent } from "../Jira/WebhookTypes";
import { FormatUtil } from "../FormatUtil"; import { FormatUtil } from "../FormatUtil";
import markdownit from "markdown-it"; import markdownit from "markdown-it";
import { generateJiraWebLinkFromIssue } from "../Jira"; import { generateJiraWebLinkFromIssue } from "../Jira";
import { JiraIssue, JiraProject } from "../Jira/Types";
import { botCommand, BotCommands, compileBotCommands } from "../BotCommands";
import { MatrixMessageContent } from "../MatrixEvent";
import { CommandConnection } from "./CommandConnection";
import { start } from "repl";
import { UserTokenStore } from "../UserTokenStore";
import { CommandError, NotLoggedInError } from "../errors";
type JiraAllowedEventsNames = "issue.created"; type JiraAllowedEventsNames = "issue.created";
const JiraAllowedEvents: JiraAllowedEventsNames[] = ["issue.created"]; const JiraAllowedEvents: JiraAllowedEventsNames[] = ["issue.created"];
export interface JiraProjectConnectionState { export interface JiraProjectConnectionState {
id: string; // legacy field, prefer url
id?: string;
url?: string;
events?: JiraAllowedEventsNames[], events?: JiraAllowedEventsNames[],
commandPrefix?: string;
} }
const log = new LogWrapper("JiraProjectConnection"); const log = new LogWrapper("JiraProjectConnection");
@ -21,7 +31,9 @@ const md = new markdownit();
/** /**
* Handles rooms connected to a github repo. * Handles rooms connected to a github repo.
*/ */
export class JiraProjectConnection implements IConnection { export class JiraProjectConnection extends CommandConnection implements IConnection {
static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.jira.project"; static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.jira.project";
static readonly LegacyCanonicalEventType = "uk.half-shot.matrix-github.jira.project"; static readonly LegacyCanonicalEventType = "uk.half-shot.matrix-github.jira.project";
@ -29,6 +41,8 @@ export class JiraProjectConnection implements IConnection {
JiraProjectConnection.CanonicalEventType, JiraProjectConnection.CanonicalEventType,
JiraProjectConnection.LegacyCanonicalEventType, JiraProjectConnection.LegacyCanonicalEventType,
]; ];
static botCommands: BotCommands;
static helpMessage: (cmdPrefix?: string) => MatrixMessageContent;
static getTopicString(authorName: string, state: string) { static getTopicString(authorName: string, state: string) {
`Author: ${authorName} | State: ${state === "closed" ? "closed" : "open"}` `Author: ${authorName} | State: ${state === "closed" ? "closed" : "open"}`
@ -38,16 +52,58 @@ export class JiraProjectConnection implements IConnection {
return this.state.id; return this.state.id;
} }
public get instanceOrigin() {
return this.projectUrl?.origin;
}
public get projectKey() {
const parts = this.projectUrl?.pathname.split('/');
return parts ? parts[parts.length - 1] : undefined;
}
public isInterestedInHookEvent(eventName: string) { public isInterestedInHookEvent(eventName: string) {
return !this.state.events || this.state.events?.includes(eventName as JiraAllowedEventsNames); return !this.state.events || this.state.events?.includes(eventName as JiraAllowedEventsNames);
} }
constructor(public readonly roomId: string, public interestedInProject(project: JiraProject) {
if (this.projectId === project.id) {
return true;
}
if (this.instanceOrigin) {
const url = new URL(project.self);
return this.instanceOrigin === url.origin && this.projectKey === project.key;
}
return false;
}
/**
* The URL of the project
* @example https://test.atlassian.net/jira/software/c/projects/PLAY
*/
private projectUrl?: URL;
constructor(roomId: string,
private readonly as: Appservice, private readonly as: Appservice,
private state: JiraProjectConnectionState, private state: JiraProjectConnectionState,
private readonly stateKey: string, private readonly stateKey: string,
private commentProcessor: CommentProcessor, private readonly commentProcessor: CommentProcessor,
private messageClient: MessageSenderClient,) { private readonly messageClient: MessageSenderClient,
private readonly tokenStore: UserTokenStore,) {
super(
roomId,
as.botClient,
JiraProjectConnection.botCommands,
JiraProjectConnection.helpMessage,
state.commandPrefix || "!jira"
);
if (state.url) {
this.projectUrl = new URL(state.url);
} else if (state.id) {
log.warn(`Legacy ID option in use, needs to be switched to 'url'`);
} else {
throw Error('State is missing both id and url, cannot create connection');
}
} }
public isInterestedInStateEvent(eventType: string, stateKey: string) { public isInterestedInStateEvent(eventType: string, stateKey: string) {
@ -72,7 +128,155 @@ export class JiraProjectConnection implements IConnection {
}); });
} }
public toString() { public async onJiraIssueUpdated(data: JiraIssueUpdatedEvent) {
return `JiraProjectConnection ${this.projectId}`; log.info(`onJiraIssueUpdated ${this.roomId} ${this.projectId} ${data.issue.id}`);
const url = generateJiraWebLinkFromIssue(data.issue);
let content = `${data.user.displayName} updated JIRA [${data.issue.key}](${url}): `;
const changes = data.changelog.items.map((change) => `**${change.field}** changed from '${change.fromString || "not set"}' to '${change.toString || "not set"}'`);
if (changes.length < 0) {
// Empty changeset?
log.warn(`Empty changeset, not sending message`);
return;
} else if (changes.length === 1) {
content += changes[0];
} else {
content += `\n - ` + changes.join(`\n - `);
}
await this.as.botIntent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content,
formatted_body: md.renderInline(content),
format: "org.matrix.custom.html",
...FormatUtil.getPartialBodyForJiraIssue(data.issue)
});
} }
}
private async getUserClientForProject(userId: string) {
const jiraClient = await this.tokenStore.getJiraForUser(userId);
if (!jiraClient) {
throw new NotLoggedInError();
}
const resource = (await jiraClient.getAccessibleResources()).find((r) => new URL(r.url).origin === this.instanceOrigin);
if (!resource) {
throw new CommandError("No-resource", "You do not have permission to create issues for this JIRA org");
}
return jiraClient.getClientForResource(resource);
}
@botCommand("create", "Create an issue for this project", ["type", "title"], ["description", "labels"], true)
public async onCreateIssue(userId: string, type: string, title: string, description?: string, labels?: string) {
const api = await this.getUserClientForProject(userId);
const keyOrId = this.projectKey || this.projectId;
if (!keyOrId) {
throw Error('Neither Key or ID are specified');
}
const project = await api.getProject(keyOrId);
if (!project.issueTypes || project.issueTypes.length === 0) {
throw new CommandError("project has no issue types", "Cannot create issue, project has no issue types");
}
const issueTypeId = project.issueTypes.find((issueType) => issueType.name.toLowerCase() === type.toLowerCase())?.id;
if (!issueTypeId) {
const content = project.issueTypes.map((t) => t.name).join(', ');
throw new CommandError("invalid-issuetype", `You must specify a valid issue type (one of ${content}). E.g. ${this.commandPrefix} create ${project.issueTypes[0].name}`);
}
log.info(`Creating new issue on behalf of ${userId}`);
let result: any;
try {
result = await api.addNewIssue({
//update: {},
fields: {
"summary": title,
"project": {
"key": this.projectKey,
},
"issuetype": {
id: issueTypeId,
},
...( description ? {"description": {
"type": "doc",
"version": 1,
"content": [
{
"type": "paragraph",
"content": [
{
"text": description,
"type": "text"
}
]
}
]
}} : undefined),
...( labels ? {"labels": labels.split(",")} : undefined),
}
})
} catch (ex) {
log.warn("Failed to create JIRA issue:", ex);
throw new CommandError(ex.message, "Failed to create JIRA issue");
}
const link = generateJiraWebLinkFromIssue({self: this.projectUrl?.toString() || result.self, key: result.key as string});
const content = `Created JIRA issue ${result.key}: [${link}](${link})`;
return this.as.botIntent.sendEvent(this.roomId,{
msgtype: "m.notice",
body: content,
formatted_body: md.render(content),
format: "org.matrix.custom.html"
});
}
@botCommand("issue-types", "Get issue types for this project", [], [], true)
public async getIssueTypes(userId: string) {
const api = await this.getUserClientForProject(userId);
log.info(`Creating new issue on behalf of ${userId}`);
let result: JiraProject;
try {
const keyOrId = this.projectKey || this.projectId;
if (!keyOrId) {
throw Error('Neither Key or ID are specified');
}
result = await api.getProject(keyOrId);
} catch (ex) {
log.warn("Failed to get issue types:", ex);
throw new CommandError(ex.message, "Failed to create JIRA issue");
}
const content = `Issue types: ${(result.issueTypes || []).map((t) => t.name).join(', ')}`;
return this.as.botIntent.sendEvent(this.roomId,{
msgtype: "m.notice",
body: content,
formatted_body: md.render(content),
format: "org.matrix.custom.html"
});
}
@botCommand("assign", "Assign an issue to a user", ["issueKey", "user"], [], true)
public async onAssignIssue(userId: string, issueKey: string, user: string) {
const api = await this.getUserClientForProject(userId);
try {
await api.getIssue(issueKey);
} catch (ex) {
log.warn(`Failed to find issue`, ex);
throw new CommandError(ex.message, "Failed to find issue");
}
log.info(`Assinging issue on behalf of ${userId}`);
const searchForUser = await api.searchUsers({query: user, maxResults: 1, includeInactive: false, includeActive: true, username: ""});
if (searchForUser.length === 0) {
throw new CommandError("not-found", `Could not find a user matching '${user}'`);
}
await api.updateAssigneeWithId(issueKey, searchForUser[0].accountId);
}
public toString() {
return `JiraProjectConnection ${this.projectId || this.projectUrl}`;
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const res = compileBotCommands(JiraProjectConnection.prototype as any, CommandConnection.prototype as any);
JiraProjectConnection.helpMessage = res.helpMessage;
JiraProjectConnection.botCommands = res.botCommands;

72
src/Jira/AdminCommands.ts Normal file
View File

@ -0,0 +1,72 @@
import { AdminRoomCommandHandler } from "../AdminRoomCommandHandler";
import { botCommand } from "../BotCommands";
import qs from "querystring";
import {v4 as uuid} from "uuid";
import { JiraAPIAccessibleResource } from "./Types";
import LogWrapper from "../LogWrapper";
const log = new LogWrapper('JiraBotCommands');
const JiraOAuthScopes = [
// Reading issues, comments
"read:jira-work",
// Creating issues, comments
"write:jira-work",
// Reading user
"read:jira-user",
"read:me",
"read:account",
// To get a refresh token
"offline_access",
];
export class JiraBotCommands extends AdminRoomCommandHandler {
@botCommand("jira login", "Login to JIRA")
public async loginCommand() {
if (!this.config.jira?.oauth) {
this.sendNotice(`Bot is not configured with JIRA OAuth support`);
return;
}
this.pendingJiraOAuthState = uuid();
const options = {
audience: "api.atlassian.com",
client_id: this.config.jira.oauth.client_id,
scope: JiraOAuthScopes.join(" "),
redirect_uri: this.config.jira.oauth.redirect_uri,
state: this.pendingJiraOAuthState,
response_type: "code",
prompt: "consent",
};
const url = `https://auth.atlassian.com/authorize?${qs.stringify(options)}`;
await this.sendNotice(`To login, open ${url} and follow the steps`);
}
@botCommand("jira whoami", "Determine JIRA identity")
public async whoami() {
if (!this.config.jira) {
await this.sendNotice(`Bot is not configured with JIRA OAuth support`);
return;
}
const client = await this.tokenStore.getJiraForUser(this.userId);
if (!client) {
await this.sendNotice(`You are not logged into JIRA`);
return;
}
// Get all resources for user
let resources: JiraAPIAccessibleResource[];
try {
resources = await client.getAccessibleResources();
} catch (ex) {
log.warn(`Could not request resources from JIRA API: `, ex);
await this.sendNotice(`Could not request JIRA resources due to an error`);
return;
}
let response = resources.length === 0 ? `You do not have any instances authorised with this bot` : 'You have access to the following instances:';
for (const resource of resources) {
const user = await (await client.getClientForResource(resource)).getCurrentUser();
response += `\n - ${resource.name} ${user.name} (${user.displayName || ""})`;
}
await this.sendNotice(response);
}
}

113
src/Jira/Client.ts Normal file
View File

@ -0,0 +1,113 @@
import axios from 'axios';
import JiraApi, { SearchUserOptions } from 'jira-client';
import QuickLRU from "@alloc/quick-lru";
import { JiraAccount, JiraAPIAccessibleResource, JiraIssue, JiraOAuthResult, JiraProject } from './Types';
import { BridgeConfigJira } from '../Config/Config';
const ACCESSIBLE_RESOURCE_CACHE_LIMIT = 100;
const ACCESSIBLE_RESOURCE_CACHE_TTL_MS = 60000;
export class HookshotJiraApi extends JiraApi {
constructor(private options: JiraApi.JiraApiOptions) {
super(options);
}
async getProject(projectIdOrKey: string) {
return await super.getProject(projectIdOrKey) as JiraProject;
}
async getIssue(issueIdOrKey: string) {
const res = await axios.get<JiraIssue>(`https://api.atlassian.com/${this.options.base}/rest/api/3/issue/${issueIdOrKey}`, {
headers: {
Authorization: `Bearer ${this.options.bearer}`
},
responseType: 'json',
});
return res.data;
}
async searchUsers(opts: SearchUserOptions): Promise<JiraAccount[]> {
return super.searchUsers(opts) as unknown as JiraAccount[];
}
}
export class JiraClient {
/**
* Cache of accessible resources for a user.
*/
static resourceCache = new QuickLRU<string, Promise<JiraAPIAccessibleResource[]>>({
maxSize: ACCESSIBLE_RESOURCE_CACHE_LIMIT,
maxAge: ACCESSIBLE_RESOURCE_CACHE_TTL_MS
});
constructor(private oauth2State: JiraOAuthResult, private readonly onTokenRefreshed: (newData: JiraOAuthResult) => Promise<void>, private readonly config: BridgeConfigJira) {
}
private get bearer() {
return this.oauth2State.access_token;
}
async getAccessibleResources() {
try {
const existingPromise = JiraClient.resourceCache.get(this.bearer);
if (existingPromise) {
return await existingPromise;
}
} catch (ex) {
// Existing failed promise, break out and try again.
JiraClient.resourceCache.delete(this.bearer);
}
const promise = (async () => {
const res = await axios.get(`https://api.atlassian.com/oauth/token/accessible-resources`, {
headers: {
Authorization: `Bearer ${this.bearer}`
},
responseType: 'json',
});
return res.data as JiraAPIAccessibleResource[];
})();
JiraClient.resourceCache.set(this.bearer, promise);
return promise;
}
async checkTokenAge() {
if (this.oauth2State.expires_in + 60000 > Date.now()) {
return;
}
// Refresh the token
const res = await axios.post<unknown, JiraOAuthResult>(`https://api.atlassian.com/oauth/token`, {
grant_type: "refresh_token",
client_id: this.config.oauth.client_id,
client_secret: this.config.oauth.client_secret,
refresh_token: this.oauth2State.refresh_token,
});
res.expires_in += Date.now() + (res.expires_in * 1000);
this.oauth2State = res;
}
async getClientForName(name: string) {
const resources = await this.getAccessibleResources();
const resource = resources.find((res) => res.name === name);
await this.checkTokenAge();
if (!resource) {
throw Error('User does not have access to this resource');
}
return this.getClientForResource(resource);
}
async getClientForResource(res: JiraAPIAccessibleResource) {
// Check token age
await this.checkTokenAge();
return new HookshotJiraApi({
protocol: 'https',
host: `api.atlassian.com`,
base: `/ex/jira/${res.id}`,
apiVersion: '3',
strictSSL: true,
bearer: this.bearer,
});
}
}

View File

@ -1,10 +1,10 @@
import axios from "axios"; import axios from "axios";
import { Router, Request, Response } from "express"; import { Router, Request, Response } from "express";
import qs from "querystring";
import { BridgeConfigJira } from "../Config/Config"; import { BridgeConfigJira } from "../Config/Config";
import LogWrapper from "../LogWrapper"; import LogWrapper from "../LogWrapper";
import { MessageQueue } from "../MessageQueue/MessageQueue"; import { MessageQueue } from "../MessageQueue/MessageQueue";
import { OAuthRequest, OAuthTokens } from "../Webhooks"; import { OAuthRequest } from "../Webhooks";
import { JiraOAuthResult } from "./Types";
const log = new LogWrapper("JiraRouter"); const log = new LogWrapper("JiraRouter");
@ -12,38 +12,45 @@ export default class JiraRouter {
constructor(private readonly config: BridgeConfigJira, private readonly queue: MessageQueue) { } constructor(private readonly config: BridgeConfigJira, private readonly queue: MessageQueue) { }
private async onOAuth(req: Request, res: Response) { private async onOAuth(req: Request, res: Response) {
log.info("Got new JIRA oauth request"); if (typeof req.query.state !== "string") {
return res.status(400).send({error: "Missing 'state' parameter"});
}
if (typeof req.query.code !== "string") {
return res.status(400).send({error: "Missing 'state' parameter"});
}
const state = req.query.state as string;
const code = req.query.code as string;
log.info(`Got new JIRA oauth request (${state.substring(0, 8)})`);
try { try {
const exists = await this.queue.pushWait<OAuthRequest, boolean>({ const exists = await this.queue.pushWait<OAuthRequest, boolean>({
eventName: "jira.oauth.response", eventName: "jira.oauth.response",
sender: "GithubWebhooks", sender: "GithubWebhooks",
data: { data: {
code: req.query.code as string, state,
state: req.query.state as string,
}, },
}); });
if (!exists) { if (!exists) {
res.status(404).send(`<p>Could not find user which authorised this request. Has it timed out?</p>`); return res.status(404).send(`<p>Could not find user which authorised this request. Has it timed out?</p>`);
return;
} }
const accessTokenRes = await axios.post(`https://github.com/login/oauth/access_token?${qs.encode({ const accessTokenRes = await axios.post("https://auth.atlassian.com/oauth/token", {
client_id: this.config.oauth.client_id, client_id: this.config.oauth.client_id,
client_secret: this.config.oauth.client_secret, client_secret: this.config.oauth.client_secret,
code: req.query.code as string, code: code,
grant_type: "authorization_code",
redirect_uri: this.config.oauth.redirect_uri, redirect_uri: this.config.oauth.redirect_uri,
state: req.query.state as string,
})}`);
// eslint-disable-next-line camelcase
const result = qs.parse(accessTokenRes.data) as { access_token: string, token_type: string };
await this.queue.push<OAuthTokens>({
eventName: "oauth.tokens",
sender: "GithubWebhooks",
data: { state: req.query.state as string, ... result },
}); });
res.send(`<p> Your account has been bridged </p>`); const result = accessTokenRes.data as { access_token: string, scope: string, expires_in: number, refresh_token: string};
result.expires_in = Date.now() + (result.expires_in * 1000);
log.debug("JIRA token response:", result);
await this.queue.push<JiraOAuthResult>({
eventName: "jira.oauth.tokens",
sender: "GithubWebhooks",
data: { state, ... result },
});
return res.send(`<p> Your account has been bridged </p>`);
} catch (ex) { } catch (ex) {
log.error("Failed to handle oauth request:", ex); log.error("Failed to handle oauth request:", ex);
res.status(500).send(`<p>Encountered an error handing oauth request</p>`); return res.status(500).send(`<p>Encountered an error handing oauth request</p>`);
} }
} }

View File

@ -1,3 +1,14 @@
export interface JiraIssueType {
self: string;
id: string;
description: string;
iconUrl: string;
name: string;
subtask: boolean;
avatarId: number;
hierachyLevel: number;
}
export interface JiraProject { export interface JiraProject {
/** /**
* URL * URL
@ -9,6 +20,7 @@ export interface JiraProject {
projectTypeKey: string; projectTypeKey: string;
simplified: boolean; simplified: boolean;
avatarUrls: Record<string, string>; avatarUrls: Record<string, string>;
issueTypes?: JiraIssueType[];
} }
export interface JiraAccount { export interface JiraAccount {
@ -54,4 +66,20 @@ export interface JiraIssue {
status: unknown; status: unknown;
creator?: JiraAccount; creator?: JiraAccount;
} }
}
export interface JiraOAuthResult {
state: string;
access_token: string;
refresh_token: string;
expires_in: number;
scope: string;
}
export interface JiraAPIAccessibleResource {
id: string;
url: string,
name: string,
scopes: string[],
avatarUrl: string,
} }

View File

@ -1,4 +1,4 @@
import { JiraComment, JiraIssue } from "./Types"; import { JiraAccount, JiraComment, JiraIssue } from "./Types";
export interface IJiraWebhookEvent { export interface IJiraWebhookEvent {
timestamp: number; timestamp: number;
@ -15,4 +15,21 @@ export interface JiraIssueEvent extends IJiraWebhookEvent {
webhookEvent: "issue_updated"|"issue_created"; webhookEvent: "issue_updated"|"issue_created";
comment: JiraComment; comment: JiraComment;
issue: JiraIssue; issue: JiraIssue;
}
export interface JiraIssueUpdatedEvent extends JiraIssueEvent {
webhookEvent: "issue_updated";
user: JiraAccount;
changelog: {
id: string;
items: {
field: string;
fieldtype: string;
fieldId: string;
from: string|null;
fromString: string|null;
to: string|null;
toString: null;
}[];
}
} }

View File

@ -1,4 +1,4 @@
import { LogService } from "matrix-bot-sdk"; import { LogLevel, LogService } from "matrix-bot-sdk";
import util from "util"; import util from "util";
import winston from "winston"; import winston from "winston";
@ -67,6 +67,7 @@ export default class LogWrapper {
log.verbose(getMessageString(messageOrObject), { module }); log.verbose(getMessageString(messageOrObject), { module });
}, },
}); });
LogService.setLevel(LogLevel.fromString(level));
LogService.info("LogWrapper", "Reconfigured logging"); LogService.info("LogWrapper", "Reconfigured logging");
} }

View File

@ -1,12 +1,17 @@
import { GithubInstance } from "./Github/GithubInstance";
import { GitLabClient } from "./Gitlab/Client";
import { Intent } from "matrix-bot-sdk"; import { Intent } from "matrix-bot-sdk";
import { promises as fs } from "fs"; import { promises as fs } from "fs";
import { publicEncrypt, privateDecrypt } from "crypto"; import { publicEncrypt, privateDecrypt } from "crypto";
import JiraApi from 'jira-client';
import LogWrapper from "./LogWrapper"; import LogWrapper from "./LogWrapper";
import { GitLabClient } from "./Gitlab/Client"; import { JiraClient } from "./Jira/Client";
import { GithubInstance } from "./Github/GithubInstance"; import { JiraOAuthResult } from "./Jira/Types";
import { BridgeConfig } from "./Config/Config";
const ACCOUNT_DATA_TYPE = "uk.half-shot.matrix-hookshot.github.password-store:"; const ACCOUNT_DATA_TYPE = "uk.half-shot.matrix-hookshot.github.password-store:";
const ACCOUNT_DATA_GITLAB_TYPE = "uk.half-shot.matrix-hookshot.gitlab.password-store:"; const ACCOUNT_DATA_GITLAB_TYPE = "uk.half-shot.matrix-hookshot.gitlab.password-store:";
const ACCOUNT_DATA_JIRA_TYPE = "uk.half-shot.matrix-hookshot.jira.password-store:";
const LEGACY_ACCOUNT_DATA_TYPE = "uk.half-shot.matrix-github.password-store:"; const LEGACY_ACCOUNT_DATA_TYPE = "uk.half-shot.matrix-github.password-store:";
const LEGACY_ACCOUNT_DATA_GITLAB_TYPE = "uk.half-shot.matrix-github.gitlab.password-store:"; const LEGACY_ACCOUNT_DATA_GITLAB_TYPE = "uk.half-shot.matrix-github.gitlab.password-store:";
@ -19,16 +24,20 @@ function tokenKey(type: TokenType, userId: string, legacy = false, instanceUrl?:
if (type === "github") { if (type === "github") {
return `${legacy ? LEGACY_ACCOUNT_DATA_TYPE : ACCOUNT_DATA_TYPE}${userId}`; return `${legacy ? LEGACY_ACCOUNT_DATA_TYPE : ACCOUNT_DATA_TYPE}${userId}`;
} }
if (type === "jira") {
return `${ACCOUNT_DATA_JIRA_TYPE}${userId}`;
}
if (!instanceUrl) { if (!instanceUrl) {
throw Error(`Expected instanceUrl for ${type}`); throw Error(`Expected instanceUrl for ${type}`);
} }
return `${legacy ? LEGACY_ACCOUNT_DATA_GITLAB_TYPE : ACCOUNT_DATA_GITLAB_TYPE}${instanceUrl}${userId}`; return `${legacy ? LEGACY_ACCOUNT_DATA_GITLAB_TYPE : ACCOUNT_DATA_GITLAB_TYPE}${instanceUrl}${userId}`;
} }
const MAX_TOKEN_PART_SIZE = 128;
export class UserTokenStore { export class UserTokenStore {
private key!: Buffer; private key!: Buffer;
private userTokens: Map<string, string>; private userTokens: Map<string, string>;
constructor(private keyPath: string, private intent: Intent) { constructor(private keyPath: string, private intent: Intent, private config: BridgeConfig) {
this.userTokens = new Map(); this.userTokens = new Map();
} }
@ -39,8 +48,14 @@ export class UserTokenStore {
public async storeUserToken(type: TokenType, userId: string, token: string, instanceUrl?: string): Promise<void> { public async storeUserToken(type: TokenType, userId: string, token: string, instanceUrl?: string): Promise<void> {
const key = tokenKey(type, userId, false, instanceUrl); const key = tokenKey(type, userId, false, instanceUrl);
const tokenParts: string[] = [];
while (token && token.length > 0) {
const part = token.slice(0, MAX_TOKEN_PART_SIZE);
token = token.substring(MAX_TOKEN_PART_SIZE);
tokenParts.push(publicEncrypt(this.key, Buffer.from(part)).toString("base64"));
}
const data = { const data = {
encrypted: publicEncrypt(this.key, Buffer.from(token)).toString("base64"), encrypted: tokenParts,
instance: instanceUrl, instance: instanceUrl,
}; };
await this.intent.underlyingClient.setAccountData(key, data); await this.intent.underlyingClient.setAccountData(key, data);
@ -58,16 +73,15 @@ export class UserTokenStore {
try { try {
let obj; let obj;
if (AllowedTokenTypes.includes(type)) { if (AllowedTokenTypes.includes(type)) {
obj = await this.intent.underlyingClient.getSafeAccountData<{encrypted: string}>(key); obj = await this.intent.underlyingClient.getSafeAccountData<{encrypted: string|string[]}>(key);
if (!obj) { if (!obj) {
obj = await this.intent.underlyingClient.getAccountData<{encrypted: string}>(tokenKey(type, userId, true, instanceUrl)); obj = await this.intent.underlyingClient.getAccountData<{encrypted: string|string[]}>(tokenKey(type, userId, true, instanceUrl));
} }
} else { } else {
throw Error('Unknown type'); throw Error('Unknown type');
} }
const encryptedTextB64 = obj.encrypted; const encryptedParts = typeof obj.encrypted === "string" ? [obj.encrypted] : obj.encrypted;
const encryptedText = Buffer.from(encryptedTextB64, "base64"); const token = encryptedParts.map((t) => privateDecrypt(this.key, Buffer.from(t, "base64")).toString("utf-8")).join("");
const token = privateDecrypt(this.key, encryptedText).toString("utf-8");
this.userTokens.set(key, token); this.userTokens.set(key, token);
return token; return token;
} catch (ex) { } catch (ex) {
@ -94,4 +108,18 @@ export class UserTokenStore {
} }
return new GitLabClient(instanceUrl, senderToken); return new GitLabClient(instanceUrl, senderToken);
} }
public async getJiraForUser(userId: string) {
if (!this.config.jira?.oauth) {
throw Error('Jira not configured');
}
const jsonData = await this.getUserToken("jira", userId);
if (!jsonData) {
return null;
}
// TODO: Hacks
return new JiraClient(JSON.parse(jsonData) as JiraOAuthResult, (data) => {
return this.storeUserToken('jira', userId, JSON.stringify(data));
}, this.config.jira);
}
} }

View File

@ -18,11 +18,10 @@ export interface GenericWebhookEvent {
} }
export interface OAuthRequest { export interface OAuthRequest {
code: string;
state: string; state: string;
} }
export interface OAuthTokens { export interface GitHubOAuthTokens {
// eslint-disable-next-line camelcase // eslint-disable-next-line camelcase
access_token: string; access_token: string;
// eslint-disable-next-line camelcase // eslint-disable-next-line camelcase
@ -216,7 +215,6 @@ export class Webhooks extends EventEmitter {
eventName: "oauth.response", eventName: "oauth.response",
sender: "GithubWebhooks", sender: "GithubWebhooks",
data: { data: {
code: req.query.code as string,
state: req.query.state as string, state: req.query.state as string,
}, },
}); });
@ -233,7 +231,7 @@ export class Webhooks extends EventEmitter {
})}`); })}`);
// eslint-disable-next-line camelcase // eslint-disable-next-line camelcase
const result = qs.parse(accessTokenRes.data) as { access_token: string, token_type: string }; const result = qs.parse(accessTokenRes.data) as { access_token: string, token_type: string };
await this.queue.push<OAuthTokens>({ await this.queue.push<GitHubOAuthTokens>({
eventName: "oauth.tokens", eventName: "oauth.tokens",
sender: "GithubWebhooks", sender: "GithubWebhooks",
data: { state: req.query.state as string, ... result }, data: { state: req.query.state as string, ... result },

155
yarn.lock
View File

@ -2,6 +2,11 @@
# yarn lockfile v1 # yarn lockfile v1
"@alloc/quick-lru@^5.2.0":
version "5.2.0"
resolved "https://registry.yarnpkg.com/@alloc/quick-lru/-/quick-lru-5.2.0.tgz#7bf68b20c0a350f936915fcae06f58e32007ce30"
integrity sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==
"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.16.0": "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.16.0":
version "7.16.0" version "7.16.0"
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.16.0.tgz#0dfc80309beec8411e65e706461c408b0bb9b431" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.16.0.tgz#0dfc80309beec8411e65e706461c408b0bb9b431"
@ -181,6 +186,13 @@
dependencies: dependencies:
"@babel/helper-plugin-utils" "^7.12.13" "@babel/helper-plugin-utils" "^7.12.13"
"@babel/runtime@^7.6.0":
version "7.16.3"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.16.3.tgz#b86f0db02a04187a3c17caa77de69840165d42d5"
integrity sha512-WBwekcqacdY2e9AF/Q7WLFUWmdJGJTkbjqTjoMDgXkVZ3ZRUvOPsLb5KdwISoQVsbP+DQzVZW4Zhci0DvpbNTQ==
dependencies:
regenerator-runtime "^0.13.4"
"@babel/template@^7.16.0": "@babel/template@^7.16.0":
version "7.16.0" version "7.16.0"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.16.0.tgz#d16a35ebf4cd74e202083356fab21dd89363ddd6" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.16.0.tgz#d16a35ebf4cd74e202083356fab21dd89363ddd6"
@ -623,6 +635,22 @@
"@octokit/webhooks-types" "4.15.0" "@octokit/webhooks-types" "4.15.0"
aggregate-error "^3.1.0" aggregate-error "^3.1.0"
"@postman/form-data@~3.1.1":
version "3.1.1"
resolved "https://registry.yarnpkg.com/@postman/form-data/-/form-data-3.1.1.tgz#d0446d0d3639a291f5e800e89fa1d0d3723f9414"
integrity sha512-vjh8Q2a8S6UCm/KKs31XFJqEEgmbjBmpPNVV2eVav6905wyFAwaUOBGA1NPBI4ERH9MMZc6w0umFgM6WbEPMdg==
dependencies:
asynckit "^0.4.0"
combined-stream "^1.0.8"
mime-types "^2.1.12"
"@postman/tunnel-agent@^0.6.3":
version "0.6.3"
resolved "https://registry.yarnpkg.com/@postman/tunnel-agent/-/tunnel-agent-0.6.3.tgz#23048d8d8618d453c571f03189e944afdc2292b7"
integrity sha512-k57fzmAZ2PJGxfOA4SGR05ejorHbVAa/84Hxh/2nAztjNXc4ZjOm9NUIk6/Z6LCrBvJZqjRZbN8e/nROVUPVdg==
dependencies:
safe-buffer "^5.0.1"
"@prefresh/babel-plugin@0.4.1": "@prefresh/babel-plugin@0.4.1":
version "0.4.1" version "0.4.1"
resolved "https://registry.yarnpkg.com/@prefresh/babel-plugin/-/babel-plugin-0.4.1.tgz#c4e843f7c5e56c15f1185979a8559c893ffb4a35" resolved "https://registry.yarnpkg.com/@prefresh/babel-plugin/-/babel-plugin-0.4.1.tgz#c4e843f7c5e56c15f1185979a8559c893ffb4a35"
@ -780,6 +808,11 @@
"@types/node" "*" "@types/node" "*"
"@types/responselike" "*" "@types/responselike" "*"
"@types/caseless@*":
version "0.12.2"
resolved "https://registry.yarnpkg.com/@types/caseless/-/caseless-0.12.2.tgz#f65d3d6389e01eeb458bd54dc8f52b95a9463bc8"
integrity sha512-6ckxMjBBD8URvjB6J3NcnuAn5Pkl7t3TizAg+xdlzzQGSPSmBcXf8KoIH0ua/i+tio+ZRUHEXp0HEmvaR4kt0w==
"@types/chai@^4.2.22": "@types/chai@^4.2.22":
version "4.2.22" version "4.2.22"
resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.2.22.tgz#47020d7e4cf19194d43b5202f35f75bd2ad35ce7" resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.2.22.tgz#47020d7e4cf19194d43b5202f35f75bd2ad35ce7"
@ -843,6 +876,14 @@
dependencies: dependencies:
"@types/node" "*" "@types/node" "*"
"@types/jira-client@^7.1.0":
version "7.1.0"
resolved "https://registry.yarnpkg.com/@types/jira-client/-/jira-client-7.1.0.tgz#f3352630eae8e19d9f90ff618b080ad9b5d82420"
integrity sha512-KU4YGAD6ls57YL9SRtOmsBSsT20rBJg/PRvVqupGGeaMiGKI+ovWU1B0/Y534wgIyfVT26LEDpVrPRU1eLJXNw==
dependencies:
"@types/node" "*"
"@types/request" "*"
"@types/json-schema@^7.0.9": "@types/json-schema@^7.0.9":
version "7.0.9" version "7.0.9"
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.9.tgz#97edc9037ea0c38585320b28964dde3b39e4660d" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.9.tgz#97edc9037ea0c38585320b28964dde3b39e4660d"
@ -937,6 +978,16 @@
resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc" resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc"
integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw== integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==
"@types/request@*":
version "2.48.7"
resolved "https://registry.yarnpkg.com/@types/request/-/request-2.48.7.tgz#a962d11a26e0d71d9a9913d96bb806dc4d4c2f19"
integrity sha512-GWP9AZW7foLd4YQxyFZDBepl0lPsWLMEXDZUjQ/c1gqVPDPECrRZyEzuhJdnPWioFCq3Tv0qoGpMD6U+ygd4ZA==
dependencies:
"@types/caseless" "*"
"@types/node" "*"
"@types/tough-cookie" "*"
form-data "^2.5.0"
"@types/resolve@1.17.1": "@types/resolve@1.17.1":
version "1.17.1" version "1.17.1"
resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.17.1.tgz#3afd6ad8967c77e4376c598a82ddd58f46ec45d6" resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.17.1.tgz#3afd6ad8967c77e4376c598a82ddd58f46ec45d6"
@ -959,6 +1010,11 @@
"@types/mime" "^1" "@types/mime" "^1"
"@types/node" "*" "@types/node" "*"
"@types/tough-cookie@*":
version "4.0.1"
resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.1.tgz#8f80dd965ad81f3e1bc26d6f5c727e132721ff40"
integrity sha512-Y0K95ThC3esLEYD6ZuqNek29lNX2EM1qxV8y2FTLUB0ff5wWrk7az+mLrnNFUnaXcgKye22+sFBRXOgpPILZNg==
"@types/uuid@^8.3.3": "@types/uuid@^8.3.3":
version "8.3.3" version "8.3.3"
resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.3.tgz#c6a60686d953dbd1b1d45e66f4ecdbd5d471b4d0" resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.3.tgz#c6a60686d953dbd1b1d45e66f4ecdbd5d471b4d0"
@ -1250,7 +1306,7 @@ balanced-match@^1.0.0:
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
base64-js@^1.3.1: base64-js@^1.1.2, base64-js@^1.3.1:
version "1.5.1" version "1.5.1"
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
@ -1310,6 +1366,11 @@ bl@^4.1.0:
inherits "^2.0.4" inherits "^2.0.4"
readable-stream "^3.4.0" readable-stream "^3.4.0"
bluebird@^2.6.2:
version "2.11.0"
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-2.11.0.tgz#534b9033c022c9579c56ba3b3e5a5caafbb650e1"
integrity sha1-U0uQM8AiyVecVro7Plpcqvu2UOE=
bluebird@^3.5.0: bluebird@^3.5.0:
version "3.7.2" version "3.7.2"
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f"
@ -1358,6 +1419,13 @@ braces@^3.0.1, braces@~3.0.2:
dependencies: dependencies:
fill-range "^7.0.1" fill-range "^7.0.1"
brotli@~1.3.2:
version "1.3.2"
resolved "https://registry.yarnpkg.com/brotli/-/brotli-1.3.2.tgz#525a9cad4fcba96475d7d388f6aecb13eed52f46"
integrity sha1-UlqcrU/LqWR119OI9q7LE+7VL0Y=
dependencies:
base64-js "^1.1.2"
browser-stdout@1.3.1: browser-stdout@1.3.1:
version "1.3.1" version "1.3.1"
resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.1.tgz#baa559ee14ced73452229bad7326467c61fabd60" resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.1.tgz#baa559ee14ced73452229bad7326467c61fabd60"
@ -1702,7 +1770,7 @@ colorspace@1.1.x:
color "^3.1.3" color "^3.1.3"
text-hex "1.0.x" text-hex "1.0.x"
combined-stream@^1.0.6, combined-stream@~1.0.6: combined-stream@^1.0.6, combined-stream@^1.0.8, combined-stream@~1.0.6:
version "1.0.8" version "1.0.8"
resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f"
integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==
@ -2541,6 +2609,15 @@ forever-agent@~0.6.1:
resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91"
integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE= integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=
form-data@^2.5.0:
version "2.5.1"
resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.5.1.tgz#f2cbec57b5e59e23716e128fe44d4e5dd23895f4"
integrity sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==
dependencies:
asynckit "^0.4.0"
combined-stream "^1.0.6"
mime-types "^2.1.12"
form-data@~2.3.2: form-data@~2.3.2:
version "2.3.3" version "2.3.3"
resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6" resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6"
@ -2877,6 +2954,15 @@ http-signature@~1.2.0:
jsprim "^1.2.2" jsprim "^1.2.2"
sshpk "^1.7.0" sshpk "^1.7.0"
http-signature@~1.3.1:
version "1.3.6"
resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.3.6.tgz#cb6fbfdf86d1c974f343be94e87f7fc128662cf9"
integrity sha512-3adrsD6zqo4GsTqtO7FyrejHNv+NgiIfAfv68+jVlFmSr9OGy7zrxONceFRLKvnnZA5jbxQBX1u9PpB6Wi32Gw==
dependencies:
assert-plus "^1.0.0"
jsprim "^2.0.2"
sshpk "^1.14.1"
http2-wrapper@^1.0.0-beta.5.2: http2-wrapper@^1.0.0-beta.5.2:
version "1.0.3" version "1.0.3"
resolved "https://registry.yarnpkg.com/http2-wrapper/-/http2-wrapper-1.0.3.tgz#b8f55e0c1f25d4ebd08b3b0c2c079f9590800b3d" resolved "https://registry.yarnpkg.com/http2-wrapper/-/http2-wrapper-1.0.3.tgz#b8f55e0c1f25d4ebd08b3b0c2c079f9590800b3d"
@ -3198,6 +3284,14 @@ isstream@~0.1.2:
resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a"
integrity sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo= integrity sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=
jira-client@^8.0.0:
version "8.0.0"
resolved "https://registry.yarnpkg.com/jira-client/-/jira-client-8.0.0.tgz#ce00c964f2aeed1f4a0bf8ed28bc3fe6f045216e"
integrity sha512-vei9WuUboBAcQRxO6b5SwO5JXEyC1Y0ydiW4JiD7tPYc65wjXSbZvGDtQWsz7IOssFG/BQNAcJdiS79qVwnAJw==
dependencies:
"@babel/runtime" "^7.6.0"
postman-request "^2.88.1-postman.30"
js-tokens@^4.0.0: js-tokens@^4.0.0:
version "4.0.0" version "4.0.0"
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
@ -3247,6 +3341,11 @@ json-schema@0.2.3:
resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13" resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13"
integrity sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM= integrity sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=
json-schema@0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.4.0.tgz#f7de4cf6efab838ebaeb3236474cbba5a1930ab5"
integrity sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==
json-stable-stringify-without-jsonify@^1.0.1: json-stable-stringify-without-jsonify@^1.0.1:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651"
@ -3312,6 +3411,16 @@ jsprim@^1.2.2:
json-schema "0.2.3" json-schema "0.2.3"
verror "1.10.0" verror "1.10.0"
jsprim@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-2.0.2.tgz#77ca23dbcd4135cd364800d22ff82c2185803d4d"
integrity sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ==
dependencies:
assert-plus "1.0.0"
extsprintf "1.3.0"
json-schema "0.4.0"
verror "1.10.0"
just-diff-apply@^3.0.0: just-diff-apply@^3.0.0:
version "3.1.2" version "3.1.2"
resolved "https://registry.yarnpkg.com/just-diff-apply/-/just-diff-apply-3.1.2.tgz#710d8cda00c65dc4e692df50dbe9bac5581c2193" resolved "https://registry.yarnpkg.com/just-diff-apply/-/just-diff-apply-3.1.2.tgz#710d8cda00c65dc4e692df50dbe9bac5581c2193"
@ -4375,6 +4484,34 @@ postcss@^8.3.11, postcss@^8.3.5:
picocolors "^1.0.0" picocolors "^1.0.0"
source-map-js "^0.6.2" source-map-js "^0.6.2"
postman-request@^2.88.1-postman.30:
version "2.88.1-postman.30"
resolved "https://registry.yarnpkg.com/postman-request/-/postman-request-2.88.1-postman.30.tgz#44554fd3c19bf19e0a583e92b654d3c4f7e4669e"
integrity sha512-zsGvs8OgNeno1Q44zTgGP2IL7kCqUy4DAtl8/ms0AQpqkIoysrxzR/Zg4kM1Kz8/duBvwxt8NN717wB7SMNm6w==
dependencies:
"@postman/form-data" "~3.1.1"
"@postman/tunnel-agent" "^0.6.3"
aws-sign2 "~0.7.0"
aws4 "^1.8.0"
brotli "~1.3.2"
caseless "~0.12.0"
combined-stream "~1.0.6"
extend "~3.0.2"
forever-agent "~0.6.1"
har-validator "~5.1.3"
http-signature "~1.3.1"
is-typedarray "~1.0.0"
isstream "~0.1.2"
json-stringify-safe "~5.0.1"
mime-types "~2.1.19"
oauth-sign "~0.9.0"
performance-now "^2.1.0"
qs "~6.5.2"
safe-buffer "^5.1.2"
stream-length "^1.0.2"
tough-cookie "~2.5.0"
uuid "^3.3.2"
preact@^10.5.15: preact@^10.5.15:
version "10.5.15" version "10.5.15"
resolved "https://registry.yarnpkg.com/preact/-/preact-10.5.15.tgz#6df94d8afecf3f9e10a742fd8c362ddab464225f" resolved "https://registry.yarnpkg.com/preact/-/preact-10.5.15.tgz#6df94d8afecf3f9e10a742fd8c362ddab464225f"
@ -4577,6 +4714,11 @@ reflect-metadata@^0.1.13:
resolved "https://registry.yarnpkg.com/reflect-metadata/-/reflect-metadata-0.1.13.tgz#67ae3ca57c972a2aa1642b10fe363fe32d49dc08" resolved "https://registry.yarnpkg.com/reflect-metadata/-/reflect-metadata-0.1.13.tgz#67ae3ca57c972a2aa1642b10fe363fe32d49dc08"
integrity sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg== integrity sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==
regenerator-runtime@^0.13.4:
version "0.13.9"
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz#8925742a98ffd90814988d7566ad30ca3b263b52"
integrity sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==
regexpp@^3.2.0: regexpp@^3.2.0:
version "3.2.0" version "3.2.0"
resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.2.0.tgz#0425a2768d8f23bad70ca4b90461fa2f1213e1b2" resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.2.0.tgz#0425a2768d8f23bad70ca4b90461fa2f1213e1b2"
@ -4981,7 +5123,7 @@ sourcemap-codec@^1.4.4:
resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4" resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4"
integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA== integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==
sshpk@^1.7.0: sshpk@^1.14.1, sshpk@^1.7.0:
version "1.16.1" version "1.16.1"
resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877" resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877"
integrity sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg== integrity sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg==
@ -5030,6 +5172,13 @@ steno@^0.4.1:
dependencies: dependencies:
graceful-fs "^4.1.3" graceful-fs "^4.1.3"
stream-length@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/stream-length/-/stream-length-1.0.2.tgz#8277f3cbee49a4daabcfdb4e2f4a9b5e9f2c9f00"
integrity sha1-gnfzy+5JpNqrz9tOL0qbXp8snwA=
dependencies:
bluebird "^2.6.2"
string-argv@^0.3.1: string-argv@^0.3.1:
version "0.3.1" version "0.3.1"
resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.1.tgz#95e2fbec0427ae19184935f816d74aaa4c5c19da" resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.1.tgz#95e2fbec0427ae19184935f816d74aaa4c5c19da"