From d4f78bd6e64b1b8aef22d15aac291233a6f74499 Mon Sep 17 00:00:00 2001 From: Sami Samhuri Date: Sun, 5 Jun 2011 00:41:47 -0700 Subject: [PATCH] first commit --- .gitignore | 1 + Readme.md | 85 +++++++++++++++ package.json | 24 +++++ pusher.js | 285 +++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 395 insertions(+) create mode 100644 .gitignore create mode 100644 Readme.md create mode 100644 package.json create mode 100755 pusher.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c3629e --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/Readme.md b/Readme.md new file mode 100644 index 0000000..842532d --- /dev/null +++ b/Readme.md @@ -0,0 +1,85 @@ + +# ThePusher + +Github post-receive hook router. + +## Installation + +Make sure you've [got node and npm installed](https://gist.github.com/579814) and then +run `npm i --global thepusher`. + +## Use + +Run `thepusher` from the command line. It does not daemonize itself, it only logs to +stdout, and it doesn't run at startup. Yet. Pull requests accepted and encouraged. + +ThePusher's config file resides at `~/.pusher` and looks like this: + + host github.samhuri.net + port 6177 + + # a unique identifier used in the receive hook url + token e815fb07bb390b5e47e509fd1e31e0d82e5d9c24 + + # a branch named "feature" is created + create branch feature notify-mailing-list.sh + + # any branch is deleted in a repo named "my-project" + delete branch my-project:* notify-mailing-list.sh + + # commits are pushed to any branch on samsonjs/ThePusher, fast-forward merge + # (e.g. https://github.com/samsonjs/ThePusher) + merge branch samsonjs/ThePusher post-to-twitter.sh + + # someone force pushed to master in any of my projects + force branch samsonjs/*:master send-an-angry-email.sh + + # any tag is created in "my-project" + create tag my-project:* build-tag.sh + + # any tag is deleted in "my-project" + delete tag my-project:* delete-build-for-tag.sh + +As you probably noticed triggers follow the form: + + + +Actions are `create`, `delete`, `merge`, and `force`. `merge` and `force` are only +valid when the ref type is `branch`. + +The ref type is `branch` or `tag`. + +The ref spec consists of 1 to 3 parts, any of which can be omitted as long as at least +one of them is present. To reference the branch or tag named `staging` in the repo +named `server` owned by `samsonjs` you write `samsonjs/server:staging`. If there is one +name it's the branch or tag name, effectively `*/*:name`. + +(It's not real globbing the value `*` is just special cased.) + +Everything after the ref spec is the command. Commands are not quoted, just good old +terrible space splitting. + +## License + +(The MIT License) + +Copyright (c) 2011 Sami Samhuri <sami@samhuri.net> + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..46ea5d7 --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ "name" : "thepusher" +, "version" : "0.1.0" +, "description" : "Github post-receive hook router" +, "keywords" : [ "github", "git", "post-receive", "post", "receive", "hook", "router" ] +, "author": "Sami Samhuri " +, "homepage" : "http://samhuri.net/proj/ThePusher" +, "dependencies" : { "batteries" : "0.4.x" } +, "main" : "pusher" +, "bin" : { "thepusher" : "./pusher.js" } +, "engines" : { "node" : "0.4.x" } +, "repository" : + { "type" : "git" + , "url" : "http://github.com/samsonjs/ThePusher.git" + } +, "bugs" : + { "mail" : "sami@samhuri.net" + , "web" : "http://github.com/samsonjs/ThePusher/issues" + } +, "licenses" : + [ { "type" : "MIT" + , "url" : "http://github.com/samsonjs/ThePusher/raw/master/LICENSE" + } + ] +} diff --git a/pusher.js b/pusher.js new file mode 100755 index 0000000..2bf122c --- /dev/null +++ b/pusher.js @@ -0,0 +1,285 @@ +#!/usr/bin/env node + +// ThePusher by Sami Samhuri 2011 MIT +// +// TODO: +// - fork & run headless +// - watch .pusher and automatically reload changes, rolling back on errors +// +// Possible TODOs, may be out of scope: +// - mail output to MAILTO + +var crypto = require('crypto') + , fs = require('fs') + , http = require('http') + , querystring = require('querystring') + , server = http.createServer(routeRequest) + , serverOptions = { host: '127.0.0.1' + , port: 6177 + , githubToken: sha1('pusher') + } + +// why not export the pieces if someone wants to play around +module.exports = +{ serverOptions: serverOptions +, startServer: startServer +, stopServer: stopServer +, addTrigger: addTrigger +, parseLine: parseLine +, parseRefSpec: parseRefSpec +, routeRequest: routeRequest +, main: main +} + +function sha1(s) { + return crypto.createHash('sha1').update(s).digest('hex') +} + +function main() { + var path = require('path') + , eachLine = require('batteries').fs.eachLine + , rcFile = require('path').join(process.env.HOME, '.pusher') + + fs.stat(rcFile, function(err, s) { + if (err) { + spiel() + return + } + eachLine(rcFile, { line: parseLine, end: startServer }) + }) +} + +var triggers = [] + +function addTrigger(t) { triggers.push(t) } + +function parseLine(line) { + // ignore comments and blank lines + if (line.match(/^\s*(#.*|)?$/)) return + + line = line.trim() // don't want to match \s* everywhere + + var m + + // variables, host and port + if (m = line.match(/^\s*([a-z]+)\s+(\S+)\s*$/i)) { + var name = m[1].trim().toLowerCase() + , val = m[2].trim() + if (name === 'host' || name === 'port') { + serverOptions[name] = val + } + else if (name === 'token') { + serverOptions.githubToken = val + process.env.PUSHER_GITHUB_TOKEN = val + } + else { + console.warn('>>> unrecognized variable: ' + name + ' = ' + val) + } + } + + // + else if (m = line.match(/^(create|delete|force|merge)\s+(branch|tag)\s+(\S+)\s+(.+)$/i)) { + var action = m[1].toLowerCase() + , refType = m[2].toLowerCase() + , ref = parseRefSpec(m[3]) + if (refType === 'tag' && (action === 'force' || action === 'merge')) { + throw new Error(action + ' is not supported with tags, try create or delete') + } + console.log('>>> ' + line) + addTrigger({ action: action + , refType: refType + , refSpec: ref.spec + , owner: ref.owner + , repo: ref.repo + , ref: ref.ref + , command: m[4] + }) + } + + // if you're happy and you know it, syntax error! + else { + throw new Error('syntax error: ' + line) + } +} + +// TODO run these things through git check-ref-format or do similar checks +function parseRefSpec(spec) { + var result = { owner: '*', repo: '*', ref: '*' } + , m + // + if (m = spec.match(/^([^\/]+)\/([^:]+):(\S+)$/)) { + result.owner = m[1] + result.repo = m[2] + result.ref = m[3] + } + // + else if (m = spec.match(/^([^:]+):(\S+)$/)) { + result.repo = m[1] + result.ref = m[2] + } + // + else if (m = spec.match(/^([^\/]+)\/(\S+)$/)) { + result.owner = m[1] + result.repo = m[2] + } + // + else { + result.ref = spec + } + result.spec = result.owner + '/' + result.repo + ':' + result.ref + return result +} + +function textResponder(status, text) { + var headers = { 'content-type': 'text/plain', 'content-length': (text || ''.length) } + return function(req, res) { + res.writeHead(status, headers) + res.end(text) + } +} + +var notFound = textResponder(404, 'not found') +var badRequest = textResponder(400, 'bad request') + +function parseRequest(req, cb) { + var parts = [] + req.on('data', function(b) { parts.push(b) }) + req.on('end', function() { + var body = parts.join('') + try { + cb(null, JSON.parse(querystring.parse(body).payload)) + } + catch (err) { + err.body = body + cb(err) + } + }) +} + +function routeRequest(req, res) { + console.log([req.method, req.url, req.connection.remoteAddress, req.headers['content-length'], req.headers['content-type']].join(' ')) + if (req.url === '/' + serverOptions.githubToken && req.method === 'POST') { + parseRequest(req, function(err, payload) { + if (err) { + console.error('!!! invalid json or missing payload: ' + err.body) + console.error(err.message) + console.error(err.stack) + badRequest(req, res) + return + } + + res.writeHead(204) + res.end() + + var action + , owner = payload.repository.owner.name + , repo = payload.repository.name + , ref = payload.ref + , branch = ref.match(/^refs\/heads\//) ? ref.split('/')[2] : null + , tag = ref.match(/^refs\/tags\//) ? ref.split('/')[2] : null + , refType = branch ? 'branch' : tag ? 'tag' : 'unknown' + + if (payload.created) { + action = 'create' + } + else if (payload.deleted) { + action = 'delete' + } + else if (payload.forced && branch) { + action = 'force' + } + else { + action = 'merge' + } + + console.log('* ' + [action, refType, owner + '/' + repo + ':' + (branch || tag || ref)].join(' ')) + + triggers.forEach(function(t) { + if (t.action === action && + t.refType === refType && + (t.owner === '*' || t.owner === owner) && + (t.repo === '*' || t.repo === repo) && + (t.ref === '*' || t.ref === (branch || tag))) + { + console.log('>>> match: ', [t.action, t.refType, t.owner + '/' + t.repo + ':' + t.ref].join(' ')) + runCommand({ command: t.command + , action: action + , owner: owner + , repo: repo + , branch: branch + , tag: tag + , refType: refType + }) + } + }) + }) + } + else { + notFound(req, res) + } +} + +function runCommand(options) { + console.log('>>> running command: ' + options.command) + var spawn = require('child_process').spawn + // TODO quoting + , args = options.command.split(/\s+/) + , cmd = args.shift() + process.env.PUSHER_ACTION = options.action || '' + process.env.PUSHER_OWNER = options.owner || '' + process.env.PUSHER_REPO = options.repo || '' + process.env.PUSHER_REFTYPE = options.refType || '' + process.env.PUSHER_BRANCH = options.branch || '' + process.env.PUSHER_TAG = options.tag || '' + var child = spawn(cmd, args) + if (options.verbose) { + child.stdout.on('data', function(b) { console.log('out>>> ' + b) }) + child.stderr.on('data', function(b) { console.log('err>>> ' + b) }) + } +} + +function startServer() { + server.listen(serverOptions.port, serverOptions.host) +} + +function stopServer() { + server.stop() +} + +function spiel() { + [ "ThePusher is a Github post-receive hook router." + , "" + , "Lines that begin with # are comments. No trailing comments." + , "" + , "ThePusher's config file resides at `~/.pusher` and looks like this:" + , "" + , "host github.samhuri.net" + , "port 6177" + , "" + , "# a unique identifier used in the receive hook url" + , "token e815fb07bb390b5e47e509fd1e31e0d82e5d9c24" + , "" + , "# a branch named \"feature\" is created" + , "create branch feature notify-mailing-list.sh" + , "" + , "# any branch is deleted in a repo named \"my-project\"" + , "delete branch my-project:* notify-mailing-list.sh" + , "" + , "# commits are pushed to any branch on samsonjs/ThePusher, fast-forward merge" + , "# (e.g. https://github.com/samsonjs/ThePusher)" + , "merge branch samsonjs/ThePusher post-to-twitter.sh" + , "" + , "# someone force pushed to master in any of my projects" + , "force branch samsonjs/*:master send-an-angry-email.sh" + , "" + , "# any tag is created in \"my-project\"" + , "create tag my-project:* build-tag.sh" + , "" + , "# any tag is deleted in \"my-project\"" + , "delete tag my-project:* delete-build-for-tag.sh" + , "" + , "Create ~/.pusher and run `thepusher` again." + ].forEach(function(s) { console.log(s) }) +} + +if (require.main === module) main()