Add Travis CI integration (scalar)

Adds #15
This commit is contained in:
turt2live 2017-08-27 19:05:38 -06:00
parent 7cee8c99c4
commit 3f360cb4ab
12 changed files with 461 additions and 5 deletions

View File

@ -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"

View File

@ -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

View File

@ -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<string>} 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<string>} 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;

View File

@ -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;

View File

@ -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");
};

View File

@ -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;

View File

@ -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,
]
})

View File

@ -0,0 +1,63 @@
<div class="config-wrapper">
<img src="/img/close.svg" (click)="dialog.close()" class="close-icon">
<div class="config-header">
<img src="/img/avatars/travisci.png">
<h4>Configure Travis CI hooks</h4>
</div>
<div class="config-content">
<form (submit)="addRepository()" novalidate name="addRepoForm">
<div class="row">
<div class="col-md-12" style="margin-bottom: 12px;">
<h6>.travis.yml configuration</h6>
The following will need to be added to your .travis.yml file:
<pre class="yaml">{{ travisYaml }}</pre>
</div>
<div class="col-md-8" style="margin-bottom: 12px;">
<h6>Your Travis CI hooks</h6>
<div class="input-group input-group-sm">
<input type="text" class="form-control"
placeholder="owner/repo-name"
[(ngModel)]="repoKey" name="repoKey"
[disabled]="isUpdating">
<span class="input-group-btn">
<button type="submit" class="btn btn-success" [disabled]="isUpdating">
<i class="fa fa-plus-circle"></i> Add Repository
</button>
</span>
</div>
</div>
<div class="col-md-12 removable" *ngFor="let repo of integration.repoTemplates trackById">
{{ repo.repoKey }}
<button type="button" class="btn btn-outline-info btn-sm" (click)="editTemplate(repo.repoKey)"
style="margin-top: -5px;" [disabled]="isUpdating" *ngIf="!isTemplateToggled(repo.repoKey)">
<i class="fa fa-pencil"></i> Edit
</button>
<button type="button" class="btn btn-sm btn-outline-danger" (click)="removeRepository(repo.repoKey)"
style="margin-top: -5px;" [disabled]="isUpdating">
<i class="fa fa-times"></i> Remove Repository
</button>
<div *ngIf="isTemplateToggled(repo.repoKey)">
<textarea [(ngModel)]="repo.newTemplate" name="template-{{repo.repoKey}}"
style="width: 100%; height: 100px; margin-top: 5px;"></textarea>
<button type="button" class="btn btn-primary btn-sm" (click)="saveTemplate(repo.repoKey)">Save
</button>
<button type="button" class="btn btn-outline btn-sm" (click)="toggleTemplate(repo.repoKey)">
Cancel
</button>
</div>
</div>
<div class="col-md-12" *ngIf="integration.immutableRepoTemplates.length > 0">
<h6 class="other-items-title">Hooks from other users in the room</h6>
</div>
<div class="col-md-12 list" *ngFor="let repo of integration.immutableRepoTemplates trackById">
{{ repo.repoKey }} <span class="text-muted">(added by {{ repo.ownerId }})</span>
<button type="button" class="btn btn-outline-info btn-sm" (click)="toggleTemplate(repo.repoKey)"
style="margin-top: -5px;" [disabled]="isUpdating">
{{ isTemplateToggled(repo.repoKey) ? "Hide" : "Show" }} Template
</button>
<pre *ngIf="isTemplateToggled(repo.repoKey)">{{ repo.template }}</pre>
</div>
</div>
</form>
</div>
</div>

View File

@ -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;
}

View File

@ -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<ConfigModalContext> {
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<ConfigModalContext>,
private toaster: ToasterService,
private api: ApiService) {
this.integration = <TravisCiIntegration>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;
});
}
}

View File

@ -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,
}
};

View File

@ -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[]};