From 3f360cb4ab23e5ba34336878f7334a6ab9aae7c6 Mon Sep 17 00:00:00 2001 From: turt2live Date: Sun, 27 Aug 2017 19:05:38 -0600 Subject: [PATCH] Add Travis CI integration (scalar) Adds #15 --- config/integrations/travisci.yaml | 8 ++ src/integration/impl/index.js | 4 +- .../impl/travisci/StubbedTravisCiBackbone.js | 63 ++++++++++ src/integration/impl/travisci/TravisCiBot.js | 53 ++++++++ .../impl/travisci/TravisCiFactory.js | 12 ++ .../impl/travisci/VectorTravisCiBackbone.js | 108 ++++++++++++++++ web/app/app.module.ts | 3 + .../travisci/travisci-config.component.html | 63 ++++++++++ .../travisci/travisci-config.component.scss | 19 +++ .../travisci/travisci-config.component.ts | 116 ++++++++++++++++++ web/app/shared/integration.service.ts | 11 +- web/app/shared/models/integration.ts | 6 + 12 files changed, 461 insertions(+), 5 deletions(-) create mode 100644 config/integrations/travisci.yaml create mode 100644 src/integration/impl/travisci/StubbedTravisCiBackbone.js create mode 100644 src/integration/impl/travisci/TravisCiBot.js create mode 100644 src/integration/impl/travisci/TravisCiFactory.js create mode 100644 src/integration/impl/travisci/VectorTravisCiBackbone.js create mode 100644 web/app/configs/travisci/travisci-config.component.html create mode 100644 web/app/configs/travisci/travisci-config.component.scss create mode 100644 web/app/configs/travisci/travisci-config.component.ts diff --git a/config/integrations/travisci.yaml b/config/integrations/travisci.yaml new file mode 100644 index 0000000..a05c75b --- /dev/null +++ b/config/integrations/travisci.yaml @@ -0,0 +1,8 @@ +type: "complex-bot" +integrationType: "travisci" +enabled: true +name: "Travis CI" +about: "Sends Travis CI build results into the room" +avatar: "img/avatars/travisci.png" +upstream: + type: "vector" \ No newline at end of file diff --git a/src/integration/impl/index.js b/src/integration/impl/index.js index 93a1f92..39e64e2 100644 --- a/src/integration/impl/index.js +++ b/src/integration/impl/index.js @@ -3,10 +3,12 @@ var StubbedFactory = require("./StubbedFactory"); var SimpleBotFactory = require("./simple_bot/SimpleBotFactory"); var RSSFactory = require("./rss/RSSFactory"); var IRCFactory = require("./irc/IRCFactory"); +var TravisCiFactory = require("./travisci/TravisCiFactory"); var mapping = { "complex-bot": { - "rss": RSSFactory + "rss": RSSFactory, + "travisci": TravisCiFactory, }, "bridge": { "irc": IRCFactory diff --git a/src/integration/impl/travisci/StubbedTravisCiBackbone.js b/src/integration/impl/travisci/StubbedTravisCiBackbone.js new file mode 100644 index 0000000..58b60c1 --- /dev/null +++ b/src/integration/impl/travisci/StubbedTravisCiBackbone.js @@ -0,0 +1,63 @@ +/** + * Stubbed/placeholder Travis CI backbone + */ +class StubbedTravisCiBackbone { + + /** + * Creates a new stubbed RSS backbone + */ + constructor() { + } + + /** + * Gets the user ID for this backbone + * @returns {Promise} resolves to the user ID + */ + getUserId() { + throw new Error("Not implemented"); + } + + /** + * Gets the repository templates for this backbone + * @returns {Promise<{repoKey:string,template:string}[]>} resolves to the collection of repositories and their templates + */ + getRepos() { + throw new Error("Not implemented"); + } + + /** + * Gets the immutable repository templates for this backbone (set by other users) + * @returns {Promise<{repoKey:string,template:string,ownerId:string}[]>} resolves to the collection of repositories and their templates + */ + getImmutableRepos() { + throw new Error("Not implemented"); + } + + /** + * Sets the new repository templates for this backbone + * @param {{repoKey:string,template:string}[]} newRepos the new templates for the repositories + * @returns {Promise<>} resolves when complete + */ + setRepos(newRepos) { + throw new Error("Not implemented"); + } + + /** + * Gets the webhook url for this backbone + * @returns {Promise} resolves to the webhook URL + */ + getWebhookUrl() { + throw new Error("Not implemented"); + } + + /** + * Removes the bot from the given room + * @param {string} roomId the room ID to remove the bot from + * @returns {Promise<>} resolves when completed + */ + removeFromRoom(roomId) { + throw new Error("Not implemented"); + } +} + +module.exports = StubbedTravisCiBackbone; \ No newline at end of file diff --git a/src/integration/impl/travisci/TravisCiBot.js b/src/integration/impl/travisci/TravisCiBot.js new file mode 100644 index 0000000..dbedbed --- /dev/null +++ b/src/integration/impl/travisci/TravisCiBot.js @@ -0,0 +1,53 @@ +var ComplexBot = require("../../generic_types/ComplexBot"); + +/** + * Represents a Travis CI bot + */ +class TravisCiBot extends ComplexBot { + + /** + * Creates a new Travis CI bot + * @param botConfig the bot configuration + * @param backbone the backbone powering this bot + */ + constructor(botConfig, backbone) { + super(botConfig); + this._backbone = backbone; + } + + /*override*/ + getUserId() { + return this._backbone.getUserId(); + } + + /*override*/ + getState() { + var response = { + repoTemplates: [], + immutableRepoTemplates: [], + webhookUrl: "" + }; + return this._backbone.getRepos().then(templates => { + response.repoTemplates = templates; + return this._backbone.getImmutableRepos(); + }).then(immutable => { + response.immutableRepoTemplates = immutable; + return this._backbone.getWebhookUrl(); + }).then(url => { + response.webhookUrl = url; + return response; + }); + } + + /*override*/ + removeFromRoom(roomId) { + return this._backbone.removeFromRoom(roomId); + } + + /*override*/ + updateState(newState) { + return this._backbone.setRepos(newState.repoTemplates).then(() => this.getState()); + } +} + +module.exports = TravisCiBot; \ No newline at end of file diff --git a/src/integration/impl/travisci/TravisCiFactory.js b/src/integration/impl/travisci/TravisCiFactory.js new file mode 100644 index 0000000..7ccdfe3 --- /dev/null +++ b/src/integration/impl/travisci/TravisCiFactory.js @@ -0,0 +1,12 @@ +var TravisCiBot = require("./TravisCiBot"); +var VectorTravisCiBackbone = require("./VectorTravisCiBackbone"); + +module.exports = (db, integrationConfig, roomId, scalarToken) => { + if (integrationConfig.upstream) { + if (integrationConfig.upstream.type !== "vector") throw new Error("Unsupported upstream"); + return db.getUpstreamToken(scalarToken).then(upstreamToken => { + var backbone = new VectorTravisCiBackbone(roomId, upstreamToken); + return new TravisCiBot(integrationConfig, backbone); + }); + } else throw new Error("Unsupported config"); +}; \ No newline at end of file diff --git a/src/integration/impl/travisci/VectorTravisCiBackbone.js b/src/integration/impl/travisci/VectorTravisCiBackbone.js new file mode 100644 index 0000000..3d99d78 --- /dev/null +++ b/src/integration/impl/travisci/VectorTravisCiBackbone.js @@ -0,0 +1,108 @@ +var StubbedTravisCiBackbone = require("./StubbedTravisCiBackbone"); +var VectorScalarClient = require("../../../scalar/VectorScalarClient"); +var _ = require("lodash"); +var log = require("../../../util/LogService"); + +/** + * Backbone for Travis CI bots running on vector.im through scalar + */ +class VectorTravisCiBackbone extends StubbedTravisCiBackbone { + + /** + * Creates a new Vector Travis CI backbone + * @param {string} roomId the room ID to manage + * @param {string} upstreamScalarToken the vector scalar token + */ + constructor(roomId, upstreamScalarToken) { + super(); + this._roomId = roomId; + this._scalarToken = upstreamScalarToken; + this._info = null; + this._otherTemplates = []; + } + + /*override*/ + getUserId() { + return (this._info ? Promise.resolve() : this._getInfo()).then(() => { + return this._info.bot_user_id; + }); + } + + /*override*/ + getRepos() { + return (this._info ? Promise.resolve() : this._getInfo()).then(() => { + if (this._info.integrations.length == 0) return []; + + var rooms = _.keys(this._info.integrations[0].config.rooms); + if (rooms.indexOf(this._roomId) === -1) return []; + + var repos = _.keys(this._info.integrations[0].config.rooms[this._roomId].repos); + return _.map(repos, r => { + return {repoKey: r, template: this._info.integrations[0].config.rooms[this._roomId].repos[r].template}; + }); + }); + } + + /*override*/ + getImmutableRepos() { + return (this._info ? Promise.resolve() : this._getInfo()).then(() => { + return this._otherTemplates; + }); + } + + /*override*/ + setRepos(newRepos) { + var config = {}; + config[this._roomId] = {repos: {}}; + for (var repo of newRepos) config[this._roomId].repos[repo.repoKey] = {template: repo.template}; + + return VectorScalarClient.configureIntegration("travis-ci", this._scalarToken, { + rooms: config + }); + } + + /*override*/ + getWebhookUrl() { + // string + return (this._info ? Promise.resolve() : this._getInfo()).then(() => { + if (this._info.integrations.length == 0) return ""; + return this._info.integrations[0].config.webhook_url; + }); + } + + _getInfo() { + return VectorScalarClient.getIntegrationsForRoom(this._roomId, this._scalarToken).then(integrations => { + this._otherTemplates = []; + for (var integration of integrations) { + if (integration.self) continue; // skip - we're not looking for ones we know about + if (integration.type == "travis-ci") { + var roomIds = _.keys(integration.config.rooms); + if (roomIds.length === 0) continue; + if (roomIds.length !== 1) log.warn("VectorTravisCiBackbone", "Expected 1 room but found " + roomIds.length); + + var roomConfig = integration.config.rooms[roomIds[0]]; + var repositories = _.keys(roomConfig.repos); + + for (var repo of repositories) { + this._otherTemplates.push({ + repoKey: repo, + template: roomConfig.repos[repo].template, + ownerId: integration.user_id + }); + } + } + } + + return VectorScalarClient.getIntegration("travis-ci", this._roomId, this._scalarToken); + }).then(info => { + this._info = info; + }); + } + + /*override*/ + removeFromRoom(roomId) { + return VectorScalarClient.removeIntegration("travis-ci", roomId, this._scalarToken); + } +} + +module.exports = VectorTravisCiBackbone; \ No newline at end of file diff --git a/web/app/app.module.ts b/web/app/app.module.ts index 15e0480..1c6dd2b 100644 --- a/web/app/app.module.ts +++ b/web/app/app.module.ts @@ -21,6 +21,7 @@ import { ModalModule } from "ngx-modialog"; import { RssConfigComponent } from "./configs/rss/rss-config.component"; import { IrcConfigComponent } from "./configs/irc/irc-config.component"; import { IrcApiService } from "./shared/irc-api.service"; +import { TravisCiConfigComponent } from "./configs/travisci/travisci-config.component"; @NgModule({ imports: [ @@ -43,6 +44,7 @@ import { IrcApiService } from "./shared/irc-api.service"; ScalarCloseComponent, RssConfigComponent, IrcConfigComponent, + TravisCiConfigComponent, // Vendor ], @@ -57,6 +59,7 @@ import { IrcApiService } from "./shared/irc-api.service"; bootstrap: [AppComponent], entryComponents: [ RssConfigComponent, + TravisCiConfigComponent, IrcConfigComponent, ] }) diff --git a/web/app/configs/travisci/travisci-config.component.html b/web/app/configs/travisci/travisci-config.component.html new file mode 100644 index 0000000..e3f1ebc --- /dev/null +++ b/web/app/configs/travisci/travisci-config.component.html @@ -0,0 +1,63 @@ +
+ +
+ +

Configure Travis CI hooks

+
+
+
+
+
+
.travis.yml configuration
+ The following will need to be added to your .travis.yml file: +
{{ travisYaml }}
+
+
+
Your Travis CI hooks
+
+ + + + +
+
+
+ {{ repo.repoKey }} + + +
+ + + +
+
+
+
Hooks from other users in the room
+
+
+ {{ repo.repoKey }} (added by {{ repo.ownerId }}) + +
{{ repo.template }}
+
+
+
+
+
\ No newline at end of file diff --git a/web/app/configs/travisci/travisci-config.component.scss b/web/app/configs/travisci/travisci-config.component.scss new file mode 100644 index 0000000..0430df4 --- /dev/null +++ b/web/app/configs/travisci/travisci-config.component.scss @@ -0,0 +1,19 @@ +// component styles are encapsulated and only applied to their components +.list { + margin-top: 5px; +} + +.removable { + margin-top: 3px; +} + +.other-items-title { + margin-top: 25px; + margin-bottom: 0; +} + +.yaml { + border: 1px solid #ccc; + background: #eee; + padding: 5px; +} \ No newline at end of file diff --git a/web/app/configs/travisci/travisci-config.component.ts b/web/app/configs/travisci/travisci-config.component.ts new file mode 100644 index 0000000..60ff99e --- /dev/null +++ b/web/app/configs/travisci/travisci-config.component.ts @@ -0,0 +1,116 @@ +import { Component } from "@angular/core"; +import { TravisCiIntegration } from "../../shared/models/integration"; +import { ModalComponent, DialogRef } from "ngx-modialog"; +import { ConfigModalContext } from "../../integration/integration.component"; +import { ToasterService } from "angular2-toaster"; +import { ApiService } from "../../shared/api.service"; + +@Component({ + selector: "my-travisci-config", + templateUrl: "./travisci-config.component.html", + styleUrls: ["./travisci-config.component.scss", "./../config.component.scss"], +}) +export class TravisCiConfigComponent implements ModalComponent { + + public integration: TravisCiIntegration; + + public isUpdating = false; + public repoKey = ""; + public repoTemplate = ""; + public travisYaml = ""; + + private roomId: string; + private scalarToken: string; + private knownRepos: string[] = []; + private visibleTemplates = []; + + constructor(public dialog: DialogRef, + private toaster: ToasterService, + private api: ApiService) { + this.integration = dialog.context.integration; + this.roomId = dialog.context.roomId; + this.scalarToken = dialog.context.scalarToken; + + this.travisYaml = "notifications:\n webhooks:\n urls:\n - " + this.integration.webhookUrl + "\n on_success: change # always | never | change\n on_failure: always\n on_start: never"; + + this.calculateKnownRepos(); + this.reset(); + } + + private calculateKnownRepos() { + for (let repo of this.integration.repoTemplates) + this.knownRepos.push(repo.repoKey); + for (let immutableRepo of this.integration.immutableRepoTemplates) + this.knownRepos.push(immutableRepo.repoKey); + } + + public toggleTemplate(repoKey: string) { + let idx = this.visibleTemplates.indexOf(repoKey); + if (idx === -1) this.visibleTemplates.push(repoKey); + else this.visibleTemplates.splice(idx, 1); + } + + public isTemplateToggled(repoKey: string) { + return this.visibleTemplates.indexOf(repoKey) !== -1; + } + + public editTemplate(repoKey: string) { + this.toggleTemplate(repoKey); + var repoConfig = this.integration.repoTemplates.find(r => r.repoKey == repoKey); + repoConfig.newTemplate = repoConfig.template; + } + + public saveTemplate(repoKey: string) { + var repoConfig = this.integration.repoTemplates.find(r => r.repoKey == repoKey); + repoConfig.template = repoConfig.newTemplate; + this.updateTemplates().then(() => this.toggleTemplate(repoKey)); + } + + public addRepository() { + if (!this.repoKey || this.repoKey.trim().length === 0) { + this.toaster.pop("warning", "Please enter a repository"); + return; + } + if (this.knownRepos.indexOf(this.repoKey) !== -1) { + this.toaster.pop("error", "Repository " + this.repoKey + " is already being tracked"); + return; + } + + this.integration.repoTemplates.push({repoKey: this.repoKey, template: this.repoTemplate, newTemplate: ""}); + this.updateTemplates().then(() => this.reset()); + } + + private reset() { + this.repoKey = ""; + this.repoTemplate = "%{repository}#%{build_number} (%{branch} - %{commit} : %{author}): %{message}\n Change view : %{compare_url}\n Build details : %{build_url}\n"; + } + + public removeRepository(repoKey: string) { + for (let i = 0; i < this.integration.repoTemplates.length; i++) { + if (this.integration.repoTemplates[i].repoKey === repoKey) { + this.integration.repoTemplates.splice(i, 1); + this.updateTemplates().then(() => this.reset()); + return; + } + } + + this.toaster.pop("error", "Could not find target repository"); + } + + public updateTemplates() { + this.isUpdating = true; + return this.api.updateIntegrationState(this.roomId, this.integration.type, this.integration.integrationType, this.scalarToken, { + repoTemplates: this.integration.repoTemplates + }).then(response => { + this.integration.repoTemplates = response.repoTemplates; + this.integration.immutableRepoTemplates = response.immutableRepoTemplates; + this.calculateKnownRepos(); + this.isUpdating = false; + this.toaster.pop("success", "Repositories updated"); + }).catch(err => { + this.toaster.pop("error", err.json().error); + console.error(err); + this.isUpdating = false; + }); + } +} diff --git a/web/app/shared/integration.service.ts b/web/app/shared/integration.service.ts index 62402cd..1a8d462 100644 --- a/web/app/shared/integration.service.ts +++ b/web/app/shared/integration.service.ts @@ -3,6 +3,7 @@ import { Integration } from "./models/integration"; import { RssConfigComponent } from "../configs/rss/rss-config.component"; import { ContainerContent } from "ngx-modialog"; import { IrcConfigComponent } from "../configs/irc/irc-config.component"; +import { TravisCiConfigComponent } from "../configs/travisci/travisci-config.component"; @Injectable() export class IntegrationService { @@ -10,19 +11,21 @@ export class IntegrationService { private static supportedTypeMap = { "bot": true, "complex-bot": { - "rss": true + "rss": true, + "travisci": true, }, "bridge": { - "irc": true + "irc": true, } }; private static components = { "complex-bot": { - "rss": RssConfigComponent + "rss": RssConfigComponent, + "travisci": TravisCiConfigComponent, }, "bridge": { - "irc": IrcConfigComponent + "irc": IrcConfigComponent, } }; diff --git a/web/app/shared/models/integration.ts b/web/app/shared/models/integration.ts index a5c91eb..c26b43c 100644 --- a/web/app/shared/models/integration.ts +++ b/web/app/shared/models/integration.ts @@ -17,6 +17,12 @@ export interface RSSIntegration extends Integration { immutableFeeds: {url: string, ownerId: string}[]; } +export interface TravisCiIntegration extends Integration { + repoTemplates: {repoKey: string, template: string, newTemplate: string}[]; // newTemplate is local + immutableRepoTemplates: {repoKey: string, template: string, ownerId: string}[]; + webhookUrl: string; // immutable +} + export interface IRCIntegration extends Integration { availableNetworks: {name: string, id: string}[]; channels: {[networkId: string]: string[]};