Support Travis CI completely

Webhooks are generated eagerly but have lazily set targets so that we can give the user a webhook URL to set in their config. go-neb (upstream & standalone) doesn't give a webhook URL until after the repositories are configured.

Fixes #16
This commit is contained in:
Travis Ralston 2018-03-30 14:59:25 -06:00
parent 6f238fc13f
commit 4365cb0753
9 changed files with 199 additions and 14 deletions

View File

@ -0,0 +1,35 @@
import { FormParam, HeaderParam, Path, PathParam, POST } from "typescript-rest";
import Webhook from "../../db/models/Webhook";
import { ApiError } from "../ApiError";
import * as request from "request";
import { LogService } from "matrix-js-snippets";
@Path("/api/v1/dimension/webhooks")
export class DimensionWebhookService {
@POST
@Path("/travisci/:webhookId")
public async postTravisCiWebhook(@PathParam("webhookId") webhookId: string, @FormParam("payload") payload: string, @HeaderParam("Signature") signature: string): Promise<any> {
const webhook = await Webhook.findByPrimary(webhookId).catch(() => null);
if (!webhook) throw new ApiError(404, "Webhook not found");
if (!webhook.targetUrl) throw new ApiError(400, "Webhook not configured");
return new Promise((resolve, _reject) => {
request({
method: "POST",
url: webhook.targetUrl,
form: {payload: payload},
headers: {
"Signature": signature,
},
}, (err, res, _body) => {
if (err) {
LogService.error("DimensionWebhooksService", "Error invoking travis-ci webhook");
LogService.error("DimensionWebhooksService", res.body);
throw new ApiError(500, "Internal Server Error");
} else resolve();
});
});
}
}

View File

@ -14,6 +14,7 @@ import NebIntegration from "./models/NebIntegration";
import NebBotUser from "./models/NebBotUser";
import NebNotificationUser from "./models/NebNotificationUser";
import NebIntegrationConfig from "./models/NebIntegrationConfig";
import Webhook from "./models/Webhook";
class _DimensionStore {
private sequelize: Sequelize;
@ -39,6 +40,7 @@ class _DimensionStore {
NebBotUser,
NebNotificationUser,
NebIntegrationConfig,
Webhook,
]);
}

View File

@ -0,0 +1,22 @@
import { QueryInterface } from "sequelize";
import { DataType } from "sequelize-typescript";
export default {
up: (queryInterface: QueryInterface) => {
return Promise.resolve()
.then(() => queryInterface.createTable("dimension_webhooks", {
"hookId": {type: DataType.STRING, primaryKey: true, allowNull: false},
"ownerUserId": {
type: DataType.STRING, allowNull: false,
references: {model: "dimension_users", key: "userId"},
onUpdate: "cascade", onDelete: "cascade",
},
"purposeId": {type: DataType.STRING, allowNull: false},
"targetUrl": {type: DataType.STRING, allowNull: true},
}));
},
down: (queryInterface: QueryInterface) => {
return Promise.resolve()
.then(() => queryInterface.dropTable("dimension_webhooks"));
}
}

25
src/db/models/Webhook.ts Normal file
View File

@ -0,0 +1,25 @@
import { Column, ForeignKey, Model, PrimaryKey, Table } from "sequelize-typescript";
import User from "./User";
@Table({
tableName: "dimension_webhooks",
underscoredAll: false,
timestamps: false,
})
export default class Webhook extends Model<Webhook> {
// This is really just a holding class to keep foreign keys alive
@PrimaryKey
@Column
hookId: string;
@Column
@ForeignKey(() => User)
ownerUserId: string;
@Column
purposeId: string;
@Column
targetUrl: string;
}

View File

@ -21,7 +21,7 @@ export interface RssBotConfiguration {
}
export interface TravisCiConfiguration {
webhookUrl: string;
webhookId: string;
repos: {
[repoKey: string]: {
addedByUserId: string;

View File

@ -11,6 +11,8 @@ import { AppserviceStore } from "../db/AppserviceStore";
import { MatrixAppserviceClient } from "../matrix/MatrixAppserviceClient";
import NebIntegrationConfig from "../db/models/NebIntegrationConfig";
import { RssBotConfiguration, TravisCiConfiguration } from "../integrations/ComplexBot";
import Webhook from "../db/models/Webhook";
import * as randomString from "random-string";
interface InternalTravisCiConfig {
webhookUrl: string;
@ -65,18 +67,17 @@ export class NebProxy {
public async getServiceConfiguration(integration: NebIntegration, inRoomId: string): Promise<any> {
if (integration.nebId !== this.neb.id) throw new Error("Integration is not for this NEB proxy");
let result = null;
if (this.neb.upstreamId) {
try {
const response = await this.doUpstreamRequest<ModularIntegrationInfoResponse>("/integrations/" + NebClient.getNebType(integration.type), {
room_id: inRoomId,
});
if (integration.type === "rss") return this.parseUpstreamRssConfiguration(response.integrations);
else if (integration.type === "travisci") return this.parseUpstreamTravisCiConfiguration(response.integrations);
else return {};
if (integration.type === "rss") result = await this.parseUpstreamRssConfiguration(response.integrations);
else if (integration.type === "travisci") result = await this.parseUpstreamTravisCiConfiguration(response.integrations);
} catch (err) {
LogService.error("NebProxy", err);
return {};
}
} else {
const serviceConfig = await NebIntegrationConfig.findOne({
@ -85,8 +86,17 @@ export class NebProxy {
roomId: inRoomId,
},
});
return serviceConfig ? JSON.parse(serviceConfig.jsonContent) : {};
result = serviceConfig ? JSON.parse(serviceConfig.jsonContent) : {};
}
if (!result) result = {};
if (integration.type === "travisci") {
// Replace the webhook ID with the requesting user's webhook ID (generating it if needed)
result["webhookId"] = await this.getWebhookId(integration.type);
delete result["webhook_url"];
}
return result;
}
public async setServiceConfiguration(integration: NebIntegration, inRoomId: string, newConfig: any): Promise<any> {
@ -135,7 +145,7 @@ export class NebProxy {
private parseUpstreamTravisCiConfiguration(integrations: any[]): InternalTravisCiConfig {
if (!integrations) return {rooms: {}, webhookUrl: null};
const result: InternalTravisCiConfig = {rooms: {}, webhookUrl: "https://example.org/nowhere"};
const result: InternalTravisCiConfig = {rooms: {}, webhookUrl: null};
for (const integration of integrations) {
if (!integration.user_id || !integration.config || !integration.config.rooms) continue;
@ -240,14 +250,24 @@ export class NebProxy {
}
if (this.neb.upstreamId) {
await this.doUpstreamRequest<ModularIntegrationInfoResponse>("/integrations/travis-ci/configureService", {
await this.doUpstreamRequest("/integrations/travis-ci/configureService", {
room_id: roomId,
rooms: newConfig.rooms,
});
// Annoyingly, we don't get any kind of feedback for the webhook - we have to re-request it
const response = await this.doUpstreamRequest<ModularIntegrationInfoResponse>("/integrations/travis-ci", {
room_id: roomId,
});
const parsed = this.parseUpstreamTravisCiConfiguration(response.integrations);
if (parsed && parsed.webhookUrl)
await this.setWebhookTarget("travisci", parsed.webhookUrl);
} else {
const client = new NebClient(this.neb);
const notifUser = await NebStore.getOrCreateNotificationUser(this.neb.id, "travisci", this.requestingUserId);
await client.setServiceConfig(notifUser.serviceId, notifUser.appserviceUserId, "travis-ci", newConfig);
const result = await client.setServiceConfig(notifUser.serviceId, notifUser.appserviceUserId, "travis-ci", newConfig);
if (result['NewConfig'] && result['NewConfig']['webhook_url'])
await this.setWebhookTarget("travisci", result['NewConfig']['webhook_url']);
}
}
@ -263,6 +283,35 @@ export class NebProxy {
}
}
private async getWebhookId(serviceId: string): Promise<string> {
// We add a bit of uniqueness to the service ID to avoid conflicts
serviceId = serviceId + "_" + this.neb.id;
let webhook = await Webhook.findOne({
where: {
purposeId: serviceId,
ownerUserId: this.requestingUserId
}
}).catch(() => null);
if (!webhook) {
webhook = await Webhook.create({
hookId: randomString({length: 160}),
ownerUserId: this.requestingUserId,
purposeId: serviceId,
targetUrl: null, // Will be populated later
});
}
return webhook.hookId;
}
private async setWebhookTarget(serviceId: string, targetUrl: string): Promise<any> {
const webhookId = await this.getWebhookId(serviceId);
const webhook = await Webhook.findByPrimary(webhookId);
webhook.targetUrl = targetUrl;
return webhook.save();
}
private async doUpstreamRequest<T>(endpoint: string, body?: any): Promise<T> {
const upstream = await Upstream.findByPrimary(this.neb.upstreamId);
const token = await UserScalarToken.findOne({

View File

@ -1,6 +1,4 @@
Release checklist:
* RSS bot
* Travis CI
* IRC Bridge
* Update documentation
* Configuration migration (if possible)

View File

@ -1,5 +1,36 @@
<my-complex-bot-config [botComponent]="this">
<ng-template #botParamsTemplate>
<my-ibox [isCollapsible]="true">
<h5 class="my-ibox-title">
.travis.yml configuration and template information
</h5>
<div class="my-ibox-content">
<p>The following section needs to be added to your <code>.travis.yml</code> file in your repositories:</p>
<pre>{{ travisYaml }}</pre>
<p>
The following variables can be used in your template. This template is used to post a message to the
room when your webhook is activated.
</p>
<ul>
<li><code>{{ '%{repository_slug}' }}</code> - The repository identifier (eg: "matrix-org/synapse")</li>
<li><code>{{ '%{repository_name}' }}</code> - The repository name (eg: "synapse")</li>
<li><code>{{ '%{build_number}' }}</code> - The build number</li>
<li><code>{{ '%{build_id}' }}</code> - The build ID</li>
<li><code>{{ '%{branch}' }}</code> - The branch name</li>
<li><code>{{ '%{commit}' }}</code> - A short version of the commit SHA</li>
<li><code>{{ '%{commit_subject}' }}</code> - The first line of the commit message</li>
<li><code>{{ '%{commit_message}' }}</code> - The full commit message</li>
<li><code>{{ '%{author}' }}</code> - The author of the commit</li>
<li><code>{{ '%{result}' }}</code> - The result of the build</li>
<li><code>{{ '%{message}' }}</code> - The message from Travis CI about the build</li>
<li><code>{{ '%{duration}' }}</code> - The total duration of all builds in the matrix</li>
<li><code>{{ '%{elapsed_timed}' }}</code> - The time it took to run the build</li>
<li><code>{{ '%{compare_url}' }}</code> - A URL to see the changes which triggered the build</li>
<li><code>{{ '%{build_url}' }}</code> - A URL to see the build information</li>
</ul>
</div>
</my-ibox>
<my-ibox>
<h5 class="my-ibox-title">
Repositories
@ -19,7 +50,7 @@
<tr *ngFor="let repo of getRepos()">
<td>{{ repo.repoKey }}</td>
<td>
<textarea title="Repository Template" class="repo-template form-control" rows="3" (change)="repo.template = $event.target.value">{{ repo.template }}</textarea>
<textarea title="Repository Template" class="repo-template form-control" rows="3" (change)="repo.template = $event.target.value" [disabled]="isUpdating || !repo.isSelf">{{ repo.template }}</textarea>
</td>
<td>{{ repo.addedByUserId }}</td>
<td class="actions-col">

View File

@ -3,7 +3,7 @@ import { Component } from "@angular/core";
import { SessionStorage } from "../../../shared/SessionStorage";
interface TravisCiConfig {
webhookUrl: string; // TODO: Display webhook URL somewhere
webhookId: string;
repos: {
[repoKey: string]: { // "turt2live/matrix-dimension"
addedByUserId: string;
@ -31,13 +31,36 @@ export class TravisCiComplexBotConfigComponent extends ComplexBotComponent<Travi
super("travisci");
}
public get webhookUrl(): string {
if (!this.newConfig) return "not specified";
return window.location.origin + "/api/v1/dimension/webhooks/travisci/" + this.newConfig.webhookId;
}
public get travisYaml(): string {
return "" +
"notifications:\n" +
" webhooks:\n" +
" urls:\n" +
" - " + this.webhookUrl + "\n" +
" on_success: change # always | never | change\n" +
" on_failure: always\n" +
" on_start: never\n";
}
public addRepo(): void {
if (!this.newRepoKey.trim()) {
this.toaster.pop('warning', 'Please enter a repository');
return;
}
this.newConfig.repos[this.newRepoKey] = {addedByUserId: SessionStorage.userId, template: "TODO: Default template"};
this.newConfig.repos[this.newRepoKey] = {
addedByUserId: SessionStorage.userId,
template: "" +
"%{repository_slug}#%{build_number} (%{branch} - %{commit} : %{author}): %{message}\n" +
" Change view : %{compare_url}\n" +
" Build details : %{build_url}\n"
};
this.newRepoKey = "";
}