Partial implementation of github bridging

This commit is contained in:
Travis Ralston 2021-12-02 16:14:27 -07:00
parent 6aaf7db831
commit 0c65acedd3
8 changed files with 287 additions and 26 deletions

View File

@ -0,0 +1,103 @@
import { Context, DELETE, GET, Path, PathParam, POST, Security, ServiceContext } from "typescript-rest";
import { ApiError } from "../ApiError";
import { LogService } from "matrix-bot-sdk";
import { BridgedChannel, SlackBridge } from "../../bridges/SlackBridge";
import { SlackChannel, SlackTeam } from "../../bridges/models/slack";
import { ROLE_USER } from "../security/MatrixSecurity";
import {
HookshotConnection, HookshotGithubOrg, HookshotGithubRepo,
HookshotJiraInstance,
HookshotJiraProject,
HookshotJiraRoomConfig
} from "../../bridges/models/hookshot";
import { HookshotGithubBridge } from "../../bridges/HookshotGithubBridge";
interface BridgeRoomRequest {
orgId: string;
repoId: string;
}
/**
* API for interacting with the Hookshot/Github bridge
*/
@Path("/api/v1/dimension/hookshot/github")
export class DimensionHookshotGithubService {
@Context
private context: ServiceContext;
@GET
@Path("auth")
@Security(ROLE_USER)
public async getAuthUrl(): Promise<{ authUrl: string }> {
const userId = this.context.request.user.userId;
try {
const hookshot = new HookshotGithubBridge(userId);
const authUrl = await hookshot.getAuthUrl();
return {authUrl};
} catch (e) {
LogService.error("DimensionHookshotGithubService", e);
throw new ApiError(400, "Error getting auth info");
}
}
@GET
@Path("orgs")
@Security(ROLE_USER)
public async getOrgs(): Promise<{ orgs: HookshotGithubOrg[] }> {
const userId = this.context.request.user.userId;
const hookshot = new HookshotGithubBridge(userId);
const userInfo = await hookshot.getLoggedInUserInfo();
if (!userInfo.loggedIn) {
throw new ApiError(403, "Not logged in", "T2B_NOT_LOGGED_IN");
}
return {orgs: userInfo.organisations};
}
@GET
@Path("org/:orgId/repos")
@Security(ROLE_USER)
public async getRepos(@PathParam("orgId") orgId: string): Promise<{ repos: HookshotGithubRepo[] }> {
const userId = this.context.request.user.userId;
const hookshot = new HookshotGithubBridge(userId);
const repos = await hookshot.getRepos(orgId);
return {repos};
}
@POST
@Path("room/:roomId/connect")
@Security(ROLE_USER)
public async bridgeRoom(@PathParam("roomId") roomId: string, request: BridgeRoomRequest): Promise<HookshotJiraRoomConfig> {
const userId = this.context.request.user.userId;
try {
const hookshot = new HookshotGithubBridge(userId);
return hookshot.bridgeRoom(roomId, request.orgId, request.repoId);
} catch (e) {
LogService.error("DimensionHookshotGithubService", e);
throw new ApiError(400, "Error bridging room");
}
}
@DELETE
@Path("room/:roomId/connections/all")
@Security(ROLE_USER)
public async unbridgeRoom(@PathParam("roomId") roomId: string): Promise<any> {
const userId = this.context.request.user.userId;
try {
const hookshot = new HookshotGithubBridge(userId);
const connections = await hookshot.getRoomConfigurations(roomId);
for (const conn of connections) {
await hookshot.unbridgeRoom(roomId, conn.id);
}
return {}; // 200 OK
} catch (e) {
LogService.error("DimensionHookshotGithubService", e);
throw new ApiError(400, "Error unbridging room");
}
}
}

View File

@ -1,5 +1,11 @@
import HookshotGithubBridgeRecord from "../db/models/HookshotGithubBridgeRecord"; import HookshotGithubBridgeRecord from "../db/models/HookshotGithubBridgeRecord";
import { HookshotConnection, HookshotGithubRoomConfig, HookshotTypes } from "./models/hookshot"; import {
HookshotConnection, HookshotGithubRepo,
HookshotGithubRoomConfig,
HookshotGithubUserInfo,
HookshotJiraUserInfo,
HookshotTypes
} from "./models/hookshot";
import { HookshotBridge } from "./HookshotBridge"; import { HookshotBridge } from "./HookshotBridge";
export class HookshotGithubBridge extends HookshotBridge { export class HookshotGithubBridge extends HookshotBridge {
@ -16,6 +22,11 @@ export class HookshotGithubBridge extends HookshotBridge {
return bridges[0]; return bridges[0];
} }
public async getAuthUrl(): Promise<string> {
const bridge = await this.getDefaultBridge();
return this.doProvisionRequest(bridge, "GET", `/v1/github/oauth`).then(r => r['url']);
}
public async getBotUserId(): Promise<string> { public async getBotUserId(): Promise<string> {
const confs = await this.getAllServiceInformation(); const confs = await this.getAllServiceInformation();
const conf = confs.find(c => c.eventType === HookshotTypes.Github); const conf = confs.find(c => c.eventType === HookshotTypes.Github);
@ -27,14 +38,40 @@ export class HookshotGithubBridge extends HookshotBridge {
return !!bridges && bridges.length > 0 && !!(await this.getBotUserId()); return !!bridges && bridges.length > 0 && !!(await this.getBotUserId());
} }
public async getLoggedInUserInfo(): Promise<HookshotGithubUserInfo> {
const bridge = await this.getDefaultBridge();
return this.doProvisionRequest<HookshotGithubUserInfo>(bridge, "GET", `/v1/github/account`);
}
public async getRepos(orgId: string): Promise<HookshotGithubRepo[]> {
const bridge = await this.getDefaultBridge();
const results: HookshotGithubRepo[] = [];
let more = true;
let page = 1;
let perPage = 10;
do {
const res = await this.doProvisionRequest<HookshotGithubRepo[]>(bridge, "GET", `/v1/github/orgs/${orgId}/repositories`, {
page,
perPage,
});
results.push(...res);
if (res.length < perPage) more = false;
} while(more);
return results;
}
public async getRoomConfigurations(inRoomId: string): Promise<HookshotGithubRoomConfig[]> { public async getRoomConfigurations(inRoomId: string): Promise<HookshotGithubRoomConfig[]> {
return (await this.getAllRoomConfigurations(inRoomId)).filter(c => c.eventType === HookshotTypes.Github); return (await this.getAllRoomConfigurations(inRoomId)).filter(c => c.eventType === HookshotTypes.Github);
} }
public async bridgeRoom(roomId: string): Promise<HookshotGithubRoomConfig> { public async bridgeRoom(roomId: string, orgId: string, repoId: string): Promise<HookshotGithubRoomConfig> {
const bridge = await this.getDefaultBridge(); const bridge = await this.getDefaultBridge();
const body = {}; const body = {
commandPrefix: "!github",
org: orgId,
repo: repoId,
};
return await this.doProvisionRequest<HookshotGithubRoomConfig>(bridge, "PUT", `/v1/${roomId}/connections/${HookshotTypes.Github}`, null, body); return await this.doProvisionRequest<HookshotGithubRoomConfig>(bridge, "PUT", `/v1/${roomId}/connections/${HookshotTypes.Github}`, null, body);
} }

View File

@ -1,3 +1,8 @@
export enum HookshotTypes {
Github = "uk.half-shot.matrix-hookshot.github.repository",
Jira = "uk.half-shot.matrix-hookshot.jira.project",
}
export interface HookshotConnection { export interface HookshotConnection {
type: string; type: string;
eventType: string; // state key in the connection eventType: string; // state key in the connection
@ -16,12 +21,46 @@ export interface HookshotConnectionTypeDefinition {
botUserId: string; botUserId: string;
} }
export interface HookshotGithubRoomConfig { export interface HookshotGithubRoomConfig extends HookshotConnection {
config: {
org: string;
repo: string;
ignoreHooks: SupportedGithubRepoEventType[];
commandPrefix: string;
};
} }
export enum SupportedJiraEventType { export interface HookshotGithubOrg {
name: string;
avatarUrl: string;
}
export interface HookshotGithubRepo {
name: string;
owner: string;
fullName: string;
avatarUrl: string;
description: string;
}
export interface HookshotGithubUserInfo {
loggedIn: boolean;
organisations?: HookshotGithubOrg[];
}
export enum SupportedGithubRepoEventType {
IssueCreated = "issue.created", IssueCreated = "issue.created",
IssueChanged = "issue.changed",
IssueEdited = "issue.edited",
Issue = "issue",
PROpened = "pull_request.opened",
PRClosed = "pull_request.closed",
PRMerged = "pull_request.merged",
PRReadyForReview = "pull_request.ready_for_review",
PRReviewed = "pull_request.reviewed",
PR = "pull_request",
ReleaseCreated = "release.created",
Release = "release",
} }
export interface HookshotJiraRoomConfig extends HookshotConnection { export interface HookshotJiraRoomConfig extends HookshotConnection {
@ -32,9 +71,8 @@ export interface HookshotJiraRoomConfig extends HookshotConnection {
}; };
} }
export enum HookshotTypes { export enum SupportedJiraEventType {
Github = "uk.half-shot.matrix-hookshot.github.repository", IssueCreated = "issue.created",
Jira = "uk.half-shot.matrix-hookshot.jira.project",
} }
export interface HookshotJiraUserInfo { export interface HookshotJiraUserInfo {

View File

@ -8,15 +8,21 @@
<my-spinner></my-spinner> <my-spinner></my-spinner>
</div> </div>
<div class="my-ibox-content" *ngIf="!loadingConnections"> <div class="my-ibox-content" *ngIf="!loadingConnections">
<div *ngIf="!isBridged && needsAuth"> <div *ngIf="isBridged">
<p>{{'This room is bridged to' | translate}} {{bridgedRepoSlug}}</p>
<button type="button" class="btn btn-sm btn-danger" [disabled]="isBusy" (click)="unbridgeRoom()">
{{'Unbridge' | translate}}
</button>
</div>
<div *ngIf="!isBridged && authUrl">
<p> <p>
{{'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.' | translate}} {{'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.' | translate}}
</p> </p>
<a [href]="authUrl" rel="noopener" target="_blank"> <a [href]="authUrl" rel="noopener" target="_blank" class="btn btn-lg btn-link">
<img src="/assets/img/slack_auth_button.png" alt="sign in with slack"/> <img src="/assets/img/avatars/github.png" width="35" /> {{'Sign in with GitHub' | translate}}
</a> </a>
</div> </div>
<div *ngIf="!isBridged && !needsAuth"> <div *ngIf="!isBridged && !authUrl">
<label class="label-block"> <label class="label-block">
{{'Organization' | translate}} {{'Organization' | translate}}
<select class="form-control form-control-sm" [(ngModel)]="orgId" <select class="form-control form-control-sm" [(ngModel)]="orgId"

View File

@ -1,7 +1,7 @@
import { Component, OnInit } from "@angular/core"; import { Component, OnInit } from "@angular/core";
import { BridgeComponent } from "../bridge.component"; import { BridgeComponent } from "../bridge.component";
import { ScalarClientApiService } from "../../../shared/services/scalar/scalar-client-api.service"; import { ScalarClientApiService } from "../../../shared/services/scalar/scalar-client-api.service";
import { SafeUrl } from "@angular/platform-browser"; import { DomSanitizer, SafeUrl } from "@angular/platform-browser";
import { TranslateService } from "@ngx-translate/core"; import { TranslateService } from "@ngx-translate/core";
import { FE_HookshotGithubConnection } from "../../../shared/models/hookshot_github"; import { FE_HookshotGithubConnection } from "../../../shared/models/hookshot_github";
import { HookshotGithubApiService } from "../../../shared/services/integrations/hookshot-github-api.service"; import { HookshotGithubApiService } from "../../../shared/services/integrations/hookshot-github-api.service";
@ -18,15 +18,19 @@ interface HookshotConfig {
export class HookshotGithubBridgeConfigComponent extends BridgeComponent<HookshotConfig> implements OnInit { export class HookshotGithubBridgeConfigComponent extends BridgeComponent<HookshotConfig> implements OnInit {
public isBusy: boolean; public isBusy: boolean;
public needsAuth = false;
public authUrl: SafeUrl; public authUrl: SafeUrl;
public loadingConnections = false; public loadingConnections = true;
public bridgedRepoSlug: string;
public orgs: string[] = []; public orgs: string[] = [];
public repos: string[] = []; // for org
public orgId: string; public orgId: string;
public repos: string[] = []; // for org
public repoId: string; public repoId: string;
constructor(private hookshot: HookshotGithubApiService, private scalar: ScalarClientApiService, public translate: TranslateService) { private timerId: any;
constructor(private hookshot: HookshotGithubApiService, private scalar: ScalarClientApiService, private sanitizer: DomSanitizer, public translate: TranslateService) {
super("hookshot_github", translate); super("hookshot_github", translate);
this.translate = translate; this.translate = translate;
} }
@ -34,15 +38,58 @@ export class HookshotGithubBridgeConfigComponent extends BridgeComponent<Hooksho
public ngOnInit() { public ngOnInit() {
super.ngOnInit(); super.ngOnInit();
this.prepare(); this.loadingConnections = true;
this.tryLoadOrgs();
} }
private prepare() { private tryLoadOrgs() {
this.hookshot.getOrgs().then(r => {
console.log(r);
this.orgs = r.map(o => o.name);
this.orgId = this.orgs[0];
this.loadRepos();
if (this.timerId) {
clearTimeout(this.timerId);
}
}).catch(e => {
if (e.status === 403 && e.error.dim_errcode === "T2B_NOT_LOGGED_IN") {
this.hookshot.getAuthUrl().then(url => {
this.authUrl = this.sanitizer.bypassSecurityTrustResourceUrl(url);
this.loadingConnections = false;
this.timerId = setTimeout(() => {
this.tryLoadOrgs();
}, 1000);
});
} else {
console.error(e);
this.translate.get('Error getting Github information').subscribe((res: string) => {
this.toaster.pop("error", res);
});
}
});
} }
public loadRepos() { public loadRepos() {
// TODO this.isBusy = true;
this.hookshot.getRepos(this.orgId).then(repos => {
this.repos = repos.map(r => r.name);
this.repoId = this.repos[0];
if (this.isBridged) {
const conn = this.bridge.config.connections[0].config;
this.bridgedRepoSlug = `${conn.org}/${conn.repo}`;
}
this.isBusy = false;
this.loadingConnections = false;
}).catch(e => {
console.error(e);
this.isBusy = false;
this.translate.get('Error getting Github information').subscribe((res: string) => {
this.toaster.pop("error", res);
});
});
} }
public get isBridged(): boolean { public get isBridged(): boolean {

View File

@ -43,25 +43,26 @@ export class HookshotJiraBridgeConfigComponent extends BridgeComponent<HookshotC
public ngOnInit() { public ngOnInit() {
super.ngOnInit(); super.ngOnInit();
this.loadingConnections = true;
this.tryLoadInstances(); this.tryLoadInstances();
} }
private tryLoadInstances() { private tryLoadInstances() {
this.loadingConnections = true;
this.hookshot.getInstances().then(r => { this.hookshot.getInstances().then(r => {
this.instances = r; this.instances = r;
this.instance = this.instances[0]; this.instance = this.instances[0];
this.loadProjects(); this.loadProjects();
if (this.timerId) { if (this.timerId) {
clearInterval(this.timerId); clearTimeout(this.timerId);
} }
}).catch(e => { }).catch(e => {
if (e.status === 403 && e.error.dim_errcode === "T2B_NOT_LOGGED_IN") { if (e.status === 403 && e.error.dim_errcode === "T2B_NOT_LOGGED_IN") {
this.hookshot.getAuthUrl().then(url => { this.hookshot.getAuthUrl().then(url => {
this.authUrl = this.sanitizer.bypassSecurityTrustResourceUrl(url); this.authUrl = this.sanitizer.bypassSecurityTrustResourceUrl(url);
this.loadingConnections = false; this.loadingConnections = false;
this.timerId = setInterval(() => { this.timerId = setTimeout(() => {
this.tryLoadInstances(); this.tryLoadInstances();
}, 1000); }, 1000);
}); });

View File

@ -7,5 +7,22 @@ export interface FE_HookshotGithubBridge {
} }
export interface FE_HookshotGithubConnection { export interface FE_HookshotGithubConnection {
config: {
org: string;
repo: string;
commandPrefix?: string;
};
}
export interface FE_HookshotGithubOrg {
name: string;
avatarUrl: string;
}
export interface FE_HookshotGithubRepo {
name: string;
owner: string;
fullName: string;
avatarUrl: string;
description: string;
} }

View File

@ -1,7 +1,7 @@
import { Injectable } from "@angular/core"; import { Injectable } from "@angular/core";
import { AuthedApi } from "../authed-api"; import { AuthedApi } from "../authed-api";
import { HttpClient } from "@angular/common/http"; import { HttpClient } from "@angular/common/http";
import { FE_HookshotGithubConnection } from "../../models/hookshot_github"; import { FE_HookshotGithubConnection, FE_HookshotGithubOrg, FE_HookshotGithubRepo } from "../../models/hookshot_github";
@Injectable() @Injectable()
export class HookshotGithubApiService extends AuthedApi { export class HookshotGithubApiService extends AuthedApi {
@ -18,4 +18,16 @@ export class HookshotGithubApiService extends AuthedApi {
public unbridgeRoom(roomId: string): Promise<any> { public unbridgeRoom(roomId: string): Promise<any> {
return this.authedDelete("/api/v1/dimension/hookshot/github/room/" + roomId + "/connections/all").toPromise(); return this.authedDelete("/api/v1/dimension/hookshot/github/room/" + roomId + "/connections/all").toPromise();
} }
public getAuthUrl(): Promise<string> {
return this.authedGet("/api/v1/dimension/hookshot/github/auth").toPromise().then(r => r['authUrl']);
}
public getOrgs(): Promise<FE_HookshotGithubOrg[]> {
return this.authedGet("/api/v1/dimension/hookshot/github/orgs").toPromise().then(r => r['orgs']);
}
public getRepos(orgId: string): Promise<FE_HookshotGithubRepo[]> {
return this.authedGet("/api/v1/dimension/hookshot/github/org/" + orgId + "/repos").toPromise().then(r => r['repos']);
}
} }