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 { HookshotConnection, HookshotGithubRoomConfig, HookshotTypes } from "./models/hookshot";
import {
HookshotConnection, HookshotGithubRepo,
HookshotGithubRoomConfig,
HookshotGithubUserInfo,
HookshotJiraUserInfo,
HookshotTypes
} from "./models/hookshot";
import { HookshotBridge } from "./HookshotBridge";
export class HookshotGithubBridge extends HookshotBridge {
@ -16,6 +22,11 @@ export class HookshotGithubBridge extends HookshotBridge {
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> {
const confs = await this.getAllServiceInformation();
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());
}
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[]> {
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 body = {};
const body = {
commandPrefix: "!github",
org: orgId,
repo: repoId,
};
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 {
type: string;
eventType: string; // state key in the connection
@ -16,12 +21,46 @@ export interface HookshotConnectionTypeDefinition {
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",
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 {
@ -32,9 +71,8 @@ export interface HookshotJiraRoomConfig extends HookshotConnection {
};
}
export enum HookshotTypes {
Github = "uk.half-shot.matrix-hookshot.github.repository",
Jira = "uk.half-shot.matrix-hookshot.jira.project",
export enum SupportedJiraEventType {
IssueCreated = "issue.created",
}
export interface HookshotJiraUserInfo {

View File

@ -8,15 +8,21 @@
<my-spinner></my-spinner>
</div>
<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>
{{'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>
<a [href]="authUrl" rel="noopener" target="_blank">
<img src="/assets/img/slack_auth_button.png" alt="sign in with slack"/>
<a [href]="authUrl" rel="noopener" target="_blank" class="btn btn-lg btn-link">
<img src="/assets/img/avatars/github.png" width="35" /> {{'Sign in with GitHub' | translate}}
</a>
</div>
<div *ngIf="!isBridged && !needsAuth">
<div *ngIf="!isBridged && !authUrl">
<label class="label-block">
{{'Organization' | translate}}
<select class="form-control form-control-sm" [(ngModel)]="orgId"

View File

@ -1,7 +1,7 @@
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 { DomSanitizer, SafeUrl } from "@angular/platform-browser";
import { TranslateService } from "@ngx-translate/core";
import { FE_HookshotGithubConnection } from "../../../shared/models/hookshot_github";
import { HookshotGithubApiService } from "../../../shared/services/integrations/hookshot-github-api.service";
@ -18,15 +18,19 @@ interface HookshotConfig {
export class HookshotGithubBridgeConfigComponent extends BridgeComponent<HookshotConfig> implements OnInit {
public isBusy: boolean;
public needsAuth = false;
public authUrl: SafeUrl;
public loadingConnections = false;
public loadingConnections = true;
public bridgedRepoSlug: string;
public orgs: string[] = [];
public repos: string[] = []; // for org
public orgId: string;
public repos: string[] = []; // for org
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);
this.translate = translate;
}
@ -34,15 +38,58 @@ export class HookshotGithubBridgeConfigComponent extends BridgeComponent<Hooksho
public 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() {
// 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 {

View File

@ -43,25 +43,26 @@ export class HookshotJiraBridgeConfigComponent extends BridgeComponent<HookshotC
public ngOnInit() {
super.ngOnInit();
this.loadingConnections = true;
this.tryLoadInstances();
}
private tryLoadInstances() {
this.loadingConnections = true;
this.hookshot.getInstances().then(r => {
this.instances = r;
this.instance = this.instances[0];
this.loadProjects();
if (this.timerId) {
clearInterval(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 = setInterval(() => {
this.timerId = setTimeout(() => {
this.tryLoadInstances();
}, 1000);
});

View File

@ -7,5 +7,22 @@ export interface FE_HookshotGithubBridge {
}
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 { AuthedApi } from "../authed-api";
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()
export class HookshotGithubApiService extends AuthedApi {
@ -18,4 +18,16 @@ export class HookshotGithubApiService extends AuthedApi {
public unbridgeRoom(roomId: string): Promise<any> {
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']);
}
}