From 43f795f4dade94e52f3b101ada26b6c863ac35e6 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 30 Nov 2021 19:25:35 -0700 Subject: [PATCH] Initial support for matrix-hookshot#jira --- src/MemoryCache.ts | 1 + src/api/admin/AdminHookshotJiraService.ts | 110 ++++++++++++++++++ src/bridges/HookshotBridge.ts | 79 +++++++++++++ src/bridges/HookshotGithubBridge.ts | 78 +++---------- src/bridges/HookshotJiraBridge.ts | 50 ++++++++ src/bridges/models/hookshot.ts | 23 +++- src/db/BridgeStore.ts | 23 +++- src/db/DimensionStore.ts | 2 + .../20211130153845-AddHookshotJiraBridge.ts | 23 ++++ ...11130153945-AddHookshotJiraBridgeRecord.ts | 23 ++++ src/db/models/HookshotGithubBridgeRecord.ts | 3 +- src/db/models/HookshotJiraBridgeRecord.ts | 31 +++++ src/db/models/IHookshotBridgeRecord.ts | 8 ++ src/integrations/Bridge.ts | 5 + .../manage-selfhosted.component.html | 2 +- .../hookshot-jira.component.html | 41 +++++++ .../hookshot-jira.component.scss | 3 + .../hookshot-jira/hookshot-jira.component.ts | 85 ++++++++++++++ .../manage-selfhosted.component.html | 31 +++++ .../manage-selfhosted.component.scss | 0 .../manage-selfhosted.component.ts | 63 ++++++++++ web/app/app.module.ts | 14 ++- web/app/app.routing.ts | 19 ++- .../hookshot-jira.bridge.component.html | 44 +++++++ .../hookshot-jira.bridge.component.scss | 4 + .../hookshot-jira.bridge.component.ts | 99 ++++++++++++++++ web/app/shared/models/hookshot_jira.ts | 11 ++ .../shared/registry/integrations.registry.ts | 1 + .../admin/admin-hookshot-jira-api.service.ts | 38 ++++++ .../integrations/hookshot-jira-api.service.ts | 21 ++++ web/assets/i18n/en.json | 9 ++ web/assets/i18n/template.json | 9 ++ web/assets/img/avatars/jira.png | Bin 0 -> 17718 bytes 33 files changed, 879 insertions(+), 74 deletions(-) create mode 100644 src/api/admin/AdminHookshotJiraService.ts create mode 100644 src/bridges/HookshotBridge.ts create mode 100644 src/bridges/HookshotJiraBridge.ts create mode 100644 src/db/migrations/20211130153845-AddHookshotJiraBridge.ts create mode 100644 src/db/migrations/20211130153945-AddHookshotJiraBridgeRecord.ts create mode 100644 src/db/models/HookshotJiraBridgeRecord.ts create mode 100644 src/db/models/IHookshotBridgeRecord.ts create mode 100644 web/app/admin/bridges/hookshot-jira/hookshot-jira.component.html create mode 100644 web/app/admin/bridges/hookshot-jira/hookshot-jira.component.scss create mode 100644 web/app/admin/bridges/hookshot-jira/hookshot-jira.component.ts create mode 100644 web/app/admin/bridges/hookshot-jira/manage-selfhosted/manage-selfhosted.component.html create mode 100644 web/app/admin/bridges/hookshot-jira/manage-selfhosted/manage-selfhosted.component.scss create mode 100644 web/app/admin/bridges/hookshot-jira/manage-selfhosted/manage-selfhosted.component.ts create mode 100644 web/app/configs/bridge/hookshot-jira/hookshot-jira.bridge.component.html create mode 100644 web/app/configs/bridge/hookshot-jira/hookshot-jira.bridge.component.scss create mode 100644 web/app/configs/bridge/hookshot-jira/hookshot-jira.bridge.component.ts create mode 100644 web/app/shared/models/hookshot_jira.ts create mode 100644 web/app/shared/services/admin/admin-hookshot-jira-api.service.ts create mode 100644 web/app/shared/services/integrations/hookshot-jira-api.service.ts create mode 100644 web/assets/img/avatars/jira.png diff --git a/src/MemoryCache.ts b/src/MemoryCache.ts index 0ad8bcc..576ee75 100644 --- a/src/MemoryCache.ts +++ b/src/MemoryCache.ts @@ -52,6 +52,7 @@ export const CACHE_IRC_BRIDGE = "irc-bridge"; export const CACHE_STICKERS = "stickers"; export const CACHE_TELEGRAM_BRIDGE = "telegram-bridge"; export const CACHE_HOOKSHOT_GITHUB_BRIDGE = "hookshot-github-bridge"; +export const CACHE_HOOKSHOT_JIRA_BRIDGE = "hookshot-jira-bridge"; export const CACHE_WEBHOOKS_BRIDGE = "webhooks-bridge"; export const CACHE_SIMPLE_BOTS = "simple-bots"; export const CACHE_SLACK_BRIDGE = "slack-bridge"; diff --git a/src/api/admin/AdminHookshotJiraService.ts b/src/api/admin/AdminHookshotJiraService.ts new file mode 100644 index 0000000..057a5cc --- /dev/null +++ b/src/api/admin/AdminHookshotJiraService.ts @@ -0,0 +1,110 @@ +import { Context, GET, Path, PathParam, POST, QueryParam, Security, ServiceContext } from "typescript-rest"; +import { Cache, CACHE_HOOKSHOT_JIRA_BRIDGE, CACHE_INTEGRATIONS } from "../../MemoryCache"; +import { LogService } from "matrix-bot-sdk"; +import { ApiError } from "../ApiError"; +import { ROLE_ADMIN, ROLE_USER } from "../security/MatrixSecurity"; +import HookshotJiraBridgeRecord from "../../db/models/HookshotJiraBridgeRecord"; + +interface CreateWithUpstream { + upstreamId: number; +} + +interface CreateSelfhosted { + provisionUrl: string; + sharedSecret: string; +} + +interface BridgeResponse { + id: number; + upstreamId?: number; + provisionUrl?: string; + sharedSecret?: string; + isEnabled: boolean; +} + +/** + * Administrative API for configuring Hookshot Jira bridge instances. + */ +@Path("/api/v1/dimension/admin/hookshot/jira") +export class AdminHookshotJiraService { + + @Context + private context: ServiceContext; + + @GET + @Path("all") + @Security([ROLE_USER, ROLE_ADMIN]) + public async getBridges(): Promise { + const bridges = await HookshotJiraBridgeRecord.findAll(); + return Promise.all(bridges.map(async b => { + return { + id: b.id, + upstreamId: b.upstreamId, + provisionUrl: b.provisionUrl, + sharedSecret: b.sharedSecret, + isEnabled: b.isEnabled, + }; + })); + } + + @GET + @Path(":bridgeId") + @Security([ROLE_USER, ROLE_ADMIN]) + public async getBridge(@PathParam("bridgeId") bridgeId: number): Promise { + const jiraBridge = await HookshotJiraBridgeRecord.findByPk(bridgeId); + if (!jiraBridge) throw new ApiError(404, "Jira Bridge not found"); + + return { + id: jiraBridge.id, + upstreamId: jiraBridge.upstreamId, + provisionUrl: jiraBridge.provisionUrl, + sharedSecret: jiraBridge.sharedSecret, + isEnabled: jiraBridge.isEnabled, + }; + } + + @POST + @Path(":bridgeId") + @Security([ROLE_USER, ROLE_ADMIN]) + public async updateBridge(@PathParam("bridgeId") bridgeId: number, request: CreateSelfhosted): Promise { + const userId = this.context.request.user.userId; + + const bridge = await HookshotJiraBridgeRecord.findByPk(bridgeId); + if (!bridge) throw new ApiError(404, "Bridge not found"); + + bridge.provisionUrl = request.provisionUrl; + bridge.sharedSecret = request.sharedSecret; + await bridge.save(); + + LogService.info("AdminHookshotJiraService", userId + " updated Hookshot Jira Bridge " + bridge.id); + + Cache.for(CACHE_HOOKSHOT_JIRA_BRIDGE).clear(); + Cache.for(CACHE_INTEGRATIONS).clear(); + return this.getBridge(bridge.id); + } + + @POST + @Path("new/upstream") + @Security([ROLE_USER, ROLE_ADMIN]) + public async newConfigForUpstream(@QueryParam("scalar_token") _scalarToken: string, _request: CreateWithUpstream): Promise { + throw new ApiError(400, "Cannot create a jira bridge from an upstream"); + } + + @POST + @Path("new/selfhosted") + @Security([ROLE_USER, ROLE_ADMIN]) + public async newSelfhosted(request: CreateSelfhosted): Promise { + const userId = this.context.request.user.userId; + + const bridge = await HookshotJiraBridgeRecord.create({ + provisionUrl: request.provisionUrl, + sharedSecret: request.sharedSecret, + isEnabled: true, + }); + LogService.info("AdminHookshotJiraService", userId + " created a new Hookshot Jira Bridge with provisioning URL " + request.provisionUrl); + + Cache.for(CACHE_HOOKSHOT_JIRA_BRIDGE).clear(); + Cache.for(CACHE_INTEGRATIONS).clear(); + return this.getBridge(bridge.id); + } +} diff --git a/src/bridges/HookshotBridge.ts b/src/bridges/HookshotBridge.ts new file mode 100644 index 0000000..27bde53 --- /dev/null +++ b/src/bridges/HookshotBridge.ts @@ -0,0 +1,79 @@ +import { LogService } from "matrix-bot-sdk"; +import * as request from "request"; +import { + HookshotConnectionsResponse, HookshotConnectionTypeDefinition +} from "./models/hookshot"; +import { IHookshotBridgeRecord } from "../db/models/IHookshotBridgeRecord"; + +export abstract class HookshotBridge { + protected constructor(private requestingUserId: string) { + } + + protected abstract getDefaultBridge(): Promise; + + protected async getAllRoomConfigurations(inRoomId: string): Promise { + const bridge = await this.getDefaultBridge(); + + try { + return await this.doProvisionRequest(bridge, "GET", `/v1/${inRoomId}/connections`); + } catch (e) { + if (e.errBody['errcode'] === "HS_NOT_IN_ROOM") { + return []; + } + + throw e; + } + } + + protected async getAllServiceInformation(): Promise { + const bridge = await this.getDefaultBridge(); + const connections = await this.doProvisionRequest(bridge, "GET", `/v1/connectiontypes`); + return Object.values(connections); + } + + protected async doProvisionRequest(bridge: IHookshotBridgeRecord, method: string, endpoint: string, qs?: any, body?: any): Promise { + const provisionUrl = bridge.provisionUrl; + const apiUrl = provisionUrl.endsWith("/") ? provisionUrl.substring(0, provisionUrl.length - 1) : provisionUrl; + const url = apiUrl + (endpoint.startsWith("/") ? endpoint : "/" + endpoint); + LogService.info("HookshotBridge", "Doing provision Hookshot Bridge request: " + url); + + if (!qs) qs = {}; + + if (qs["userId"] === false) delete qs["userId"]; + else if (!qs["userId"]) qs["userId"] = this.requestingUserId; + + return new Promise((resolve, reject) => { + request({ + method: method, + url: url, + qs: qs, + json: body, + headers: { + "Authorization": `Bearer ${bridge.sharedSecret}`, + }, + }, (err, res, _body) => { + try { + if (err) { + LogService.error("HookshotBridge", "Error calling" + url); + LogService.error("HookshotBridge", err); + reject(err); + } else if (!res) { + LogService.error("HookshotBridge", "There is no response for " + url); + reject(new Error("No response provided - is the service online?")); + } else if (res.statusCode !== 200 && res.statusCode !== 202) { + LogService.error("HookshotBridge", "Got status code " + res.statusCode + " when calling " + url); + LogService.error("HookshotBridge", res.body); + if (typeof (res.body) === "string") res.body = JSON.parse(res.body); + reject({errBody: res.body, error: new Error("Request failed")}); + } else { + if (typeof (res.body) === "string") res.body = JSON.parse(res.body); + resolve(res.body); + } + } catch (e) { + LogService.error("HookshotBridge", e); + reject(e); + } + }); + }); + } +} diff --git a/src/bridges/HookshotGithubBridge.ts b/src/bridges/HookshotGithubBridge.ts index fb8d28b..f02ca03 100644 --- a/src/bridges/HookshotGithubBridge.ts +++ b/src/bridges/HookshotGithubBridge.ts @@ -1,18 +1,17 @@ -import { LogService } from "matrix-bot-sdk"; -import * as request from "request"; import HookshotGithubBridgeRecord from "../db/models/HookshotGithubBridgeRecord"; import { HookshotConnection, - HookshotConnectionsResponse, HookshotGithubRoomConfig, HookshotTypes } from "./models/hookshot"; +import { HookshotBridge } from "./HookshotBridge"; -export class HookshotGithubBridge { - constructor(private requestingUserId: string) { +export class HookshotGithubBridge extends HookshotBridge { + constructor(requestingUserId: string) { + super(requestingUserId); } - private async getDefaultBridge(): Promise { + protected async getDefaultBridge(): Promise { const bridges = await HookshotGithubBridgeRecord.findAll({where: {isEnabled: true}}); if (!bridges || bridges.length !== 1) { throw new Error("No bridges or too many bridges found"); @@ -21,24 +20,19 @@ export class HookshotGithubBridge { return bridges[0]; } + public async getBotUserId(): Promise { + const confs = await this.getAllServiceInformation(); + const conf = confs.find(c => c.eventType === HookshotTypes.Github); + return conf?.botUserId; + } + public async isBridgingEnabled(): Promise { const bridges = await HookshotGithubBridgeRecord.findAll({where: {isEnabled: true}}); - return !!bridges && bridges.length > 0; + return !!bridges && bridges.length > 0 && !!(await this.getBotUserId()); } public async getRoomConfigurations(inRoomId: string): Promise { - const bridge = await this.getDefaultBridge(); - - try { - const connections = await this.doProvisionRequest(bridge, "GET", `/v1/${inRoomId}/connections`); - return connections.filter(c => c.type === HookshotTypes.Github); - } catch (e) { - if (e.errBody['error'] === "Could not determine if the user is in the room.") { - return []; - } - - throw e; - } + return (await this.getAllRoomConfigurations(inRoomId)).filter(c => c.eventType === HookshotTypes.Github); } public async bridgeRoom(roomId: string): Promise { @@ -52,50 +46,4 @@ export class HookshotGithubBridge { const bridge = await this.getDefaultBridge(); await this.doProvisionRequest(bridge, "DELETE", `/v1/${roomId}/connections/${connectionId}`); } - - private async doProvisionRequest(bridge: HookshotGithubBridgeRecord, method: string, endpoint: string, qs?: any, body?: any): Promise { - const provisionUrl = bridge.provisionUrl; - const apiUrl = provisionUrl.endsWith("/") ? provisionUrl.substring(0, provisionUrl.length - 1) : provisionUrl; - const url = apiUrl + (endpoint.startsWith("/") ? endpoint : "/" + endpoint); - LogService.info("TelegramBridge", "Doing provision Github Hookshot Bridge request: " + url); - - if (!qs) qs = {}; - - if (qs["userId"] === false) delete qs["userId"]; - else if (!qs["userId"]) qs["userId"] = this.requestingUserId; - - return new Promise((resolve, reject) => { - request({ - method: method, - url: url, - qs: qs, - json: body, - headers: { - "Authorization": `Bearer ${bridge.sharedSecret}`, - }, - }, (err, res, _body) => { - try { - if (err) { - LogService.error("GithubHookshotBridge", "Error calling" + url); - LogService.error("GithubHookshotBridge", err); - reject(err); - } else if (!res) { - LogService.error("GithubHookshotBridge", "There is no response for " + url); - reject(new Error("No response provided - is the service online?")); - } else if (res.statusCode !== 200 && res.statusCode !== 202) { - LogService.error("GithubHookshotBridge", "Got status code " + res.statusCode + " when calling " + url); - LogService.error("GithubHookshotBridge", res.body); - if (typeof (res.body) === "string") res.body = JSON.parse(res.body); - reject({errBody: res.body, error: new Error("Request failed")}); - } else { - if (typeof (res.body) === "string") res.body = JSON.parse(res.body); - resolve(res.body); - } - } catch (e) { - LogService.error("GithubHookshotBridge", e); - reject(e); - } - }); - }); - } } diff --git a/src/bridges/HookshotJiraBridge.ts b/src/bridges/HookshotJiraBridge.ts new file mode 100644 index 0000000..70b6058 --- /dev/null +++ b/src/bridges/HookshotJiraBridge.ts @@ -0,0 +1,50 @@ +import HookshotJiraBridgeRecord from "../db/models/HookshotJiraBridgeRecord"; +import { + HookshotConnection, + HookshotGithubRoomConfig, + HookshotJiraRoomConfig, + HookshotTypes +} from "./models/hookshot"; +import { HookshotBridge } from "./HookshotBridge"; + +export class HookshotJiraBridge extends HookshotBridge { + constructor(requestingUserId: string) { + super(requestingUserId); + } + + protected async getDefaultBridge(): Promise { + const bridges = await HookshotJiraBridgeRecord.findAll({where: {isEnabled: true}}); + if (!bridges || bridges.length !== 1) { + throw new Error("No bridges or too many bridges found"); + } + + return bridges[0]; + } + + public async getBotUserId(): Promise { + const confs = await this.getAllServiceInformation(); + const conf = confs.find(c => c.eventType === HookshotTypes.Jira); + return conf?.botUserId; + } + + public async isBridgingEnabled(): Promise { + const bridges = await HookshotJiraBridgeRecord.findAll({where: {isEnabled: true}}); + return !!bridges && bridges.length > 0 && !!(await this.getBotUserId()); + } + + public async getRoomConfigurations(inRoomId: string): Promise { + return (await this.getAllRoomConfigurations(inRoomId)).filter(c => c.eventType === HookshotTypes.Jira); + } + + public async bridgeRoom(roomId: string): Promise { + const bridge = await this.getDefaultBridge(); + + const body = {}; + return await this.doProvisionRequest(bridge, "PUT", `/v1/${roomId}/connections/${HookshotTypes.Jira}`, null, body); + } + + public async unbridgeRoom(roomId: string, connectionId: string): Promise { + const bridge = await this.getDefaultBridge(); + await this.doProvisionRequest(bridge, "DELETE", `/v1/${roomId}/connections/${connectionId}`); + } +} diff --git a/src/bridges/models/hookshot.ts b/src/bridges/models/hookshot.ts index 6eebda6..b49be28 100644 --- a/src/bridges/models/hookshot.ts +++ b/src/bridges/models/hookshot.ts @@ -1,16 +1,37 @@ export interface HookshotConnection { type: string; + eventType: string; // state key in the connection id: string; service: string; // human-readable - details: any; // context-specific + botUserId: string; + config: any; // context-specific } export type HookshotConnectionsResponse = HookshotConnection[]; +export interface HookshotConnectionTypeDefinition { + type: string; // name of connection + eventType: string; // state key in the connection + service: string; // human-readable + botUserId: string; +} + export interface HookshotGithubRoomConfig { } +export enum SupportedJiraEventType { + IssueCreated = "issue.created", +} + +export interface HookshotJiraRoomConfig { + id: string; + url: string; + events: SupportedJiraEventType[]; + commandPrefix: string; +} + export enum HookshotTypes { Github = "uk.half-shot.matrix-hookshot.github.repository", + Jira = "uk.half-shot.matrix-hookshot.jira.project", } diff --git a/src/db/BridgeStore.ts b/src/db/BridgeStore.ts index cd14cc4..c931a63 100644 --- a/src/db/BridgeStore.ts +++ b/src/db/BridgeStore.ts @@ -1,5 +1,5 @@ import { - Bridge, HookshotGithubBridgeConfiguration, + Bridge, HookshotGithubBridgeConfiguration, HookshotJiraBridgeConfiguration, SlackBridgeConfiguration, TelegramBridgeConfiguration, WebhookBridgeConfiguration @@ -11,6 +11,7 @@ import { TelegramBridge } from "../bridges/TelegramBridge"; import { WebhooksBridge } from "../bridges/WebhooksBridge"; import { SlackBridge } from "../bridges/SlackBridge"; import { HookshotGithubBridge } from "../bridges/HookshotGithubBridge"; +import { HookshotJiraBridge } from "../bridges/HookshotJiraBridge"; export class BridgeStore { @@ -60,7 +61,7 @@ export class BridgeStore { const record = await BridgeRecord.findOne({where: {type: integrationType}}); if (!record) throw new Error("Bridge not found"); - const hasDedicatedApi = ["irc", "telegram", "webhooks", "slack", "hookshot_github"]; + const hasDedicatedApi = ["irc", "telegram", "webhooks", "slack", "hookshot_github", "hookshot_jira"]; if (hasDedicatedApi.indexOf(integrationType) !== -1) { throw new Error("This bridge should be modified with the dedicated API"); } else throw new Error("Unsupported bridge"); @@ -82,6 +83,9 @@ export class BridgeStore { } else if (record.type === "hookshot_github") { const hookshot = new HookshotGithubBridge(requestingUserId); return hookshot.isBridgingEnabled(); + } else if (record.type === "hookshot_jira") { + const hookshot = new HookshotJiraBridge(requestingUserId); + return hookshot.isBridgingEnabled(); } else return true; } @@ -101,6 +105,9 @@ export class BridgeStore { } else if (record.type === "hookshot_github") { const hookshot = new HookshotGithubBridge(requestingUserId); return hookshot.isBridgingEnabled(); + } else if (record.type === "hookshot_jira") { + const hookshot = new HookshotJiraBridge(requestingUserId); + return hookshot.isBridgingEnabled(); } else return false; } @@ -141,9 +148,19 @@ export class BridgeStore { } else if (record.type === "hookshot_github") { if (!inRoomId) return {}; // The bridge's admin config is handled by other APIs const hookshot = new HookshotGithubBridge(requestingUserId); + const botUserId = await hookshot.getBotUserId(); const connections = await hookshot.getRoomConfigurations(inRoomId); return { - botUserId: "@hookshot_bot:localhost", // TODO + botUserId: botUserId, + connections: connections, + }; + } else if (record.type === "hookshot_jira") { + if (!inRoomId) return {}; // The bridge's admin config is handled by other APIs + const hookshot = new HookshotJiraBridge(requestingUserId); + const botUserId = await hookshot.getBotUserId(); + const connections = await hookshot.getRoomConfigurations(inRoomId); + return { + botUserId: botUserId, connections: connections, }; } else return {}; diff --git a/src/db/DimensionStore.ts b/src/db/DimensionStore.ts index 799e4be..54bc73b 100644 --- a/src/db/DimensionStore.ts +++ b/src/db/DimensionStore.ts @@ -30,6 +30,7 @@ import TermsTextRecord from "./models/TermsTextRecord"; import TermsSignedRecord from "./models/TermsSignedRecord"; import TermsUpstreamRecord from "./models/TermsUpstreamRecord"; import HookshotGithubBridgeRecord from "./models/HookshotGithubBridgeRecord"; +import HookshotJiraBridgeRecord from "./models/HookshotJiraBridgeRecord"; class _DimensionStore { private sequelize: Sequelize; @@ -77,6 +78,7 @@ class _DimensionStore { TermsSignedRecord, TermsUpstreamRecord, HookshotGithubBridgeRecord, + HookshotJiraBridgeRecord, ]); } diff --git a/src/db/migrations/20211130153845-AddHookshotJiraBridge.ts b/src/db/migrations/20211130153845-AddHookshotJiraBridge.ts new file mode 100644 index 0000000..3fbd5fa --- /dev/null +++ b/src/db/migrations/20211130153845-AddHookshotJiraBridge.ts @@ -0,0 +1,23 @@ +import { QueryInterface } from "sequelize"; +import { DataType } from "sequelize-typescript"; + +export default { + up: (queryInterface: QueryInterface) => { + return Promise.resolve() + .then(() => queryInterface.createTable("dimension_hookshot_jira_bridges", { + "id": {type: DataType.INTEGER, primaryKey: true, autoIncrement: true, allowNull: false}, + "upstreamId": { + type: DataType.INTEGER, allowNull: true, + references: {model: "dimension_upstreams", key: "id"}, + onUpdate: "cascade", onDelete: "cascade", + }, + "provisionUrl": {type: DataType.STRING, allowNull: true}, + "sharedSecret": {type: DataType.STRING, allowNull: true}, + "isEnabled": {type: DataType.BOOLEAN, allowNull: false}, + })); + }, + down: (queryInterface: QueryInterface) => { + return Promise.resolve() + .then(() => queryInterface.dropTable("dimension_hookshot_jira_bridges")); + } +} diff --git a/src/db/migrations/20211130153945-AddHookshotJiraBridgeRecord.ts b/src/db/migrations/20211130153945-AddHookshotJiraBridgeRecord.ts new file mode 100644 index 0000000..df6f16e --- /dev/null +++ b/src/db/migrations/20211130153945-AddHookshotJiraBridgeRecord.ts @@ -0,0 +1,23 @@ +import { QueryInterface } from "sequelize"; + +export default { + up: (queryInterface: QueryInterface) => { + return Promise.resolve() + .then(() => queryInterface.bulkInsert("dimension_bridges", [ + { + type: "hookshot_jira", + name: "Jira Bridge", + avatarUrl: "/assets/img/avatars/jira.png", + isEnabled: true, + isPublic: true, + description: "Bridges Jira issues to Matrix", + }, + ])); + }, + down: (queryInterface: QueryInterface) => { + return Promise.resolve() + .then(() => queryInterface.bulkDelete("dimension_bridges", { + type: "hookshot_jira", + })); + } +} diff --git a/src/db/models/HookshotGithubBridgeRecord.ts b/src/db/models/HookshotGithubBridgeRecord.ts index 0e11930..9966ae6 100644 --- a/src/db/models/HookshotGithubBridgeRecord.ts +++ b/src/db/models/HookshotGithubBridgeRecord.ts @@ -1,12 +1,13 @@ import { AllowNull, AutoIncrement, Column, ForeignKey, Model, PrimaryKey, Table } from "sequelize-typescript"; import Upstream from "./Upstream"; +import { IHookshotBridgeRecord } from "./IHookshotBridgeRecord"; @Table({ tableName: "dimension_hookshot_github_bridges", underscored: false, timestamps: false, }) -export default class HookshotGithubBridgeRecord extends Model { +export default class HookshotGithubBridgeRecord extends Model implements IHookshotBridgeRecord { @PrimaryKey @AutoIncrement @Column diff --git a/src/db/models/HookshotJiraBridgeRecord.ts b/src/db/models/HookshotJiraBridgeRecord.ts new file mode 100644 index 0000000..01a40fa --- /dev/null +++ b/src/db/models/HookshotJiraBridgeRecord.ts @@ -0,0 +1,31 @@ +import { AllowNull, AutoIncrement, Column, ForeignKey, Model, PrimaryKey, Table } from "sequelize-typescript"; +import Upstream from "./Upstream"; +import { IHookshotBridgeRecord } from "./IHookshotBridgeRecord"; + +@Table({ + tableName: "dimension_hookshot_jira_bridges", + underscored: false, + timestamps: false, +}) +export default class HookshotJiraBridgeRecord extends Model implements IHookshotBridgeRecord { + @PrimaryKey + @AutoIncrement + @Column + id: number; + + @AllowNull + @Column + @ForeignKey(() => Upstream) + upstreamId?: number; + + @AllowNull + @Column + provisionUrl?: string; + + @AllowNull + @Column + sharedSecret?: string; + + @Column + isEnabled: boolean; +} diff --git a/src/db/models/IHookshotBridgeRecord.ts b/src/db/models/IHookshotBridgeRecord.ts new file mode 100644 index 0000000..cfc16c0 --- /dev/null +++ b/src/db/models/IHookshotBridgeRecord.ts @@ -0,0 +1,8 @@ +import { AllowNull, Column } from "sequelize-typescript"; + +export interface IHookshotBridgeRecord { + upstreamId?: number; + provisionUrl?: string; + sharedSecret?: string; + isEnabled: boolean; +} diff --git a/src/integrations/Bridge.ts b/src/integrations/Bridge.ts index bf1fc9f..c28b2cb 100644 --- a/src/integrations/Bridge.ts +++ b/src/integrations/Bridge.ts @@ -51,3 +51,8 @@ export interface HookshotGithubBridgeConfiguration { botUserId: string; connections: HookshotConnection[]; } + +export interface HookshotJiraBridgeConfiguration { + botUserId: string; + connections: HookshotConnection[]; +} diff --git a/web/app/admin/bridges/hookshot-github/manage-selfhosted/manage-selfhosted.component.html b/web/app/admin/bridges/hookshot-github/manage-selfhosted/manage-selfhosted.component.html index 58e5c71..b814377 100644 --- a/web/app/admin/bridges/hookshot-github/manage-selfhosted/manage-selfhosted.component.html +++ b/web/app/admin/bridges/hookshot-github/manage-selfhosted/manage-selfhosted.component.html @@ -9,7 +9,7 @@ {{'Provisioning URL' | translate}} {{'The provisioning URL for the bridge. This is the specific address for the bridge given in the configuration.' | translate}} diff --git a/web/app/admin/bridges/hookshot-jira/hookshot-jira.component.html b/web/app/admin/bridges/hookshot-jira/hookshot-jira.component.html new file mode 100644 index 0000000..81ecc03 --- /dev/null +++ b/web/app/admin/bridges/hookshot-jira/hookshot-jira.component.html @@ -0,0 +1,41 @@ +
+ +
+
+ +
+

+ {{'matrix-hookshot' | translate}} + {{'is a multi-purpose bridge which supports Jira as an integration. If enabled in the configuration, it can be used here to offer a UI for setting up a room to pipe to a repository.' | translate}} + + + + + + + + + + + + + + + + +
{{'Name' | translate}}{{'Actions' | translate}}
{{'No bridge configurations.' | translate}}
+ {{ bridge.upstreamId ? "matrix.org's bridge" : "Self-hosted bridge" }} + ({{ bridge.provisionUrl }}) + + + + +
+ +

+
+
diff --git a/web/app/admin/bridges/hookshot-jira/hookshot-jira.component.scss b/web/app/admin/bridges/hookshot-jira/hookshot-jira.component.scss new file mode 100644 index 0000000..788d7ed --- /dev/null +++ b/web/app/admin/bridges/hookshot-jira/hookshot-jira.component.scss @@ -0,0 +1,3 @@ +.editButton { + cursor: pointer; +} \ No newline at end of file diff --git a/web/app/admin/bridges/hookshot-jira/hookshot-jira.component.ts b/web/app/admin/bridges/hookshot-jira/hookshot-jira.component.ts new file mode 100644 index 0000000..eb364da --- /dev/null +++ b/web/app/admin/bridges/hookshot-jira/hookshot-jira.component.ts @@ -0,0 +1,85 @@ +import { Component, OnInit } from "@angular/core"; +import { ToasterService } from "angular2-toaster"; +import { + AdminHookshotJiraBridgeManageSelfhostedComponent, + ManageSelfhostedHookshotJiraBridgeDialogContext +} from "./manage-selfhosted/manage-selfhosted.component"; +import { TranslateService } from "@ngx-translate/core"; +import { NgbModal } from "@ng-bootstrap/ng-bootstrap"; +import { FE_HookshotJiraBridge } from "../../../shared/models/hookshot_jira"; +import { AdminHookshotJiraApiService } from "../../../shared/services/admin/admin-hookshot-jira-api.service"; + +@Component({ + templateUrl: "./hookshot-jira.component.html", + styleUrls: ["./hookshot-jira.component.scss"], +}) +export class AdminHookshotJiraBridgeComponent implements OnInit { + + public isLoading = true; + public isUpdating = false; + public configurations: FE_HookshotJiraBridge[] = []; + + constructor(private hookshotApi: AdminHookshotJiraApiService, + private toaster: ToasterService, + private modal: NgbModal, + public translate: TranslateService) { + this.translate = translate; + } + + public ngOnInit() { + this.reload().then(() => this.isLoading = false); + } + + private async reload(): Promise { + try { + this.configurations = await this.hookshotApi.getBridges(); + } catch (err) { + console.error(err); + this.translate.get('Error loading bridges').subscribe((res: string) => { + this.toaster.pop("error", res); + }); + } + } + + public addSelfHostedBridge() { + const selfhostedRef = this.modal.open(AdminHookshotJiraBridgeManageSelfhostedComponent, { + backdrop: 'static', + size: 'lg', + }); + selfhostedRef.result.then(() => { + try { + this.reload() + } catch (err) { + console.error(err); + this.translate.get('Failed to get an updated Jira bridge list').subscribe((res: string) => { + this.toaster.pop("error", res); + }); + } + }) + const selfhostedInstance = selfhostedRef.componentInstance as ManageSelfhostedHookshotJiraBridgeDialogContext; + selfhostedInstance.provisionUrl = ''; + selfhostedInstance.sharedSecret = ''; + } + + public editBridge(bridge: FE_HookshotJiraBridge) { + const selfhostedRef = this.modal.open(AdminHookshotJiraBridgeManageSelfhostedComponent, { + backdrop: 'static', + size: 'lg', + }); + selfhostedRef.result.then(() => { + try { + this.reload() + } catch (err) { + console.error(err); + this.translate.get('Failed to get an updated Jira bridge list').subscribe((res: string) => { + this.toaster.pop("error", res); + }); + } + }) + const selfhostedInstance = selfhostedRef.componentInstance as ManageSelfhostedHookshotJiraBridgeDialogContext; + selfhostedInstance.provisionUrl = bridge.provisionUrl; + selfhostedInstance.sharedSecret = bridge.sharedSecret; + selfhostedInstance.bridgeId = bridge.id; + selfhostedInstance.isAdding = !bridge.id; + } +} diff --git a/web/app/admin/bridges/hookshot-jira/manage-selfhosted/manage-selfhosted.component.html b/web/app/admin/bridges/hookshot-jira/manage-selfhosted/manage-selfhosted.component.html new file mode 100644 index 0000000..cbdcfd3 --- /dev/null +++ b/web/app/admin/bridges/hookshot-jira/manage-selfhosted/manage-selfhosted.component.html @@ -0,0 +1,31 @@ + + + diff --git a/web/app/admin/bridges/hookshot-jira/manage-selfhosted/manage-selfhosted.component.scss b/web/app/admin/bridges/hookshot-jira/manage-selfhosted/manage-selfhosted.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/web/app/admin/bridges/hookshot-jira/manage-selfhosted/manage-selfhosted.component.ts b/web/app/admin/bridges/hookshot-jira/manage-selfhosted/manage-selfhosted.component.ts new file mode 100644 index 0000000..98eaa66 --- /dev/null +++ b/web/app/admin/bridges/hookshot-jira/manage-selfhosted/manage-selfhosted.component.ts @@ -0,0 +1,63 @@ +import { Component } from "@angular/core"; +import { NgbActiveModal } from "@ng-bootstrap/ng-bootstrap"; +import { ToasterService } from "angular2-toaster"; +import { TranslateService } from "@ngx-translate/core"; +import { AdminHookshotJiraApiService } from "../../../../shared/services/admin/admin-hookshot-jira-api.service"; + +export interface ManageSelfhostedHookshotJiraBridgeDialogContext { + provisionUrl: string; + sharedSecret: string; + bridgeId: number; + isAdding: boolean; +} + +@Component({ + templateUrl: "./manage-selfhosted.component.html", + styleUrls: ["./manage-selfhosted.component.scss"], +}) +export class AdminHookshotJiraBridgeManageSelfhostedComponent { + + isSaving = false; + provisionUrl: string; + sharedSecret: string; + bridgeId: number; + isAdding = true; + + constructor(public modal: NgbActiveModal, + private hookshotApi: AdminHookshotJiraApiService, + private toaster: ToasterService, + public translate: TranslateService) { + this.translate = translate; + } + + public add() { + this.isSaving = true; + if (this.isAdding) { + this.hookshotApi.newSelfhosted(this.provisionUrl, this.sharedSecret).then(() => { + this.translate.get('Jira bridge added').subscribe((res: string) => { + this.toaster.pop("success", res); + }); + this.modal.close(); + }).catch(err => { + console.error(err); + this.isSaving = false; + this.translate.get('Failed to create Jira bridge').subscribe((res: string) => { + this.toaster.pop("error", res); + }); + }); + } else { + this.hookshotApi.updateSelfhosted(this.bridgeId, this.provisionUrl, this.sharedSecret).then(() => { + this.translate.get('Jira bridge updated').subscribe((res: string) => { + this.toaster.pop("success", res); + }); + this.modal.close(); + }).catch(err => { + console.error(err); + this.isSaving = false; + this.translate.get('Failed to update Jira bridge').subscribe((res: string) => { + this.toaster.pop("error", res); + }); + }); + } + } +} diff --git a/web/app/app.module.ts b/web/app/app.module.ts index 32c1414..0fdd649 100644 --- a/web/app/app.module.ts +++ b/web/app/app.module.ts @@ -124,6 +124,13 @@ import { AdminHookshotGithubBridgeManageSelfhostedComponent } from "./admin/brid import { AdminHookshotGithubApiService } from "./shared/services/admin/admin-hookshot-github-api.service"; import { HookshotGithubApiService } from "./shared/services/integrations/hookshot-github-api.service"; import { HookshotGithubBridgeConfigComponent } from "./configs/bridge/hookshot-github/hookshot-github.bridge.component"; +import { AdminHookshotJiraBridgeComponent } from "./admin/bridges/hookshot-jira/hookshot-jira.component"; +import { + AdminHookshotJiraBridgeManageSelfhostedComponent +} from "./admin/bridges/hookshot-jira/manage-selfhosted/manage-selfhosted.component"; +import { AdminHookshotJiraApiService } from "./shared/services/admin/admin-hookshot-jira-api.service"; +import { HookshotJiraApiService } from "./shared/services/integrations/hookshot-jira-api.service"; +import { HookshotJiraBridgeConfigComponent } from "./configs/bridge/hookshot-jira/hookshot-jira.bridge.component"; // AoT requires an exported function for factories export function HttpLoaderFactory(http: HttpClient) { @@ -236,6 +243,9 @@ export function HttpLoaderFactory(http: HttpClient) { AdminHookshotGithubBridgeComponent, AdminHookshotGithubBridgeManageSelfhostedComponent, HookshotGithubBridgeConfigComponent, + AdminHookshotJiraBridgeComponent, + AdminHookshotJiraBridgeManageSelfhostedComponent, + HookshotJiraBridgeConfigComponent, // Vendor ], @@ -267,6 +277,8 @@ export function HttpLoaderFactory(http: HttpClient) { AdminTermsApiService, AdminHookshotGithubApiService, HookshotGithubApiService, + AdminHookshotJiraApiService, + HookshotJiraApiService, {provide: Window, useValue: window}, // Vendor @@ -292,7 +304,7 @@ export function HttpLoaderFactory(http: HttpClient) { AdminSlackBridgeManageSelfhostedComponent, AdminLogoutConfirmationDialogComponent, AdminTermsNewEditPublishDialogComponent, - AdminWidgetWhiteboardConfigComponent + AdminWidgetWhiteboardConfigComponent, ] }) export class AppModule { diff --git a/web/app/app.routing.ts b/web/app/app.routing.ts index ed7e6f8..e8f6fb1 100644 --- a/web/app/app.routing.ts +++ b/web/app/app.routing.ts @@ -50,11 +50,13 @@ import { TermsWidgetWrapperComponent } from "./widget-wrappers/terms/terms.compo import { WhiteboardWidgetComponent } from "./configs/widget/whiteboard/whiteboard.widget.component"; import { AdminHookshotGithubBridgeComponent } from "./admin/bridges/hookshot-github/hookshot-github.component"; import { HookshotGithubBridgeConfigComponent } from "./configs/bridge/hookshot-github/hookshot-github.bridge.component"; +import { AdminHookshotJiraBridgeComponent } from "./admin/bridges/hookshot-jira/hookshot-jira.component"; +import { HookshotJiraBridgeConfigComponent } from "./configs/bridge/hookshot-jira/hookshot-jira.bridge.component"; const routes: Routes = [ {path: "", component: HomeComponent}, - {path: "riot", pathMatch: "full", redirectTo: "riot-app"}, - {path: "element", pathMatch: "full", redirectTo: "riot-app"}, + {path: "riot", pathMatch: "full", redirectTo: "riot-app", data: {breadcrumb: "Home", name: "Dimension"}}, + {path: "element", pathMatch: "full", redirectTo: "riot-app", data: {breadcrumb: "Home", name: "Dimension"}}, { path: "riot-app", component: RiotComponent, @@ -141,6 +143,11 @@ const routes: Routes = [ component: AdminHookshotGithubBridgeComponent, data: {breadcrumb: "Github Bridge", name: "Github Bridge"}, }, + { + path: "hookshot_jira", + component: AdminHookshotJiraBridgeComponent, + data: {breadcrumb: "Jira Bridge", name: "Jira Bridge"}, + }, ], }, { @@ -243,6 +250,7 @@ const routes: Routes = [ }, { path: "complex-bot", + data: {breadcrumb: {skip: true}}, children: [ { path: "rss", @@ -258,6 +266,7 @@ const routes: Routes = [ }, { path: "bridge", + data: {breadcrumb: {skip: true}}, children: [ { path: "irc", @@ -284,6 +293,11 @@ const routes: Routes = [ component: HookshotGithubBridgeConfigComponent, data: {breadcrumb: "Github Bridge Configuration", name: "Github Bridge Configuration"}, }, + { + path: "hookshot_jira", + component: HookshotJiraBridgeConfigComponent, + data: {breadcrumb: "Jira Bridge Configuration", name: "Jira Bridge Configuration"}, + }, ], }, { @@ -295,6 +309,7 @@ const routes: Routes = [ }, { path: "widgets", + data: {breadcrumb: {skip: true}}, children: [ {path: "terms/:shortcode/:lang/:version", component: TermsWidgetWrapperComponent}, {path: "generic", component: GenericWidgetWrapperComponent}, diff --git a/web/app/configs/bridge/hookshot-jira/hookshot-jira.bridge.component.html b/web/app/configs/bridge/hookshot-jira/hookshot-jira.bridge.component.html new file mode 100644 index 0000000..7edaa68 --- /dev/null +++ b/web/app/configs/bridge/hookshot-jira/hookshot-jira.bridge.component.html @@ -0,0 +1,44 @@ + + + +
+ {{'Bridge to Jira' | translate}} +
+
+ +
+
+
+

+ {{'In order to bridge to Jira, you\'ll need to authorize the bridge to access your organization(s). Please click the button below to do so.' | translate}} +

+ + sign in with slack + +
+
+ + + +
+
+
+
+
diff --git a/web/app/configs/bridge/hookshot-jira/hookshot-jira.bridge.component.scss b/web/app/configs/bridge/hookshot-jira/hookshot-jira.bridge.component.scss new file mode 100644 index 0000000..9e914bf --- /dev/null +++ b/web/app/configs/bridge/hookshot-jira/hookshot-jira.bridge.component.scss @@ -0,0 +1,4 @@ +.actions-col { + width: 120px; + text-align: center; +} diff --git a/web/app/configs/bridge/hookshot-jira/hookshot-jira.bridge.component.ts b/web/app/configs/bridge/hookshot-jira/hookshot-jira.bridge.component.ts new file mode 100644 index 0000000..0801dc7 --- /dev/null +++ b/web/app/configs/bridge/hookshot-jira/hookshot-jira.bridge.component.ts @@ -0,0 +1,99 @@ +import { Component, OnInit } from "@angular/core"; +import { BridgeComponent } from "../bridge.component"; +import { ScalarClientApiService } from "../../../shared/services/scalar/scalar-client-api.service"; +import { SafeUrl } from "@angular/platform-browser"; +import { TranslateService } from "@ngx-translate/core"; +import { HookshotJiraApiService } from "../../../shared/services/integrations/hookshot-jira-api.service"; +import { FE_HookshotJiraConnection } from "../../../shared/models/hookshot_jira"; + +interface HookshotConfig { + botUserId: string; + connections: FE_HookshotJiraConnection[]; +} + +@Component({ + templateUrl: "hookshot-jira.bridge.component.html", + styleUrls: ["hookshot-jira.bridge.component.scss"], +}) +export class HookshotJiraBridgeConfigComponent extends BridgeComponent implements OnInit { + + public isBusy: boolean; + public needsAuth = false; + public authUrl: SafeUrl; + public loadingConnections = false; + public orgs: string[] = []; + public repos: string[] = []; // for org + public orgId: string; + public repoId: string; + + constructor(private hookshot: HookshotJiraApiService, private scalar: ScalarClientApiService, public translate: TranslateService) { + super("hookshot_jira", translate); + this.translate = translate; + } + + public ngOnInit() { + super.ngOnInit(); + + this.prepare(); + } + + private prepare() { + + } + + public loadRepos() { + // TODO + } + + public get isBridged(): boolean { + return this.bridge.config.connections.length > 0; + } + + public async bridgeRoom(): Promise { + this.isBusy = true; + + try { + await this.scalar.inviteUser(this.roomId, this.bridge.config.botUserId); + } catch (e) { + if (!e.response || !e.response.error || !e.response.error._error || + e.response.error._error.message.indexOf("already in the room") === -1) { + this.isBusy = false; + this.translate.get('Error inviting bridge').subscribe((res: string) => { + this.toaster.pop("error", res); + }); + return; + } + } + + this.hookshot.bridgeRoom(this.roomId).then(conn => { + this.bridge.config.connections.push(conn); + this.isBusy = false; + this.translate.get('Bridge requested').subscribe((res: string) => { + this.toaster.pop("success", res); + }); + }).catch(error => { + this.isBusy = false; + console.error(error); + this.translate.get('Error requesting bridge').subscribe((res: string) => { + this.toaster.pop("error", res); + }); + }); + } + + public unbridgeRoom(): void { + this.isBusy = true; + this.hookshot.unbridgeRoom(this.roomId).then(() => { + this.bridge.config.connections = []; + this.isBusy = false; + this.translate.get('Bridge removed').subscribe((res: string) => { + this.toaster.pop("success", res); + }); + }).catch(error => { + this.isBusy = false; + console.error(error); + this.translate.get('Error removing bridge').subscribe((res: string) => { + this.toaster.pop("error", res); + }); + }); + } +} diff --git a/web/app/shared/models/hookshot_jira.ts b/web/app/shared/models/hookshot_jira.ts new file mode 100644 index 0000000..d1d80de --- /dev/null +++ b/web/app/shared/models/hookshot_jira.ts @@ -0,0 +1,11 @@ +export interface FE_HookshotJiraBridge { + id: number; + upstreamId?: number; + provisionUrl?: string; + sharedSecret?: string; + isEnabled: boolean; +} + +export interface FE_HookshotJiraConnection { + +} diff --git a/web/app/shared/registry/integrations.registry.ts b/web/app/shared/registry/integrations.registry.ts index 4c91532..291009f 100644 --- a/web/app/shared/registry/integrations.registry.ts +++ b/web/app/shared/registry/integrations.registry.ts @@ -32,6 +32,7 @@ export class IntegrationsRegistry { "webhooks": {}, "slack": {}, "hookshot_github": {}, + "hookshot_jira": {}, }, "widget": { "custom": { diff --git a/web/app/shared/services/admin/admin-hookshot-jira-api.service.ts b/web/app/shared/services/admin/admin-hookshot-jira-api.service.ts new file mode 100644 index 0000000..e9ffb57 --- /dev/null +++ b/web/app/shared/services/admin/admin-hookshot-jira-api.service.ts @@ -0,0 +1,38 @@ +import { Injectable } from "@angular/core"; +import { AuthedApi } from "../authed-api"; +import { FE_Upstream } from "../../models/admin-responses"; +import { HttpClient } from "@angular/common/http"; +import { FE_HookshotJiraBridge } from "../../models/hookshot_jira"; + +@Injectable() +export class AdminHookshotJiraApiService extends AuthedApi { + constructor(http: HttpClient) { + super(http); + } + + public getBridges(): Promise { + return this.authedGet("/api/v1/dimension/admin/hookshot/jira/all").toPromise(); + } + + public getBridge(bridgeId: number): Promise { + return this.authedGet("/api/v1/dimension/admin/hookshot/jira/" + bridgeId).toPromise(); + } + + public newFromUpstream(upstream: FE_Upstream): Promise { + return this.authedPost("/api/v1/dimension/admin/hookshot/jira/new/upstream", {upstreamId: upstream.id}).toPromise(); + } + + public newSelfhosted(provisionUrl: string, sharedSecret: string): Promise { + return this.authedPost("/api/v1/dimension/admin/hookshot/jira/new/selfhosted", { + provisionUrl: provisionUrl, + sharedSecret: sharedSecret, + }).toPromise(); + } + + public updateSelfhosted(bridgeId: number, provisionUrl: string, sharedSecret: string): Promise { + return this.authedPost("/api/v1/dimension/admin/hookshot/jira/" + bridgeId, { + provisionUrl: provisionUrl, + sharedSecret: sharedSecret, + }).toPromise(); + } +} diff --git a/web/app/shared/services/integrations/hookshot-jira-api.service.ts b/web/app/shared/services/integrations/hookshot-jira-api.service.ts new file mode 100644 index 0000000..3f2fb28 --- /dev/null +++ b/web/app/shared/services/integrations/hookshot-jira-api.service.ts @@ -0,0 +1,21 @@ +import { Injectable } from "@angular/core"; +import { AuthedApi } from "../authed-api"; +import { HttpClient } from "@angular/common/http"; +import { FE_HookshotJiraConnection } from "../../models/hookshot_jira"; + +@Injectable() +export class HookshotJiraApiService extends AuthedApi { + constructor(http: HttpClient) { + super(http); + } + + public bridgeRoom(roomId: string): Promise { + return this.authedPost("/api/v1/dimension/hookshot/jira/room/" + roomId + "/connect", { + // TODO + }).toPromise(); + } + + public unbridgeRoom(roomId: string): Promise { + return this.authedDelete("/api/v1/dimension/hookshot/jira/" + roomId + "/connections/all").toPromise(); + } +} diff --git a/web/assets/i18n/en.json b/web/assets/i18n/en.json index 53272f2..5a60c02 100644 --- a/web/assets/i18n/en.json +++ b/web/assets/i18n/en.json @@ -25,6 +25,8 @@ "The shared secret defined in the configuration for provisioning.": "The shared secret defined in the configuration for provisioning.", "Save": "Save", "Cancel": "Cancel", + "is a multi-purpose bridge which supports Jira as an integration. If enabled in the configuration, it can be used here to offer a UI for setting up a room to pipe to a repository.": "is a multi-purpose bridge which supports Jira as an integration. If enabled in the configuration, it can be used here to offer a UI for setting up a room to pipe to a repository.", + "Self-hosted Jira bridges must have": "Self-hosted Jira bridges must have", "Add a new self-hosted IRC Bridge": "Add a new self-hosted IRC Bridge ", "Self-hosted IRC bridges must have": "Self-hosted IRC bridges must have", "The provisioning URL for the bridge. This is usually the same as the URL given in the registration. This API is not authenticated and should be treated with caution.": "The provisioning URL for the bridge. This is usually the same as the URL given in the registration. This API is not authenticated and should be treated with caution.", @@ -154,6 +156,8 @@ "In order to bridge to Github, you'll need to authorize the bridge to access your organization(s). Please click the button below to do so.": "In order to bridge to Github, you'll need to authorize the bridge to access your organization(s). Please click the button below to do so.", "Organization": "Organization", "Repository": "Repository", + "Bridge to Jira": "Bridge to Jira", + "In order to bridge to Jira, you'll need to authorize the bridge to access your organization(s). Please click the button below to do so.": "In order to bridge to Jira, you'll need to authorize the bridge to access your organization(s). Please click the button below to do so.", "Add an IRC channel": "Add an IRC channel", "Bridging a channel requires authorization from a channel operator. When entering a channel below, a bot will join the channel to ensure it exists and has operators available.": "Bridging a channel requires authorization from a channel operator. When entering a channel below, a bot will join the channel to ensure it exists and has operators available.", "Channel Name": "Channel Name", @@ -320,6 +324,11 @@ "Failed to create Github bridge": "Failed to create Github bridge", "Github bridge updated": "Github bridge updated", "Failed to update Github bridge": "Failed to update Github bridge", + "Failed to get an updated Jira bridge list": "Failed to get an updated Jira bridge list", + "Jira bridge added": "Jira bridge added", + "Failed to create Jira bridge": "Failed to create Jira bridge", + "Jira bridge updated": "Jira bridge updated", + "Failed to update Jira bridge": "Failed to update Jira bridge", "IRC Bridge added": "IRC Bridge added", "Failed to create IRC bridge": "Failed to create IRC bridge", "Click the pencil icon to enable networks.": "Click the pencil icon to enable networks.", diff --git a/web/assets/i18n/template.json b/web/assets/i18n/template.json index 4fd3535..1b3be02 100644 --- a/web/assets/i18n/template.json +++ b/web/assets/i18n/template.json @@ -25,6 +25,8 @@ "The shared secret defined in the configuration for provisioning.": "The shared secret defined in the configuration for provisioning.", "Save": "Save", "Cancel": "Cancel", + "is a multi-purpose bridge which supports Jira as an integration. If enabled in the configuration, it can be used here to offer a UI for setting up a room to pipe to a repository.": "is a multi-purpose bridge which supports Jira as an integration. If enabled in the configuration, it can be used here to offer a UI for setting up a room to pipe to a repository.", + "Self-hosted Jira bridges must have": "Self-hosted Jira bridges must have", "Add a new self-hosted IRC Bridge": "Add a new self-hosted IRC Bridge", "Self-hosted IRC bridges must have": "Self-hosted IRC bridges must have", "The provisioning URL for the bridge. This is usually the same as the URL given in the registration. This API is not authenticated and should be treated with caution.": "The provisioning URL for the bridge. This is usually the same as the URL given in the registration. This API is not authenticated and should be treated with caution.", @@ -154,6 +156,8 @@ "In order to bridge to Github, you'll need to authorize the bridge to access your organization(s). Please click the button below to do so.": "In order to bridge to Github, you'll need to authorize the bridge to access your organization(s). Please click the button below to do so.", "Organization": "Organization", "Repository": "Repository", + "Bridge to Jira": "Bridge to Jira", + "In order to bridge to Jira, you'll need to authorize the bridge to access your organization(s). Please click the button below to do so.": "In order to bridge to Jira, you'll need to authorize the bridge to access your organization(s). Please click the button below to do so.", "Add an IRC channel": "Add an IRC channel", "Bridging a channel requires authorization from a channel operator. When entering a channel below, a bot will join the channel to ensure it exists and has operators available.": "Bridging a channel requires authorization from a channel operator. When entering a channel below, a bot will join the channel to ensure it exists and has operators available.", "Channel Name": "Channel Name", @@ -320,6 +324,11 @@ "Failed to create Github bridge": "Failed to create Github bridge", "Github bridge updated": "Github bridge updated", "Failed to update Github bridge": "Failed to update Github bridge", + "Failed to get an updated Jira bridge list": "Failed to get an updated Jira bridge list", + "Jira bridge added": "Jira bridge added", + "Failed to create Jira bridge": "Failed to create Jira bridge", + "Jira bridge updated": "Jira bridge updated", + "Failed to update Jira bridge": "Failed to update Jira bridge", "IRC Bridge added": "IRC Bridge added", "Failed to create IRC bridge": "Failed to create IRC bridge", "Click the pencil icon to enable networks.": "Click the pencil icon to enable networks.", diff --git a/web/assets/img/avatars/jira.png b/web/assets/img/avatars/jira.png new file mode 100644 index 0000000000000000000000000000000000000000..0bffc9c9b476d845cfa166c092e345652bc7d4b5 GIT binary patch literal 17718 zcmeIZWmufcwl3PZyKA6vcXxMp*T%JRcLKrPHMoV~E&+lDm!QER5G1(gkU8gCYp#3t z-RJJ-x#!>Fp`pL3cf4cNTVqsxA5=%HD$AfC5+VWs02Db{N%hy?-oIb)u&+P)EV*X^ z0J3RcODqk!qTX0-*dRfGH~d*lxUtR2<#Og zI=g)=?|Pnme(3V~6>(76XW{)~aCv;~Jk@!NB#4$*Fk6naaQ`rLca6u?Q)kj^c_XAS%QbcG_;FdAo;Z&1@Q?q~mr>|2x$Inyj=TnCU`8Udg-GPtSzdqLcGPo9O zKBF@0_B?JDKtMmcQM(6j-I0bGABuNRl{~KZN`DIVZ&kjTtC(1-==!xA=>4LgDjt~6 zBlz>!eE)XXxxnlD;}DNmw8%Q&#I3-u3;)$q|Gdf7UN>#7gCEwOLRRUVz0}v-X^&Ve z#Ed>|&g1?#FI0idx79zjP?iM{?i=P%d&=x5vC?;n4eB~~ZUSm$EP2DI-e*qC8(y`kPh5j_4jynPz5+P&_cM#M7A(7j`>TxoiB67=(WCt^}< zOu+j1QJ;Yp z#+ly!c3;x2I{*rU-B@8okj0HzO| zm%b%r`^9AUCe0#^`^|gX(>Z6jgr<~aVz9j-!C-j@KsdqSFffYUpml$j<(OY3o3BO< zW6dWO3;+4VpZ`?I^G(ttl@s;nU-V8dOknAvM-$0OT6Yo2)7!QHtY)f~EbdGq^0Xq{ z1=ul``~`hJfdkJ;+MnlZM+_wCU5d|V$T(bQ1q_F5XPy%Tl?g^elRcg|>RNu7iNKXFPkSZuOMvNHU(niQ-HLvMQa>cUhgouA0 z31H#T6YCLcp_D}Eb~YatuN{3zp07^35d;G zcCAeFvRfcw#)J)9Z_m{Re=X%zFTzh|S#p&pX0|`wDq)v}a#k7l`_Om%tVea0;%_qN z=*OrxuW~w^tj1N++{Gb$;5J*lA`#XnE3E>?m94rHw3?tula1DGC{M4S8mX59g?6?q zNPFgJ)hwO|SefX(L3H~(q#miMZzA~a5Od&5NVu`Jja;&VTVA%e&Ni?|TiYDt<)}!1 z3c4L@T+sWNuvV>P=$_bLJa{3ZPHtEuoXH7$@~U0Ml#g1*_M`C(L^C6363{!BaC0!+ zS`%NLE2p%cTc+(zo!~fP`)*%&nKo~6me=p(fZ#LoQ-B8g+4cF&hC}kk8eQ$RFF{P_ z4-?drIf~ISb`rQ)&MWyUL+S#>WyyN8+LUN?-H$3GPj)1-Q&3GVn}V^W8Epy|AF(Eq zX>4~;+C{YUF5A?s+`}H;kTR0O6!9ioqv$K!Bojy&A?HoNbC)l}owvSIpi~xFjm5pu z?S8v1CUQIy_tkH8ldg}aaCv{!ef5)K-G|yLQe@Y>QGG{tUXC&8=v<+}@$ZJ9xasVn zTEP-+HDf0AJC0oHw$*cqdMuRG)v^*qf;V)@{oAa?&}8KnL?N=aKlH{80n&wuMD4l) z;xYDvJNN0t6wETqDs=4W{u#o0JI^cA0GHM0QxEs;vuAQ>4E@m}&;^>M9ch}2UMTRe zdu^?3zQqfRHH-R(4`O594M5X)$bYd)bKEq$Q!fOn}~BcX4~9{Jnat0 z%;xw-xw~L%^LrLuMvJTLpdHz^p76|&`H)`HC%fE3I-c#fDt2Mvu9HwsyBK>W(s~vd zSy2!1NQeZDNpp=2?96eZ*GMMOSmBr2)&u0BAzbN}I(rzps8i2TS8H2aNs1?`Kh4vB z3StSf#oYJ6mwx(1K$pcLbw`7#uXv5V%4Y>o?$l5uPOc13)*co4G2^J&IC0U(vkw z3;P#Q@vy@uh7xBgj7(L%@tUkkVmc6J*U~QK2ReQ#MIrP_Y+VPIoeY4N64~T_rt;LG z#x6uPwi$*=B)Fn!i{fK>2`#?2ivTqIJM+Z)c?tq--P~vjN|^xiT}{^I=!#(cYFiq< z&`4TH72i;pO`^|ui;85hB{&@g^nA74-~)!f_IkUO8PT-P_ozwMZgE{ggI#8jesh9S zvNfmKa3KY!rZxmx;hO-8Sn#}`4@17^uo|}Hto@{(5C7e09|7$x0vY57B65c=C$})a zg%b`!R^kLZ0147V=mc|~3wMchbykdKt%Ay@>nS{_BvqUy!tD=PBi;%%F^(lTT!C93oo4x;ow0Ra|5?c7nP$8kVSW8${zs43;-&>ARo=gH3R(6|Q<)0H!a^n|S#$-_o=!udLpG6?hCH9vl z^Uzya>xZ?Uj4jTZwMV9K=rDqb2SbMMZ>Y&iFuM zPUxgSKn!OY&EOS{Z8u-c*85I_=ms#K`eQ`qBN~PY)x*0!dd1E@?Oh&mQn6GB*$F7b z=1p8Hk-kq+yLdKXTQWQ(#84~n35R|kVGw(-OgEeERrr1gAaeXdcpD&NwvT1qze6C7 z*G&w0nlp`Bv?-rzmBeNhN4tBR=K_l#%(!^8nFcYjTD>O52TFuM=0$yw_)xe_LOH2Tg#A(vw~F42J@AtZ}uPKpQz&%R?-hrFp9@h$Xpe9CZ=N zFz?&)!V;u4g|%o>ZPN%_JhC?a8*DjMwYIc*F)>quF#U`!VPF)G8x<4;cO|(NkRdk* zmpGwYN{gKl6@`GBa&z(I3-kt>=z^UXL{)(|KsMrAh|CEqrB`&KY+-KThgo^%@VD?H zmz&lb96`XI-f_QumBHJ7zw=RTn$ehpi+pd$pSiINg4V!`5QRO5YJUQ>qd_1{J3PQZ zIKx~Vt_qip4oWYw9-QS{QtZghym@CQT})ZPHrt%o4#seq7{tRWig!{4n_7&d1#?Wi zptH@w{vu>(px~wul2m7#sGJa8fe1=8ki#g$`l6(S7r9z!rOM#nTJF-$jf|pVsW(#v zJHKIMKYQD(n;$xW9r+1qxVWpbUS7)u^gMziH&XfTeT({aE3BTw_4MoqhbYxj*>A@8 z54DD{THG}>JQ6#fproafIim%lr*ZWVY#rYg$+XoYQM9Vtv!Ttz6po8VDVsr*dOIExuT%tB@ zr7O-CRwP|8>P|&d0v458-j7zTK-$iDR?Mxb=juS{7_#X>PwRbcHhn@rT3kuBWFaw-joaa;b>6dpw%`N(lCSm{VbCudGnNf@&d zFRuZW6E2^;7epx(RH~dFQn60B`?>1Vx8{R#NJ=2R7!H{r;5?Xh*RnJ=tE?X}cF4|; zn79umtm#tcv>NJgbkec@ zpWtB~-s(^gpM=#!5R;9_<)M5K63&I^3R;ChxLL2ljy$;K}|DZv0U)_~{WBGpeYecM@-A<(?ENwUod& zR=eoR8rIwye1yvIy`jgbDg%7l$zV2nhwLgj_Y6EO>A)uZCNx_urSVPSU^4OJ#LT7g z1Ei>NCLJdqvJxpfHy;OnRmPuF7g}QjCj?03pW(6_-lPO7SnQT_!uQ-5CI`?%akTy- zOlo0y{s36Oglos=PavU$yV6i*R}lV=YX3G3x0So@M|Q{$ zW1HuSXxJ0+5Is@OxyvIyRV{TvGIVb<{Eh|XDrj{NQs(*Z0BvVpuOz$Wc-~=8m;_x4 z3)$)DqeVn4*^{b|-ixZJiPs$jERR4K^v5sD&LPHdW%U5XZ|;PGhHHLsA~F9ywKvV$*LsFwT>8z zR#W z5(l82`R>i&N$Ta9id=Qk%OGilo12IMQcPlwjK<-g)!v$B`ob-lSHvdhliD+-fV>w- z5T^TZ1xLoiIy52mj?pFqF#20WC2@LCn?=(bu`$J&086(~8-4o}vC+)=g-Q6XT;FYE zKj>29>*l<7RB+_BCW9*Hxp6cWGxmn^_#kJl!INKbpC@Ate{hQy;uC}%u%$1CqgjC1 z^*(>%9V&qYwQ6zOH42=Aql|2melL&B@Sv#EtYvbP@$&bIltgDweN0k4ZIl%BI7D5D9>N%4Ap&_C)=5~^ zGrorp8Micm-P|2UZCediVDM_4om9W1qRyM%wO-bn>T0PXm<+P<&JxI%+9YStj907( zl|b+2@s-5*iPK?B+#poSW{08(32)fFBc4pwGlCd-po|Rua`SWRdkeQ#)d+sA`51n+ zgYQXq1()^$SjGa+V@dCKHBr8#0|K-&5QqJ?ds1#{Ev}NGvVOEAwsx&fdrF-M^goLo zpyjP}@j~#3Qm*+W$D0HceylIja(n|@FI>lOs*Ynohl5tC!-=@y`a5dgW)YoK(L=notA6mw=P#ijOlwvL;7#OMZ^mSx- z{zo{b{EA8N!MZqaae|!EwJ=Y!)QiB$v^MdXC#8@L6!ZwcbtDEG50hUQxNA3*e^{&j zt9oKoDYS&B(A&(EApWFa2$>&rFlumml!OeKmC07m$Unst3|!P|<{EZ`tmU3CaDg_u z1i9La1UbcIWZmaor&jKEQU?SW{*rM?ArW#094jB`y*=JEI}0Rw($>ht$VxsrJXFVl zm-&$}UCt7YVBXG5T`D8~kdWHctay;g6NskQkKE>)gk zB3n-ITjDz4VU(5N3ZYiF&qg?U5a4kuf>Q- zSeWL{%is>FpJRq?p;p=xiP29sAd$JkG`AO5VBTc33YyTEUF6&Xi3TIRl*rqomK^GeT~;@9WZX%e$an;1iVh zByT1EpXAiH(Z8=YSRY~Ox+`h7AO+s|tEx!%HCdcE9Oy-e1v=j0Ct-Iw~JwI^~fLInXBq3X@g_ zOAscy_@VpRlENawtBXkJLh3BcqWDw`(kA%X)D%?V!z&cC*4AM)MN6$nFl^fJgO?lK zSDF~v3s$#!)m|z+PZIAaEg;^p66Dsj5*+8#$oeGs>T{(o-tQJd8%u0nec6YGnL>@}!U6P}2f@q~7IX>3?(ntVq6?a!W-%&-Wug-7vZp*s0;QAoJ zrDD*w@sgOQvT9#N$-1Uw^r4=D|KPiCB?A&;R#Qe{ zt;bVn<0nMMpNx<#Wh4-qLzvOu@fR}DP0|6u$t}K#p~>_Dh8~iRTn&9p0u+0^?wr(S z;a}z`XN!hFL|P4^KJTt=vA&Q5SuNpP_NPAP{9rd0C?P40PDWh0vJP}X-Q7?H$oJaZ z&gg~Aso&Vav{m8~tw9ZbB(54xpU_CWQDA#_*FN1wH8Sq8aw4opk}pdSCs}){HL2U| zNXFRwCVq003n``eOS&ZyLU(ia*STaP$rCnRuAxyW$8VQwxqS>h)euYYH>+=>z~gwW z&+Ard(>0!hqJD?ZYd4SKv*0Sm`m}d9MnVO&QWNF|xH9DM7*#-MHlgmQBFDKFcTQ!9 z!{9=F6qR_pV^Qv(F1C^(t02PUD<_f)E{4<-7u@J6nT1-%Cj12Rbi4v4{nyh}}hAe|_x_EKZPvsVn<%_nxdU}w#7{(wx zm7}nLnkJ1gjmRn^ZBQWDS<*!)EHAw$-i!ghE~e?*+5qPOZ%D+snH4|Lafw0~B3m4G z?eJ;B!tW#Fd_ii3#pR2upF#OvSYJd!seWJ@&dHh6$d~F8>@?bya&iQ%v4-m*=>ZZk z8}fj9+vGc(o4BPJljt0PTG3EjEOb)zTthV5{Jf(?E~S+eEw>^2n8NSjkyUY3iwL`q zm=$LlA=w786Pf0z;=|bHk)!7XVcb}1>?{6f(V&lQ{EpPcL_guRov2718!VKQ#R#H( zX6(D9LW?DCVMsy=zpX%{ASqPsJEFp^Q~3FdZo`u{bTPKKl$=ycs@5S9N>(X3`d7C< z5wG@Ju37O_$C>+Af9l+Bzs7^u502q6wT(c4qr(hHJtObpZ9tBzFnL2vTSH-Rh_km3 z>d(n};cH7F73W})h8kRWKP23iY7!FOc=RSdH!zow>(_eHTmn}4eu9j$hh=3WVtv!3 z)?r_vXVcHW<4vcq-mK)x+o)294j(=XV({V1}E2ICFuo(9MN=VJH)BBF6H!C z8`b)@(ES=i0)Hts{e>|hRG>srI*izG$QW6}U^j_>t<17nIc)U8%sacsO%|*Tv3!!x zjGNJuMP?=*p2RgKOMbhCFaZL`B?*eFLYtT&az}5J9LiADso%k0r+`)(-n4i(;f=n> z3V2*NF0>4E_TVN_k0E&L94bLhoyX57Rf&*Wcp0KW;-!_Hc(-yQbjs*BaoiQuno5R~ z`7H{Co&zXaQrbzB)Kiu5q0!!ZrOe3U#I(|r`JA65zP~Dj*}7`|ErgDB(ufL{{H;b8 z53y{81*)DZnr-pgBO|SgD4M3F64}>-!aM$lwegaD+3@g+p%3KNQhb{98KLh>e?^ml z(elym;=a4YVka5Rl8U0a15#5=u=CEwbQakOKbJ?QqY_yl(L~~xiX_BG_WMO4?fpbQ zKcY46q#1yB4n1Ko^G1AMiAPvyJfUOO4c4Ri zWg9t9nDi$zZ_r%Mtx>`m_qW`_$99^?gPFQFi+bfM?K8D-^L3GJA*8(bXV+-!Vg`bnJxi_Q> zZq8RIQO-x%e|>;#`zY?S+#&TYU0)=%)giq;BPPp7S&uo!oa);rwNB`k;^> zWO_|o5UJ53`sxx;5TeQkB!+RA9cDHnMs}u@VQpL1?3sRS&TU!f08;Er&329i))7%X zYA^ai8G^6HSUuQ;$kWX=Y=w>eb0i+&^0mFTmmc1h0d#U-Zj{W|U}Zm>*$=faZbX}5 zV-Po{3=5P=?qayOy#gU#|S5v4%>Xm$0f!Oy1_6?eZ_;KGzCl>Y5;&c2S5U z8?Q353ft@vNvbmTY>T+mR@?WvNO^f$lnKn8y#H<)eDAk?SuVqF@C*U>@oVPf7xcLC0Kt&d?}jw-C8h1Y$SAcDFR_J9#)K$e1fqby;}JL*%I{7 z6`ke)-i>nGby8O0i4*SxS?e?e(+GNBfzE9@rx_0=JLE5$Zy2OD)@wd_eTn4~hlAW# z81LFxMhUp4=;K{A^StJ?*Jkb)<$P^)&{v5sBqc5lMf1fz*J#Ulkh%7s!@`6SH$ybr z7Ne94gUqv-XO3OPmPG?~J2{~tTkI-^cWGnH(-Iy_LPMsDTBstcc1YhYWZj$t0gOl(&6}5WADETgO&scFOZPJt`1j6RV_fSGo1y2m2 zA6BwaqbLK^?PR4eLqj%ljK4?$6+==s>aop+j)|N-_`@J=xAF3<4ch{!Did~=SLetN z_SM{nJUt>TzVl!2oV((jDCK3aed)*|p-Ofi^4H6zY#Ibys@?*}697%twb8TKbg%iD6 zR>%{r9w&jOhplo(SBXoj=qB4$nQJU<-|%!VmK1%cd%P&v`c-;8A#xfc8DJj3 zb?J;&{{zliAv_!afQYn}kWiJAkod>hhSw7fxdBN+vIC;{k>ASXr5I4PRb59^^BALo zc;#_oOpBUu^gUSHuVidy6HD575$j;!f^e%*nI}8=+Ym!H(V}v4Qi@|z?~K%KFm_0W zg}QFLS-ieMh}RBkHaa4!a*fM=E;gs`1i~86NHwBq;3sBQ4r?IUddGk8ySod-+tSoy@d3HMixq>x=DDSu72;d7WLg6>t{YXoAdG%bCjCl9CZI-z}%wz3h7=2++Vi z?1tgjS?}SRbbSDnRO%@3^!1aosu%v?O7QY(x;golE&VGH&`Aj%6dpaV0|3x+ZC_8D z=qf4lTR1y1n^`)WgP6S?U0zS4004p_-Y#Yq_8@mMbC9*IlMwK%vlmEaYbgZO;ZkB% za*+Vp*vk5XK^nfwnijtH7JQaK5n)6@Z~j*TN07T2nYW{ZlN-Oc5bzIP{@3@vn^}Nl ze?Z*rg@C$Bs$>$*U=SH6Gbb}Ela#luCp%CWkxUS5X~nNDDg774>y;4D#@*e8pM}NC z%Zu5IgV`Bu&BDgV$M^d0A{KUbrdI@$n~#&bnKzS@8^v#mKRF~pZWdr$7k67{C$it1 zX6DWw?m|G|Yd_gvwPl{Rh39yCsX<>jmfQcwPZ4?5wQ3Oswoo?0hVL zw}0(bQu?R0liOb^zUs;1ZRWzl#>~p%==g6IZthZ^|MK@=TDWPx9{OQX2e~u7Z0$*9~esu7LWtT@ih>)S7x?yKFe z(3M}(*}~(uQ#nZ?;O}_(EuAfFE&2a=%WekZv*Kd2VB)akV`JjvW;0_lvod32;$!D~ z{RZ(_@bPf`jY`hR&E3q&0`!~em7Ll3m52THAb1#?EBM z!O6*EWzNG1GH3mR>i0bGi>b;90oj@V)}rcQ=5FN-c6==_TPI6rFSmbzHEkV18t!Jl zWn<&!;N@Uv<6vXwzw!JF`cD=yu)CKt*j@#!Vr~br zaR2Xl{wwf5nbcobJ2!W*kKF%ZQ2!5{;GYRA`)cb9_W7HC4Up@fSAQ-^4z_9!QX2XzufOc z)bMa|aj*q}|6Qkl>#({O_A#$CnB>A!XVUjYAPP_(rG zIk`FiccK3s@~13++lXFc{D$^?#iFkM#YI zT>p{lf26?w2>hSy`j1@yBL)6P;QwUT|8H_3{_Bw&qYpx(8 z2_ORG0hV`L4MkpC5L{&S+yDSvs^2dNId#hO*G4#ZIVCB$J!A+7G-U7RL}~y4vsO-0 zOw)ViB+tjeaLMCs?ryV9`{mo?1PXOmr%O_jEchQXzMfhw0XTCR?h zP<8dmYIaciL3Ks#eCGO#md*yS{$l;Z2M{`{mG=`fC*LhE8fQ8>s;aVAzHjs(^PvB=~g#b<#ajT*X+yCgIW{NiCawVEi8>ZntP2f@k|v!^pu zUtN9qeAgI49ke6c*>;I*7`g4c@!6fd-3^~vw5f-5patpY&lo7BmjfR>Bh=QX?-YLO z5)uX1hD!wB6RYg2+(-I~L1Hf6FEecA65@0}(`^H!gDm*9hVG7}`kY`2bP1Lg1vkxY z>(m{yEWyJztxw7ynXtxP-(6ob^@R}%bD_2IX8Vz)$u{}c34DhiYjH$wtWdIpxcR*8 zd(s7qC-0`yyI*4DWk8?QCgXD!n?B4X84eSJJJaPrfE{%C0jW)8lfkS&JYpr3$XVE| zJ-?MIMX}|GZnH;dgk7QKxE1|t?Un|EM9%{L5ky$^b-p5hvoakh#w>Wzrj~}oOmUbl z;yRw#`($AgV3O-K!Rgl(SR5XP~h1p6!6=PHCUk|YvO%`KT?|lzG!sX29=veRb zl=1$7MjnfdkQd`*{HSfoniX5FQMo$J4+KV;kpamlQa z_=VDp#4sPO;T;LS*9m>7dA!Pi=uD%A^fxmOlSv&q?sQmEdLv7d(n?!e)wGCweA?D4 z(?wc68mAP;*l{6LN^GN~=^K0knGiwFUh?C#1oRwXH|6Hlku^bxqGe%p(bik;vQ&3m z+||H>!vnF(xkd=9#LTb>vR8>-^ld%p$eI2cX%-ur&akWac}S^LoJ||r(^i+G9HKMn zC@lYYqo}zBT@3vq;uJI&Q`%C}E{m%Mh~w-0jwABM(y1WWXdJf*7f;o>hmfB@&3kiF4W+Nye4 z&L}aMBkQ<%aZ=NXo5o8h$-b={drL$LjN!#o_}1$b;MovY05AtV?&; zwHXD3JwH>jphOb5kW5#Z1=Pr98@lu{?5yA>1pphQC2L#@rTa!<8YTb@Ij-N&P7!yS zY-^}nx6ryKRH1r&Te&$MV4N^9+#0BcJ|o z4Hlt~jVBxeAuNwTUARvH#i#C(kgsneNWj=_Is=j7Vv|JYRc+jyO^^nm(NA|pzNm>* zXd@az)lF8L+e1iWm(1v=>Szu#(;f8ry`9__O@<2np8kE;8k56dm!;&lo%uiX23RNQ zlLS!ax*&M9AQBC59HzY3EI(hqeuiQR+IJOk-s*8pvxtY{ptXG>MW#C_)QA00dDZju zyO8$~PiV(4rgKUc(1@4`Bq{XG8^;XEQATf&p$?1-&mLqe@#zX{ERRFE-8!pb@cWYs ziYJ>G9SPJn&$favVHV*{Q8$552(9(lHEHJ4mCW`|ZpF*gJ>J`|EzfxfgfHwacrJ{) ziN~QKI>U;bdf;Y5l+|;Y2_KsNkwv2eLQv&X>DXMlV{Kyu-lSlI<)@;{$Frqmb$|Rj zOc$vUF*FD?gdc*l%$1=~@k1Vtg{`-{N9p5$d!@MsZewrDpRElJt8YVSg41vcM+=}x z1y`6e5ciA2&Zn6AK8t_)GHPT^&_WUE`l;c>gDd{oyv3tF{LRjpcegr3b{EFiWJlRL zONgb}dZIS03OemCNM@rp0kf=MI+2sk3WTR~=i-08P7Jm`z#FlRezy>-?>(gBT%Jlg zW0^QfpKswI2K>D!tTI7a`{~t78Qx{31Y?~DJTc@35qLyy^Rln5vP|_eHcH&~8F)<# zvCXIBcu8@!hr^_;ANz==&u$Zq9F7>_YZFXO0~~R8+U+otE{?d#}{gX2fV!JuM@v`8T_D2lx*pY+oQC5-mD@C@T`SWGsl+pn9ZcXlsg znd(PGm!$-xH6HFZF^(&ukx>b zO|el%J%(#Gl}af6%F><1z5&pvQ6G26H>I{|42Q!YOx`}^_gwP2#xPDO`;}4*I_{Jr zwL)islIe=L;)9yYxAHo$!Eul#Bn0TJGwP+Kub<8tzyaLT)~{d(svQzZ>Ns>;(zY4X zHKXa~Dh*l_7~KF|TKhOGTG56bxruG@bT+H+V!oPrEwOF7s<c0#W#`hu8_d+uyBstK-xoOVjSdMOV7(GNe`V zNx65nKlAk3LBlqn#`)RR*vZCT) zU8A65QhBE^lmohVAax8M$BSHTti8d&a2TDY+lW{JU=g|;(YEKkn!!KaZabQSDmbJN0Wj5y5Pjhl4Khrt-OF(B4lQH#N&{nNRN%H7J*OVv@-UWea7#)Y(xR32QW%d(f zAo28fRw!=9>K?PLPrh@Um%>B5>`340sSh50P-goR2I(9|h&vQwJ5*9-iU)AIEEO&8 z_Q&9T7xvpHVL(iv@`AF5F=m`E&45`A;hSZyWAeum;revX3RUN5SNuxpx~OTTa0=(t z7~zb2Aw3srrR7(u)I`@6^(L5PMUfjQ9^l5&JY#bzqeUaqk&U)%Y}(q_YC?D3)zw zeko~%b_Q0YTW6FkAy9~{UIuw*%YsC@fp(+nYaV{ganNAGg#o7Yh!g*p}g%$Z#hjr*ZM7kF_X3!3Ns2)hT6O4_0icAE|gg^mgks?GK z6yC2YFQhMlU|KG|mHF@!UaFAT#qP)vX5^jd9f9h#cW{C=qv_{Ho802~oTiz$9&vH+ zZ+&9`1nvd4*|)3d_vgm~)$(9ClhGYP3)kn{J0k3iw+LWrzQIb1;c0oLit2m22K;@g z&avo%6^&a}xu7npg0N2tcLdPJXgN4KOY@30aq`t!1`P!e6{}4Xj>UU3$*tA%CivmM zkR?##sB>DB9Gh-Pc!XxZRAqrap=ek5Dy?qVX76jz+)8tRSSv2lUROzyz?Ya(v*6sr zldY>9=LaX>j;MIXwPv$yA!U#2I*vy?K~3%{%}G?D6=oKQR>%(#=3|;T{VgZ9VMh%g zEit|~5SPEtbs0zHbs-n&>rHF?k?p{bn!fkVAw}!!BxR!1;VWRBv$lLpxP{gWCcY`g z8va*&K}L0BP4Me;jQ_z=agArf=O>ga-lQXvbjLUR-Mw@qkWh#!(L>iV=eH?3snt9k zD*3zSI!hH_?rMq|rC6D&8Pti^XRb%KWd>%;r85DmpU{5JJ&-b)B*dO2e_v}}Bz(Qy z(UAfgR7;YgEnKlEruht^{km7`$bM9SsC4tqPkM=zN>;JHH4sJCUH62u9fgJ)IW}ls-&4)I~1oTV(FH z%PL)4&84zLbf66wwhuTKBi~H{z_y%xNHq4D`w>Sr?$tY*LUYf9V3vshT{eQ%&>S^X zwTaa+=t&Y0X%mxQnl4i^M1%qlXtlh$2+vlUh?}qXUF?BX&&BJp#fE588XB?nwH(bJ z*EtL@PEru4^p6^^Aru?RWyqDvc4m!k=GWH6weZJZDn?xE?WGQ!6f#q%SrcW zY3V7d{WblroZ(|ZtUQc=lTqNi-i5oMh&#clFRZ+*ucu5vt9e{A9e(sUPEt^)^uJ<6 zQ+{Rs0*r$WXG_(fs*Y9;yW<^2OhyAJCqgmTVlOy3x{{UczYNXgxfci>!`0;MFWX4$ zq`ycoyp+y9e$GS*4vTH%VINT7=u(XA$tOK;ed2$Q5_dZZ+tvun8cgwoDRRnxaP0iR zY}lt%{?XyFHrvKsqS;YIz~BM22zZgmv5~mrdTH2yGio=3F*{26EQH6#RNY9e-#Yp+ z&pFS}JS(5+894C?bM4p#-uqkA%IZCCw~$cY%tItofQr%235R1O3vNi%x?Vk$C(o)| zl?oWBm!4QgDJRzMg>y}W}FZuZ?EhjZ@jK>knD`<_l=hcqIBwM}b z@3~9}Do5q%9y&TVAeS|Fc)xx31-EC{K9fy^{1&^ literal 0 HcmV?d00001