More work in progress GitLab support

This commit is contained in:
Half-Shot 2020-07-20 18:33:38 +01:00
parent 24cfd9e542
commit 4b3eca1913
9 changed files with 373 additions and 52 deletions

View File

@ -11,6 +11,7 @@ import "reflect-metadata";
import markdown from "markdown-it";
import { FormatUtil } from "./FormatUtil";
import { botCommand, compileBotCommands, handleCommand, BotCommands } from "./BotCommands";
import { GitLabClient } from "./Gitlab/Client";
const md = new markdown();
const log = new LogWrapper('AdminRoom');
@ -84,10 +85,9 @@ export class AdminRoom extends EventEmitter {
return this.botIntent.underlyingClient.sendMessage(this.roomId, AdminRoom.helpMessage);
}
@botCommand("setpersonaltoken", "Set your personal access token for GitHub", ['accessToken'])
// @ts-ignore - property is used
private async setPersonalAccessToken(accessToken: string) {
private async setGHPersonalAccessToken(accessToken: string) {
let me;
try {
const octokit = new Octokit({
@ -104,6 +104,22 @@ export class AdminRoom extends EventEmitter {
await this.tokenStore.storeUserToken("github", this.userId, accessToken);
}
@botCommand("gitlab personaltoken", "Set your personal access token for GitLab", ['instanceUrl', 'accessToken'])
// @ts-ignore - property is used
private async setGitLabPersonalAccessToken(instanceUrl: string, accessToken: string) {
let me: GetUserResponse;
try {
const client = new GitLabClient(instanceUrl, accessToken);
me = await client.user();
} catch (ex) {
log.error("Gitlab auth error:", ex);
await this.sendNotice("Could not authenticate with GitLab. Is your token correct?");
return;
}
await this.sendNotice(`Connected as ${me.username}. Token stored`);
await this.tokenStore.storeUserToken("gitlab", this.userId, accessToken, instanceUrl);
}
@botCommand("hastoken", "Check if you have a token stored for GitHub")
// @ts-ignore - property is used
private async hasPersonalToken() {
@ -259,13 +275,14 @@ export class AdminRoom extends EventEmitter {
if (error) {
return this.sendNotice("Failed to handle command:" + error);
}
return this.botIntent.underlyingClient.sendEvent(this.roomId, "m.reaction", {
"m.relates_to": {
rel_type: "m.annotation",
event_id: event_id,
key: "✅",
}
});
return null;
// return this.botIntent.underlyingClient.sendEvent(this.roomId, "m.reaction", {
// "m.relates_to": {
// rel_type: "m.annotation",
// event_id: event_id,
// key: "✅",
// }
// });
}
}

View File

@ -1,7 +1,6 @@
import markdown from "markdown-it";
// @ts-ignore
import argvSplit from "argv-split";
import e from "express";
const md = new markdown();
@ -53,10 +52,9 @@ export function compileBotCommands(prototype: any): {helpMessage: any, botComman
}
export async function handleCommand(userId: string, command: string, botCommands: BotCommands, obj: any): Promise<{error?: string, handled?: boolean}> {
const cmdLower = command.toLowerCase();
const parts = argvSplit(cmdLower);
const parts = argvSplit(command);
for (let i = parts.length; i > 0; i--) {
const prefix = parts.slice(0, i).join(" ");
const prefix = parts.slice(0, i).join(" ").toLowerCase();
// We have a match!
const command = botCommands[prefix];
if (command) {

View File

@ -0,0 +1,135 @@
import { IConnection } from "./IConnection";
import { UserTokenStore } from "../UserTokenStore";
import { Appservice } from "matrix-bot-sdk";
import { BotCommands, handleCommand, botCommand, compileBotCommands } from "../BotCommands";
import { MatrixEvent, MatrixMessageContent } from "../MatrixEvent";
import markdown from "markdown-it";
import LogWrapper from "../LogWrapper";
export interface GitLabRepoConnectionState {
instance_url: string;
org: string;
repo: string;
state: string;
}
const log = new LogWrapper("GitHubRepoConnection");
const md = new markdown();
/**
* Handles rooms connected to a github repo.
*/
export class GitLabRepoConnection implements IConnection {
static readonly CanonicalEventType = "uk.half-shot.matrix-github.gitlab.repository";
static readonly EventTypes = [
GitLabRepoConnection.CanonicalEventType, // Legacy event, with an awful name.
];
static helpMessage: any;
static botCommands: BotCommands;
constructor(public readonly roomId: string,
private readonly as: Appservice,
private readonly state: GitLabRepoConnectionState,
private readonly tokenStore: UserTokenStore) {
}
public get org() {
return this.state.org;
}
public get repo() {
return this.state.repo;
}
public isInterestedInStateEvent(eventType: string) {
return false;
}
public async onMessageEvent(ev: MatrixEvent<MatrixMessageContent>) {
const { error, handled } = await handleCommand(ev.sender, ev.content.body, GitLabRepoConnection.botCommands, this);
if (!handled) {
// Not for us.
return;
}
if (error) {
log.error(error);
await this.as.botIntent.sendEvent(this.roomId,{
msgtype: "m.notice",
body: "Failed to handle command",
});
return;
}
await this.as.botClient.sendEvent(this.roomId, "m.reaction", {
"m.relates_to": {
rel_type: "m.annotation",
event_id: ev.event_id,
key: "✅",
}
});
}
@botCommand("gl create", "Create an issue for this repo", ["title"], ["description", "labels"], true)
// @ts-ignore
private async onCreateIssue(userId: string, title: string, description?: string, labels?: string) {
const client = await this.tokenStore.getGitLabForUser(userId, this.state.instance_url);
if (!client) {
await this.as.botIntent.sendText(this.roomId, "You must login to create an issue", "m.notice");
throw Error('Not logged in');
}
const res = await client.issues.create({
id: encodeURIComponent(`${this.state.org}/${this.state.repo}`),
title,
description,
labels: labels ? labels.split(",") : undefined,
});
const content = `Created issue #${res.iid}: [${res.web_url}](${res.web_url})`;
return this.as.botIntent.sendEvent(this.roomId,{
msgtype: "m.notice",
body: content,
formatted_body: md.render(content),
format: "org.matrix.custom.html"
});
}
@botCommand("gl close", "Close an issue", ["number"], ["comment"], true)
// @ts-ignore
private async onClose(userId: string, number: string, comment?: string) {
const client = await this.tokenStore.getGitLabForUser(userId, this.state.instance_url);
if (!client) {
await this.as.botIntent.sendText(this.roomId, "You must login to create an issue", "m.notice");
throw Error('Not logged in');
}
await client.issues.edit({
id: encodeURIComponent(`${this.state.org}/${this.state.repo}`),
issue_iid: number,
state_event: "close",
});
}
// public async onIssueCreated(event: IGitHubWebhookEvent) {
// }
// public async onIssueStateChange(event: IGitHubWebhookEvent) {
// }
public async onEvent(evt: MatrixEvent<unknown>) {
}
public async onStateUpdate() { }
public toString() {
return `GitHubRepo`;
}
}
const res = compileBotCommands(GitLabRepoConnection.prototype);
GitLabRepoConnection.helpMessage = res.helpMessage;
GitLabRepoConnection.botCommands = res.botCommands;

View File

@ -22,6 +22,8 @@ import { IConnection } from "./Connections/IConnection";
import { GitHubRepoConnection } from "./Connections/GithubRepo";
import { GitHubIssueConnection } from "./Connections/GithubIssue";
import { GitHubProjectConnection } from "./Connections/GithubProject";
import { GitLabRepoConnection } from "./Connections/GitlabRepo";
import { IGitLabWebhookMREvent } from "./Gitlab/WebhookTypes";
const log = new LogWrapper("GithubBridge");
@ -52,6 +54,9 @@ export class GithubBridge {
if (GitHubIssueConnection.EventTypes.includes(state.type)) {
return new GitHubIssueConnection(roomId, this.as, state.content, state.state_key || "", this.tokenStore, this.commentProcessor, this.messageClient, this.octokit);
}
if (GitLabRepoConnection.EventTypes.includes(state.type)) {
return new GitLabRepoConnection(roomId, this.as, state.content, this.tokenStore);
}
return;
}
@ -65,6 +70,10 @@ export class GithubBridge {
(c instanceof GitHubRepoConnection && c.org === org && c.repo === repo));
}
private getConnectionsForGitLabIssue(org: string, repo: string, issueNumber: number) {
return this.connections.filter((c) => (c instanceof GitLabRepoConnection && c.org === org && c.repo === repo));
}
public stop() {
this.as.stop();
this.queue.stop();
@ -151,6 +160,7 @@ export class GithubBridge {
this.queue.subscribe("issue.*");
this.queue.subscribe("response.matrix.message");
this.queue.subscribe("notifications.user.events");
this.queue.subscribe("merge_request.*")
this.queue.on<IGitHubWebhookEvent>("comment.created", async (msg) => {
const connections = this.getConnectionsForGithubIssue(msg.data.repository!.owner.login, msg.data.repository!.name, msg.data.issue!.number);
@ -212,6 +222,18 @@ export class GithubBridge {
})
});
// this.queue.on<IGitLabWebhookMREvent>("merge_request.open", async (msg) => {
// const connections = this.getConnectionsForGitLabIssue(msg.data.project.namespace, msg.data.repository!.name, msg.data.issue!.number);
// connections.map(async (c) => {
// try {
// if (c.onIssueCreated)
// await c.onIssueStateChange(msg.data);
// } catch (ex) {
// log.warn(`Connection ${c.toString()} failed to handle comment.created:`, ex);
// }
// })
// });
this.queue.on<UserNotificationsEvent>("notifications.user.events", async (msg) => {
const adminRoom = this.adminRooms.get(msg.data.roomId);
if (!adminRoom) {
@ -281,11 +303,9 @@ export class GithubBridge {
const accountData = await this.as.botIntent.underlyingClient.getRoomAccountData(
BRIDGE_ROOM_TYPE, roomId,
);
if (accountData.type === "admin") {
const adminRoom = this.setupAdminRoom(roomId, accountData);
// Call this on startup to set the state
await this.onAdminRoomSettingsChanged(adminRoom, accountData);
}
const adminRoom = this.setupAdminRoom(roomId, accountData);
// Call this on startup to set the state
await this.onAdminRoomSettingsChanged(adminRoom, accountData);
} catch (ex) {
log.warn(`Room ${roomId} has no connections and is not an admin room`);
}
@ -310,7 +330,10 @@ export class GithubBridge {
}
await retry(() => this.as.botIntent.joinRoom(roomId), 5);
if (event.content.is_direct) {
this.setupAdminRoom(roomId, {admin_user: event.sender, notifications: { enabled: false, participating: false}});
const room = this.setupAdminRoom(roomId, {admin_user: event.sender, notifications: { enabled: false, participating: false}});
await this.as.botIntent.underlyingClient.setRoomAccountData(
BRIDGE_ROOM_TYPE, roomId, room.data,
);
}
// This is a group room, don't add the admin settings and just sit in the room.
}
@ -360,7 +383,7 @@ export class GithubBridge {
const command = event.content.body;
if (command) {
await this.adminRooms.get(roomId)!.handleCommand(command);
await this.adminRooms.get(roomId)!.handleCommand(event.event_id, command);
}
}
@ -445,6 +468,7 @@ export class GithubBridge {
}
}
throw Error('No regex matching query pattern');
}

View File

@ -9,6 +9,7 @@ import qs from "querystring";
import { Server } from "http";
import axios from "axios";
import { UserNotificationWatcher } from "./UserNotificationWatcher";
import { IGitLabWebhookEvent } from "./Gitlab/WebhookTypes";
const log = new LogWrapper("GithubWebhooks");
@ -91,27 +92,41 @@ export class GithubWebhooks extends EventEmitter {
}
private onGitHubPayload(body: IGitHubWebhookEvent) {
let eventName;
let from;
if (body.sender) {
from = body.sender.login;
if (body.action === "created" && body.comment) {
return "comment.created";
} else if (body.action === "edited" && body.comment) {
return "comment.edited";
} else if (body.action === "opened" && body.issue) {
return "issue.opened";
} else if (body.action === "edited" && body.issue) {
return "issue.edited";
} else if (body.action === "closed" && body.issue) {
return "issue.closed";
} else if (body.action === "reopened" && body.issue) {
return "issue.reopened";
}
return null;
}
private onGitLabPayload(body: IGitLabWebhookEvent) {
if (body.event_type === "merge_request") {
return `merge_request.${body.object_attributes.action}`;
}
return null;
}
private onPayload(req: Request, res: Response) {
log.debug(`New webhook: ${req.url}`);
try {
if (body.action === "created" && body.comment) {
eventName = "comment.created";
} else if (body.action === "edited" && body.comment) {
eventName = "comment.edited";
} else if (body.action === "opened" && body.issue) {
eventName = "issue.opened";
} else if (body.action === "edited" && body.issue) {
eventName = "issue.edited";
} else if (body.action === "closed" && body.issue) {
eventName = "issue.closed";
} else if (body.action === "reopened" && body.issue) {
eventName = "issue.reopened";
let eventName: string|null = null;
let body = req.body;
res.sendStatus(200);
if (req.headers['x-hub-signature']) {
eventName = this.onGitHubPayload(body);
} else if (req.headers['x-gitlab-token']) {
eventName = this.onGitLabPayload(body);
}
if (eventName) {
log.info(`Got event ${eventName} ${from ? "from " + from : ""}`);
this.queue.push({
eventName,
sender: "GithubWebhooks",
@ -119,21 +134,12 @@ export class GithubWebhooks extends EventEmitter {
}).catch((err) => {
log.info(`Failed to emit payload: ${err}`);
});
} else {
log.debug("Unknown event:", req.body);
}
} catch (ex) {
log.error("Failed to emit");
}
}
private onPayload(req: Request, res: Response) {
log.debug(`New webhook: ${req.url}`);
const body = req.body as IGitHubWebhookEvent;
log.debug("Got", body);
res.sendStatus(200);
if (req.headers['x-hub-signature']) {
return this.onGitHubPayload(body);
}
}
public async onGetOauth(req: Request, res: Response) {

40
src/Gitlab/Client.ts Normal file
View File

@ -0,0 +1,40 @@
import axios from "axios";
export class GitLabClient {
constructor(private instanceUrl: string, private token: string) {
}
get defaultConfig() {
return {
headers: {
Authorization: `Bearer ${this.token}`,
UserAgent: "matrix-github v0.0.1",
},
baseURL: this.instanceUrl
};
}
async version() {
return (await axios.get(`${this.instanceUrl}/api/v4/versions`, this.defaultConfig)).data;
}
async user(): Promise<GetUserResponse> {
return (await axios.get(`${this.instanceUrl}/api/v4/user`, this.defaultConfig)).data;
}
private async createIssue(opts: CreateIssueOpts): Promise<CreateIssueResponse> {
return (await axios.post(`${this.instanceUrl}/api/v4/projects/${opts.id}/issues`, opts, this.defaultConfig)).data;
}
private async editIssue(opts: EditIssueOpts): Promise<CreateIssueResponse> {
return (await axios.put(`${this.instanceUrl}/api/v4/projects/${opts.id}/issues/${opts.issue_iid}`, opts, this.defaultConfig)).data;
}
get issues() {
return {
create: this.createIssue.bind(this),
edit: this.editIssue.bind(this),
}
}
}

67
src/Gitlab/Types.ts Normal file
View File

@ -0,0 +1,67 @@
interface GetUserResponse {
id: number;
username: string;
email: string;
name: string;
state: string;
avatar_url: string;
web_url: string;
created_at: string;
bio: string;
bio_html: string;
location: null|string;
public_email: string;
skype: string;
linkedin: string;
twitter: string;
website_url: string;
organization: string;
last_sign_in_at: string;
confirmed_at: string;
theme_id: number;
last_activity_on: string;
color_scheme_id: number;
projects_limit: number;
current_sign_in_at: string;
identities: [
{provider: string, extern_uid: string},
];
can_create_group: boolean;
can_create_project: boolean;
two_factor_enabled: boolean;
external: boolean;
private_profile: boolean;
}
// https://docs.gitlab.com/ee/api/issues.html#new-issue
interface CreateIssueOpts {
id: string|number;
title: string;
description?: string;
confidential?: boolean;
labels?: string[];
}
interface CreateIssueResponse {
state: string;
id: string;
iid: string;
web_url: string;
}
// https://docs.gitlab.com/ee/api/issues.html#new-issue
interface EditIssueOpts {
id: string|number;
issue_iid: string|number;
title?: string;
description?: string;
confidential?: boolean;
labels?: string[];
state_event?: string;
}
interface CreateIssueResponse {
state: string;
id: string;
web_url: string;
}

View File

@ -0,0 +1,20 @@
export interface IGitLabWebhookEvent {
object_kind: string;
event_type: string;
object_attributes: {
action: string;
}
}
export interface IGitLabWebhookMREvent {
object_kind: "merge_request";
user: {
name: string;
username: string;
avatar_url: string;
};
project: {
namespace: string;
};
}

View File

@ -4,6 +4,7 @@ import { publicEncrypt, privateDecrypt } from "crypto";
import LogWrapper from "./LogWrapper";
import { Octokit } from "@octokit/rest";
import { createTokenAuth } from "@octokit/auth-token";
import { GitLabClient } from "./Gitlab/Client";
const ACCOUNT_DATA_TYPE = "uk.half-shot.matrix-github.password-store:";
const ACCOUNT_DATA_GITLAB_TYPE = "uk.half-shot.matrix-github.gitlab.password-store:";
@ -21,16 +22,20 @@ export class UserTokenStore {
this.key = await fs.readFile(this.keyPath);
}
public async storeUserToken(type: "github"|"gitlab", userId: string, token: string): Promise<void> {
const prefix = type === "github" ? ACCOUNT_DATA_TYPE : ACCOUNT_DATA_GITLAB_TYPE;
public async storeUserToken(type: "github"|"gitlab", userId: string, token: string, instanceUrl?: string): Promise<void> {
let prefix = type === "github" ? ACCOUNT_DATA_TYPE : ACCOUNT_DATA_GITLAB_TYPE;
if (instanceUrl) {
prefix += instanceUrl;
}
await this.intent.underlyingClient.setAccountData(`${prefix}${userId}`, {
encrypted: publicEncrypt(this.key, Buffer.from(token)).toString("base64"),
instance_url: instanceUrl,
});
this.userTokens.set(userId, token);
log.info(`Stored new ${type} token for ${userId}`);
}
public async getUserToken(type: "github"|"gitlab", userId: string): Promise<string|null> {
public async getUserToken(type: "github"|"gitlab", userId: string, instanceUrl?: string): Promise<string|null> {
if (this.userTokens.has(userId)) {
return this.userTokens.get(userId)!;
}
@ -39,7 +44,7 @@ export class UserTokenStore {
if (type === "github") {
obj = await this.intent.underlyingClient.getAccountData(`${ACCOUNT_DATA_TYPE}${userId}`);
} else if (type === "gitlab") {
obj = await this.intent.underlyingClient.getAccountData(`${ACCOUNT_DATA_GITLAB_TYPE}${userId}`);
obj = await this.intent.underlyingClient.getAccountData(`${ACCOUNT_DATA_GITLAB_TYPE}${instanceUrl}${userId}`);
}
const encryptedTextB64 = obj.encrypted;
const encryptedText = Buffer.from(encryptedTextB64, "base64");
@ -65,4 +70,13 @@ export class UserTokenStore {
userAgent: "matrix-github v0.0.1",
});
}
public async getGitLabForUser(userId: string, instanceUrl: string) {
// TODO: Move this somewhere else.
const senderToken = await this.getUserToken("gitlab", userId, instanceUrl);
if (!senderToken) {
return null;
}
return new GitLabClient(instanceUrl, senderToken);
}
}