├── .gitignore ├── ngrok.yml ├── scripts ├── systemd │ ├── README.md │ └── gitlab-slack.service ├── init │ ├── README.md │ └── gitlab-slack.conf └── init.d │ ├── README.md │ └── gitlab-slack ├── .eslintrc.js ├── .editorconfig ├── package.json ├── LICENSE ├── .eslintrc-es6plus.js ├── app ├── handlers │ ├── wikiPage.js │ ├── branch.js │ ├── tag.js │ ├── mergeRequest.js │ ├── index.js │ ├── commit.js │ └── issue.js ├── lib │ ├── slack.js │ ├── helpers.js │ ├── server.js │ └── gitlabapi.js └── app.js ├── config.js ├── .eslintrc-base.js ├── CHANGELOG.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | node_modules/ 3 | 4 | npm-debug.log* 5 | -------------------------------------------------------------------------------- /ngrok.yml: -------------------------------------------------------------------------------- 1 | tunnels: 2 | gs: 3 | proto: http 4 | addr: 4646 5 | subdomain: gitlab-slack 6 | -------------------------------------------------------------------------------- /scripts/systemd/README.md: -------------------------------------------------------------------------------- 1 | # gitlab-slack systemd Script 2 | 3 | This is an example **Systemd** unit for the **gitlab-slack** service. 4 | -------------------------------------------------------------------------------- /scripts/init/README.md: -------------------------------------------------------------------------------- 1 | # gitlab-slack Upstart Script 2 | 3 | This is an example **Upstart** config/script for the **gitlab-slack** service. 4 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /* eslint-version 5.15 */ 2 | 3 | module.exports = { 4 | 'root': true, 5 | 'extends': [ 6 | 'eslint:recommended', 7 | '.eslintrc-base.js', 8 | '.eslintrc-es6plus.js' 9 | ], 10 | 'env': { 11 | 'node': true 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /scripts/init.d/README.md: -------------------------------------------------------------------------------- 1 | # gitlab-slack init.d Script 2 | 3 | This is the **init.d** bash script for the **gitlab-slack** service. 4 | 5 | ## Source 6 | 7 | This script is modified from [node-startup](https://github.com/chovy/node-startup), a generic node app service script. 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | indent_style = tab 6 | indent_size = 4 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | 10 | [*.{md,markdown}] 11 | trim_trailing_whitespace = false 12 | indent_style = space 13 | indent_size = 3 14 | -------------------------------------------------------------------------------- /scripts/init/gitlab-slack.conf: -------------------------------------------------------------------------------- 1 | start on (started gitlab-runsvdir) 2 | stop on shutdown 3 | respawn 4 | script 5 | APP_DIR="/opt/gitlab-slack" 6 | NODE_APP="server.js" 7 | LOG_DIR=$APP_DIR 8 | LOG_FILE="$LOG_DIR/gitlab-slack.log" 9 | NODE_EXEC=$(which nodejs) 10 | cd $APP_DIR 11 | exec $NODE_EXEC --harmony "$APP_DIR/$NODE_APP" 1>>$LOG_FILE 2>&1 12 | end script 13 | -------------------------------------------------------------------------------- /scripts/systemd/gitlab-slack.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=gitlab-slack service 3 | After=gitlab-runsvdir.service 4 | 5 | [Service] 6 | Type=simple 7 | Environment=DEBUG=gitlab-slack:* 8 | WorkingDirectory=/opt/gitlab-slack 9 | Restart=on-failure 10 | StandardOutput=syslog 11 | StandardError=syslog 12 | SyslogIdentifier=gitlab-slack 13 | ExecStart=/usr/bin/npm start 14 | 15 | [Install] 16 | WantedBy=multi-user.target 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "gitlab-slack", 4 | "version": "2.1.3", 5 | "description": "A service that receives webhook notifications from GitLab and posts information to an incoming webhook on Slack.", 6 | "main": "app/app.js", 7 | "author": "Chris Harwood", 8 | "license": "MIT", 9 | "homepage": "https://github.com/nazrhyn/gitlab-slack", 10 | "bugs": "https://github.com/nazrhyn/gitlab-slack/issues", 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/nazrhyn/gitlab-slack.git" 14 | }, 15 | "scripts": { 16 | "start": "node app/app.js", 17 | "ngrok": "ngrok start -config ~/.ngrok2/ngrok.yml -config ngrok.yml gs", 18 | "relock": "rm -rf node_modules/ package-lock.json && npm install && { npm outdated || true; }" 19 | }, 20 | "engines": { 21 | "node": ">=8.x", 22 | "npm": ">=6.x" 23 | }, 24 | "dependencies": { 25 | "bluebird": "3.x", 26 | "chalk": "2.x", 27 | "debug": "4.x", 28 | "lodash": "4.x", 29 | "request": "2.x", 30 | "request-promise": "4.x", 31 | "supports-color": "7.x" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Chris Harwood 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. -------------------------------------------------------------------------------- /.eslintrc-es6plus.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'parserOptions': { 3 | 'ecmaVersion': 9 // 2018 4 | }, 5 | 'env': { 6 | 'es6': true, 7 | }, 8 | 'rules': { 9 | 'arrow-parens': [2, 'as-needed', { 'requireForBlockBody': true }], 10 | 'arrow-spacing': 2, 11 | 'class-methods-use-this': 1, 12 | 'consistent-this': [2, 'self'], 13 | 'generator-star-spacing': ['error', { 'before': false, 'after': true }], 14 | 'implicit-arrow-linebreak': 2, 15 | 'lines-between-class-members': 2, 16 | 'no-buffer-constructor': 2, 17 | 'no-confusing-arrow': 2, 18 | 'no-duplicate-imports': [2, { 'includeExports': true }], 19 | 'no-template-curly-in-string': 2, 20 | 'no-useless-computed-key': 2, 21 | 'no-useless-constructor': 2, 22 | 'no-useless-rename': 2, 23 | 'no-var': 2, 24 | 'object-shorthand': 1, 25 | 'prefer-const': [2, { 'destructuring': 'all' }], 26 | 'prefer-object-spread': 2, 27 | 'prefer-numeric-literals': 2, 28 | 'prefer-rest-params': 2, 29 | 'prefer-spread': 2, 30 | 'require-atomic-updates': 2, 31 | 'require-yield': 2, 32 | 'rest-spread-spacing': 2, 33 | 'strict': [2, 'global'], 34 | 'symbol-description': 2, 35 | 'template-curly-spacing': 2, 36 | 'template-tag-spacing': 2, 37 | 'yield-star-spacing': 2 38 | } 39 | }; 40 | -------------------------------------------------------------------------------- /app/handlers/wikiPage.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const /** 4 | * @type {Configuration} 5 | */ 6 | config = require('../../config'), 7 | debugCreate = require('debug'), 8 | helpers = require('../lib/helpers'), 9 | util = require('util'); 10 | 11 | const debug = debugCreate('gitlab-slack:handler:wikipage'); 12 | 13 | /** 14 | * Handles an wiki page message. 15 | * @param {Object} data The message data. 16 | * @returns {Promise} A promise that will be resolved with the output data structure. 17 | */ 18 | module.exports = async function (data) { 19 | debug('Handling message...'); 20 | 21 | const wikiDetails = data.object_attributes, 22 | verb = helpers.actionToVerb(wikiDetails.action), 23 | output = { 24 | parse: 'none', 25 | text: util.format( 26 | '[%s] <%s/u/%s|%s> %s wiki page <%s|%s>', 27 | data.project.path_with_namespace, 28 | config.gitLab.baseUrl, 29 | data.user.username, 30 | data.user.username, 31 | verb, 32 | wikiDetails.url, 33 | wikiDetails.slug 34 | ) 35 | }; 36 | 37 | debug('Message handled.'); 38 | 39 | output.__kind = module.exports.KIND; 40 | 41 | return output; 42 | }; 43 | 44 | /** 45 | * Provides metadata for this kind of handler. 46 | * @type {HandlerKind} 47 | */ 48 | module.exports.KIND = Object.freeze({ 49 | name: 'wiki_page', 50 | title: 'Wiki Page' 51 | }); 52 | -------------------------------------------------------------------------------- /config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * gitlab-slack configuration. 5 | * @typedef {Object} Configuration 6 | * @property {Number} port The port on which to listen. 7 | * @property {String} slackWebhookUrl The URL of the Slack incoming webhook. 8 | * @property {GitLabConfiguration} gitLab GitLab configuration. 9 | */ 10 | 11 | /** 12 | * gitlab-slack GitLab configuration. 13 | * @typedef {Object} GitLabConfiguration 14 | * @property {String} baseUrl The protocol/host/port of the GitLab installation. 15 | * @property {String} apiToken The API token with which to query GitLab. 16 | * @property {ProjectConfiguration[]} projects The project configuration. 17 | */ 18 | 19 | /** 20 | * gitlab-slack GitLab project configuration. 21 | * @typedef {Object} ProjectConfiguration 22 | * @property {Number} id The project ID. 23 | * @property {String} name The name of the project. This value is only used for logging; the group/name namespace is recommended. 24 | * @property {String} [channel] Overrides the default channel for the Slack webhook. 25 | * @property {Array} [patterns] An array of regular expressions or strings (that will be turned into case-insensitive regular expressions) used to select issue labels that should be tracked for changes. 26 | */ 27 | 28 | /** 29 | * The gitlab-slack configuration. 30 | * @type {Configuration} 31 | */ 32 | module.exports = { 33 | port: 4646, 34 | slackWebhookUrl: '', 35 | gitLab: { 36 | baseUrl: '', 37 | apiToken: '', 38 | projects: [] 39 | } 40 | }; 41 | -------------------------------------------------------------------------------- /app/handlers/branch.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const debugCreate = require('debug'), 4 | /** 5 | * @type {Configuration} 6 | */ 7 | config = require('../../config'), 8 | util = require('util'); 9 | 10 | const debug = debugCreate('gitlab-slack:handler:branch'); 11 | 12 | /** 13 | * Handles an branch message. 14 | * @param {Object} data The message data. 15 | * @param {Boolean} beforeZero Indicates whether the before hash is all zeroes. 16 | * @param {Boolean} afterZero Indicates whether the after hash is all zeroes. 17 | * @returns {Promise} A promise that will be resolved with the output data structure. 18 | */ 19 | module.exports = async function (data, beforeZero, afterZero) { 20 | debug('Handling message...'); 21 | 22 | let action = '[unknown]'; 23 | 24 | if (beforeZero) { 25 | action = 'pushed new branch'; 26 | } else if (afterZero) { 27 | action = 'deleted branch'; 28 | } 29 | 30 | const branch = data.ref.replace('refs/heads/', ''), 31 | output = { 32 | parse: 'none', 33 | text: util.format( 34 | '[%s] <%s/u/%s|%s> %s <%s/tree/%s|%s>', 35 | data.project.path_with_namespace, 36 | config.gitLab.baseUrl, 37 | data.user_username, 38 | data.user_username, 39 | action, 40 | data.project.web_url, 41 | branch, 42 | branch 43 | ) 44 | }; 45 | 46 | debug('Message handled.'); 47 | 48 | output.__kind = module.exports.KIND; 49 | 50 | return output; 51 | }; 52 | 53 | /** 54 | * Provides metadata for this kind of handler. 55 | * @type {HandlerKind} 56 | */ 57 | module.exports.KIND = Object.freeze({ 58 | name: 'push', 59 | title: 'Branch' 60 | }); 61 | -------------------------------------------------------------------------------- /app/lib/slack.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const chalk = require('chalk'), 4 | /** 5 | * @type {Configuration} 6 | */ 7 | config = require('../../config'), 8 | debugCreate = require('debug'), 9 | http = require('http'), 10 | rp = require('request-promise'), 11 | supportsColor = require('supports-color'), 12 | util = require('util'); 13 | 14 | const debug = debugCreate('gitlab-slack:slack'); 15 | 16 | /** 17 | * Sends data to the Slack webhook. 18 | * @param {Object} body The data. 19 | * @returns {Promise} A promise that will be resolved when the data is sent. 20 | */ 21 | exports.send = async function (body) { 22 | // Grab the kind metadata and then remove it. 23 | const kind = body.__kind; 24 | delete body.__kind; 25 | 26 | debug(chalk`{cyan SEND} -> {blue %s} to webhook`, kind.title); 27 | 28 | try { 29 | const response = await rp({ 30 | method: 'POST', 31 | url: config.slackWebhookUrl, 32 | json: true, 33 | body, 34 | resolveWithFullResponse: true 35 | }); 36 | 37 | debug(chalk`{cyan RECV} <- {blue %s} to webhook -> {green %d %s}`, kind.title, response.statusCode, http.STATUS_CODES[response.statusCode]); 38 | } catch (e) { 39 | const output = e.response.toJSON(), 40 | message = output.body.error || output.body.message || output.body; 41 | 42 | const failure = new Error(`Slack Webhook for ${kind.title} - ${message}`); 43 | failure.statusCode = e.statusCode; 44 | 45 | debug(chalk`{red FAIL} <- {blue %s} to webhook -> {red %d %s} ! %s`, kind.title, failure.statusCode, http.STATUS_CODES[failure.statusCode], message); 46 | console.log(chalk`{red FAIL} {yellow Request Body ---------------------}`, '\n', util.inspect(body, { colors: supportsColor.stdout.level > 0, depth: 5 })); 47 | console.log(chalk`{red FAIL} {yellow Response Body ---------------------}`, '\n', util.inspect(output, { colors: supportsColor.stdout.level > 0, depth: 5 })); 48 | 49 | throw failure; 50 | } 51 | }; 52 | -------------------------------------------------------------------------------- /app/handlers/tag.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const debugCreate = require('debug'), 4 | /** 5 | * @type {Configuration} 6 | */ 7 | config = require('../../config'), 8 | util = require('util'); 9 | 10 | const debug = debugCreate('gitlab-slack:handler:tag'); 11 | 12 | /** 13 | * Handles an tag message. 14 | * @param {Object} data The message data. 15 | * @param {Boolean} beforeZero Indicates whether the before hash is all zeroes. 16 | * @param {Boolean} afterZero Indicates whether the after hash is all zeroes. 17 | * @returns {Promise} A promise that will be resolved with the output data structure. 18 | */ 19 | module.exports = async function (data, beforeZero, afterZero) { 20 | debug('Handling message...'); 21 | 22 | let action = '[unknown]'; 23 | 24 | if (beforeZero) { 25 | action = 'pushed new tag'; 26 | } else if (afterZero) { 27 | action = 'deleted tag'; 28 | } else { 29 | action = 'moved tag'; 30 | } 31 | 32 | const tag = data.ref.replace('refs/tags/', ''), 33 | output = { 34 | text: util.format( 35 | '[%s] <%s/u/%s|%s> %s <%s/commits/%s|%s>', 36 | data.project.path_with_namespace, 37 | config.gitLab.baseUrl, 38 | data.user_username, 39 | data.user_username, 40 | action, 41 | data.project.web_url, 42 | tag, 43 | tag 44 | ) 45 | }; 46 | 47 | if (data.message) { 48 | // If we have a message, send that along in an attachment. 49 | output.attachments = [{ 50 | color: module.exports.COLOR, 51 | text: data.message, 52 | fallback: data.message 53 | }]; 54 | } 55 | 56 | debug('Message handled.'); 57 | 58 | output.__kind = module.exports.KIND; 59 | 60 | return output; 61 | }; 62 | 63 | /** 64 | * Provides metadata for this kind of handler. 65 | * @type {HandlerKind} 66 | */ 67 | module.exports.KIND = Object.freeze({ 68 | name: 'tag_push', 69 | title: 'Tag' 70 | }); 71 | 72 | /** 73 | * The color for this kind of handler. 74 | * @type {string} 75 | */ 76 | module.exports.COLOR = '#5DB5FD'; 77 | -------------------------------------------------------------------------------- /scripts/init.d/gitlab-slack: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | APP_NAME="GitLab/Slack Integration" 4 | APP_DIR="/opt/gitlab-slack" 5 | NODE_APP="server.js" 6 | CONFIG_DIR="$APP_DIR" 7 | PID_DIR="$APP_DIR/pid" 8 | PID_FILE="$PID_DIR/app.pid" 9 | LOG_DIR=$APP_DIR 10 | LOG_FILE="$LOG_DIR/gitlab-slack.log" 11 | NODE_EXEC=$(which nodejs) 12 | 13 | USAGE="Usage: $0 {start|stop|restart|status}" 14 | 15 | pid_file_exists() { 16 | [ -f "$PID_FILE" ] 17 | } 18 | 19 | get_pid() { 20 | echo "$(cat "$PID_FILE")" 21 | } 22 | 23 | is_running() { 24 | PID=$(get_pid) 25 | [ -a "/proc/$PID" ] && [[ "$(cat "/proc/$PID/cmdline")" == *"$APP_DIR/$NODE_APP"* ]] 26 | } 27 | 28 | start_it() { 29 | mkdir -p "$PID_DIR" 30 | mkdir -p "$LOG_DIR" 31 | 32 | if ! [ -a $LOG_FILE ]; then 33 | touch $LOG_FILE 34 | chmod 666 $LOG_FILE 35 | fi 36 | 37 | echo "Starting $APP_NAME..." 38 | cd $APP_DIR 39 | $NODE_EXEC --harmony "$APP_DIR/$NODE_APP" 1>>$LOG_FILE 2>&1 & 40 | echo $! > $PID_FILE 41 | echo "$APP_NAME started with PID $!." 42 | } 43 | 44 | stop_process() { 45 | PID=$(get_pid) 46 | echo "Killing process $PID..." 47 | kill $PID 48 | } 49 | 50 | remove_pid_file() { 51 | echo "Removing PID file..." 52 | rm -f "$PID_FILE" 53 | } 54 | 55 | start_app() { 56 | if pid_file_exists; then 57 | if is_running; then 58 | echo "$APP_NAME is already running with PID $(get_pid)." 59 | exit 1 60 | else 61 | echo "Stale PID file detected." 62 | remove_pid_file 63 | start_it 64 | fi 65 | else 66 | start_it 67 | fi 68 | } 69 | 70 | stop_app() { 71 | if pid_file_exists; then 72 | if is_running; then 73 | echo "Stopping $APP_NAME..." 74 | stop_process 75 | remove_pid_file 76 | echo "$APP_NAME stopped." 77 | else 78 | echo "Stale PID file detected." 79 | remove_pid_file 80 | fi 81 | else 82 | echo "$APP_NAME is not running." 83 | exit 1 84 | fi 85 | } 86 | 87 | status_app() { 88 | if pid_file_exists; then 89 | if is_running; then 90 | echo "$APP_NAME is running with PID $(get_pid)." 91 | return 0 92 | else 93 | echo "$APP_NAME is not running. Stale PID file detected." 94 | return 2 95 | fi 96 | else 97 | echo "$APP_NAME is not running." 98 | return 3 99 | fi 100 | } 101 | 102 | case "$1" in 103 | start) 104 | start_app 105 | ;; 106 | 107 | stop) 108 | stop_app 109 | ;; 110 | 111 | restart) 112 | stop_app 113 | start_app 114 | ;; 115 | 116 | status) 117 | status_app 118 | exit $? 119 | ;; 120 | 121 | *) 122 | echo $USAGE 123 | exit 1 124 | ;; 125 | esac 126 | -------------------------------------------------------------------------------- /app/handlers/mergeRequest.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const chalk = require('chalk'), 4 | /** 5 | * @type {Configuration} 6 | */ 7 | config = require('../../config'), 8 | debugCreate = require('debug'), 9 | helpers = require('../lib/helpers'), 10 | util = require('util'); 11 | 12 | const debug = debugCreate('gitlab-slack:handler:mergerequest'); 13 | 14 | /** 15 | * Handles an merge request message. 16 | * @param {Object} data The message data. 17 | * @returns {Promise} A promise that will be resolved with the output data structure. 18 | */ 19 | module.exports = async function (data) { 20 | debug('Handling message...'); 21 | 22 | const mr = data.object_attributes; 23 | 24 | if (mr.action === 'update') { 25 | // We always ignore updates as they're too spammy. 26 | debug(chalk`Ignored. ({blue update})`); 27 | return; 28 | } 29 | 30 | const verb = helpers.actionToVerb(mr.action), 31 | description = mr.description.split(/(?:\r\n|[\r\n])/)[0], // Take only the first line of the description. 32 | /* eslint-disable camelcase */ // Required property naming. 33 | attachment = { 34 | mrkdwn_in: ['text'], 35 | color: module.exports.COLOR, 36 | title: mr.title, 37 | title_link: mr.url 38 | }; 39 | /* eslint-enable camelcase */ 40 | 41 | let assigneeName = '_none_'; 42 | 43 | if (data.assignee) { 44 | assigneeName = util.format('<%s/u/%s|%s>', config.gitLab.baseUrl, data.assignee.username, data.assignee.username); 45 | } 46 | 47 | const output = { 48 | parse: 'none', 49 | text: util.format( 50 | '[%s] <%s/u/%s|%s> %s merge request *!%s* — *source:* <%s/tree/%s|%s> — *target:* <%s/tree/%s|%s> - *assignee* - %s', 51 | data.project.path_with_namespace, 52 | config.gitLab.baseUrl, 53 | data.user.username, 54 | data.user.username, 55 | verb, 56 | mr.iid, 57 | mr.source.web_url, 58 | mr.source_branch, 59 | mr.source_branch, 60 | mr.target.web_url, 61 | mr.target_branch, 62 | mr.target_branch, 63 | assigneeName 64 | ), 65 | attachments: [attachment] 66 | }; 67 | 68 | // Start the fallback with the title. 69 | attachment.fallback = attachment.title; 70 | 71 | switch (mr.action) { 72 | case 'open': 73 | case 'reopen': 74 | // Open and re-open are the only ones that get the full merge request description. 75 | attachment.fallback += '\n' + mr.description; 76 | attachment.text = helpers.convertMarkdownToSlack(description, mr.source.web_url); 77 | 78 | break; 79 | } 80 | 81 | debug('Message handled.'); 82 | 83 | output.__kind = module.exports.KIND; 84 | 85 | return output; 86 | }; 87 | 88 | /** 89 | * Provides metadata for this kind of handler. 90 | * @type {HandlerKind} 91 | */ 92 | module.exports.KIND = Object.freeze({ 93 | name: 'merge_request', 94 | title: 'Merge Request' 95 | }); 96 | 97 | /** 98 | * The color for this kind of handler. 99 | * @type {string} 100 | */ 101 | module.exports.COLOR = '#31B93D'; 102 | -------------------------------------------------------------------------------- /app/handlers/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = require('lodash'), 4 | bluebird = require('bluebird'), 5 | chalk = require('chalk'), 6 | debugCreate = require('debug'), 7 | helpers = require('../lib/helpers'), 8 | slack = require('../lib/slack'), 9 | supportsColor = require('supports-color'), 10 | util = require('util'); 11 | 12 | const handleIssue = require('./issue'), 13 | handleBranch = require('./branch'), 14 | handleCommit = require('./commit'), 15 | handleTag = require('./tag'), 16 | handleMergeRequest = require('./mergeRequest'), 17 | handleWikiPage = require('./wikiPage'); 18 | 19 | const debug = debugCreate('gitlab-slack:handler'); 20 | 21 | const REGEX_ALL_ZEROES = /^0+$/; 22 | 23 | /** 24 | * The kind metadata. 25 | * @typedef {Object} HandlerKind 26 | * @property {String} name The internal name. 27 | * @property {String} title The display title. 28 | */ 29 | 30 | /** 31 | * Handles an incoming message. 32 | * @param {Map} projectConfigs A map of project ID to project configuration. 33 | * @param {Map} labelColors A map of project ID to a map of labels to label colors. 34 | * @param {Map} issueLabels A map of project ID to a map of issue ID to issue labels. 35 | * @param {GitLabApi} api The GitLab API. 36 | * @param {Object} data The message data. 37 | * @returns {Promise} A promise that will be resolved when the message was handled. 38 | */ 39 | exports.handleMessage = async function (projectConfigs, labelColors, issueLabels, api, data) { 40 | let outputs; 41 | 42 | if (data.object_kind) { 43 | // Both tags and commits have before/after values that need to be examined. 44 | const beforeZero = REGEX_ALL_ZEROES.test(data.before), 45 | afterZero = REGEX_ALL_ZEROES.test(data.after); 46 | 47 | switch (data.object_kind) { 48 | case handleIssue.KIND.name: 49 | outputs = await handleIssue(projectConfigs, labelColors, issueLabels, api, data); 50 | break; 51 | case handleBranch.KIND.name: 52 | case handleCommit.KIND.name: { 53 | if (beforeZero || afterZero) { 54 | // If before or after is all zeroes, this is a branch being pushed. 55 | outputs = await handleBranch(data, beforeZero, afterZero); 56 | 57 | if (beforeZero) { 58 | // If before is zero, it's a new branch; we also want to handle any 59 | // commits that came with it. We tell the commit handler to filter 60 | // the commits so that we don't include commits irrelevant to this push. 61 | outputs = [outputs, await handleCommit(api, data, true)]; 62 | } 63 | } else { 64 | outputs = await handleCommit(api, data); 65 | } 66 | break; 67 | } 68 | case handleTag.KIND.name: 69 | outputs = await handleTag(data, beforeZero, afterZero); 70 | break; 71 | case handleMergeRequest.KIND.name: 72 | outputs = await handleMergeRequest(data); 73 | break; 74 | case handleWikiPage.KIND.name: 75 | outputs = await handleWikiPage(data); 76 | break; 77 | default: 78 | /* eslint-disable camelcase */ // Required property naming. 79 | // Unhandled/unrecognized messages go to the default channel for the webhook 80 | // as a kind of notification that something unexpected came through. 81 | outputs = { 82 | parse: 'none', 83 | attachments: [{ 84 | title: 'GitLab Webhook - Unrecognized Data', 85 | fallback: '(cannot display JSON unformatted)', 86 | text: '```' + JSON.stringify(data, null, 4) + '```', 87 | color: 'danger', 88 | mrkdwn_in: ['text'] 89 | }] 90 | }; 91 | /* eslint-enable camelcase */ 92 | break; 93 | } 94 | } 95 | 96 | if (!_.isArray(outputs)) { 97 | outputs = [outputs]; 98 | } 99 | 100 | outputs = _.compact(outputs); 101 | 102 | if (!outputs.length) { 103 | // If we get here and there's nothing to output, that means none of the handlers processed the message. 104 | debug(chalk`{cyanBright IGNORED} No handler processed the message.`); 105 | console.log(chalk`{cyanBright IGNORED} {yellow Message Body ---------------------}`, '\n', util.inspect(data, { colors: supportsColor.stdout.level > 0, depth: 5 })); 106 | return; 107 | } 108 | 109 | const projectId = await helpers.getProjectId(data, api), 110 | projectConfig = projectConfigs.get(projectId); 111 | 112 | if (projectConfig && projectConfig.channel) { 113 | // If we can assign the message to a configured project and that project has a channel, 114 | // make sure all outgoing messages go to the configured channel. 115 | for (const output of outputs) { 116 | output.channel = projectConfig.channel; 117 | } 118 | } 119 | 120 | // Send all the outputs to Slack and we're done. 121 | await bluebird.map(outputs, output => slack.send(output)); 122 | }; 123 | -------------------------------------------------------------------------------- /.eslintrc-base.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'rules': { 3 | 'array-bracket-newline': [2, 'consistent'], 4 | 'array-bracket-spacing': 2, 5 | 'array-element-newline': [2, 'consistent'], 6 | 'block-scoped-var': 2, 7 | 'block-spacing': 2, 8 | 'brace-style': [2, '1tbs', { 'allowSingleLine': true }], 9 | 'camelcase': 2, 10 | 'comma-spacing': 2, 11 | 'comma-style': 2, 12 | 'complexity': 1, 13 | 'computed-property-spacing': 2, 14 | 'curly': 2, 15 | 'dot-location': [2, 'property'], 16 | 'dot-notation': 2, 17 | 'eol-last': 2, 18 | 'eqeqeq': 2, 19 | 'func-call-spacing': 2, 20 | 'function-paren-newline': [2, 'consistent'], 21 | 'guard-for-in': 2, 22 | 'indent': [2, 'tab', { 'SwitchCase': 1 }], 23 | 'key-spacing': 2, 24 | 'keyword-spacing': 2, 25 | 'linebreak-style': 2, 26 | 'max-depth': [1, { 'max': 10 }], 27 | 'max-lines-per-function': [1, { 'max': 100, 'skipBlankLines': true, 'skipComments': true }], 28 | 'max-nested-callbacks': [1, { 'max': 8 }], 29 | 'max-params': [1, { 'max': 8 }], 30 | 'new-cap': 2, 31 | 'new-parens': 2, 32 | 'no-array-constructor': 2, 33 | 'no-bitwise': 2, 34 | 'no-caller': 2, 35 | 'no-catch-shadow': 2, 36 | 'no-console': 0, 37 | 'no-else-return': [2, { 'allowElseIf': false }], 38 | 'no-empty': [2, { 'allowEmptyCatch': true }], 39 | 'no-empty-function': 2, 40 | 'no-eval': 2, 41 | 'no-extra-bind': 2, 42 | 'no-extra-label': 2, 43 | 'no-extra-parens': [2, 'all', { 'nestedBinaryExpressions': false }], 44 | 'no-floating-decimal': 2, 45 | 'no-implicit-coercion': [2, { 'boolean': false }], 46 | 'no-implied-eval': 2, 47 | 'no-lone-blocks': 2, 48 | 'no-lonely-if': 2, 49 | 'no-loop-func': 2, 50 | 'no-mixed-operators': 2, 51 | 'no-mixed-requires': [2, { 'allowCall': false }], 52 | 'no-mixed-spaces-and-tabs': [2, 'smart-tabs'], 53 | 'no-multi-spaces': [2, { 'exceptions': { 'Property': false } }], 54 | 'no-multiple-empty-lines': [2, { 'max': 1 }], 55 | 'no-nested-ternary': 2, 56 | 'no-new': 2, 57 | 'no-new-func': 2, 58 | 'no-new-object': 2, 59 | 'no-new-require': 2, 60 | 'no-new-wrappers': 2, 61 | 'no-octal': 2, 62 | 'no-proto': 2, 63 | 'no-redeclare': 2, 64 | 'no-return-assign': 2, 65 | 'no-self-compare': 2, 66 | 'no-sequences': 2, 67 | 'no-shadow': [1, { 'builtinGlobals': true }], 68 | 'no-shadow-restricted-names': 2, 69 | 'no-throw-literal': 2, 70 | 'no-trailing-spaces': 2, 71 | 'no-undef-init': 2, 72 | 'no-unmodified-loop-condition': 2, 73 | 'no-unneeded-ternary': [2, { 'defaultAssignment': false }], 74 | 'no-unused-expressions': 2, 75 | 'no-use-before-define': [2, { 'functions': false, 'variables': false }], 76 | 'no-useless-call': 2, 77 | 'no-useless-concat': 2, 78 | 'no-useless-catch': 2, 79 | 'no-useless-return': 2, 80 | 'no-whitespace-before-property': 2, 81 | 'no-with': 2, 82 | 'object-curly-newline': [2, { 'multiline': true, 'consistent': true }], 83 | 'object-curly-spacing': [2, 'always'], 84 | 'object-property-newline': [2, { 'allowAllPropertiesOnSameLine': true }], 85 | // one-var is broken when it comes to requires. https://github.com/eslint/eslint/issues/10179 86 | // 'one-var': [2, { 'var': 'consecutive', 'let': 'consecutive', 'const': 'consecutive', 'separateRequires': true }], 87 | 'one-var-declaration-per-line': 2, 88 | 'operator-assignment': 2, 89 | 'operator-linebreak': 2, 90 | 'padded-blocks': [2, 'never'], 91 | 'prefer-promise-reject-errors': 2, 92 | 'quote-props': [2, 'as-needed'], 93 | 'quotes': [2, 'single', { 'avoidEscape': true, 'allowTemplateLiterals': true }], 94 | 'radix': 2, 95 | 'require-jsdoc': [2, { 'require': { 'ClassDeclaration': true, 'MethodDefinition': true } }], 96 | 'semi': 2, 97 | 'semi-spacing': 2, 98 | 'semi-style': 2, 99 | 'space-before-blocks': 2, 100 | 'space-before-function-paren': [2, { 'anonymous': 'always', 'named': 'never', 'asyncArrow': 'always' }], 101 | 'space-in-parens': 2, 102 | 'space-infix-ops': 2, 103 | 'space-unary-ops': 2, 104 | // The //////// allows for the 10-slash delimiter we use in Angular controllers. 105 | 'spaced-comment': [2, 'always', { 'block': { 'balanced': true }, 'line': { 'exceptions': ['/'] } }], 106 | 'switch-colon-spacing': 2, 107 | 'valid-jsdoc': [ 108 | 2, 109 | { 110 | 'matchDescription': '\\.$', 111 | 'preferType': { 112 | 'function': 'Function', 113 | 'boolean': 'Boolean', 114 | 'number': 'Number', 115 | 'integer': 'Number', 116 | 'int': 'Number', 117 | 'string': 'String', 118 | 'object': 'Object' 119 | }, 120 | 'requireParamType': true, 121 | 'requireReturn': false 122 | } 123 | ], 124 | 'wrap-iife': [2, 'inside', { 'functionPrototypeMethods': true }], 125 | 'yoda': 2 126 | } 127 | }; 128 | -------------------------------------------------------------------------------- /app/lib/helpers.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = require('lodash'), 4 | chalk = require('chalk'), 5 | debugCreate = require('debug'); 6 | 7 | const REGEX_MARKDOWN_LINK = /(!)?\[([^\]]*)]\(([^)]+)\)/g, 8 | REGEX_MARKDOWN_BOLD = /(\*\*|__)(.+?)\1/g, 9 | REGEX_MARKDOWN_BOLD_INTERMEDIARY = /\vb/g, 10 | REGEX_MARKDOWN_ITALIC = /([*_])(.+?)\1/g, 11 | REGEX_MARKDOWN_ITALIC_INTERMEDIARY = /\vi/g, 12 | REGEX_MARKDOWN_BULLET = /^([ \t]+)?\*(?!\*)/mg, 13 | REGEX_MARKDOWN_HEADER = /^#+\s*(.+)$/mg; 14 | 15 | const debug = debugCreate('gitlab-slack:app'); // Log as 'app' from here. 16 | 17 | /** 18 | * Filters `items` to those matching any of `patterns`. 19 | * @param {Array} items The items. 20 | * @param {RegExp[]} patterns The patterns. 21 | * @param {function(*): String} [getter] The property getter. (default = identity) 22 | * @returns {Array} The matching items. 23 | */ 24 | exports.matchingAnyPattern = function (items, patterns, getter = _.identity) { 25 | return _.filter(items, function (item) { 26 | return _.some(patterns, function (pattern) { 27 | return pattern.test(getter(item)); 28 | }); 29 | }); 30 | }; 31 | 32 | /** 33 | * Converts a GitLab object action to a friendly verb. 34 | * @param {String} action The action. 35 | * @returns {String} A friendly verb. 36 | */ 37 | exports.actionToVerb = function (action) { 38 | let verb; 39 | 40 | switch (action) { 41 | case 'open': 42 | case 'create': 43 | verb = 'created'; 44 | break; 45 | case 'reopen': 46 | verb = 're-opened'; 47 | break; 48 | case 'update': 49 | verb = 'modified'; 50 | break; 51 | case 'close': 52 | verb = 'closed'; 53 | break; 54 | case 'merge': 55 | verb = 'merged'; 56 | break; 57 | case 'delete': 58 | verb = 'deleted'; 59 | break; 60 | default: 61 | verb = '(' + action + ')'; 62 | break; 63 | } 64 | 65 | return verb; 66 | }; 67 | 68 | /** 69 | * Attempts to get the project ID from message data. 70 | * @param {Object} data The message data. 71 | * @param {GitLabApi} api The GitLab API. 72 | * @returns {Promise} A Promise that will be resolved with the project ID. 73 | */ 74 | exports.getProjectId = async function (data, api) { 75 | let projectId = data.project_id; 76 | 77 | if (!_.isNil(projectId)) { 78 | return projectId; 79 | } 80 | 81 | // If the project ID isn't on the data root, we'll try to look up the project information 82 | // by its path-with-namespace value. 83 | 84 | if (data.project && data.project.path_with_namespace) { 85 | const project = await api.getProject(encodeURIComponent(data.project.path_with_namespace)); 86 | 87 | projectId = project.id; 88 | } 89 | 90 | if (!projectId) { 91 | debug(chalk`Could not find project ID in a {blue ${data.object_kind}} message.`); 92 | } 93 | 94 | // At this point, we might have found nothing, but just return whatever we've got. 95 | return projectId; 96 | }; 97 | 98 | /** 99 | * Converts several Markdown constructs to Slack-style formatting. 100 | * @param {String} description The description. 101 | * @param {String} projectUrl The project web URL. 102 | * @returns {String} The formatted description. 103 | */ 104 | exports.convertMarkdownToSlack = function (description, projectUrl) { 105 | // Reset the last indices... 106 | REGEX_MARKDOWN_BULLET.lastIndex = 107 | REGEX_MARKDOWN_LINK.lastIndex = 108 | REGEX_MARKDOWN_BOLD.lastIndex = 109 | REGEX_MARKDOWN_BOLD_INTERMEDIARY.lastIndex = 110 | REGEX_MARKDOWN_ITALIC.lastIndex = 111 | REGEX_MARKDOWN_ITALIC_INTERMEDIARY.lastIndex = 112 | REGEX_MARKDOWN_HEADER.lastIndex = 0; 113 | 114 | return description 115 | .replace(REGEX_MARKDOWN_BULLET, function (match, indent) { 116 | // If the indent is present, replace it with a tab. 117 | return (indent ? '\t' : '') + '•'; 118 | }) 119 | .replace(REGEX_MARKDOWN_LINK, function (match, image, name, url) { 120 | if (image) { 121 | // Image links are sent without the project web URL prefix. 122 | return `<${projectUrl + url}|${name}>`; 123 | } 124 | 125 | return `<${url}|${name}>`; 126 | }) 127 | // Bold and italic use each other's characters, so to be safe, use an intermediary. 128 | .replace(REGEX_MARKDOWN_BOLD, '\vb$2\vb') 129 | .replace(REGEX_MARKDOWN_ITALIC, '\vi$2\vi') 130 | // Finalize bold and italic from the intermediary. 131 | .replace(REGEX_MARKDOWN_BOLD_INTERMEDIARY, '*') 132 | .replace(REGEX_MARKDOWN_ITALIC_INTERMEDIARY, '_') 133 | .replace(REGEX_MARKDOWN_HEADER, function (match, heading) { 134 | if (heading.includes('*')) { 135 | // If it looks like there's already bolding in the header, don't try to add more. 136 | return heading; 137 | } 138 | 139 | // TODO improve 140 | 141 | return `*${heading}*`; 142 | }); 143 | }; 144 | -------------------------------------------------------------------------------- /app/lib/server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = require('lodash'), 4 | chalk = require('chalk'), 5 | debugCreate = require('debug'), 6 | http = require('http'), 7 | supportsColor = require('supports-color'), 8 | util = require('util'); 9 | 10 | const debug = debugCreate('gitlab-slack:server'); 11 | 12 | /** 13 | * Creates the HTTP server that handles webhooks. 14 | * @param {function(Object)} dataCallback The data callback. 15 | * @returns {module:http.Server} The HTTP server. 16 | */ 17 | exports.createServer = function (dataCallback) { 18 | debug('Creating server...'); 19 | const server = http.createServer(function (req, res) { 20 | debug( 21 | chalk`{cyan RECV} -> {blue %s} - %s {gray %s}`, 22 | req.headers['x-forwarded-for'] || req.connection.remoteAddress, 23 | req.method, 24 | req.url 25 | ); 26 | 27 | if (req.method !== 'POST') { 28 | _sendFailure(res, 405); 29 | return; 30 | } 31 | 32 | _handleRequest(req, res, dataCallback); 33 | }); 34 | 35 | const sockets = []; 36 | 37 | /* 38 | Handle the connection event so that when the server needs to terminate, we can 39 | manually destroy every remaining socket that's still open. 40 | If we dont' do this, we have to wait for those socket connections to time out 41 | before the server will actually close. 42 | */ 43 | server.on('connection', function (socket) { 44 | sockets.push(socket); 45 | 46 | socket.on('close', function () { 47 | _.pull(sockets, socket); 48 | }); 49 | }); 50 | 51 | server.on('error', function (error) { 52 | debug(chalk`{red ERROR} {redBright %s} Server error. ! {red %s}`, error.code || 'UNKNOWN', error.message); 53 | 54 | // If we get a server error, just assume that we should close the server. 55 | server.close(); 56 | }); 57 | 58 | server.on('listening', function () { 59 | const address = server.address(); 60 | 61 | debug(chalk`Server listening on port {blue ${address.port}}.`); 62 | }); 63 | 64 | server.on('close', function () { 65 | debug('Server closed.'); 66 | }); 67 | 68 | /* 69 | Wrap the close function to destroy all remaining sockets before calling the 70 | real close function. 71 | */ 72 | const _close = server.close; 73 | server.close = function (cb) { 74 | for (const socket of sockets) { 75 | socket.destroy(); 76 | } 77 | 78 | _close.call(server, cb); 79 | }; 80 | 81 | return server; 82 | }; 83 | 84 | // region ---- HELPER FUNCTIONS -------------------- 85 | 86 | /** 87 | * Sends a failure response. 88 | * @param {module:http.ServerResponse} res The response. 89 | * @param {Number} statusCode The status code. 90 | * @param {String} [message] The message. (default = status code text) 91 | * @private 92 | */ 93 | function _sendFailure(res, statusCode, message) { 94 | const output = message || http.STATUS_CODES[statusCode]; 95 | 96 | res.statusCode = statusCode; 97 | res.end(output); 98 | debug(chalk`{cyan SEND} <- {red %d %s}`, res.statusCode, output); 99 | } 100 | 101 | /** 102 | * Handles an incoming request. 103 | * @param {IncomingMessage} req The request. 104 | * @param {module:http.ServerResponse} res The response. 105 | * @param {function(Object)} dataCallback The data callback. 106 | * @private 107 | */ 108 | function _handleRequest(req, res, dataCallback) { 109 | const buffers = []; 110 | 111 | let totalLength = 0; 112 | 113 | req.on('data', function (buffer) { 114 | buffers.push(buffer); 115 | totalLength += buffer.length; 116 | }); 117 | 118 | req.on('end', function () { 119 | let body = Buffer.concat(buffers, totalLength).toString(); 120 | 121 | try { 122 | body = JSON.parse(body); 123 | } catch (e) { 124 | debug(chalk`{red FAIL} Could not JSON parse body. ! {red %s}${'\n'} {blue Body} %s`, e.message, body); 125 | _sendFailure(res, 400); 126 | return; 127 | } 128 | 129 | let handle; 130 | 131 | try { 132 | handle = dataCallback(body); 133 | } catch (e) { 134 | debug(chalk`{red FAIL} Failed calling data handler. ! {red %s}`, e.message); 135 | console.log(chalk`{red FAIL} {yellow Stack Trace ----------------------}`, '\n', e.stack); 136 | console.log(chalk`{red FAIL} {yellow Message Body ---------------------}`, '\n', util.inspect(body, { colors: supportsColor.stdout.level > 0, depth: 5 })); 137 | _sendFailure(res, 500); 138 | return; 139 | } 140 | 141 | const handleThen = handle.then; 142 | if (!handleThen || !_.isFunction(handleThen)) { 143 | // If the data callback doesn't return a promise, that's a programmer error; kill it with fire. 144 | throw new Error('Server data callback must return a promise.'); 145 | } 146 | 147 | handleThen.call(handle, function () { 148 | res.statusCode = 200; 149 | res.end(); 150 | debug(chalk`{cyan SEND} <- {green %d %s}`, res.statusCode, http.STATUS_CODES[res.statusCode]); 151 | }) 152 | .catch(function (err) { 153 | debug(chalk`{red FAIL} Failed handling data. ! {red %s}`, err.message); 154 | console.log(chalk`{red FAIL} {yellow Stack Trace ----------------------}`, '\n', err.stack); 155 | console.log(chalk`{red FAIL} {yellow Message Body ---------------------}`, '\n', util.inspect(body, { colors: supportsColor.stdout.level > 0, depth: 5 })); 156 | _sendFailure(res, 500); 157 | }); 158 | }); 159 | } 160 | 161 | // endregion ---- HELPER FUNCTIONS -------------------- 162 | -------------------------------------------------------------------------------- /app/handlers/commit.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = require('lodash'), 4 | bluebird = require('bluebird'), 5 | chalk = require('chalk'), 6 | /** 7 | * @type {Configuration} 8 | */ 9 | config = require('../../config'), 10 | debugCreate = require('debug'), 11 | util = require('util'); 12 | 13 | const debug = debugCreate('gitlab-slack:handler:commit'); 14 | 15 | const MENTION_TYPE_PATHS = new Map([ 16 | ['#', '/issues/'], 17 | ['!', '/merge_requests/'] 18 | ]), 19 | REGEX_ISSUE_MENTION = /#\d+/g, 20 | REGEX_MERGE_REQUEST_MENTION = /!\d+/g, 21 | REGEX_FIRST_LINE_ISSUE = /\s*\(?(?:#\d+(?:,\s*)?)+\)?/g, 22 | REGEX_FIRST_LINE_MERGE_REQUEST = /\s*\(?(?:!\d+(?:,\s*)?)+\)?/g; 23 | 24 | /** 25 | * Handles an commit message. 26 | * @param {GitLabApi} api The GitLab API. 27 | * @param {Object} data The message data. 28 | * @param {Boolean} filterCommits Indicates whether the commits should be filtered. (default = false) 29 | * @returns {Promise} A promise that will be resolved with the output data structure. 30 | */ 31 | module.exports = async function (api, data, filterCommits = false) { 32 | debug('Handling message...'); 33 | 34 | if (!data.commits.length) { 35 | debug(chalk`Ignored. {blue no commits}`); 36 | return; 37 | } 38 | 39 | // Reverse the commits list so that "more recent" stuff is on top for display. 40 | // This is not reverse chronological. 41 | data.commits.reverse(); 42 | 43 | if (filterCommits) { 44 | // If we're told to, we filter the commits down to only those made by the user who pushed. 45 | const filtered = []; 46 | 47 | for (let i = 0; i < data.commits.length; i++) { 48 | const commit = data.commits[i], 49 | commitAuthor = commit.author.email; 50 | 51 | if (data.user_email === commitAuthor) { 52 | filtered.push(commit); 53 | } else { 54 | break; 55 | } 56 | } 57 | 58 | if (!filtered.length) { 59 | // If the user pushed a new branch without making commits, for example, this filter 60 | // step may have resulted in no commits. In that case, we don't send a notification. 61 | return; 62 | } 63 | 64 | data.commits = filtered; 65 | } 66 | 67 | // Try to find GitLab users for each commit user's email address. 68 | const commitUsers = await bluebird.map(data.commits, async function (commit) { 69 | const email = commit.author.email.toLowerCase(), 70 | users = await api.searchUsers(email); 71 | 72 | return _.find(users, user => user.email.toLowerCase() === email); 73 | }), 74 | /* eslint-disable camelcase */ // Required property naming. 75 | attachment = { 76 | color: module.exports.COLOR, 77 | mrkdwn_in: ['text'] 78 | }, 79 | output = { 80 | parse: 'none', 81 | text: util.format( 82 | '[%s:%s] <%s/u/%s|%s> pushed %s commits:', 83 | data.project.path_with_namespace, 84 | data.ref.replace('refs/heads/', ''), 85 | config.gitLab.baseUrl, 86 | data.user_username, 87 | data.user_username, 88 | data.total_commits_count 89 | ), 90 | attachments: [attachment] 91 | }, 92 | /* eslint-enable camelcase */ 93 | attachmentFallbacks = [], 94 | attachmentTexts = []; 95 | 96 | _.each(data.commits, function (commit, index) { 97 | const commitUser = commitUsers[index], 98 | commitId = commit.id.substr(0, 8); // Use the first 8 characters of the commit hash. 99 | 100 | let message = commit.message.split(/(?:\r\n|[\r\n])/)[0], // Only print the first line; support all line ending types. 101 | commitUserName, 102 | mentions = []; 103 | 104 | const issueMentions = commit.message.match(REGEX_ISSUE_MENTION), // Find all issue mentions. 105 | mergeRequestMentions = commit.message.match(REGEX_MERGE_REQUEST_MENTION); // Find all merge request mentions. 106 | 107 | if (commitUser) { 108 | commitUserName = commitUser.username; 109 | } else { 110 | // If the username couldn't be resolved, use the email in its place. 111 | commitUserName = commit.author.email; 112 | } 113 | 114 | if (issueMentions) { 115 | // If there were issues, make sure each is only mentioned once. 116 | mentions.push(..._.uniq(issueMentions)); 117 | 118 | // Make sure the first line doesn't have any issue mentions left or that would be redundant. 119 | REGEX_FIRST_LINE_ISSUE.lastIndex = 0; 120 | message = message.replace(REGEX_FIRST_LINE_ISSUE, ''); 121 | } 122 | 123 | if (mergeRequestMentions) { 124 | // If there were merge requests, make sure each is only mentioned once. 125 | mentions.push(..._.uniq(mergeRequestMentions)); 126 | 127 | // Make sure the first line doesn't have any merge request mentions left or that would be redundant. 128 | REGEX_FIRST_LINE_MERGE_REQUEST.lastIndex = 0; 129 | message = message.replace(REGEX_FIRST_LINE_MERGE_REQUEST, ''); 130 | } 131 | 132 | attachmentFallbacks.push(util.format( 133 | '[%s] %s: %s', 134 | commitUserName, 135 | commitId, 136 | message + (mentions.length ? ` (${mentions.join(', ')})` : '') 137 | )); 138 | 139 | if (mentions) { 140 | // If there were mentions, build the formatted suffix here. 141 | mentions = _.map(mentions, function (mention) { 142 | return util.format( 143 | '<%s|%s>', 144 | data.project.web_url + MENTION_TYPE_PATHS.get(mention[0]) + mention.substr(1), 145 | mention 146 | ); 147 | }); 148 | } 149 | 150 | attachmentTexts.push(util.format( 151 | '[%s] <%s|%s>: %s', 152 | commitUserName, 153 | commit.url, 154 | commitId, 155 | message + (mentions.length ? ` (${mentions.join(', ')})` : '') 156 | )); 157 | }); 158 | 159 | attachment.fallback = attachmentFallbacks.join('\n'); 160 | attachment.text = attachmentTexts.join('\n'); 161 | 162 | debug('Message handled.'); 163 | 164 | output.__kind = module.exports.KIND; 165 | 166 | return output; 167 | }; 168 | 169 | /** 170 | * Provides metadata for this kind of handler. 171 | * @type {HandlerKind} 172 | */ 173 | module.exports.KIND = Object.freeze({ 174 | name: 'push', 175 | title: 'Commit' 176 | }); 177 | 178 | /** 179 | * The color for this kind of handler. 180 | * @type {string} 181 | */ 182 | module.exports.COLOR = '#1B6EB1'; 183 | -------------------------------------------------------------------------------- /app/app.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = require('lodash'), 4 | chalk = require('chalk'), 5 | /** 6 | * @type {Configuration} 7 | */ 8 | config = require('../config'), 9 | debugCreate = require('debug'), 10 | { GitLabApi } = require('./lib/gitlabapi'), 11 | handlers = require('./handlers'), 12 | helpers = require('./lib/helpers'), 13 | bluebird = require('bluebird'), 14 | server = require('./lib/server'); 15 | 16 | const api = new GitLabApi(config.gitLab.baseUrl, config.gitLab.apiToken), 17 | debug = debugCreate('gitlab-slack:app'); 18 | 19 | let gitLabSlack; 20 | 21 | process.on('uncaughtException', function (err) { 22 | debug(chalk`{red UNCAUGHT EXCEPTION} - ${err.message}${'\n'}${err.stack}`); 23 | process.exit(1); 24 | }); 25 | process.on('SIGINT', function () { 26 | debug(chalk`{yellow SIGINT} received!`); 27 | return _terminate(); 28 | }); 29 | process.on('SIGTERM', function () { 30 | debug(chalk`{yellow SIGTERM} received!`); 31 | return _terminate(); 32 | }); 33 | 34 | bluebird.config({ 35 | longStackTraces: true 36 | }); 37 | 38 | (async function () { 39 | debug('Starting up...'); 40 | 41 | if (!config.gitLab.projects || !config.gitLab.projects.length) { 42 | // Make sure this gets logged somehow. 43 | (debug.enabled ? debug : console.error)(chalk`{red ERROR} No projects defined in configuration. Terminating...`); 44 | process.exit(1); 45 | } 46 | 47 | // Be nice and add the # character to channels in project configuration if it's not there. 48 | for (const project of config.gitLab.projects) { 49 | if (project.channel && !project.channel.startsWith('#')) { 50 | project.channel = '#' + project.channel; 51 | } 52 | } 53 | 54 | const projectConfigs = new Map(_.map(config.gitLab.projects, p => [p.id, p])), 55 | { labelColors, issueLabels } = await _buildIssueLabelCaches(); 56 | 57 | gitLabSlack = server.createServer( 58 | data => handlers.handleMessage( 59 | projectConfigs, 60 | labelColors, 61 | issueLabels, 62 | api, 63 | data 64 | ) 65 | ); 66 | 67 | gitLabSlack.on('close', function () { 68 | // If the service closes for some other reason, make sure 69 | // the process also exits. 70 | _terminate(); 71 | }); 72 | 73 | gitLabSlack.listen(config.port); 74 | 75 | debug('Startup complete.'); 76 | })() 77 | .catch(function (err) { 78 | // Make sure this gets logged somehow. 79 | (debug.enabled ? debug : console.error)(chalk`{red ERROR} Processing failure in main branch. ! {red %s}\n{blue Stack} %s`, err.message, err.stack); 80 | _terminate(1); 81 | }); 82 | 83 | // region ---- HELPER FUNCTIONS -------------------- 84 | 85 | /** 86 | * Caches project and issue labels for projects with label-tracking enabled. 87 | * @returns {Promise<{ labelColors: Map, issueLabels: Map }>} The issue and label caches. 88 | * @private 89 | */ 90 | async function _buildIssueLabelCaches() { 91 | const cachers = [], 92 | issueLabels = new Map(), 93 | labelColors = new Map(); 94 | 95 | _.each(config.gitLab.projects, function (project) { 96 | // For the label patterns that aren't already regex, compile them. 97 | _.each(project.labels, function (label, index) { 98 | if (!_.isRegExp(label)) { 99 | project.labels[index] = new RegExp(label, 'i'); 100 | } 101 | }); 102 | 103 | if (_.size(project.labels)) { 104 | const cacher = async function buildProjectCache() { 105 | debug(chalk`{cyan CACHE}[{cyanBright %d}] Caching information for {blue %d} / {blue %s}...`, project.id, project.id, project.name || ''); 106 | 107 | // region ---- CACHE PROJECT LABEL COLORS -------------------- 108 | 109 | const projectLabelColors = new Map(), 110 | projectLabels = await api.getLabels(project.id); 111 | 112 | let projectLabelsCached = 0; 113 | 114 | // In API requests, labels have a name. 115 | for (const label of helpers.matchingAnyPattern(projectLabels, project.labels, l => l.name)) { 116 | projectLabelColors.set(label.name, label.color); 117 | projectLabelsCached++; 118 | } 119 | 120 | debug(chalk`{cyan CACHE}[{cyanBright %d}] Cached {blue %d} project label colors.`, project.id, projectLabelsCached); 121 | 122 | // endregion ---- CACHE PROJECT LABEL COLORS -------------------- 123 | 124 | // region ---- CACHE PROJECT ISSUE LABELS -------------------- 125 | 126 | const projectIssueLabels = new Map(); 127 | 128 | let issueLabelsCached = 0, 129 | currentIssuePage = 1, 130 | totalIssuePages = 0; 131 | 132 | while (!totalIssuePages || currentIssuePage < totalIssuePages) { 133 | const result = await api.getOpenIssues(project.id, currentIssuePage); 134 | 135 | if (!result.data.length) { 136 | break; 137 | } 138 | 139 | if (!totalIssuePages) { 140 | totalIssuePages = result.totalPages; 141 | } 142 | 143 | for (const issue of result.data) { 144 | // We cache if the issue has labels and they match any of our patterns. 145 | const matchingLabels = helpers.matchingAnyPattern(issue.labels, project.labels); 146 | 147 | if (issue.labels.length && matchingLabels.length) { 148 | projectIssueLabels.set(issue.id, matchingLabels); 149 | issueLabelsCached++; 150 | } 151 | } 152 | 153 | currentIssuePage++; 154 | } 155 | 156 | debug(chalk`{cyan CACHE}[{cyanBright %d}] Cached labels of {blue %d} issues.`, project.id, issueLabelsCached); 157 | 158 | // endregion ---- CACHE PROJECT ISSUE LABELS -------------------- 159 | 160 | labelColors.set(project.id, projectLabelColors); 161 | issueLabels.set(project.id, projectIssueLabels); 162 | 163 | debug(chalk`{cyan CACHE}[{cyanBright %d}] Cached all issue and label information.`, project.id); 164 | }; 165 | 166 | cachers.push(cacher()); 167 | } 168 | }); 169 | 170 | await Promise.all(cachers); 171 | 172 | return { labelColors, issueLabels }; 173 | } 174 | 175 | /** 176 | * Terminates the service. 177 | * @param {Number} exitCode The exit code. (default = 0) 178 | */ 179 | function _terminate(exitCode = 0) { 180 | debug('Terminating...'); 181 | if (gitLabSlack && gitLabSlack.listening) { 182 | gitLabSlack.close(function () { 183 | process.exit(exitCode); 184 | }); 185 | } else { 186 | process.exit(exitCode); 187 | } 188 | } 189 | 190 | // endregion ---- HELPER FUNCTIONS -------------------- 191 | -------------------------------------------------------------------------------- /app/lib/gitlabapi.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = require('lodash'), 4 | chalk = require('chalk'), 5 | debugCreate = require('debug'), 6 | http = require('http'), 7 | rp = require('request-promise'), 8 | supportsColor = require('supports-color'), 9 | util = require('util'); 10 | 11 | const API_BASE_ROUTE = '/api/v4', 12 | ROUTE_PARAM_PATTERN = /:([^/:]+)/g; 13 | 14 | const debug = debugCreate('gitlab-slack:api'); 15 | 16 | /** 17 | * Defines a wrapper for the GitLab API. 18 | */ 19 | class GitLabApi { 20 | /** 21 | * Creates an instance of `GitLabApi`. 22 | * @param {String} baseUrl The API base URL. 23 | * @param {String} token The API token. 24 | */ 25 | constructor(baseUrl, token) { 26 | this._baseUrl = baseUrl + API_BASE_ROUTE; 27 | this._token = token; 28 | } 29 | 30 | /** 31 | * Gets user information by ID. 32 | * @param {String|Number} userId The user ID. 33 | * @returns {Promise} A promise that will be resolved with the user information. 34 | */ 35 | getUserById(userId) { 36 | return this.sendRequest( 37 | '/users/:userId', 38 | { userId } 39 | ); 40 | } 41 | 42 | /** 43 | * Searches for a user by username or email address. 44 | * @param {String} search Search term. 45 | * @returns {Promise} A promise that will be resolved with a list of matching users. 46 | */ 47 | searchUsers(search) { 48 | // The provided search terms should not be returning more than 100 users. 49 | return this.sendRequest( 50 | '/users', 51 | /* eslint-disable camelcase */ // Required property naming. 52 | { 53 | per_page: 100, 54 | search 55 | } 56 | /* eslint-enable camelcase */ 57 | ); 58 | } 59 | 60 | /** 61 | * Gets a project by ID. 62 | * @param {String|Number} projectId The project ID. 63 | * @returns {Promise} A promise that will be resolved with the project information. 64 | */ 65 | getProject(projectId) { 66 | return this.sendRequest( 67 | '/projects/:projectId', 68 | { projectId } 69 | ); 70 | } 71 | 72 | /** 73 | * Gets a page of open issues for a project. 74 | * @param {String|Number} projectId The project ID. 75 | * @param {Number} [page] The page to get. 76 | * @returns {Promise.<{data: *, page: Number, totalPages: Number}>} A promise that will be resolved with a page of open issues. 77 | */ 78 | getOpenIssues(projectId, page) { 79 | return this.sendPaginatedRequest( 80 | '/projects/:projectId/issues', 81 | { 82 | projectId, 83 | state: 'opened' 84 | }, 85 | page 86 | ); 87 | } 88 | 89 | /** 90 | * Gets the labels for a project. 91 | * @param {Number} projectId The project ID. 92 | * @returns {Promise} A promise that will be resolved with project labels. 93 | */ 94 | getLabels(projectId) { 95 | // Technically the labels API is paginated, but who has more than 100 labels? 96 | return this.sendRequest( 97 | '/projects/:projectId/labels', 98 | /* eslint-disable camelcase */ // Required property naming. 99 | { 100 | per_page: 100, 101 | projectId 102 | } 103 | /* eslint-enable camelcase */ 104 | ); 105 | } 106 | 107 | /** 108 | * Gets a milestone. 109 | * @param {Number} projectId The project ID. 110 | * @param {Number} milestoneId The milestone ID. 111 | * @returns {Promise} A promise that will be resolved with the milestone. 112 | */ 113 | getMilestone(projectId, milestoneId) { 114 | return this.sendRequest( 115 | '/projects/:projectId/milestones/:milestoneId', 116 | { projectId, milestoneId } 117 | ); 118 | } 119 | 120 | /** 121 | * Sends a request to a paginated resource. 122 | * @param {String} route The route. 123 | * @param {Object} [params] A map of parameters. 124 | * @param {Number} [page] The page to get. (default = `1`) 125 | * @returns {Promise<{ data: *, page: Number, totalPages: Number }>} A promise that will be resolved with the paginated result. 126 | */ 127 | async sendPaginatedRequest(route, params = {}, page = 1) { 128 | /* eslint-disable camelcase */ // Required property naming. 129 | params.per_page = 100; 130 | params.page = page; 131 | /* eslint-enable camelcase */ 132 | 133 | const response = await this.sendRequest(route, params, true); 134 | 135 | return { 136 | data: response.body, 137 | page: parseInt(response.headers['x-page'], 10), 138 | totalPages: parseInt(response.headers['x-total-pages'], 10) 139 | }; 140 | } 141 | 142 | /** 143 | * Sends a request to the GitLab API. 144 | * @param {String} route The route. 145 | * @param {Object} [params] A map of parameters. 146 | * @param {Boolean} [full] Indicates whether the full response should be returned. 147 | * @returns {Promise<*>} A promise that will be resolved with the API result. 148 | */ 149 | async sendRequest(route, params, full) { 150 | if (!route) { 151 | throw new Error('GitLabApi sendRequest - route is required.'); 152 | } 153 | 154 | if (!params) { 155 | params = {}; 156 | } 157 | 158 | // We drain any route parameters out of params first; the rest are query string parameters. 159 | const filledRoute = route.replace(ROUTE_PARAM_PATTERN, function (match, name) { 160 | const value = params[name]; 161 | 162 | if (_.isNil(value)) { 163 | throw new Error(`GitLabApi ${route} - Route parameter ${name} was not present in params.`); 164 | } 165 | 166 | delete params[name]; 167 | 168 | return value.toString(); 169 | }); 170 | 171 | if (_.isEmpty(params)) { 172 | debug(chalk`{cyan SEND} -> GET {gray %s}`, filledRoute); 173 | } else { 174 | debug(chalk`{cyan SEND} -> GET {gray %s} %o`, filledRoute, !_.isEmpty(params) ? params : undefined); 175 | } 176 | 177 | try { 178 | const response = await rp({ 179 | url: this._baseUrl + filledRoute, 180 | qs: params, 181 | headers: { 182 | Accept: 'application/json', 183 | 'Private-Token': this._token 184 | }, 185 | json: true, 186 | rejectUnauthorized: false, 187 | resolveWithFullResponse: true 188 | }); 189 | 190 | debug(chalk`{cyan RECV} <- GET {gray %s} -> {green %d %s}`, filledRoute, response.statusCode, http.STATUS_CODES[response.statusCode]); 191 | 192 | return full ? response : response.body; 193 | } catch (e) { 194 | let output, message; 195 | 196 | if (!e.response) { 197 | message = e.message; 198 | } else { 199 | output = e.response.toJSON(); 200 | message = output.body.error || output.body.message || output.body; 201 | } 202 | 203 | const failure = new Error(`GitLabApi ${filledRoute} - ${message}`); 204 | failure.statusCode = e.statusCode; 205 | 206 | if (output) { 207 | debug(chalk`{red FAIL} <- GET {gray %s} -> {red %d} {redBright %s} ! %s`, filledRoute, failure.statusCode, http.STATUS_CODES[failure.statusCode], message); 208 | console.log(chalk`{red FAIL} {yellow Response Body ---------------------}`, '\n', util.inspect(output, { colors: supportsColor.stdout.level > 0, depth: 5 })); 209 | } else { 210 | debug(chalk`{red FAIL} <- GET {gray %s} -> {red %s} ! %s`, filledRoute, e.name, message); 211 | } 212 | 213 | throw failure; 214 | } 215 | } 216 | } 217 | 218 | exports.GitLabApi = GitLabApi; 219 | -------------------------------------------------------------------------------- /app/handlers/issue.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = require('lodash'), 4 | chalk = require('chalk'), 5 | /** 6 | * @type {Configuration} 7 | */ 8 | config = require('../../config'), 9 | debugCreate = require('debug'), 10 | helpers = require('../lib/helpers'), 11 | util = require('util'); 12 | 13 | const debug = debugCreate('gitlab-slack:handler:issue'); 14 | 15 | /** 16 | * Adds an attachment for each of the labels. 17 | * @param {Map} projectLabelColors The label-to-color map for the project. 18 | * @param {Array} attachments The attachments array. 19 | * @param {String} action The action. 20 | * @param {String[]} labels The labels. 21 | */ 22 | function _addLabelChangeAttachments(projectLabelColors, attachments, action, labels) { 23 | for (const label of labels) { 24 | /* eslint-disable camelcase */ // Required property naming. 25 | attachments.push({ 26 | fallback: util.format('%s label %s', action, label), 27 | text: util.format('_%s_ label *%s*', action, label), 28 | color: projectLabelColors.get(label), 29 | mrkdwn_in: ['text'] 30 | }); 31 | /* eslint-enable camelcase */ 32 | } 33 | } 34 | 35 | /** 36 | * Handles an issue message. 37 | * @param {Map} projectConfigs A map of project ID to project configuration. 38 | * @param {Map} labelColors A map of project ID to a map of labels to label colors. 39 | * @param {Map} issueLabels A map of project ID to a map of issue ID to issue labels. 40 | * @param {GitLabApi} api The GitLab API. 41 | * @param {Object} data The message data. 42 | * @returns {Promise} A promise that will be resolved with the output data structure. 43 | */ 44 | module.exports = async function (projectConfigs, labelColors, issueLabels, api, data) { 45 | debug('Handling message...'); 46 | 47 | const issueDetails = data.object_attributes, 48 | projectConfig = projectConfigs.get(issueDetails.project_id), 49 | projectLabelsTracked = !!projectConfig && !!_.size(projectConfig.labels), 50 | assignee = data.assignees && data.assignees[0], // If assigned, take the first; otherwise, it'll be undefined. 51 | author = await api.getUserById(issueDetails.author_id); 52 | 53 | let milestone; 54 | 55 | if (issueDetails.milestone_id) { 56 | milestone = await api.getMilestone(issueDetails.project_id, issueDetails.milestone_id); 57 | } 58 | 59 | let projectLabelColors, projectIssueLabels, matchingIssueLabels, addedLabels, removedLabels; 60 | 61 | if (projectLabelsTracked) { 62 | projectLabelColors = labelColors.get(issueDetails.project_id); 63 | projectIssueLabels = issueLabels.get(issueDetails.project_id); 64 | 65 | if (['open', 'reopen', 'update'].includes(issueDetails.action)) { 66 | // If this is open, reopen or update and there are labels tracked for this project, 67 | // make sure we care about it before we do anything else. 68 | 69 | if (issueDetails.action === 'update' && issueDetails.state === 'closed') { 70 | // Sometimes GitLab generates an update event after the issue has been closed; if it does, 71 | // we want to just ignore that. 72 | debug(chalk`Ignored. ({blue extraneous update})`); 73 | return; 74 | } 75 | 76 | const projectLabels = [...projectLabelColors.keys()], 77 | cachedIssueLabels = projectIssueLabels.get(issueDetails.id); 78 | 79 | // In issue webhooks, labels are full objects and have a title. 80 | matchingIssueLabels = _.map(helpers.matchingAnyPattern(data.labels, projectConfig.labels, l => l.title), 'title'); 81 | 82 | addedLabels = _.chain(matchingIssueLabels) // Difference between the new label set... 83 | .difference(cachedIssueLabels) // ...and the old label set... 84 | .intersection(projectLabels) // ...intersected with what we care about. 85 | .value(); 86 | 87 | // Same as above but as difference between old and new. 88 | removedLabels = _.chain(cachedIssueLabels) 89 | .difference(matchingIssueLabels) 90 | .intersection(projectLabels) 91 | .value(); 92 | 93 | if (issueDetails.action === 'update' && _.size(addedLabels) + _.size(removedLabels) === 0) { 94 | // If there is no label difference for an update, we do not continue. 95 | debug(chalk`Ignored. ({blue no-changes update})`); 96 | return; 97 | } 98 | } 99 | } else if (issueDetails.action === 'update') { 100 | // If there's no label tracking going on, always ignore updates. 101 | debug(chalk`Ignored. ({blue no-track update})`); 102 | return; 103 | } 104 | 105 | const verb = helpers.actionToVerb(issueDetails.action); 106 | 107 | let assigneeName = '_none_', 108 | milestoneName = '_none_'; 109 | 110 | if (assignee) { 111 | assigneeName = util.format('<%s/u/%s|%s>', config.gitLab.baseUrl, assignee.username, assignee.username); 112 | } 113 | 114 | if (milestone) { 115 | milestoneName = util.format('<%s/milestones/%s|%s>', data.project.web_url, milestone.iid, milestone.title); 116 | } 117 | 118 | const text = util.format( 119 | '[%s] <%s/u/%s|%s> %s issue *#%s* — *assignee:* %s — *milestone:* %s — *creator:* <%s/u/%s|%s>', 120 | data.project.path_with_namespace, 121 | config.gitLab.baseUrl, 122 | data.user.username, 123 | data.user.username, 124 | verb, 125 | issueDetails.iid, 126 | assigneeName, 127 | milestoneName, 128 | config.gitLab.baseUrl, 129 | author.username, 130 | author.username 131 | ), 132 | /* eslint-disable camelcase */ // Required property naming. 133 | output = { 134 | text, 135 | attachments: [] 136 | }, 137 | mainAttachment = { 138 | fallback: util.format( 139 | '#%s %s', 140 | issueDetails.iid, 141 | issueDetails.title 142 | ), 143 | title: issueDetails.title.replace('<', '<').replace('>', '>'), // Allow people use < & > in their titles. 144 | title_link: issueDetails.url, 145 | color: module.exports.COLOR, 146 | mrkdwn_in: ['title', 'text'] 147 | }; 148 | /* eslint-enable camelcase */ 149 | 150 | // Add the main attachment; all action types include some form of this. 151 | output.attachments.push(mainAttachment); 152 | 153 | switch (issueDetails.action) { 154 | case 'open': 155 | case 'reopen': 156 | // Open and re-open are the only ones that get the full issue description. 157 | mainAttachment.fallback += '\n' + issueDetails.description; 158 | mainAttachment.text = helpers.convertMarkdownToSlack(issueDetails.description, data.project.web_url); 159 | 160 | break; 161 | } 162 | 163 | if (projectLabelsTracked) { 164 | // This switch handles the label tracking management and reporting stuff. 165 | switch (issueDetails.action) { 166 | case 'open': 167 | case 'reopen': 168 | case 'update': 169 | _addLabelChangeAttachments(projectLabelColors, output.attachments, 'Added', addedLabels); 170 | _addLabelChangeAttachments(projectLabelColors, output.attachments, 'Removed', removedLabels); 171 | 172 | // Now, update the cache to the current state of affairs. 173 | projectIssueLabels.set(issueDetails.id, matchingIssueLabels); 174 | break; 175 | case 'close': 176 | // When issues are closed, we remove them from the cache. 177 | projectIssueLabels.delete(issueDetails.id); 178 | break; 179 | } 180 | } 181 | 182 | debug('Message handled.'); 183 | 184 | output.__kind = module.exports.KIND; 185 | return output; 186 | }; 187 | 188 | /** 189 | * Provides metadata for this kind of handler. 190 | * @type {HandlerKind} 191 | */ 192 | module.exports.KIND = Object.freeze({ 193 | name: 'issue', 194 | title: 'Issue' 195 | }); 196 | 197 | /** 198 | * The color for this kind of handler. 199 | * @type {string} 200 | */ 201 | module.exports.COLOR = '#F28A2B'; 202 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 2.1.3 / 2020-07-16 2 | * Update `package-lock.json` for a vulnerability 3 | 4 | ## 2.1.2 / 2019-07-11 5 | * Update dependencies for audit 6 | * **supports-color** -> 7.0.0 7 | 8 | ## 2.1.1 / 2019-04-12 9 | * Fix missing require 10 | 11 | ## 2.1.0 / 2019-03-27 12 | * Remove the use of global `_`, `path`, `config` and the `Promise` override 13 | * Switch to using relative path requires for everything 14 | * Remove strange `Object.defineProperty` thing from handlers 15 | * Remove `WeakMap` "privates" pattern; I don't think I like it 16 | * Update all NPM dependencies to latest versions 17 | * Fix all package vulnerabilities 18 | * Update ESLint rules to a **5.15.x** set 19 | * Update branch name parsing to allow for branches with a `/` character in the name 20 | * Update all notifications to refer to the project by its `path_with_namespace` (Fixes #26) 21 | * Normalize GitLab webhook and API result property uses in several cases 22 | * Change to present tense in changelog (!) 23 | * Change all **Bluebird** coroutines to `async`/`await` and default to native promises 24 | * Some **Bluebird** helper functions are still used 25 | * Change failure during initialization to exit with `1` (Fixes #24) 26 | * Improve error reporting for various failures of GitLab API configuration 27 | * Change how source/target branch links are built in **merge request** notifications (Fixes #23) 28 | * Change the **commit** notification that accompanies a new **branch** notification to only include any initial, contiguous block of commits made by the user who pushed. If no commits are left after this filtering, no notification is made. (Fixes #28) 29 | * Change uses of `util.inspect` to only print colors if **supports-color** says it should 30 | * Add local ngrok configuration and NPM script for local development 31 | * Add more settings to the .editorconfig 32 | * Add **tag** notification for moved tags 33 | * Add **commit** summary of mentioned merge requests as `/!\d+/` (Fixes #25) 34 | * Add comprehensive JSDoc for config 35 | * Add systemd unit script example 36 | 37 | ## 2.0.1 / 2017-07-24 38 | * Fixed issue with filename casing 39 | 40 | ## 2.0.0 / 2017-07-21 41 | I wrote this service only a few months after I had started writing Node.js for the first time. The more time went by, the more what I had written distressed me and became harder to maintain. I finally got some time to rewrite the whole thing, fixing some bugs and adding features in the process. While I'm not willing to send this release to the Ivory Tower, I'm still significantly happier with it. 42 | 43 | **Enjoy.** 44 | 45 | ### Project Changes 46 | * Complete re-write of the whole service from the ground up using ES6 syntax, coroutines, and other delightful, modern things 47 | * Now targeting Node.js 6.x LTS and NPM 5.x 48 | * Added `package-lock.json` 49 | * Designed to work with the **v4** GitLab API and other features from GitLab **9.x** 50 | * Added `.editorconfig` to enforce line endings and help with Markdown ([more info](http://editorconfig.org/)) 51 | * Switched from **JSHint** to **ESLint** (4.2.0) 52 | * Switched from **underscore** to **lodash** (4.17.x) 53 | * Switched to **debug** for logging and dropped my _special_ logger (Fixes #15) 54 | * In supported terminals, logging output is colored for improved readability 55 | * Updated the `package.json` with some more information and marked the package as private 56 | * Updated the `README.md` with new features, up-to-date screenshots and more details 57 | * Added a **Limitations** section that hopefully will cover the constraints of the project 58 | 59 | ### Service Changes 60 | * Added handling for `SIGINT` and `SIGTERM` for more graceful exits 61 | * Where appropriate, **Bluebird** coroutines using generator functions are used to simplify asynchronous code 62 | * All code is strict-mode ES6, taking advantage of fun language features where appropriate 63 | * Significant modularization applied to split components into small working parts 64 | * Issue and label caching system slimmed and improved 65 | * Only watched labels are cached rather than all labels of qualifying issues 66 | * All handler code is appropriately connected through uninterrupted promise chains 67 | * Fixes some phantom, unhandled promise return issues and allows **Bluebird** warnings to remain on :tada: 68 | * Tried using the `WeakMap` "privates" pattern with ES6 classes; I'm still on the fence on this one 69 | * Added an attempt at intellingent resolution of GitLab project ID from available information 70 | * When `project_id` is present in webhook objects is inconsistent and this service needs it to look up the project configuration 71 | * Significantly cleaned up and improved HTTP server code 72 | 73 | ### Notification Changes 74 | * Significant simplification and reduction in GitLab API calls due to GitLab increasing what is available in webhook messages 75 | * Added support for merge request notifications 76 | * Added support for wiki page notifications 77 | * When a new-branch message is processed, if it includes any commits, those are also notified (Fixes #14) 78 | * When a tag includes a message, it is notified as well 79 | * Re-ordered first line of issue notification to match data-point order of other notification types 80 | * Issue links no longer duplicate issue mentions found in the first line of commit messages 81 | * Improved Markdown-to-Slack-formatting converter 82 | * Headings that are already bolded will be left as such 83 | * Simplification and tightening up of issue handling and label tracking 84 | 85 | ------ 86 | 87 | ## 1.7.2 / 2016-10-03 88 | * Fixed an issue where label update notifications are sent after an issue is closed (Fixes #20) 89 | 90 | ## 1.7.1 / 2016-08-08 91 | * Updated dependencies 92 | * **bluebird** -> 3.4.1 93 | * **request-promise** -> 4.1.1 (added **request** as peer dependency) 94 | * Updated bullet regex to make sure it doesn't match initial bold text (Fixes #18) 95 | * Added unique filter for detected issue mentions (Fixes #17) 96 | * Changed cacheIssueLabels to use map->each with concurrency (Fixes #10) 97 | * Refactored initial loading promise chain error handling to actually work properly 98 | 99 | ## 1.7.0 / 2015-12-11 100 | * Fixed some issues with missing configuration not falling back to defaults caused by label tracking changes. 101 | * Fixed image link formatting translation (Fixes #12). 102 | * Reversed the commit list in commit notifications for a more useful display order (Fixes #6). 103 | * Added milestone to issue notification header (Fixes #11). 104 | * Updated README and screenshots for changes and to remove init-flavor-specific instructions. 105 | * Updated **bluebird** to 3.0.x. 106 | * Changed promisified **request** out for **request-promise**. 107 | 108 | ## 1.6.0 / 2015-10-19 109 | * Added feature information to the README file. See this file for more information on this version's changes. 110 | * Added issue label tracking (Fixes #7). 111 | * Significantly changed the structure of the **config.json** file. 112 | * Fixed issue with user resolution of similarly-named users (Fixes #5). 113 | * Removed remaining hard-coded URLs (Fixes #8). 114 | 115 | ## 1.5.0 / 2015-08-07 116 | * Added limited translation from Markdown to Slack-style formatting. Supported formatting: 117 | * **Bold** -- `**|__` -> `*` 118 | * **Italic** -- `*|_` -> `_` 119 | * **Links/Images** -- `![T](U)|[T](U)` -> `` 120 | _Since there's no way to send more than one image with an attachment, images are simply converted into links._ 121 | * **Bullets** -- `*| *` -> `�|\t�` 122 | _Initial asterisks indented by one or more spaces are changed to be indented by a single tab._ 123 | * **Headings** -- `#... T` -> `*T*` 124 | _Headings are converted to bolded text._ 125 | 126 | ## 1.4.0 / 2015-08-07 127 | * Added .jshintrc and cleaned up JSHint issues. 128 | * Updated **request** module. 129 | * Migrated from **q** to **bluebird**. 130 | * Broad cleanup and simplification of promises. 131 | * Minor changes in preparation for partitioning code. 132 | * Rephrased commit message to not imply ownership (Fixes #2). 133 | * Added issue mention summary for commit messages (Fixes #1). 134 | * The entire commit message is searched for issue mentions. If found, they are appended to the first line in the notification. 135 | * Reworked request response/error processing (Fixes #4). 136 | 137 | ## 1.3.1 / 2015-03-31 138 | * Cleanup for initial push to GitHub. 139 | * Added MIT license. 140 | * Added TODO. 141 | * Changed configuration variables from URI to URL. 142 | * Added `gitlab_api_base_url` config setting. 143 | 144 | ## 1.3.0 / 2015-03-27 145 | * Updated **q** and **request** modules. 146 | * Fixed bug where uncaught exceptions were not being logged. 147 | * Added link/image markdown stripping from issue descriptions. Only the URL will show now. 148 | * Re-enabled standard parsing mode for the attachment portion of issue notifications. 149 | * Added `.done()` to promise chains where appropriate. 150 | * Updated parser for webhook message schema changes in GitLab 7.9.x. 151 | * Added full support for tag/branch new/delete detection. 152 | * Changed Slack messages to use just line feed (`\n`) rather than carriage return, line feed (`\r\n`). 153 | * Changed issue notification to fill out the attachment's `title_link` instead of putting a manually-constructed link in `title`. 154 | 155 | ## 1.2.2 / 2015-03-18 156 | * Added assignee to issue notifications. 157 | * Fixed bug that prevented notification when there was no GitLab user matching the commit email. 158 | * Changed to single-quoted strings. 159 | 160 | ## 1.2.1 / 2015-02-02 161 | * Fixed bug that allowed issue modification notifications to be sent. 162 | 163 | ## 1.2.0 / 2015-01-30 164 | * Added CHANGELOG. 165 | * Removed `--force` parameter from usage in **init.d** bash script. 166 | * Added support for **new branch** webhook messages. 167 | * Changed **issue** handler to be aware of and report on the new `user` object that contains information 168 | about the user who performed the action that triggered the webhook. 169 | 170 | ## 1.1.0 / 2014-01-16 171 | * Added support for **tag** webhook messages. 172 | * Added README. 173 | * Changed configuration to be read from **config.json**. 174 | * Several improvements to the **init.d** bash script. 175 | * More resilient to crashes; it will determine if a PID file is stale and remove it. 176 | * Removed `--force` parameter due to the above. 177 | * Cleaned up echo output. 178 | * Added logging for uncaught exceptions. 179 | * Changed **issue** message handling to ignore modifications. 180 | 181 | ## 1.0.1 / 2015-01-02 182 | * Removed third-party logging frameworks. 183 | * Added more debug logging output. 184 | 185 | ## 1.0.0 / 2014-12-23 186 | * Added support for **issue** and **commit** webhook messages. 187 | * Added rudimentary logging. 188 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **gitlab-slack** is a service that receives outgoing webhook notifications from GitLab and posts information to an incoming webhook on Slack. 2 | # Information 3 | 4 | ### Features 5 | * Processes GitLab webhook messages for **commits**, **branches**, **tags**, **issues**, **merge requests** and **wiki pages**. ([more info](#attractive-and-functional-notifications)) 6 | * Provides translation from Markdown to Slack's formatting syntax for the formatting features that Slack supports. ([more info](#markdown-to-slack-formatting-translation)) 7 | * Issues or merge requests mentioned anywhere in the commit message are aggregated and provided as links to the right of the commit summary line. ([more info](#mention-summary)) 8 | * Changes to tracked labels on issues are notified as issue updates with a summary of label changes as attachments. ([more info](#issue-label-change-tracking)) 9 | * Status and error messages are logged to `stderr`; if the terminal supports colors, they are output for improved readability. ([more info](#configuring-logging)) 10 | 11 | #### Limitations 12 | * **GitLab API Token** 13 | The GitLab API token must have administrative privileges to be able to search for users by email address. This is used to translate the commit author email address into a username. 14 | * **GitLab and GitLab API Version** 15 | The GitLab API interactions were written for **v4** of their API against GitLab version **11.x**. Older or newer versions _may_ work, but are unsupported. 16 | * **Node.js and NPM Version** 17 | The code is written targeting Node.js **8.x LTS** and NPM **6.x**. Older or newer versions _may_ work, but are unsupported. 18 | 19 | ### Configuration Syntax 20 | **gitlab-slack** is configured by values in the `config.js` file in the application directory. This file is ingested as a Node.js module and has the following structure: 21 | 22 | ```js 23 | module.exports = { 24 | port: 4646, 25 | slackWebhookUrl: '', 26 | gitLab: { 27 | baseUrl: '', 28 | apiToken: '', 29 | projects: [ 30 | { 31 | id: 0, 32 | name: '', 33 | channel: '', 34 | labels: [] 35 | } 36 | ] 37 | } 38 | }; 39 | ``` 40 | 41 | * `port` - The port on which to listen. (Default = `4646`) 42 | * `slackWebhookUrl` - The URL of the Slack incoming webhook. (Example = `'https://hooks.slack.com/services/...'`) 43 | * `gitLab` - The GitLab configuration. 44 | * `baseUrl` - The protocol/host/port of the GitLab installation. (Example = `'https://gitlab.company.com'`) 45 | _This is expected **NOT** to have a trailing slash._ 46 | * `apiToken` - The API token with which to query GitLab. (Example = `'hxg1qaDqX8xVELvMefPr'`) 47 | * `projects` - The project configuration. This section defines which projects should be tracked. 48 | * `id` - The ID of the project. 49 | * `name` - The name of the project. This value is only used for logging; the group/name namespace is recommended. (Example = `'group/project-name'`) 50 | * `channel` - **Optional.** Overrides the default channel for the Slack webhook. (Example = `'#project-name'`) 51 | _The `#` prefix is added if it is not present._ 52 | * `labels` - **Optional.** An array of regular expressions or strings (that will be turned into case-insensitive regular expressions) used to select issue labels that should be tracked for changes. (Example = `[ '^Status:', /^Size:/ ]`) 53 | 54 | **NOTE:** If the service receives a message for a project that is not configured (or does not have a channel defined), its notifications will go to the default channel for the incoming webhook. 55 | 56 | # Feature Details 57 | 58 | ### Attractive and Functional Notifications 59 | **gitlab-slack** improves upon GitLab's built-in Slack integration with attractive notification formatting that provides more detail and functionality 60 | while cutting the fat and remaining as concise as possible. 61 | 62 | #### Commits 63 | ![Commit Notification](https://user-images.githubusercontent.com/1672405/55261990-d2d4ee80-5242-11e9-8b93-68ee3088a98c.png) 64 | Commit notifications include the repository path and branch, the username of the user who pushed the commit as a link and 65 | a list of commits included in the push. Each commit includes the username of the user who made it, a short-form commit hash 66 | as a link, the first line of the commit message, and a summary of all mentions in the commit message. ([more info](#mention-summary)) 67 | 68 | #### Tags and Branches 69 | ![Tag and Branch Notifications](https://user-images.githubusercontent.com/1672405/55262004-db2d2980-5242-11e9-8323-7e3f4a08c8ee.png) 70 | Tag and branch notifications include the repository path, the username of the user who pushed them as a link and the branch or 71 | tag name as a link. 72 | 73 | If any commits are included in the new-branch message, they are also notified. If a tag includes a message, it is included below the tag. 74 | 75 | #### Issues 76 | ![Issue Notifications](https://user-images.githubusercontent.com/1672405/55262018-e84a1880-5242-11e9-85a6-4dab51e84d4c.png) 77 | Issue notifications include the repository path, the username of the user who performed the issue action, the username of the user to 78 | whom the issue is assigned, the milestone to which the issue is assigned and the username of the user who created the issue. 79 | Milestones and usernames are formatted as a links. Issue notifications include a main attachment that includes the title of the issue, 80 | and, depending on the kind of action, also the issue description. Additional attachments will be included for changes 81 | to tracked labels. ([more info](#issue-label-change-tracking) 82 | 83 | #### Merge Requests 84 | ![Merge Request Notification](https://user-images.githubusercontent.com/1672405/55262028-f13aea00-5242-11e9-8277-d035688c5ea2.png) 85 | Merge request notifications include the repository path, the username of the user who performed the merge request action, the username 86 | of the user to whom the merge request is assigned, the source branch and the target branch. Usernames and branches are formatted as links. 87 | Merge request notifications include an attachment that includes the title of the merge request and, depending on the kind of action, 88 | also the first line of its description. 89 | 90 | #### Wiki Page 91 | ![Wiki Page Notification](https://user-images.githubusercontent.com/1672405/55262039-f7c96180-5242-11e9-8835-09b2140403a4.png) 92 | Wiki page notifications include the repository path, the username of the user who performed the wiki page action and the slug of 93 | the affected wiki page as a link to that page. 94 | 95 | ### Mention Summary 96 | As commit messages are truncated to their first line for notification, any **issues** or **merge requests** mentioned elsewhere in the message are 97 | de-duplicated and summarized as links at the end of the notified commit message. The following two commit messages... 98 | 99 | ```text 100 | Removes the fun file (#8, !6) 101 | 102 | * Fixes an issue where there was a fun file. 103 | ``` 104 | 105 | ```text 106 | Adds a fun file. 107 | 108 | * This is more description. 109 | * Fixes an issue with not having a fun file. (#3, #6, !6) 110 | * Fixes another issue. (#3, !6) 111 | * This line only mentions a merge request. (!5) 112 | ``` 113 | 114 | ...produce the following notification: 115 | 116 | ![Commit Message Mention Summary](https://user-images.githubusercontent.com/1672405/55262205-77573080-5243-11e9-9494-082d8e5a4bd8.png) 117 | 118 | When the mention was in the first line, the original mention is removed to avoid duplication. 119 | 120 | ### Markdown to Slack Formatting Translation 121 | The following Markdown structures will be translated to a Slack-formatting analogue: 122 | * Bold 123 | * Italic 124 | * Links (files and images) 125 | * Headings 126 | * Bulleted Lists (up to two levels deep) 127 | 128 | An issue titled **Markdown to Slack formatting is awesome** with the following following markdown in the description... 129 | 130 | ```markdown 131 | # Heading H1 132 | * Something is _italic_ or *italic*. 133 | * Something else is __bold__ or **bold**. 134 | * Here's a link to [Google](https://google.com). 135 | 136 | Here's an [uploaded_file.7z](https://example.com/uploaded_file.7z). 137 | 138 | Do you like pictures? 139 | ![rubbercheeseburger.jpg](/rubbercheeseburger.jpg) 140 | 141 | ## **Heading H2** 142 | * A list with... 143 | * ...more than one level! 144 | * Back to the base level. 145 | ``` 146 | ...produces an issue notification similiar to the following... 147 | 148 | ![Markdown to Slack Formatting](https://user-images.githubusercontent.com/1672405/55262249-981f8600-5243-11e9-98a3-0daabc0f3fec.png) 149 | 150 | Headings are simply bolded; those that are already bolded are handled appropriately. Images are processed into simple links; they do 151 | not include the base host/protocol/port of the GitLab instance, so that is added. 152 | 153 | ### Issue Label Change Tracking 154 | For configured projects, label change tracking can be enabled by providing a list of regular expressions or strings (which will be 155 | converted to case-insensitive regular expressions) defining which labels **gitlab-slack** should be interested in. When enabled, 156 | label changes will be notified in additional attachments following the main summary attachment. Each label attachment will follow 157 | the label's configured color and indicate whether the label was _Added_ or _Removed_. 158 | 159 | ![Issue Label Change Tracking](https://user-images.githubusercontent.com/1672405/55262271-ac638300-5243-11e9-9c28-1d39b89dc6e4.png) 160 | 161 | ### Configuring Logging 162 | The [debug](https://github.com/visionmedia/debug) module is used for logging under the prefix `gitlab-slack`. The logging is split 163 | into the following components: 164 | 165 | | Component | Description | 166 | |:----------|:------------| 167 | | `app` | The main application responsible for the start-up and shut-down process. | 168 | | `server` | The HTTP server that handles incoming webhook requests. | 169 | | `api` | The wrapper that handles communication with the GitLab API. | 170 | | `slack` | The wrapper that handles sending notifications to the Slack incoming webhook. | 171 | | `handler` | A set of components that handle incoming messages of various types. Sub-components are logged as `gitlab-slack:handler:`.

**Sub-Components**: `commit`, `branch`, `tag`, `issue`, `mergerequest`, `wikipage` | 172 | 173 | To turn on or off logging of components, assign the `DEBUG` environment variable. For example, to only show handler log messages, set 174 | `DEBUG` to `gitlab-slack:handler:*`. Read the documentation for **debug** for more information. 175 | 176 | # Installation 177 | **nodejs** and **npm** are prerequisites to the installation of this application. 178 | 179 | ### Installing the Service 180 | 181 | The **/scripts** directory contains some example service definitions scripts for various init flavors. Check the **README** files 182 | in those directories for more information about each script. 183 | 184 | ### Adding the GitLab Webhook 185 | > _The **Master** or **Owner** permission level is required to modify webhooks in GitLab._ 186 | 187 | 1. From the project home, click **Settings**. 188 | 1. Click **Integrations**. 189 | 1. Enter `http://127.0.0.1:PORT` into the **URL** field if, for example, **gitlab-slack** is running on the same server as GitLab. 190 | Use the value of the `port` key from the `config.js` file in place of `PORT`. 191 | * If **gitlab-slack** is running on another server, enter the appropriate DNS or URI. 192 | 1. Check the **Push events**, **Tag push events** **Issues events**, **Merge Request events** and **Wiki Page events** triggers. If 193 | desired, check the **Confidential Issues events** trigger as well. 194 | _The other Trigger options are not supported and will result in an "unrecognized" message being sent to the default Slack 195 | channel for the webhook._ 196 | 1. Depending on your configuration you may want to check or un-check **Enable SSL verification**. 197 | 1. The **Secret Token** feature is not supported. 198 | 1. Click **Add webhook**. 199 | 200 | Once added, the webhook can be tested using the **Test** button to the right of the webhook under **Webhooks**. 201 | --------------------------------------------------------------------------------