Skip to content

Commit

Permalink
Merge pull request #1 from hispanic/email-notifs-with-gitlab
Browse files Browse the repository at this point in the history
Allow for email notifications with GitLab
  • Loading branch information
hispanic authored Dec 5, 2020
2 parents b2e5fdb + 791e56a commit 8b844d2
Show file tree
Hide file tree
Showing 8 changed files with 673 additions and 112 deletions.
12 changes: 12 additions & 0 deletions config.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,12 @@ const schema = {
default: null,
env: 'GITHUB_TOKEN'
},
githubWebhookSecret: {
doc: 'Token to verify that webhook requests are from GitHub',
format: String,
default: null,
env: 'GITHUB_WEBHOOK_SECRET'
},
gitlabAccessTokenUri: {
doc: 'URI for the GitLab authentication provider.',
format: String,
Expand All @@ -102,6 +108,12 @@ const schema = {
default: null,
env: 'GITLAB_TOKEN'
},
gitlabWebhookSecret: {
doc: 'Token to verify that webhook requests are from GitLab',
format: String,
default: null,
env: 'GITLAB_WEBHOOK_SECRET'
},
port: {
doc: 'The port to bind the application to.',
format: 'port',
Expand Down
137 changes: 102 additions & 35 deletions controllers/handlePR.js
Original file line number Diff line number Diff line change
@@ -1,61 +1,128 @@
'use strict'

const config = require('../config')
const GitHub = require('../lib/GitHub')
const gitFactory = require('../lib/GitServiceFactory')
const Staticman = require('../lib/Staticman')

module.exports = async (repo, data) => {
const ua = config.get('analytics.uaTrackingId')
? require('universal-analytics')(config.get('analytics.uaTrackingId'))
: null

if (!data.number) {
return
/*
* Unfortunately, all we have available to us at this point is the request body (as opposed to
* the full request). Meaning, we don't have the :service portion of the request URL available.
* As such, switch between GitHub and GitLab using the repo URL. For example:
* "url": "https://api.github.com/repos/foo/staticman-test"
* "url": "git@gitlab.com:foo/staticman-test.git"
*/
const calcIsGitHub = function (data) {
return data.repository.url.includes('github.com')
}
const calcIsGitLab = function (data) {
return data.repository.url.includes('gitlab.com')
}

/*
* Because we don't have the full request available to us here, we can't set the (Staticman)
* version option. Fortunately, it isn't critical.
*/
const unknownVersion = ''

const github = await new GitHub({
username: data.repository.owner.login,
repository: data.repository.name,
version: '1'
let gitService = null
let mergeReqNbr = null
if (calcIsGitHub(data)) {
gitService = await gitFactory.create('github', {
branch: data.pull_request.base.ref,
repository: data.repository.name,
username: data.repository.owner.login,
version: unknownVersion
})
mergeReqNbr = data.number
} else if (calcIsGitLab(data)) {
const repoUrl = data.repository.url
const repoUsername = repoUrl.substring(repoUrl.indexOf(':') + 1, repoUrl.indexOf('/'))
gitService = await gitFactory.create('gitlab', {
branch: data.object_attributes.target_branch,
repository: data.repository.name,
username: repoUsername,
version: unknownVersion
})
mergeReqNbr = data.object_attributes.iid
} else {
return Promise.reject(new Error('Unable to determine service.'))
}

if (!mergeReqNbr) {
return Promise.reject(new Error('No pull/merge request number found.'))
}

let review = await gitService.getReview(mergeReqNbr).catch((error) => {
return Promise.reject(new Error(error))
})

try {
let review = await github.getReview(data.number)
if (review.sourceBranch.indexOf('staticman_')) {
return null
}
if (review.sourceBranch.indexOf('staticman_') < 0) {
/*
* Don't throw an error here, as we might receive "real" (non-bot) pull requests for files
* other than Staticman-processed comments.
*/
return null
}

if (review.state !== 'merged' && review.state !== 'closed') {
return null
}
if (review.state !== 'merged' && review.state !== 'closed') {
/*
* Don't throw an error here, as we'll regularly receive webhook calls whenever a pull/merge
* request is opened, not just merged/closed.
*/
return null
}

if (review.state === 'merged') {
/*
* The "staticman_notification" comment section of the comment pull/merge request only
* exists if notifications were enabled at the time the pull/merge request was created.
*/
const bodyMatch = review.body.match(/(?:.*?)<!--staticman_notification:(.+?)-->(?:.*?)/i)

if (review.state === 'merged') {
const bodyMatch = review.body.match(/(?:.*?)<!--staticman_notification:(.+?)-->(?:.*?)/i)
if (bodyMatch && (bodyMatch.length === 2)) {
try {
const parsedBody = JSON.parse(bodyMatch[1])
const staticman = await new Staticman(parsedBody.parameters)

if (bodyMatch && (bodyMatch.length === 2)) {
try {
const parsedBody = JSON.parse(bodyMatch[1])
const staticman = await new Staticman(parsedBody.parameters)
staticman.setConfigPath(parsedBody.configPath)

staticman.setConfigPath(parsedBody.configPath)
staticman.processMerge(parsedBody.fields, parsedBody.options)
} catch (err) {
return Promise.reject(err)
await staticman.processMerge(parsedBody.fields, parsedBody.options)
if (ua) {
ua.event('Hooks', 'Create/notify mailing list').send()
}
} catch (err) {
if (ua) {
ua.event('Hooks', 'Create/notify mailing list error').send()
}

return Promise.reject(err)
}
}

if (ua) {
ua.event('Hooks', 'Delete branch').send()
}
return github.deleteBranch(review.sourceBranch)
} catch (e) {
console.log(e.stack || e)
/*
* Only necessary for GitHub, as GitLab automatically deletes the backing branch for the
* pull/merge request. For GitHub, this will throw the following error if the branch has
* already been deleted:
* HttpError: Reference does not exist"
*/
if (calcIsGitHub(data)) {
try {
await gitService.deleteBranch(review.sourceBranch)
if (ua) {
ua.event('Hooks', 'Delete branch').send()
}
} catch (err) {
if (ua) {
ua.event('Hooks', 'Delete branch error').send()
}

if (ua) {
ua.event('Hooks', 'Delete branch error').send()
return Promise.reject(err)
}
}

return Promise.reject(e)
}
}
64 changes: 64 additions & 0 deletions controllers/webhook.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
'use strict'

const path = require('path')
const config = require(path.join(__dirname, '/../config'))
const handlePR = require('./handlePR')

module.exports = async (req, res, next) => {
switch (req.params.service) {
case 'gitlab':
let errorMsg = null
let event = req.headers['x-gitlab-event']

if (!event) {
errorMsg = 'No event found in the request'
} else {
if (event === 'Merge Request Hook') {
const webhookSecretExpected = config.get('gitlabWebhookSecret')
const webhookSecretSent = req.headers['x-gitlab-token']

let reqAuthenticated = true
if (webhookSecretExpected) {
reqAuthenticated = false
if (!webhookSecretSent) {
errorMsg = 'No secret found in the webhook request'
} else if (webhookSecretExpected === webhookSecretSent) {
/*
* Whereas GitHub uses the webhook secret to sign the request body, GitLab does not.
* As such, just check that the received secret equals the expected value.
*/
reqAuthenticated = true
} else {
errorMsg = 'Unable to verify authenticity of request'
}
}

if (reqAuthenticated) {
await handlePR(req.params.repository, req.body).catch((error) => {
console.error(error.stack || error)
errorMsg = error.message
})
}
}
}

if (errorMsg !== null) {
res.status(400).send({
error: errorMsg
})
} else {
res.status(200).send({
success: true
})
}

break
default:
res.status(400).send({
/*
* We are expecting GitHub webhooks to be handled by the express-github-webhook module.
*/
error: 'Unexpected service specified.'
})
}
}
7 changes: 6 additions & 1 deletion lib/GitHub.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,12 @@ class GitHub extends GitService {
const isAppAuth = config.get('githubAppID') &&
config.get('githubPrivateKey')
const isLegacyAuth = config.get('githubToken') &&
['1', '2'].includes(options.version)
/*
* Allowing/requiring the Staticman version to bleed-through to here is problematic, as
* it may not always be available to the caller (e.g., controllers/handlePR.js). To allow
* for this, we allow for a blank version value.
*/
['1', '2', ''].includes(options.version)

let authToken

Expand Down
57 changes: 49 additions & 8 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ class StaticmanAPI {
auth: require('./controllers/auth'),
handlePR: require('./controllers/handlePR'),
home: require('./controllers/home'),
process: require('./controllers/process')
process: require('./controllers/process'),
webhook: require('./controllers/webhook')
}

this.server = express()
Expand All @@ -23,7 +24,7 @@ class StaticmanAPI {
// type: '*'
}))

this.initialiseWebhookHandler()
this.initialiseGitHubWebhookHandler()
this.initialiseCORS()
this.initialiseBruteforceProtection()
this.initialiseRoutes()
Expand Down Expand Up @@ -96,21 +97,61 @@ class StaticmanAPI {
this.controllers.auth
)

this.server.post(
'/v:version/webhook/:service/',
this.bruteforce.prevent,
this.requireApiVersion([3]),
this.requireService(['gitlab']),
this.controllers.webhook
)

// Route: root
this.server.get(
'/',
this.controllers.home
)
}

initialiseWebhookHandler () {
const webhookHandler = GithubWebHook({
path: '/v1/webhook'
})
initialiseGitHubWebhookHandler () {
/*
* The express-github-webhook module is frustrating, as it only allows for a simplistic match
* for equality against one path. No string patterns (e.g., /v1?3?/webhook(/github)?). No
* regular expressions (e.g., /\/v[13]\/webhook(?:\/github)?/). As such, create one instance
* of the module per supported path. This won't scale well as Staticman API versions are added.
*/
for (const onePath of ['/v1/webhook', '/v3/webhook/github']) {
const webhookHandler = GithubWebHook({
path: onePath,
secret: config.get('githubWebhookSecret')
})

/*
* Wrap the handlePR callback so that we can catch any errors thrown and log them. This
* also has the benefit of eliminating noisy UnhandledPromiseRejectionWarning messages.
*
* Frustratingly, the express-github-webhook module only passes along body.data (and the
* repository name) to the callback, not the whole request.
*/
const handlePrWrapper = function (repo, data) {
this.controllers.handlePR(repo, data).catch((error) => {
/*
* Unfortunately, the express-github-webhook module returns a 200 (success) regardless
* of any errors raised in the downstream handler. So, all we can do is log errors.
*/
console.error(error)
})
}.bind(this)

webhookHandler.on('pull_request', this.controllers.handlePR)
webhookHandler.on('pull_request', handlePrWrapper)

this.server.use(webhookHandler)
/*
* Explicit handler for errors raised inside the express-github-webhook module that mimmicks
* the system/express error handler. But, allows for customization and debugging.
*/
webhookHandler.on('error', (error) => console.error(error.stack || error))

this.server.use(webhookHandler)
}
}

requireApiVersion (versions) {
Expand Down
Loading

0 comments on commit 8b844d2

Please sign in to comment.