Initial support for matrix-hookshot#jira

This commit is contained in:
Travis Ralston 2021-11-30 19:25:35 -07:00
parent 089925ee4c
commit 43f795f4da
33 changed files with 879 additions and 74 deletions

View File

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

View File

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

View File

@ -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<IHookshotBridgeRecord>;
protected async getAllRoomConfigurations(inRoomId: string): Promise<HookshotConnectionsResponse> {
const bridge = await this.getDefaultBridge();
try {
return await this.doProvisionRequest<HookshotConnectionsResponse>(bridge, "GET", `/v1/${inRoomId}/connections`);
} catch (e) {
if (e.errBody['errcode'] === "HS_NOT_IN_ROOM") {
return [];
}
throw e;
}
}
protected async getAllServiceInformation(): Promise<HookshotConnectionTypeDefinition[]> {
const bridge = await this.getDefaultBridge();
const connections = await this.doProvisionRequest(bridge, "GET", `/v1/connectiontypes`);
return Object.values(connections);
}
protected async doProvisionRequest<T>(bridge: IHookshotBridgeRecord, method: string, endpoint: string, qs?: any, body?: any): Promise<T> {
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<T>((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);
}
});
});
}
}

View File

@ -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<HookshotGithubBridgeRecord> {
protected async getDefaultBridge(): Promise<HookshotGithubBridgeRecord> {
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<string> {
const confs = await this.getAllServiceInformation();
const conf = confs.find(c => c.eventType === HookshotTypes.Github);
return conf?.botUserId;
}
public async isBridgingEnabled(): Promise<boolean> {
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<HookshotGithubRoomConfig[]> {
const bridge = await this.getDefaultBridge();
try {
const connections = await this.doProvisionRequest<HookshotConnectionsResponse>(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<HookshotGithubRoomConfig> {
@ -52,50 +46,4 @@ export class HookshotGithubBridge {
const bridge = await this.getDefaultBridge();
await this.doProvisionRequest(bridge, "DELETE", `/v1/${roomId}/connections/${connectionId}`);
}
private async doProvisionRequest<T>(bridge: HookshotGithubBridgeRecord, method: string, endpoint: string, qs?: any, body?: any): Promise<T> {
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<T>((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);
}
});
});
}
}

View File

@ -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<HookshotJiraBridgeRecord> {
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<string> {
const confs = await this.getAllServiceInformation();
const conf = confs.find(c => c.eventType === HookshotTypes.Jira);
return conf?.botUserId;
}
public async isBridgingEnabled(): Promise<boolean> {
const bridges = await HookshotJiraBridgeRecord.findAll({where: {isEnabled: true}});
return !!bridges && bridges.length > 0 && !!(await this.getBotUserId());
}
public async getRoomConfigurations(inRoomId: string): Promise<HookshotGithubRoomConfig[]> {
return (await this.getAllRoomConfigurations(inRoomId)).filter(c => c.eventType === HookshotTypes.Jira);
}
public async bridgeRoom(roomId: string): Promise<HookshotJiraRoomConfig> {
const bridge = await this.getDefaultBridge();
const body = {};
return await this.doProvisionRequest<HookshotConnection>(bridge, "PUT", `/v1/${roomId}/connections/${HookshotTypes.Jira}`, null, body);
}
public async unbridgeRoom(roomId: string, connectionId: string): Promise<void> {
const bridge = await this.getDefaultBridge();
await this.doProvisionRequest(bridge, "DELETE", `/v1/${roomId}/connections/${connectionId}`);
}
}

View File

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

View File

@ -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 <HookshotGithubBridgeConfiguration>{
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 <HookshotJiraBridgeConfiguration>{
botUserId: botUserId,
connections: connections,
};
} else return {};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,8 @@
import { AllowNull, Column } from "sequelize-typescript";
export interface IHookshotBridgeRecord {
upstreamId?: number;
provisionUrl?: string;
sharedSecret?: string;
isEnabled: boolean;
}

View File

@ -51,3 +51,8 @@ export interface HookshotGithubBridgeConfiguration {
botUserId: string;
connections: HookshotConnection[];
}
export interface HookshotJiraBridgeConfiguration {
botUserId: string;
connections: HookshotConnection[];
}

View File

@ -9,7 +9,7 @@
{{'Provisioning URL' | translate}}
<span class="text-muted ">{{'The provisioning URL for the bridge. This is the specific address for the bridge given in the configuration.' | translate}}</span>
<input type="text" class="form-control"
placeholder="http://localhost:9999/_matrix/provision/v1"
placeholder="http://localhost:9000"
[(ngModel)]="provisionUrl" [disabled]="isSaving"/>
</label>

View File

@ -0,0 +1,41 @@
<div *ngIf="isLoading">
<my-spinner></my-spinner>
</div>
<div *ngIf="!isLoading">
<my-ibox boxTitle="Jira Bridge Configurations">
<div class="my-ibox-content">
<p>
<a href="https://github.com/half-shot/matrix-hookshot" target="_blank">{{'matrix-hookshot' | translate}}</a>
{{'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}}
<table class="table table-striped table-condensed table-bordered">
<thead>
<tr>
<th>{{'Name' | translate}}</th>
<th class="text-center" style="width: 120px;">{{'Actions' | translate}}</th>
</tr>
</thead>
<tbody>
<tr *ngIf="!configurations || configurations.length === 0">
<td colspan="3"><i>{{'No bridge configurations.' | translate}}</i></td>
</tr>
<tr *ngFor="let bridge of configurations trackById">
<td>
{{ bridge.upstreamId ? "matrix.org's bridge" : "Self-hosted bridge" }}
<span class="text-muted" style="display: inline-block;"
*ngIf="!bridge.upstreamId">({{ bridge.provisionUrl }})</span>
</td>
<td class="text-center">
<span class="editButton" (click)="editBridge(bridge)">
<i class="fa fa-pencil-alt"></i>
</span>
</td>
</tr>
</tbody>
</table>
<button type="button" class="btn btn-success btn-sm" (click)="addSelfHostedBridge()"
[disabled]="configurations && configurations.length > 0">
<i class="fa fa-plus"></i> {{'Add self-hosted bridge' | translate}}
</button>
</div>
</my-ibox>
</div>

View File

@ -0,0 +1,3 @@
.editButton {
cursor: pointer;
}

View File

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

View File

@ -0,0 +1,31 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{'self-hosted Github bridge' | translate}} ({{ isAdding ? "Add a new" : "Edit" }})</h4>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" (click)="modal.close()"></button>
</div>
<div class="modal-body">
<p>{{'Self-hosted Jira bridges must have' | translate}} <code>{{'provisioning' | translate}}</code> {{'enabled in the configuration.' | translate}}</p>
<label class="label-block">
{{'Provisioning URL' | translate}}
<span class="text-muted ">{{'The provisioning URL for the bridge. This is the specific address for the bridge given in the configuration.' | translate}}</span>
<input type="text" class="form-control"
placeholder="http://localhost:9000"
[(ngModel)]="provisionUrl" [disabled]="isSaving"/>
</label>
<label class="label-block">
{{'Shared Secret' | translate}}
<span class="text-muted ">{{'The shared secret defined in the configuration for provisioning.' | translate}}</span>
<input type="text" class="form-control"
placeholder="some_secret_value"
[(ngModel)]="sharedSecret" [disabled]="isSaving"/>
</label>
</div>
<div class="modal-footer">
<button type="button" (click)="add()" title="save" class="btn btn-primary btn-sm">
<i class="far fa-save"></i> {{'Save' | translate}}
</button>
<button type="button" (click)="modal.close()" title="close" class="btn btn-secondary btn-sm">
<i class="far fa-times-circle"></i> {{'Cancel' | translate}}
</button>
</div>

View File

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

View File

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

View File

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

View File

@ -0,0 +1,44 @@
<my-bridge-config [bridgeComponent]="this">
<ng-template #bridgeParamsTemplate>
<my-ibox [isCollapsible]="false">
<h5 class="my-ibox-title">
{{'Bridge to Jira' | translate}}
</h5>
<div class="my-ibox-content" *ngIf="loadingConnections">
<my-spinner></my-spinner>
</div>
<div class="my-ibox-content" *ngIf="!loadingConnections">
<div *ngIf="!isBridged && needsAuth">
<p>
{{'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}}
</p>
<a [href]="authUrl" rel="noopener" target="_blank">
<img src="/assets/img/slack_auth_button.png" alt="sign in with slack"/>
</a>
</div>
<div *ngIf="!isBridged && !needsAuth">
<label class="label-block">
{{'Organization' | translate}}
<select class="form-control form-control-sm" [(ngModel)]="orgId"
(change)="loadRepos()" [disabled]="isBusy">
<option *ngFor="let org of orgs" [ngValue]="org">
{{ org }}
</option>
</select>
</label>
<label class="label-block">
{{'Repository' | translate}}
<select class="form-control form-control-sm" [(ngModel)]="repoId" [disabled]="isBusy">
<option *ngFor="let repo of repos" [ngValue]="repo">
{{ repo }}
</option>
</select>
</label>
<button type="button" class="btn btn-sm btn-primary" [disabled]="isBusy" (click)="bridgeRoom()">
Bridge
</button>
</div>
</div>
</my-ibox>
</ng-template>
</my-bridge-config>

View File

@ -0,0 +1,4 @@
.actions-col {
width: 120px;
text-align: center;
}

View File

@ -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<HookshotConfig> 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<any> {
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);
});
});
}
}

View File

@ -0,0 +1,11 @@
export interface FE_HookshotJiraBridge {
id: number;
upstreamId?: number;
provisionUrl?: string;
sharedSecret?: string;
isEnabled: boolean;
}
export interface FE_HookshotJiraConnection {
}

View File

@ -32,6 +32,7 @@ export class IntegrationsRegistry {
"webhooks": {},
"slack": {},
"hookshot_github": {},
"hookshot_jira": {},
},
"widget": {
"custom": {

View File

@ -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<FE_HookshotJiraBridge[]> {
return this.authedGet<FE_HookshotJiraBridge[]>("/api/v1/dimension/admin/hookshot/jira/all").toPromise();
}
public getBridge(bridgeId: number): Promise<FE_HookshotJiraBridge> {
return this.authedGet<FE_HookshotJiraBridge>("/api/v1/dimension/admin/hookshot/jira/" + bridgeId).toPromise();
}
public newFromUpstream(upstream: FE_Upstream): Promise<FE_HookshotJiraBridge> {
return this.authedPost<FE_HookshotJiraBridge>("/api/v1/dimension/admin/hookshot/jira/new/upstream", {upstreamId: upstream.id}).toPromise();
}
public newSelfhosted(provisionUrl: string, sharedSecret: string): Promise<FE_HookshotJiraBridge> {
return this.authedPost<FE_HookshotJiraBridge>("/api/v1/dimension/admin/hookshot/jira/new/selfhosted", {
provisionUrl: provisionUrl,
sharedSecret: sharedSecret,
}).toPromise();
}
public updateSelfhosted(bridgeId: number, provisionUrl: string, sharedSecret: string): Promise<FE_HookshotJiraBridge> {
return this.authedPost<FE_HookshotJiraBridge>("/api/v1/dimension/admin/hookshot/jira/" + bridgeId, {
provisionUrl: provisionUrl,
sharedSecret: sharedSecret,
}).toPromise();
}
}

View File

@ -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<FE_HookshotJiraConnection> {
return this.authedPost<FE_HookshotJiraConnection>("/api/v1/dimension/hookshot/jira/room/" + roomId + "/connect", {
// TODO
}).toPromise();
}
public unbridgeRoom(roomId: string): Promise<any> {
return this.authedDelete("/api/v1/dimension/hookshot/jira/" + roomId + "/connections/all").toPromise();
}
}

View File

@ -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.",

View File

@ -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.",

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB