implement handling multiple repos

Signed-off-by: Jakub Sokołowski <jakub@status.im>
This commit is contained in:
Jakub Sokołowski 2019-02-06 22:50:48 +01:00
parent 9e2dd15c10
commit 303079ea7d
No known key found for this signature in database
GPG Key ID: 4EF064D0E6D63020
12 changed files with 154 additions and 86 deletions

View File

@ -64,7 +64,7 @@ There are few environment variables you can set:
* `DB_PATH` - Path where the [LokiJS](http://lokijs.org/#/) DB file is stored. (Default: `/tmp/builds.db`)
* `GH_TOKEN` - Required for GitHub API access.
* `GH_REPO_OWNER` - Name of owner of repo to manage.
* `GH_REPO_NAME` - Name of GitHub repo to manage.
* `GH_REPO_NAMES` - Whitelist of names of GitHub repos to manage. (Empty means all)
# Building

View File

@ -1,6 +1,6 @@
{
"name": "github-comment-manager",
"version": "0.1.1",
"version": "0.2.0",
"description": "Minimal API for managing GitHub comments for CIclicks.",
"repository": "https://github.com/status-im/github-comment-manager",
"main": "index.js",

View File

@ -6,7 +6,7 @@ const JsonError = require('koa-json-error')
const JoiRouter = require('koa-joi-router')
const BodyParser = require('koa-bodyparser')
const App = (ghc) => {
const App = ({ghc, schema}) => {
const app = new Koa()
const router = new JoiRouter()
@ -24,35 +24,61 @@ const App = (ghc) => {
ctx.body = 'OK'
})
/* TEMPORARY fix to keep backwards compatibility */
router.route({
method: 'post',
path: '/builds/:pr',
validate: {
type: 'json',
body: ghc.db.schema,
body: schema,
},
handler: async (ctx) => {
/* save the build */
await ghc.db.addBuild(ctx.params.pr, ctx.request.body)
/* post or update the comment */
await ghc.update(ctx.params.pr)
await ghc.db.addBuild({
repo: 'status-react',
pr: ctx.params.pr,
build: ctx.request.body,
})
await ghc.update({
repo: 'status-react',
pr: ctx.params.pr,
})
ctx.status = 201
ctx.body = {status:'ok'}
}
})
router.post('/builds/:pr/refresh', async (ctx) => {
/* just re-render the comment */
await ghc.update(ctx.params.pr)
/* store build and post/update the comment */
router.route({
method: 'post',
path: '/builds/:repo/:pr',
validate: {
type: 'json',
body: schema,
},
handler: async (ctx) => {
await ghc.db.addBuild({
...ctx.params, build: ctx.request.body
})
await ghc.update(ctx.params)
ctx.status = 201
ctx.body = {status:'ok'}
}
})
/* just re-render the comment */
router.post('/builds/:repo/:pr/refresh', async (ctx) => {
await ghc.update(ctx.params)
ctx.status = 201
ctx.body = {status:'ok'}
})
router.get('/builds/:pr', async (ctx) => {
const builds = await ghc.db.getBuilds(ctx.params.pr)
/* list builds for repo+pr */
router.get('/builds/:repo/:pr', async (ctx) => {
const builds = await ghc.db.getBuilds(ctx.params)
ctx.body = {count: builds.length, builds}
})
/* list all managed comments */
router.get('/comments', async (ctx) => {
const comments = await ghc.db.getComments()
ctx.body = {count: comments.length, comments}

View File

@ -2,11 +2,9 @@ const log = require('loglevel')
const Joi = require('joi')
const Loki = require('lokijs')
const AwaitLock = require('await-lock')
const schema = require('./schema')
class Builds {
constructor(path, interval) {
this.schema = schema
this.lock = new AwaitLock()
this.db = new Loki(path, {
autoload: true,
@ -29,10 +27,6 @@ class Builds {
this.db.on('close', () => this.save())
}
validate (build) {
return Joi.validate(build, this.schema)
}
async save () {
this.db.saveDatabase((err) => {
if (err) { console.error('error saving', err) }
@ -59,9 +53,9 @@ class Builds {
return [].concat.apply([], bc)
}
async getBuilds (pr) {
async getBuilds (query) {
let builds = await this.builds.chain()
.find({pr})
.find(query)
.compoundsort(['$loki'])
.data()
/* sort groups of builds for commit based on $loki */
@ -73,33 +67,33 @@ class Builds {
})
}
async addBuild (pr, build) {
async addBuild ({repo, pr, build}) {
log.info(`Storing build for PR-${pr}: #${build.id} for ${build.platform}`)
return await this.builds.insert({pr, ...build})
}
async addComment (pr, comment_id) {
async addComment ({repo, pr, comment_id}) {
await this.lock.acquireAsync()
try {
log.info(`Storing comment for PR-${pr}: ${comment_id}`)
return await this.comments.insert({pr, comment_id})
return await this.comments.insert({repo, pr, comment_id})
} finally {
this.lock.release()
}
}
async getCommentID (pr) {
async getCommentID (repo, pr) {
await this.lock.acquireAsync()
try {
const rval = await this.comments.findOne({pr: pr})
const rval = await this.comments.findOne({repo, pr})
return rval ? rval.comment_id : null
} finally {
this.lock.release()
}
}
async getComments (pr) {
const comments = await this.comments.chain().simplesort('pr').data();
async getComments () {
const comments = await this.comments.chain().simplesort('pr').data()
/* strip the loki attributes */
return comments.map((c) => {
const {$loki, meta, ...comment} = c

View File

@ -46,10 +46,9 @@ const extractArchiveBuilds = (builds) => {
}
class Comments {
constructor(client, owner, repo, builds) {
constructor({client, owner, builds}) {
this.gh = client
this.db = builds
this.repo = repo /* name of repo to query */
this.owner = owner /* name of user who makes the comments */
/* add helper for formatting dates */
Handlebars.registerHelper('date', dateHelper)
@ -67,8 +66,8 @@ class Comments {
this.template = Handlebars.compile(template.main);
}
async renderComment (pr) {
const builds = await this.db.getBuilds(pr)
async renderComment ({repo, pr}) {
const builds = await this.db.getBuilds({repo, pr})
if (builds.length == 0) {
throw Error('No builds exist for this PR')
}
@ -77,38 +76,38 @@ class Comments {
return this.template({visible, archived})
}
async postComment (pr) {
async postComment ({repo, pr}) {
log.info(`Creating comment in PR-${pr}`)
const body = await this.renderComment(pr)
const body = await this.renderComment({repo, pr})
const rval = await this.gh.issues.createComment({
owner: this.owner,
repo: this.repo,
repo: repo,
number: pr,
body,
})
return rval.data.id
}
async updateComment (pr, comment_id) {
async updateComment ({repo, pr, comment_id}) {
log.info(`Updating comment in PR-${pr}`)
const body = await this.renderComment(pr)
const body = await this.renderComment({repo, pr})
const rval = await this.gh.issues.updateComment({
owner: this.owner,
repo: this.repo,
repo: repo,
comment_id,
body,
})
return rval.data.id
}
async update (pr) {
async update ({repo, pr}) {
/* check if comment was already posted */
let id = await this.db.getCommentID(pr)
if (id) {
await this.updateComment(pr, id)
let comment_id = await this.db.getCommentID(repo, pr)
if (comment_id) {
await this.updateComment({repo, pr, comment_id})
} else {
id = await this.postComment(pr)
await this.db.addComment(pr, id)
comment_id = await this.postComment({repo, pr})
await this.db.addComment({repo, pr, comment_id})
}
}
}

View File

@ -1,13 +1,17 @@
const Joi = require('joi')
const schema = Joi.object().keys({
id: Joi.alternatives().try(Joi.number().positive(), Joi.string()).required(),
commit: Joi.string().regex(/^[a-zA-Z0-9]{6,40}$/).required(),
success: Joi.boolean().required(),
platform: Joi.string().max(20).required(),
duration: Joi.string().max(20).required(),
url: Joi.string().uri().required(),
pkg_url: Joi.string().uri().allow(null),
})
/* whitelisted repos are controlled by env variables in server.js */
const genSchema = (REPOS_WHITELIST) => (
Joi.object().keys({
id: Joi.alternatives().try(Joi.number().positive(), Joi.string()).required(),
commit: Joi.string().regex(/^[a-zA-Z0-9]{6,40}$/).required(),
repo: Joi.string().max(30).required().valid(REPOS_WHITELIST),
success: Joi.boolean().required(),
platform: Joi.string().max(20).required(),
duration: Joi.string().max(20).required(),
url: Joi.string().uri().required(),
pkg_url: Joi.string().uri().allow(null),
})
)
module.exports = schema
module.exports = genSchema

View File

@ -5,13 +5,14 @@ const Octokit = require('@octokit/rest')
const App = require('./app')
const Builds = require('./builds')
const Comments = require('./comments')
const Schema = require('./schema')
/* DEFAULTS */
const LOG_LEVEL = process.env.LOG_LEVEL || 'INFO'
const LISTEN_PORT = process.env.LISTEN_PORT || 8000
const GH_TOKEN = process.env.GH_TOKEN || null
const GH_REPO_OWNER = process.env.GH_REPO_OWNER || 'status-im'
const GH_REPO_NAME = process.env.GH_REPO_NAME || 'status-react'
const GH_REPO_NAMES = process.env.GH_REPO_NAMES || []
const DB_PATH = process.env.DB_PATH || '/tmp/builds.db'
const DB_SAVE_INTERVAL = process.env.DB_SAVE_INTERVAL || 5000
@ -25,8 +26,15 @@ const builds = new Builds(DB_PATH, DB_SAVE_INTERVAL)
const gh = new Octokit()
gh.authenticate({type: 'token', token: GH_TOKEN})
const ghc = new Comments(gh, GH_REPO_OWNER, GH_REPO_NAME, builds)
const app = App(ghc, builds)
/* set valid repo names */
const schema = Schema(GH_REPO_NAMES)
const ghc = new Comments({
client: gh,
owner: GH_REPO_OWNER,
builds: builds,
})
const app = App({ghc, schema})
app.use(Logger())

View File

@ -15,7 +15,7 @@ describe('App', () => {
ghc.db = sinon.createStubInstance(Builds, {
getComments: sample.COMMENTS,
}),
app = App(ghc)
app = App({ghc})
})
describe('GET /health', () => {
@ -38,28 +38,36 @@ describe('App', () => {
})
})
describe('POST /builds/:pr', () => {
describe('POST /builds/:repo/:pr', () => {
it('should store the POSTed build', async () => {
const resp = await request(app.callback())
.post('/builds/PR-1')
.post('/builds/REPO-1/PR-1')
.send(sample.BUILD)
expect(resp.body).to.eql({status:'ok'})
expect(resp.status).to.eq(201)
expect(ghc.db.addBuild).calledOnceWith('PR-1', sample.BUILD)
expect(ghc.update).calledOnceWith('PR-1')
expect(ghc.db.addBuild).calledOnceWith({
repo: 'REPO-1', pr: 'PR-1', build: sample.BUILD,
})
expect(ghc.update).calledOnceWith({
repo: 'REPO-1', pr: 'PR-1'
})
})
})
describe('POST /builds/:pr/refresh', () => {
describe('POST /builds/:repo/:pr/refresh', () => {
it('should update github comment', async () => {
const resp = await request(app.callback())
.post('/builds/PR-1/refresh')
.post('/builds/REPO-1/PR-1/refresh')
.send(sample.BUILD)
expect(resp.body).to.eql({status:'ok'})
expect(resp.status).to.eq(201)
expect(ghc.db.addBuild).not.calledOnceWith('PR-1', sample.BUILD)
expect(ghc.update).calledOnceWith('PR-1')
expect(ghc.db.addBuild).not.calledOnceWith({
repo: 'REPO-1', pr: 'PR-1', build: sample.BUILD
})
expect(ghc.update).calledOnceWith({
repo: 'REPO-1', pr: 'PR-1',
})
})
})
})

View File

@ -36,7 +36,7 @@ describe('Builds', () => {
/* need to add the builds before they can be sorted */
for (let i=0; i<BUILDS.length; i++) {
let b = BUILDS[i]
await builds.addBuild('PR-1', b)
await builds.addBuild({repo: 'REPO-1', pr: 'PR-1', build: b})
/* verify the build was added */
let rval = await builds.builds.findOne({id: b.id, platform: b.platform})
expect(rval.commit).to.equal(BUILDS[i].commit)
@ -44,10 +44,10 @@ describe('Builds', () => {
})
it('should sort by commits and ids', async () => {
let rval = await builds.getBuilds('PR-1')
let rval = await builds.getBuilds('REPO-1', 'PR-1')
/* remove fields we don't care about for easier comparison */
rval = rval.map((b) => {
const { pr, success, duration, url, pkg_url, meta, ...build } = b
const { pr, repo, success, duration, url, pkg_url, meta, ...build } = b
return build
})
expect(rval).to.deep.equal([

View File

@ -54,7 +54,12 @@ describe('Comments', () => {
builds = sinon.createStubInstance(Builds, {
getBuilds: sample.BUILDS.slice(0, 2),
})
comments = new Comments(client, 'owner', 'repo', builds)
comments = new Comments({
client: client,
owner: 'owner',
repos: ['repo'],
builds: builds
})
})
describe('renderComment', () => {
@ -77,26 +82,30 @@ describe('Comments', () => {
describe('postComment', () => {
it('should create a new comment', async () => {
let id = await comments.postComment('PR-ID')
let id = await comments.postComment({
repo: 'REPO-1', pr: 'PR-ID',
})
expect(id).to.eq('ISSUE-ID')
expect(client.issues.createComment).calledOnceWith({
body: sinon.match.any,
number: 'PR-ID',
owner: 'owner',
repo: 'repo',
number: 'PR-ID',
repo: 'REPO-1',
})
})
})
describe('updateComment', () => {
it('should update existing comment', async () => {
let id = await comments.updateComment('PR-ID', 'COMMENT-ID')
let id = await comments.updateComment({
repo: 'REPO-1', pr: 'PR-ID', comment_id: 'COMMENT-ID',
})
expect(id).to.eq('ISSUE-ID')
expect(client.issues.updateComment).calledOnceWith({
body: sinon.match.any,
comment_id: 'COMMENT-ID',
owner: 'owner',
repo: 'repo',
comment_id: 'COMMENT-ID',
repo: 'REPO-1',
})
})
})

View File

@ -2,6 +2,7 @@
const BUILD = {
id: 'ID-1',
commit: 'abcd1234',
repo: 'REPO-1',
success: true,
platform: 'PLATFORM-1',
duration: 'DURATION-1',
@ -12,6 +13,7 @@ const BUILD = {
const getBuild = (idx) => ({
id: `ID-${idx}`,
commit: `COMMIT-${Math.floor(idx/4)}`,
repo: `REPO-${Math.floor(idx/8)}`,
success: (idx%3) ? true : false,
platform: `PLATFORM-${idx}`,
duration: `DURATION-${idx} 12 sec`,

View File

@ -5,64 +5,82 @@ const Joi = require('joi')
const sample = require('./sample')
const Schema = require('../src/schema')
let build
let build, schema
describe('Schema', () => {
beforeEach(() => {
/* refresh for every test */
build = Object.assign({}, sample.BUILD)
schema = Schema(['REPO-1'])
})
describe('id', () => {
it('can be a string', async () => {
let rval = await Joi.validate(build, Schema)
let rval = await Joi.validate(build, schema)
expect(rval).to.eql(build)
})
it('can be a number', async () => {
build.id = 123
let rval = await Joi.validate(build, Schema)
let rval = await Joi.validate(build, schema)
expect(rval).to.eql(build)
})
it('can\'t be null', () => {
build.id = null
expect(Joi.validate(build, Schema)).rejectedWith('"id" must be a number, "id" must be a string')
expect(Joi.validate(build, schema)).rejectedWith('"id" must be a number, "id" must be a string')
})
})
describe('commit', () => {
it('has to be a commit', async () => {
let rval = await Joi.validate(build, Schema)
let rval = await Joi.validate(build, schema)
expect(rval).to.eql(build)
})
it('can\'t be a null', () => {
build.commit = null
expect(Joi.validate(build, Schema)).rejectedWith('"commit" must be a string')
expect(Joi.validate(build, schema)).rejectedWith('"commit" must be a string')
})
it('can\'t be a number', () => {
build.commit = 1
expect(Joi.validate(build, Schema)).rejectedWith('"commit" must be a string')
expect(Joi.validate(build, schema)).rejectedWith('"commit" must be a string')
})
})
describe('repo', () => {
it('has to be a repo', async () => {
let rval = await Joi.validate(build, schema)
expect(rval).to.eql(build)
})
it('can\'t be a null', () => {
build.repo = null
expect(Joi.validate(build, schema)).rejectedWith('"repo" must be a string')
})
it('has to be on whitelist', () => {
build.repo = 'REPO-WRONG'
expect(Joi.validate(build, schema)).rejectedWith('"repo" must be one of [REPO-1]')
})
})
describe('pkg_url', () => {
it('has to be a URL', async () => {
let rval = await Joi.validate(build, Schema)
let rval = await Joi.validate(build, schema)
expect(rval).to.eql(build)
})
it('can be a null', async () => {
build.pkg_url = null
let rval = await Joi.validate(build, Schema)
let rval = await Joi.validate(build, schema)
expect(rval).to.eql(build)
})
it('can\'t be a number', () => {
build.pkg_url = 1
expect(Joi.validate(build, Schema)).rejectedWith('"pkg_url" must be a string')
expect(Joi.validate(build, schema)).rejectedWith('"pkg_url" must be a string')
})
})
})