diff --git a/config/integrations/circleci.yaml b/config/integrations/circleci.yaml new file mode 100644 index 0000000..f0941c5 --- /dev/null +++ b/config/integrations/circleci.yaml @@ -0,0 +1,8 @@ +type: "complex-bot" +integrationType: "circleci" +enabled: false # disabled because the API is considered unstable/inoperable. Use at your own risk! +name: "CircleCI" +about: "Sends CircleCI build results into the room" +avatar: "img/avatars/circleci.png" +upstream: + type: "vector" diff --git a/src/integration/impl/circleci/CircleCiBot.js b/src/integration/impl/circleci/CircleCiBot.js new file mode 100644 index 0000000..0a70bfd --- /dev/null +++ b/src/integration/impl/circleci/CircleCiBot.js @@ -0,0 +1,53 @@ +var ComplexBot = require("../../generic_types/ComplexBot"); + +/** + * Represents a CircleCI bot + */ +class CircleCiBot extends ComplexBot { + + /** + * Creates a new CircleCI 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 = CircleCiBot; \ No newline at end of file diff --git a/src/integration/impl/circleci/CircleCiFactory.js b/src/integration/impl/circleci/CircleCiFactory.js new file mode 100644 index 0000000..c3e7fa7 --- /dev/null +++ b/src/integration/impl/circleci/CircleCiFactory.js @@ -0,0 +1,20 @@ +var CircleCiBot = require("./CircleCiBot"); +var VectorCircleCiBackbone = require("./VectorCircleCiBackbone"); +var UpstreamConfiguration = require("../../../UpstreamConfiguration"); + +var factory = (db, integrationConfig, roomId, scalarToken) => { + factory.validateConfig(integrationConfig); + + return db.getUpstreamToken(scalarToken).then(upstreamToken => { + var backbone = new VectorCircleCiBackbone(roomId, upstreamToken); + return new CircleCiBot(integrationConfig, backbone); + }); +}; + +factory.validateConfig = (integrationConfig) => { + if (!integrationConfig.upstream) throw new Error("Unsupported configuration"); + if (integrationConfig.upstream.type !== "vector") throw new Error("Unsupported upstream"); + if (!UpstreamConfiguration.hasUpstream("vector")) throw new Error("Vector upstream not specified"); +}; + +module.exports = factory; \ No newline at end of file diff --git a/src/integration/impl/circleci/StubbedCircleCiBackbone.js b/src/integration/impl/circleci/StubbedCircleCiBackbone.js new file mode 100644 index 0000000..0a454db --- /dev/null +++ b/src/integration/impl/circleci/StubbedCircleCiBackbone.js @@ -0,0 +1,63 @@ +/** + * Stubbed/placeholder CircleCI backbone + */ +class StubbedCircleCiBackbone { + + /** + * Creates a new stubbed CircleCI 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 = StubbedCircleCiBackbone; \ No newline at end of file diff --git a/src/integration/impl/circleci/VectorCircleCiBackbone.js b/src/integration/impl/circleci/VectorCircleCiBackbone.js new file mode 100644 index 0000000..9b3ee4a --- /dev/null +++ b/src/integration/impl/circleci/VectorCircleCiBackbone.js @@ -0,0 +1,108 @@ +var StubbedCircleCiBackbone = require("./StubbedCircleCiBackbone"); +var VectorScalarClient = require("../../../scalar/VectorScalarClient"); +var _ = require("lodash"); +var log = require("../../../util/LogService"); + +/** + * Backbone for CircleCI bots running on vector.im through scalar + */ +class VectorCircleCiBackbone extends StubbedCircleCiBackbone { + + /** + * Creates a new Vector CircleCI 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("circleci", 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 == "circleci") { + var roomIds = _.keys(integration.config.rooms); + if (roomIds.length === 0) continue; + if (roomIds.length !== 1) log.warn("VectorCircleCiBackbone", "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("circleci", this._roomId, this._scalarToken); + }).then(info => { + this._info = info; + }); + } + + /*override*/ + removeFromRoom(roomId) { + return VectorScalarClient.removeIntegration("circleci", roomId, this._scalarToken); + } +} + +module.exports = VectorCircleCiBackbone; \ No newline at end of file diff --git a/src/integration/impl/index.js b/src/integration/impl/index.js index d3be9ea..92d7e9b 100644 --- a/src/integration/impl/index.js +++ b/src/integration/impl/index.js @@ -4,12 +4,14 @@ var SimpleBotFactory = require("./simple_bot/SimpleBotFactory"); var RSSFactory = require("./rss/RSSFactory"); var IRCFactory = require("./irc/IRCFactory"); var TravisCiFactory = require("./travisci/TravisCiFactory"); +var CircleCiFactory = require("./circleci/CircleCiFactory"); var SimpleWidgetFactory = require("./simple_widget/SimpleWidgetFactory"); var mapping = { "complex-bot": { "rss": RSSFactory, "travisci": TravisCiFactory, + "circleci": CircleCiFactory, }, "bridge": { "irc": IRCFactory, diff --git a/web/app/configs/circleci/circleci-config.component.html b/web/app/configs/circleci/circleci-config.component.html new file mode 100644 index 0000000..4685cec --- /dev/null +++ b/web/app/configs/circleci/circleci-config.component.html @@ -0,0 +1,63 @@ +
+ +
+ +

Configure CircleCI hooks

+
+
+
+
+
+
.circleci/config.yml configuration
+ The following will need to be added to your .circleci/config.yml file: +
{{ circleYaml }}
+
+
+
Your CircleCI 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/circleci/circleci-config.component.scss b/web/app/configs/circleci/circleci-config.component.scss new file mode 100644 index 0000000..0430df4 --- /dev/null +++ b/web/app/configs/circleci/circleci-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/circleci/circleci-config.component.ts b/web/app/configs/circleci/circleci-config.component.ts new file mode 100644 index 0000000..c524063 --- /dev/null +++ b/web/app/configs/circleci/circleci-config.component.ts @@ -0,0 +1,116 @@ +import { Component } from "@angular/core"; +import { CircleCiIntegration } 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-circleci-config", + templateUrl: "./circleci-config.component.html", + styleUrls: ["./circleci-config.component.scss", "./../config.component.scss"], +}) +export class CircleCiConfigComponent implements ModalComponent { + + public integration: CircleCiIntegration; + + public isUpdating = false; + public repoKey = ""; + public repoTemplate = ""; + public circleYaml = ""; + + 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.circleYaml = "notify:\n webhooks:\n - url: " + this.integration.webhookUrl; + + 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); + let repoConfig = this.integration.repoTemplates.find(r => r.repoKey === repoKey); + repoConfig.newTemplate = repoConfig.template; + } + + public saveTemplate(repoKey: string) { + let 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 = "%{build_num}#%{build_num} (%{branch} - %{commit} : %{committer_name}): %{outcome}\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 c4fe7d2..67c5e5e 100644 --- a/web/app/shared/integration.service.ts +++ b/web/app/shared/integration.service.ts @@ -4,6 +4,7 @@ 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"; +import { CircleCiConfigComponent } from "../configs/circleci/circleci-config.component"; import { CustomWidgetConfigComponent } from "../configs/widget/custom_widget/custom_widget-config.component"; import { YoutubeWidgetConfigComponent } from "../configs/widget/youtube/youtube-config.component"; import { TwitchWidgetConfigComponent } from "../configs/widget/twitch/twitch-config.component"; @@ -28,6 +29,9 @@ export class IntegrationService { "travisci": { component: TravisCiConfigComponent, }, + "circleci": { + component: CircleCiConfigComponent, + }, }, "bridge": { "irc": { diff --git a/web/app/shared/models/integration.ts b/web/app/shared/models/integration.ts index 8f02e8c..c28eaeb 100644 --- a/web/app/shared/models/integration.ts +++ b/web/app/shared/models/integration.ts @@ -26,6 +26,12 @@ export interface TravisCiIntegration extends Integration { webhookUrl: string; // immutable } +export interface CircleCiIntegration 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[] }; diff --git a/web/public/img/avatars/circleci.png b/web/public/img/avatars/circleci.png new file mode 100644 index 0000000..1e9ba51 Binary files /dev/null and b/web/public/img/avatars/circleci.png differ