From 9748fe09cf89773719473410d66254254c2e3b97 Mon Sep 17 00:00:00 2001 From: Will Hunt Date: Sun, 21 Nov 2021 16:51:04 +0000 Subject: [PATCH] More tweaking --- Cargo.lock | 42 ++++++++++++++ Cargo.toml | 4 +- package.json | 1 - src/ConnectionManager.ts | 13 ++++- src/Connections/GithubProject.ts | 4 ++ src/Connections/GithubRepo.ts | 12 ++-- src/Connections/JiraProject.ts | 2 +- src/FormatUtil.rs | 94 +++++++++++++++++++++++++++++++- src/FormatUtil.ts | 25 +++------ src/GithubBridge.ts | 17 +++--- src/Jira/{Utils.ts => index.ts} | 0 src/libRs.ts | 2 + tests/FormatUtilTest.ts | 34 +++++++++++- tests/jira/Utils.ts | 2 +- 14 files changed, 209 insertions(+), 43 deletions(-) rename src/Jira/{Utils.ts => index.ts} (100%) diff --git a/Cargo.lock b/Cargo.lock index 895ada35..76b821b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,28 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "autocfg" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" + +[[package]] +name = "bytemuck" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72957246c41db82b8ef88a5486143830adeb8227ef9837740bdec67724cf2c5b" + +[[package]] +name = "contrast" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e2e6885a8c59c03522edaa351a7ad5b6d47ed2632fcfbc0f2b00fcce520eb1" +dependencies = [ + "num-traits", + "rgb", +] + [[package]] name = "form_urlencoded" version = "1.0.1" @@ -39,9 +61,11 @@ checksum = "a3e378b66a060d48947b590737b30a1be76706c8dd7b8ba0f2fe3989c68a853f" name = "matrix-github" version = "0.1.0" dependencies = [ + "contrast", "napi", "napi-build", "napi-derive", + "rgb", "serde", "serde_derive", "serde_json", @@ -83,6 +107,15 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67cf20e0081fea04e044aa4adf74cfea8ddc0324eec2894b1c700f4cafc72a56" +[[package]] +name = "num-traits" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290" +dependencies = [ + "autocfg", +] + [[package]] name = "percent-encoding" version = "2.1.0" @@ -107,6 +140,15 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rgb" +version = "0.8.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a27fa03bb1e3e2941f52d4a555a395a72bf79b0a85fbbaab79447050c97d978c" +dependencies = [ + "bytemuck", +] + [[package]] name = "ryu" version = "1.0.5" diff --git a/Cargo.toml b/Cargo.toml index ec86f1b4..aded7a6b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,9 @@ napi-derive = "1" url = "2" serde_json = "1" serde = "1" -serde_derive = "*" +serde_derive = "1" +contrast = "0" +rgb = "0" [build-dependencies] napi-build = "1" \ No newline at end of file diff --git a/package.json b/package.json index 383ca686..dbfc8e71 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,6 @@ "@octokit/rest": "^18.10.0", "@octokit/webhooks": "^9.1.2", "axios": "^0.21.1", - "contrast-color": "^1.0.1", "cors": "^2.8.5", "express": "^4.17.1", "ioredis": "^4.26.0", diff --git a/src/ConnectionManager.ts b/src/ConnectionManager.ts index 45bafc08..be01b4ed 100644 --- a/src/ConnectionManager.ts +++ b/src/ConnectionManager.ts @@ -7,7 +7,7 @@ import { Appservice, StateEvent } from "matrix-bot-sdk"; import { CommentProcessor } from "./CommentProcessor"; import { BridgeConfig, GitLabInstance } from "./Config/Config"; -import { GitHubDiscussionConnection, GitHubDiscussionSpace, GitHubIssueConnection, GitHubRepoConnection, GitHubUserSpace, GitLabIssueConnection, GitLabRepoConnection, IConnection } from "./Connections"; +import { GitHubDiscussionConnection, GitHubDiscussionSpace, GitHubIssueConnection, GitHubProjectConnection, GitHubRepoConnection, GitHubUserSpace, GitLabIssueConnection, GitLabRepoConnection, IConnection } from "./Connections"; import { GenericHookConnection } from "./Connections/GenericHook"; import { JiraProjectConnection } from "./Connections/JiraProject"; import { GithubInstance } from "./Github/GithubInstance"; @@ -183,7 +183,7 @@ export class ConnectionManager { owner = owner.toLowerCase(); repo = repo.toLowerCase(); return this.connections.filter( - (c) => ( + c => ( c instanceof GitHubDiscussionConnection && c.owner === owner && c.repo === repo && @@ -192,6 +192,15 @@ export class ConnectionManager { ) as GitHubDiscussionConnection[]; } + public getForGitHubProject(projectId: number): GitHubProjectConnection[] { + return this.connections.filter( + c => ( + c instanceof GitHubProjectConnection && + c.projectId === projectId + ) + ) as GitHubProjectConnection[]; + } + public getConnectionsForGitLabIssueWebhook(repoHome: string, issueId: number) { if (!this.config.gitlab) { throw Error('GitLab configuration missing, cannot handle note'); diff --git a/src/Connections/GithubProject.ts b/src/Connections/GithubProject.ts index 7d91e544..a78e3316 100644 --- a/src/Connections/GithubProject.ts +++ b/src/Connections/GithubProject.ts @@ -48,6 +48,10 @@ export class GitHubProjectConnection implements IConnection { return new GitHubProjectConnection(roomId, as, state, project.url) } + get projectId() { + return this.state.project_id; + } + constructor(public readonly roomId: string, as: Appservice, private state: GitHubProjectConnectionState, diff --git a/src/Connections/GithubRepo.ts b/src/Connections/GithubRepo.ts index 4276626d..c7ef19b9 100644 --- a/src/Connections/GithubRepo.ts +++ b/src/Connections/GithubRepo.ts @@ -289,11 +289,11 @@ export class GitHubRepoConnection implements IConnection { const orgRepoName = event.repository.full_name; const content = emoji.emojify(`${event.issue.user?.login} created new issue [${orgRepoName}#${event.issue.number}](${event.issue.html_url}): "${event.issue.title}"`); - const { labelsHtml, labelsStr } = FormatUtil.formatLabels(event.issue.labels); + const labels = FormatUtil.formatLabels(event.issue.labels?.map(l => ({ name: l.name, description: l.description || undefined, color: l.color || undefined }))); await this.as.botIntent.sendEvent(this.roomId, { msgtype: "m.notice", - body: content + (labelsStr.length > 0 ? ` with labels ${labelsStr}`: ""), - formatted_body: md.renderInline(content) + (labelsHtml.length > 0 ? ` with labels ${labelsHtml}`: ""), + body: content + (labels.plain.length > 0 ? ` with labels ${labels.plain}`: ""), + formatted_body: md.renderInline(content) + (labels.html.length > 0 ? ` with labels ${labels.html}`: ""), format: "org.matrix.custom.html", // TODO: Fix types. ...FormatUtil.getPartialBodyForIssue(event.repository, event.issue as any), @@ -358,11 +358,11 @@ export class GitHubRepoConnection implements IConnection { const orgRepoName = event.repository.full_name; const verb = event.pull_request.draft ? 'drafted' : 'opened'; const content = emoji.emojify(`**${event.sender.login}** ${verb} a new PR [${orgRepoName}#${event.pull_request.number}](${event.pull_request.html_url}): "${event.pull_request.title}"`); - const { labelsHtml, labelsStr } = FormatUtil.formatLabels(event.pull_request.labels); + const labels = FormatUtil.formatLabels(event.pull_request.labels?.map(l => ({ name: l.name, description: l.description || undefined, color: l.color || undefined }))); await this.as.botIntent.sendEvent(this.roomId, { msgtype: "m.notice", - body: content + (labelsStr.length > 0 ? ` with labels ${labelsStr}`: ""), - formatted_body: md.renderInline(content) + (labelsHtml.length > 0 ? ` with labels ${labelsHtml}`: ""), + body: content + (labels.plain.length > 0 ? ` with labels ${labels}`: ""), + formatted_body: md.renderInline(content) + (labels.html.length > 0 ? ` with labels ${labels.html}`: ""), format: "org.matrix.custom.html", // TODO: Fix types. ...FormatUtil.getPartialBodyForIssue(event.repository, event.pull_request as any), diff --git a/src/Connections/JiraProject.ts b/src/Connections/JiraProject.ts index ce57a334..3132e346 100644 --- a/src/Connections/JiraProject.ts +++ b/src/Connections/JiraProject.ts @@ -6,7 +6,7 @@ import { MessageSenderClient } from "../MatrixSender" import { JiraIssueEvent } from "../Jira/WebhookTypes"; import { FormatUtil } from "../FormatUtil"; import markdownit from "markdown-it"; -import { generateJiraWebLinkFromIssue } from "../Jira/Utils"; +import { generateJiraWebLinkFromIssue } from "../Jira"; type JiraAllowedEventsNames = "issue.created"; const JiraAllowedEvents: JiraAllowedEventsNames[] = ["issue.created"]; diff --git a/src/FormatUtil.rs b/src/FormatUtil.rs index e63a89c4..5b312c75 100644 --- a/src/FormatUtil.rs +++ b/src/FormatUtil.rs @@ -1,16 +1,106 @@ -use napi::{CallContext, Env, Error as NapiError, JsObject, JsUnknown}; - +use napi::{CallContext, Env, Error as NapiError, JsObject, JsUnknown, Status}; +use std::fmt::Write; use crate::Jira::types::{JiraIssue, JiraIssueLight}; use crate::Jira; +use rgb::RGB; +use contrast; +#[derive(Serialize, Debug, Deserialize)] +struct IssueLabelDetail { + color: Option, + name: String, + description: Option, +} pub fn get_module(env: Env) -> Result { let mut root_module = env.create_object()?; root_module.create_named_method("get_partial_body_for_jira_issue", get_partial_body_for_jira_issue)?; + root_module.create_named_method("format_labels", format_labels)?; Ok(root_module) } +fn parse_rgb(input_color: String) -> Result { + let chunk_size; + let color; + if input_color.starts_with('#') { + let mut chars = input_color.chars(); + chars.next(); + color = String::from_iter(chars); + } else { + color = input_color; + } + match color.len() { + 6 => { + chunk_size = 2; + }, + 3 => { + chunk_size = 1; + }, + _ => { + return Err(NapiError::new(Status::InvalidArg, format!("color '{}' is invalid", color).to_string())); + } + } + let rgb = color.as_bytes().chunks(chunk_size).map(std::str::from_utf8).collect::, _>>().unwrap(); + let r = u8::from_str_radix(rgb[0], 16).unwrap(); + let g = u8::from_str_radix(rgb[1], 16).unwrap(); + let b = u8::from_str_radix(rgb[2], 16).unwrap(); + Ok(RGB::new(r,g,b)) +} + +#[js_function(1)] +pub fn format_labels(ctx: CallContext) -> Result { + let array: JsObject = ctx.get::(0)?; + if array.is_array()? != true { + return Err(NapiError::new(Status::InvalidArg, "labels is not an array".to_string())); + } + let mut plain = String::new(); + let mut html = String::new(); + let mut i = 0; + while array.has_element(i)? { + let label: IssueLabelDetail = ctx.env.from_js_value(array.get_element_unchecked::(i)?)?; + + if i != 0 { + plain.push_str(", "); + html.push_str(" "); + } + plain.push_str(&label.name); + + // HTML + html.push_str(" { + write!(html, " data-mx-bg-color=\"{}\"", color).unwrap(); + // Determine the constrast + let color_rgb = parse_rgb(color)?; + let contrast_color; + if contrast::contrast::(color_rgb, RGB::new(0,0,0)) > 4.5 { + contrast_color = "#000000"; + } else { + contrast_color = "#FFFFFF"; + } + write!(html, " data-mx-color=\"{}\"", contrast_color).unwrap(); + }, + None => {}, + } + match label.description { + Some(description) => { + write!(html, " title=\"{}\"", description).unwrap(); + }, + None => {}, + } + html.push_str(">"); + html.push_str(&label.name); + html.push_str(""); + i += 1; + } + + let mut body = ctx.env.create_object()?; + body.set_named_property("plain", ctx.env.create_string_from_std(plain)?)?; + body.set_named_property("html", ctx.env.create_string_from_std(html)?)?; + Ok(body) +} + /// Generate a URL for a given Jira Issue object. #[js_function(1)] pub fn get_partial_body_for_jira_issue(ctx: CallContext) -> Result { diff --git a/src/FormatUtil.ts b/src/FormatUtil.ts index c22ed6ce..ed5f5f81 100644 --- a/src/FormatUtil.ts +++ b/src/FormatUtil.ts @@ -23,6 +23,12 @@ interface IMinimalIssue { pull_request?: any; } +export interface ILabel { + color?: string, + name: string, + description?: string +} + export class FormatUtil { public static formatIssueRoomName(issue: IMinimalIssue) { const orgRepoName = issue.repository_url.substr("https://api.github.com/repos/".length); @@ -86,23 +92,8 @@ export class FormatUtil { return f; } - public static formatLabels(labels: Array<{color?: string|null, name?: string, description?: string|null}|string> = []) { - const labelsHtml = labels.map((label: {color?: string|null, name?: string, description?: string|null}|string) => { - if (typeof(label) === "string") { - return `${label}`; - } - const fontColor = contrastColor(label.color); - return `${label.name}` - } - - ).join(" ") || ""; - const labelsStr = labels.map((label: {name?: string}|string) => - typeof(label) === "string" ? label : label.name - ).join(", ") || ""; - return { - labelsStr, - labelsHtml, - } + public static formatLabels(labels: ILabel[] = []): { plain: string, html: string } { + return format_util.format_labels(labels); } public static getPartialBodyForJiraIssue(issue: JiraIssue) { diff --git a/src/GithubBridge.ts b/src/GithubBridge.ts index 9415b7da..ef5a0ccc 100644 --- a/src/GithubBridge.ts +++ b/src/GithubBridge.ts @@ -3,20 +3,17 @@ import { Appservice, IAppserviceRegistration, RichRepliesPreprocessor, IRichRepl import { BridgeConfig, GitLabInstance } from "./Config/Config"; import { BridgeWidgetApi } from "./Widgets/BridgeWidgetApi"; import { CommentProcessor } from "./CommentProcessor"; +import { ConnectionManager } from "./ConnectionManager"; import { GetIssueResponse, GetIssueOpts } from "./Gitlab/Types" -import { GenericHookConnection } from "./Connections/GenericHook"; import { GithubInstance } from "./Github/GithubInstance"; import { GitHubIssueConnection } from "./Connections/GithubIssue"; import { GitHubProjectConnection } from "./Connections/GithubProject"; import { GitHubRepoConnection } from "./Connections/GithubRepo"; -import { GitLabClient } from "./Gitlab/Client"; import { GitLabIssueConnection } from "./Connections/GitlabIssue"; -import { GitLabRepoConnection } from "./Connections/GitlabRepo"; import { IBridgeStorageProvider } from "./Stores/StorageProvider"; import { IConnection, GitHubDiscussionSpace, GitHubDiscussionConnection, GitHubUserSpace } from "./Connections"; import { IGitLabWebhookIssueStateEvent, IGitLabWebhookMREvent, IGitLabWebhookNoteEvent } from "./Gitlab/WebhookTypes"; import { JiraIssueEvent } from "./Jira/WebhookTypes"; -import { JiraProjectConnection } from "./Connections/JiraProject"; import { MatrixEvent, MatrixMemberContent, MatrixMessageContent } from "./MatrixEvent"; import { MemoryStorageProvider } from "./Stores/MemoryStorageProvider"; import { MessageQueue, createMessageQueue } from "./MessageQueue/MessageQueue"; @@ -31,8 +28,6 @@ import { UserNotificationsEvent } from "./Notifications/UserNotificationWatcher" import { UserTokenStore } from "./UserTokenStore"; import * as GitHubWebhookTypes from "@octokit/webhooks-types"; import LogWrapper from "./LogWrapper"; -import { ConnectionManager } from "./ConnectionManager"; - const log = new LogWrapper("GithubBridge"); export class GithubBridge { @@ -857,9 +852,13 @@ export class GithubBridge { ); adminRoom.on("settings.changed", this.onAdminRoomSettingsChanged.bind(this)); adminRoom.on("open.project", async (project: ProjectsGetResponseData) => { - const connection = await GitHubProjectConnection.onOpenProject(project, this.as, adminRoom.userId); - // TODO: Surely we only do this if we don't have one already? - this.connectionManager?.push(connection); + const [connection] = this.connectionManager?.getForGitHubProject(project.id) || []; + if (!connection) { + const connection = await GitHubProjectConnection.onOpenProject(project, this.as, adminRoom.userId); + this.connectionManager?.push(connection); + } else { + await this.as.botClient.inviteUser(adminRoom.userId, connection.roomId); + } }); // adminRoom.on("open.discussion", async (owner: string, repo: string, discussions: Discussion) => { // const connection = await GitHubDiscussionConnection.createDiscussionRoom( diff --git a/src/Jira/Utils.ts b/src/Jira/index.ts similarity index 100% rename from src/Jira/Utils.ts rename to src/Jira/index.ts diff --git a/src/libRs.ts b/src/libRs.ts index fbffe90f..d187ffdb 100644 --- a/src/libRs.ts +++ b/src/libRs.ts @@ -1,4 +1,5 @@ /* eslint-disable camelcase */ +import { ILabel } from "./FormatUtil"; import { JiraIssue } from "./Jira/Types"; // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -13,6 +14,7 @@ try { interface FormatUtil { get_partial_body_for_jira_issue: (issue: JiraIssue) => Record + format_labels: (labels: ILabel[]) => { plain: string, html: string } } interface JiraModule { diff --git a/tests/FormatUtilTest.ts b/tests/FormatUtilTest.ts index db7f7509..7a68e5af 100644 --- a/tests/FormatUtilTest.ts +++ b/tests/FormatUtilTest.ts @@ -43,21 +43,49 @@ const SIMPLE_JIRA_ISSUE = { } as JiraIssue; describe("FormatUtilTest", () => { - it("correctly formats a repo room name", () => { + it("should correctly formats a repo room name", () => { expect(FormatUtil.formatRepoRoomName(SIMPLE_REPO)).to.equal( "evilcorp/lab: A simple description", ); }); - it("correctly formats a issue room name", () => { + it("should correctly formats a issue room name", () => { expect(FormatUtil.formatIssueRoomName(SIMPLE_ISSUE)).to.equal( "evilcorp/lab#123: A simple title", ); }); - it("correctly formats a room topic", () => { + it("should correctly formats a room topic", () => { expect(FormatUtil.formatRoomTopic(SIMPLE_ISSUE)).to.equal( "Status: open | https://github.com/evilcorp/lab/issues/123", ); }); + it("should correctly format one simple label", () => { + expect(FormatUtil.formatLabels([{name: "foo"}])).to.deep.equal({ + plain: "foo", + html: "foo" + }); + }); + it("should correctly format many simple labels", () => { + expect(FormatUtil.formatLabels([{name: "foo"},{name: "bar"}])).to.deep.equal({ + plain: "foo, bar", + html: "foo bar" + }); + }); + it("should correctly format one detailed label", () => { + expect(FormatUtil.formatLabels([{name: "foo", color: '#FFFFFF', description: 'My label'}])).to.deep.equal({ + plain: "foo", + html: "foo" + }); + }); + it("should correctly format many detailed labels", () => { + expect(FormatUtil.formatLabels([ + {name: "foo", color: '#FFFFFF', description: 'My label'}, + {name: "bar", color: '#AACCEE', description: 'My other label'}, + ])).to.deep.equal({ + plain: "foo, bar", + html: "foo " + + "bar" + },); + }); it("should correctly format a JIRA issue", () => { expect(FormatUtil.getPartialBodyForJiraIssue(SIMPLE_JIRA_ISSUE)).to.deep.equal({ "external_url": "http://example-api.url.com/browse/TEST-001", diff --git a/tests/jira/Utils.ts b/tests/jira/Utils.ts index d64fa02d..c70cd6c0 100644 --- a/tests/jira/Utils.ts +++ b/tests/jira/Utils.ts @@ -1,5 +1,5 @@ import { expect } from "chai"; -import { generateJiraWebLinkFromIssue } from "../../src/Jira/Utils"; +import { generateJiraWebLinkFromIssue } from "../../src/Jira"; describe("Jira", () => { describe("Utils", () => {