var irc = require('irc'); var express = require('express'); var bodyParser = require('body-parser'); var request = require('request'); var cheerio = require('cheerio'); var config = require('./config'); // Bind recipients to notifications. var channels = []; var hookToChannel = {}; for (var who in config.reports) { if (who.indexOf('#') === 0) channels.push(who); var hooks = config.reports[who]; for (var i = 0; i < hooks.length; i++) { var hook = hooks[i]; (hookToChannel[hook] = hookToChannel[hook] || []).push(who); } } // Sanitize projectUrl if (typeof config.projectUrl !== 'undefined') { var url = '' + config.projectUrl; if (url[url.length - 1] !== '/') { url += '/'; } config.projectUrl = url; } var client = new irc.Client(config.server, config.nick, { debug: config.debug || false, channels: channels, userName: config.userName, realName: config.realName, retryDelay: 120000 }); var app = express(); app.use(bodyParser.json()); // for parsing application/json app.use(bodyParser.urlencoded({ extended: true })); // for parsing application/x-www-form-urlencoded var shortenURL = function(url, callback) { callback(url); }; if (config.lstu) { if (config.lstu[config.lstu.length - 1] !== '/') { config.lstu += '/'; } shortenURL = function shortenURL(url, callback) { request(config.lstu + 'a', { method: 'POST', form: { lsturl: url, format: 'json' } }, function (err, res, body) { try { body = JSON.parse(body); } catch(err) { body = {err: 'cant parse JSON'}; } if (err || !body.success) { console.error("Error when shortening link: ", res ? "(status: " + res.statusCode + ")" : "(no response)", '\nerror:', err, '\nfailure reason:', body.msg); callback(url); } else { callback(body.short); } }); }; } function conjugatePast(verb) { // Make action displayable (e.g., open -> opened, close -> closed, merge -> merged). return verb + (verb.substr(-1) === 'e' ? '' : 'e') + 'd'; } var lastIssueActions = {}; var handlers = { push: function(body, say) { var user = body.user_username; var projectName = body.project.name; var commits = body.commits; var numCommits = body.total_commits_count; var branchName = body.ref.replace('refs/heads/', ''); var found = false; for (var i = 0; i < config.branches.length; i++) { if (branchName === config.branches[i]) { found = true; break; } } if (!found) return; var msg = null; if (!numCommits) { // Special case: a branch was created or deleted. var action = 'created'; if (body.after === '0000000000000000000000000000000000000000') action = 'deleted'; msg = user + ' ' + action + ' branch ' + branchName + ' on ' + projectName + '.'; say(msg); } else { var maybeS = numCommits === 1 ? '' : 's'; var lastCommit = commits[commits.length - 1]; var lastCommitMessage = lastCommit.message.trim().split('\n')[0].trim(); shortenURL(lastCommit.url, function(shortUrl) { msg = user + ' pushed on ' + projectName + '@' + branchName + ': '; if (numCommits === 1) { msg += lastCommitMessage + ' ' + shortUrl; } else { msg += commits.length + ' commits (last: ' + lastCommitMessage + ') ' + shortUrl; } say(msg); }); } }, issue: function(body, say) { var user = body.user.username; var projectName = body.project.name; var issue = body.object_attributes; var issueNumber = issue.iid; var issueTitle = issue.title.trim(); var issueState = issue.state; var url = issue.url; // Don't trigger the hook on issue's update; if (issue.action === 'update') return; // Don't trigger several close event. if (issue.action === lastIssueActions[issue.iid]) return; lastIssueActions[issue.iid] = issue.action; var displayedAction = conjugatePast(issue.action); shortenURL(url, function(shortUrl) { var msg = user + ' ' + displayedAction + ' issue #' + issueNumber + ' ("' + issueTitle + '") on ' + projectName + ' ' + shortUrl; say(msg); }); }, merge_request: function(body, say) { var user = body.user.username; var request = body.object_attributes; var projectName = request.target.name; var from = request.source_branch; var to = request.target_branch; var id = request.iid; var title = request.title.trim(); var url = request.url; var state = request.state; // Don't trigger the hook on mr's updates. if (request.action === 'update') { return; } var displayedAction = conjugatePast(request.action); shortenURL(url, function(shortUrl) { var msg = user + ' ' + displayedAction + ' MR !' + id + ' (' + from + '->' + to + ': ' + title + ') ' + ' on ' + projectName + '; ' + shortUrl; say(msg); }); }, build: function(body, say) { var id = body.build_id; var status = body.build_status; var isFinished = body.build_finished_at !== null; var duration = body.build_duration; var projectName = body.project_name; var stage = body.build_stage; var msg = projectName + ': build #' + id + ' (' + stage + ') changed status: ' + status; if (isFinished) msg += ' (finished in ' + duration + ' seconds.)'; say(msg); } }; function makeSay(body) { var whom = hookToChannel[body.object_kind] || []; return function say(msgs) { if (!whom.length) { return; } if (msgs) { if (msgs instanceof Array) { for (var i = 0; i < msgs.length; i++) client.say(whom, msgs[i]); } else { client.say(whom, msgs); } } }; } app.post('/', function(req, res) { var body = req.body || {}; var msgs = null; if (body.object_kind && handlers[body.object_kind]) handlers[body.object_kind](body, makeSay(body)); else console.log("Unexpected object_kind:", body.object_kind); res.sendStatus(200); }); var chanMessageCounters = {}; var mentionCache = {}; function makeCacheKey(isIssue, id, chan) { return chan + '-' + (isIssue ? 'issue' : 'mr') + id; } function fetch_and_say(isIssue, id, from, chan) { var cacheKey = makeCacheKey(isIssue, id, chan); // Don't mention if it's been already mentioned in the last // config.cacheDuration messages. var mentionCounter = mentionCache[cacheKey]; if (typeof mentionCounter !== 'undefined') { if (chanMessageCounters[chan] - mentionCounter <= config.cacheDuration) { return; } } var path, text_prefix; if (isIssue) { path = 'issues/'; text_prefix = 'Issue #'; } else { path = 'merge_requests/'; text_prefix = 'Merge request !'; } var to = chan === config.nick ? from : chan; var url = config.projectUrl + path + id; request(url, function(err, res, body) { if (res && res.statusCode === 200) { var title = cheerio.load(body)('head title').text(); if (title.length) { client.say(to, title); client.say(to, url); } else { client.say(to, text_prefix + id + ": " + url); } mentionCache[cacheKey] = chanMessageCounters[chan]; } }); } var issueRegexp = /(?:\s|^)#(\d+)/g; var mergeRequestRegexp = /(?:\s|^)!(\d+)/g; function testIssueRegexp(r) { function test(input, expected) { var match = r.exec(input); var found = 0; while (match !== null) { if (match[1] !== expected[found].toString()) { throw new Error('should have found ' + expected[found]); } found++; match = r.exec(input); } if (expected.length !== found) { throw new Error('missing expected occurrences: ' + expected.length + 'vs expected ' + found); } r.lastIndex = 0; } test('hello #301 jeej', [301]); test('#302 lol', [302]); test('lol#303', []); test('lol #303', [303]); test('\t#304', [304]); test(' #305', [305]); test('hello#305 #306 jeej#42 #307 #lol # #308', [306, 307, 308]); }; testIssueRegexp(issueRegexp); app.listen(config.port, config.hostname, function() { console.log('gitlab-to-irc running.'); client.on('message', function(from, chan, message) { chanMessageCounters[chan] = (chanMessageCounters[chan] || 0) + 1; var matches = null; while ((matches = issueRegexp.exec(message)) !== null) { var issueId = matches[1]; fetch_and_say(true, issueId, from, chan); } while ((matches = mergeRequestRegexp.exec(message)) !== null) { var mrId = matches[1]; fetch_and_say(false, mrId, from, chan); } }); });