├── .gitignore ├── Cakefile ├── README.md ├── bin └── deploy-robot ├── build ├── adapter │ └── github.js └── robot.js ├── config.json.sample ├── package.json └── src ├── adapter └── github.coffee └── robot.coffee /.gitignore: -------------------------------------------------------------------------------- 1 | .*.swp 2 | .*.swo 3 | ._* 4 | .DS_Store 5 | /Debug/ 6 | /ImgCache/ 7 | /Backup_rar/ 8 | /Debug/ 9 | /debug/ 10 | /upload/ 11 | /avatar/ 12 | /.idea/ 13 | /.vagrant/ 14 | Vagrantfile 15 | *.orig 16 | *.aps 17 | *.APS 18 | *.chm 19 | *.exp 20 | *.pdb 21 | *.rar 22 | .smbdelete* 23 | *.sublime* 24 | .sass-cache 25 | config.rb 26 | /node_modules/ 27 | npm-debug.log 28 | config.json 29 | -------------------------------------------------------------------------------- /Cakefile: -------------------------------------------------------------------------------- 1 | {print} = require 'util' 2 | {spawn} = require 'child_process' 3 | 4 | build = () -> 5 | os = require 'os' 6 | if os.platform() == 'win32' 7 | coffeeCmd = 'coffee.cmd' 8 | else 9 | coffeeCmd = 'coffee' 10 | coffee = spawn coffeeCmd, ['-c', '-o', 'build', 'src'] 11 | coffee.stderr.on 'data', (data) -> 12 | process.stderr.write data.toString() 13 | coffee.stdout.on 'data', (data) -> 14 | print data.toString() 15 | coffee.on 'exit', (code) -> 16 | if code != 0 17 | process.exit code 18 | 19 | test = () -> 20 | os = require 'os' 21 | coffee = spawn 'node', ['test/index.js'] 22 | coffee.stderr.on 'data', (data) -> 23 | process.stderr.write data.toString() 24 | coffee.stdout.on 'data', (data) -> 25 | print data.toString() 26 | coffee.on 'exit', (code) -> 27 | if code != 0 28 | process.exit code 29 | 30 | task 'build', 'Build ./ from src/', -> 31 | build() 32 | 33 | task 'test', 'Run unit test', -> 34 | test() 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 自动部署机器人 3 | ============= 4 | 5 | 将你从繁冗的部署工作中解放出来,让你的部署流程更加自动化 6 | 7 | 特点 8 | ---- 9 | 10 | - 与 GitHub 深度整合,利用 GitHub API 读取相关部署指令,并及时反馈部署情况 11 | - 与人工部署不同的是,自动部署不会疲劳,也不会喊累,你永远可以不停地折腾它 12 | 13 | 使用方法 14 | -------- 15 | 16 | 执行以下命令安装 17 | 18 | ``` 19 | npm install -g deploy-robot 20 | ``` 21 | 22 | 使用以下命令启动脚本 23 | 24 | ``` 25 | deploy-robot -c config.json 26 | ``` 27 | 28 | config.json 文件 29 | -------------- 30 | 31 | 参考目录下的 config.json.sample 文件 32 | 33 | ```javascript 34 | { 35 | "username": "", // 用户名 36 | "password": "", // token,去 https://github.com/settings/applications 的 "Personal access tokens" 选项卡点击 "Generate new token",将获取的字符串填入这里 37 | 38 | "repos": [ // 需要监听地 repo 列表 39 | { 40 | "user": "xxx", // repo 所属用户名 41 | "name": "xxx", // repo 名 42 | "labels": "xxx", // 指定 issue 的 label 43 | "command": "xxx", // 上线脚本的命令 44 | "confirm": null // 上线是否需要某人的确认, 默认为空 45 | } 46 | ] 47 | } 48 | ``` 49 | 50 | 提交上线请求 51 | ----------- 52 | 53 | 见下图 54 | 55 | ![deploy](http://joyqi.qiniudn.com/deploy.gif) 56 | 57 | -------------------------------------------------------------------------------- /bin/deploy-robot: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | require('../build/robot'); 4 | -------------------------------------------------------------------------------- /build/adapter/github.js: -------------------------------------------------------------------------------- 1 | // Generated by CoffeeScript 1.7.1 2 | (function() { 3 | var Github, GithubApi, 4 | __indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) return i; } return -1; }; 5 | 6 | GithubApi = require('github'); 7 | 8 | Github = (function() { 9 | function Github(config) { 10 | var key, repo, _i, _len, _ref; 11 | this.config = config; 12 | this.github = new GithubApi({ 13 | version: '3.0.0', 14 | timeout: 3000 15 | }); 16 | this.github.authenticate({ 17 | username: this.config.username, 18 | password: this.config.password, 19 | type: 'basic' 20 | }); 21 | this.repos = {}; 22 | _ref = this.config.repos; 23 | for (_i = 0, _len = _ref.length; _i < _len; _i++) { 24 | repo = _ref[_i]; 25 | key = repo.user + '/' + repo.name; 26 | if (this.repos[key] == null) { 27 | this.repos[key] = []; 28 | } 29 | this.repos[key].push(repo); 30 | } 31 | } 32 | 33 | Github.prototype.scheduler = function(cb) { 34 | var k, name, repos, user, _ref, _ref1, _results; 35 | _ref = this.repos; 36 | _results = []; 37 | for (k in _ref) { 38 | repos = _ref[k]; 39 | _ref1 = k.split('/'), user = _ref1[0], name = _ref1[1]; 40 | _results.push((function(_this) { 41 | return function(user, name, repos) { 42 | var data, hash, repo, _i, _len; 43 | data = {}; 44 | hash = {}; 45 | for (_i = 0, _len = repos.length; _i < _len; _i++) { 46 | repo = repos[_i]; 47 | data[repo.labels] = []; 48 | hash[repo.labels] = repo; 49 | } 50 | return _this.github.issues.repoIssues({ 51 | user: user, 52 | repo: name, 53 | state: 'open', 54 | assignee: 'none' 55 | }, function(err, issues) { 56 | var issue, items, label, labels, _j, _k, _len1, _len2, _ref2, _results1; 57 | if (err != null) { 58 | throw err; 59 | } 60 | if (issues.length === 0) { 61 | return; 62 | } 63 | for (_j = 0, _len1 = issues.length; _j < _len1; _j++) { 64 | issue = issues[_j]; 65 | for (labels in data) { 66 | items = data[labels]; 67 | labels = ',' + labels + ','; 68 | _ref2 = issue.labels; 69 | for (_k = 0, _len2 = _ref2.length; _k < _len2; _k++) { 70 | label = _ref2[_k]; 71 | if ((labels.indexOf(',' + label.name + ',')) >= 0) { 72 | items.push(issue); 73 | break; 74 | } 75 | } 76 | } 77 | } 78 | _results1 = []; 79 | for (labels in data) { 80 | items = data[labels]; 81 | if (items.length > 0) { 82 | _results1.push(cb(items, hash[labels])); 83 | } else { 84 | _results1.push(void 0); 85 | } 86 | } 87 | return _results1; 88 | }); 89 | }; 90 | })(this)(user, name, repos)); 91 | } 92 | return _results; 93 | }; 94 | 95 | Github.prototype.makeId = function(repo, issue) { 96 | return "/" + repo.user + "/" + repo.name + "/issues/" + issue.number; 97 | }; 98 | 99 | Github.prototype.selfAssign = function(repo, issue) { 100 | return this.github.issues.edit({ 101 | user: repo.user, 102 | repo: repo.name, 103 | number: issue.number, 104 | assignee: this.config.username 105 | }); 106 | }; 107 | 108 | Github.prototype.finish = function(repo, issue, content, close) { 109 | return this.comment(repo, issue, content, (function(_this) { 110 | return function() { 111 | if (close) { 112 | return _this.github.issues.edit({ 113 | user: repo.user, 114 | repo: repo.name, 115 | number: issue.number, 116 | assignee: null, 117 | state: 'closed' 118 | }); 119 | } 120 | }; 121 | })(this)); 122 | }; 123 | 124 | Github.prototype.comment = function(repo, issue, content, cb) { 125 | return this.github.issues.createComment({ 126 | user: repo.user, 127 | repo: repo.name, 128 | number: issue.number, 129 | body: content 130 | }, function(err, comment) { 131 | if (err != null) { 132 | throw err; 133 | } 134 | return cb(comment); 135 | }); 136 | }; 137 | 138 | Github.prototype.confirm = function(repo, issue, users, currentComment, confirmMatched, stopMatched, noneMatched) { 139 | return this.github.issues.getComments({ 140 | user: repo.user, 141 | repo: repo.name, 142 | number: issue.number, 143 | per_page: 100 144 | }, function(err, comments) { 145 | var comment, _i, _len, _ref; 146 | if (err != null) { 147 | throw err; 148 | } 149 | for (_i = 0, _len = comments.length; _i < _len; _i++) { 150 | comment = comments[_i]; 151 | if ((_ref = comment.user.login, __indexOf.call(users, _ref) >= 0) && comment.id > currentComment.id) { 152 | if (comment.body.match(/^\s*confirm/i)) { 153 | return confirmMatched(repo, issue); 154 | } else if (comment.body.match(/^\s*stop/i)) { 155 | return stopMatched(repo, issue, comment.user.login); 156 | } 157 | } 158 | } 159 | return noneMatched(repo, issue); 160 | }); 161 | }; 162 | 163 | return Github; 164 | 165 | })(); 166 | 167 | module.exports = Github; 168 | 169 | }).call(this); 170 | -------------------------------------------------------------------------------- /build/robot.js: -------------------------------------------------------------------------------- 1 | // Generated by CoffeeScript 1.7.1 2 | (function() { 3 | var ChildProcess, Github, adapter, adapters, argv, config, delay, delayed, fs, list, logger, process, winston, _ref, 4 | __indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) return i; } return -1; }; 5 | 6 | fs = require('fs'); 7 | 8 | ChildProcess = require('child_process'); 9 | 10 | Github = require('github'); 11 | 12 | winston = require('winston'); 13 | 14 | adapters = ['github']; 15 | 16 | argv = require('optimist')["default"]('c', 'config.json')["default"]('t', 'github').argv; 17 | 18 | logger = new winston.Logger({ 19 | transports: [ 20 | new winston.transports.Console({ 21 | handleExceptions: true, 22 | level: 'info', 23 | prettyPrint: true, 24 | colorize: true, 25 | timestamp: true 26 | }) 27 | ], 28 | exitOnError: false, 29 | levels: { 30 | info: 0, 31 | warn: 1, 32 | error: 3 33 | }, 34 | colors: { 35 | info: 'green', 36 | warn: 'yellow', 37 | error: 'red' 38 | } 39 | }); 40 | 41 | if (!fs.existsSync(argv.c)) { 42 | throw new Error('Missing config file'); 43 | } 44 | 45 | if (_ref = argv.t, __indexOf.call(adapters, _ref) < 0) { 46 | throw new Error("Adapter " + argv.t + " is not exists"); 47 | } 48 | 49 | config = JSON.parse(fs.readFileSync(argv.c)); 50 | 51 | adapter = new (require('./adapter/' + argv.t))(config); 52 | 53 | list = []; 54 | 55 | delayed = {}; 56 | 57 | delay = function(time, fn, id) { 58 | if (delayed[id] != null) { 59 | return; 60 | } 61 | list.push([Date.now() + time, fn, id]); 62 | return delayed[id] = true; 63 | }; 64 | 65 | setInterval(function() { 66 | var cb, fn, id, now, time; 67 | cb = list.shift(); 68 | now = Date.now(); 69 | if (cb != null) { 70 | time = cb[0], fn = cb[1], id = cb[2]; 71 | if (now >= time) { 72 | delete delayed[id]; 73 | return fn(); 74 | } else { 75 | return list.push(cb); 76 | } 77 | } 78 | }, 5000); 79 | 80 | setInterval(function() { 81 | logger.info('fetching issues ...'); 82 | return adapter.scheduler(process); 83 | }, 15000); 84 | 85 | process = function(issues, repo) { 86 | return issues.forEach(function(issue) { 87 | var deploy, id, users; 88 | adapter.selfAssign(repo, issue); 89 | id = adapter.makeId(repo, issue); 90 | logger.info("found " + id); 91 | deploy = function(id, delayed) { 92 | if (delayed == null) { 93 | delayed = false; 94 | } 95 | logger.info("deploying " + id); 96 | return ChildProcess.exec(repo.command, function(err, result, error) { 97 | var body, close; 98 | body = ''; 99 | close = true; 100 | if (err) { 101 | logger.error(err); 102 | if (delayed) { 103 | body += "Retry failed\n\n"; 104 | } else { 105 | close = false; 106 | body += "An exception occurred, I'll try it again later\n\n"; 107 | delay(300000, (function() { 108 | return deploy(id, true); 109 | }), id); 110 | } 111 | if (result.length > 0) { 112 | body += "## Console\n```\n" + result + "\n```\n\n"; 113 | } 114 | if (error.length > 0) { 115 | body += "## Error\n```\n" + error + "\n```\n\n"; 116 | } 117 | } else { 118 | body += "Success\n\n"; 119 | if (result.length > 0) { 120 | body += "## Console\n```\n" + result + "\n```\n\n"; 121 | } 122 | } 123 | return adapter.finish(repo, issue, body, close); 124 | }); 125 | }; 126 | logger.info("posting comment"); 127 | if (repo.confirm != null) { 128 | users = repo.confirm instanceof Array ? repo.confirm : repo.confirm.split(','); 129 | return adapter.comment(repo, issue, 'Waiting for confirmation by ' + ((users.map(function(user) { 130 | return '@' + user; 131 | })).join(', ')) + "\n\n> Please type `confirm` to confirm or type `stop` to cancel.", function(currentComment) { 132 | var delayDeploy; 133 | delayDeploy = function() { 134 | return adapter.confirm(repo, issue, users, currentComment, function(repo, issue) { 135 | return adapter.comment(repo, issue, "Confirmation received, deploying ...", function() { 136 | return deploy("" + id + "#deploy"); 137 | }); 138 | }, function(repo, issue, user) { 139 | return adapter.finish(repo, issue, "Deployment cancelled by @" + user, true); 140 | }, function(repo, issue) { 141 | return delay(15000, delayDeploy, id); 142 | }); 143 | }; 144 | return delay(15000, delayDeploy, id); 145 | }); 146 | } else { 147 | return adapter.comment(repo, issue, 'Deploying ...', function() { 148 | return deploy("" + id + "#deploy"); 149 | }); 150 | } 151 | }); 152 | }; 153 | 154 | }).call(this); 155 | -------------------------------------------------------------------------------- /config.json.sample: -------------------------------------------------------------------------------- 1 | { 2 | "username": "", 3 | "password": "", 4 | 5 | "repos": [ 6 | { 7 | "user": "xxx", 8 | "name": "xxx", 9 | "labels": "xxx", 10 | "command": "xxx", 11 | "preview": null, 12 | "confirm": null 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "deploy-robot", 3 | "version": "1.0.0", 4 | "description": "Deploy robot", 5 | "main": "build/robot.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/SegmentFault/deploy-robot.git" 12 | }, 13 | "keywords": [ 14 | "deploy", "robot" 15 | ], 16 | "bin": { 17 | "logechod": "./bin/deploy-robot" 18 | }, 19 | "preferGlobal": true, 20 | "author": "joyqi", 21 | "license": "MIT", 22 | "bugs": { 23 | "url": "https://github.com/SegmentFault/deploy-robot/issues" 24 | }, 25 | "homepage": "https://github.com/SegmentFault/deploy-robot", 26 | "dependencies": { 27 | "github": "^0.2.3", 28 | "optimist": "^0.6.1", 29 | "winston": "^0.9.0" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/adapter/github.coffee: -------------------------------------------------------------------------------- 1 | 2 | GithubApi = require 'github' 3 | 4 | class Github 5 | 6 | # 初始化 7 | constructor: (@config) -> 8 | @github = new GithubApi 9 | version: '3.0.0' 10 | timeout: 3000 11 | 12 | @github.authenticate 13 | username: @config.username 14 | password: @config.password 15 | type: 'basic' 16 | 17 | @repos = {} 18 | 19 | for repo in @config.repos 20 | key = repo.user + '/' + repo.name 21 | 22 | if not @repos[key]? 23 | @repos[key] = [] 24 | 25 | @repos[key].push repo 26 | 27 | 28 | # 调度程序 29 | scheduler: (cb) -> 30 | for k, repos of @repos 31 | [user, name] = k.split '/' 32 | 33 | do (user, name, repos) => 34 | data = {} 35 | hash = {} 36 | 37 | for repo in repos 38 | data[repo.labels] = [] 39 | hash[repo.labels] = repo 40 | 41 | # 获取所有相应状态的条目 42 | @github.issues.repoIssues 43 | user: user 44 | repo: name 45 | state: 'open' 46 | assignee: 'none' 47 | , (err, issues) -> 48 | throw err if err? 49 | return if issues.length == 0 50 | 51 | for issue in issues 52 | for labels, items of data 53 | labels = ',' + labels + ',' 54 | for label in issue.labels 55 | if (labels.indexOf (',' + label.name + ',')) >= 0 56 | items.push issue 57 | break 58 | 59 | for labels, items of data 60 | cb items, hash[labels] if items.length > 0 61 | 62 | 63 | # 生成id 64 | makeId: (repo, issue) -> 65 | "/#{repo.user}/#{repo.name}/issues/#{issue.number}" 66 | 67 | 68 | # 把任务标记给自己 69 | selfAssign: (repo, issue) -> 70 | @github.issues.edit 71 | user: repo.user 72 | repo: repo.name 73 | number: issue.number 74 | assignee: @config.username 75 | 76 | 77 | # 提交上线报告 78 | finish: (repo, issue, content, close) -> 79 | # 发布报告 80 | @comment repo, issue, content, => 81 | if close 82 | # 关闭issue 83 | @github.issues.edit 84 | user: repo.user 85 | repo: repo.name 86 | number: issue.number 87 | assignee: null 88 | state: 'closed' 89 | 90 | 91 | # 发布评论 92 | comment: (repo, issue, content, cb) -> 93 | @github.issues.createComment 94 | user: repo.user 95 | repo: repo.name 96 | number: issue.number 97 | body: content 98 | , (err, comment) -> 99 | throw err if err? 100 | cb comment 101 | 102 | 103 | # 等待确认 104 | confirm: (repo, issue, users, currentComment, confirmMatched, stopMatched, noneMatched) -> 105 | @github.issues.getComments 106 | user: repo.user 107 | repo: repo.name 108 | number: issue.number 109 | per_page: 100 110 | , (err, comments) -> 111 | throw err if err? 112 | 113 | for comment in comments 114 | if comment.user.login in users and comment.id > currentComment.id 115 | if comment.body.match /^\s*confirm/i 116 | return confirmMatched repo, issue 117 | else if comment.body.match /^\s*stop/i 118 | return stopMatched repo, issue, comment.user.login 119 | 120 | noneMatched repo, issue 121 | 122 | 123 | module.exports = Github 124 | 125 | -------------------------------------------------------------------------------- /src/robot.coffee: -------------------------------------------------------------------------------- 1 | 2 | fs = require 'fs' 3 | ChildProcess = require 'child_process' 4 | Github = require 'github' 5 | winston = require 'winston' 6 | adapters = ['github'] 7 | argv = require 'optimist' 8 | .default 'c', 'config.json' 9 | .default 't', 'github' 10 | .argv 11 | 12 | 13 | logger = new winston.Logger 14 | transports: [ 15 | new winston.transports.Console 16 | handleExceptions: yes 17 | level: 'info' 18 | prettyPrint: yes 19 | colorize: yes 20 | timestamp: yes 21 | ] 22 | exitOnError: no 23 | levels: 24 | info: 0 25 | warn: 1 26 | error: 3 27 | colors: 28 | info: 'green' 29 | warn: 'yellow' 30 | error: 'red' 31 | 32 | 33 | if not fs.existsSync argv.c 34 | throw new Error 'Missing config file' 35 | 36 | if argv.t not in adapters 37 | throw new Error "Adapter #{argv.t} is not exists" 38 | 39 | config = JSON.parse fs.readFileSync argv.c 40 | adapter = new (require './adapter/' + argv.t) config 41 | 42 | 43 | list = [] 44 | delayed = {} 45 | delay = (time, fn, id) -> 46 | return if delayed[id]? 47 | 48 | list.push [Date.now() + time, fn, id] 49 | delayed[id] = yes 50 | 51 | 52 | setInterval () -> 53 | cb = list.shift() 54 | now = Date.now() 55 | 56 | if cb? 57 | [time, fn, id] = cb 58 | 59 | if now >= time 60 | delete delayed[id] 61 | fn() 62 | else 63 | list.push cb 64 | , 5000 65 | 66 | 67 | setInterval -> 68 | logger.info 'fetching issues ...' 69 | adapter.scheduler process 70 | , 15000 71 | 72 | 73 | # 处理条目 74 | process = (issues, repo) -> 75 | issues.forEach (issue) -> 76 | adapter.selfAssign repo, issue 77 | id = adapter.makeId repo, issue 78 | 79 | logger.info "found #{id}" 80 | 81 | # 发布函数 82 | deploy = (id, delayed = no) -> 83 | logger.info "deploying #{id}" 84 | 85 | ChildProcess.exec repo.command, (err, result, error) -> 86 | body = '' 87 | close = yes 88 | 89 | if err 90 | logger.error err 91 | 92 | if delayed 93 | body += "Retry failed\n\n" 94 | else 95 | close = no 96 | body += "An exception occurred, I'll try it again later\n\n" 97 | delay 300000, (-> deploy id, yes), id 98 | 99 | body += "## Console\n```\n#{result}\n```\n\n" if result.length > 0 100 | body += "## Error\n```\n#{error}\n```\n\n" if error.length > 0 101 | else 102 | body += "Success\n\n" 103 | body += "## Console\n```\n#{result}\n```\n\n" if result.length > 0 104 | 105 | # 发布报告 106 | adapter.finish repo, issue, body, close 107 | 108 | 109 | # 及时发布状态 110 | logger.info "posting comment" 111 | 112 | if repo.confirm? 113 | users = if repo.confirm instanceof Array then repo.confirm else repo.confirm.split ',' 114 | 115 | adapter.comment repo, issue, 'Waiting for confirmation by ' + ((users.map (user) -> '@' + user).join ', ') + "\n\n> Please type `confirm` to confirm or type `stop` to cancel.", (currentComment) -> 116 | delayDeploy = -> 117 | adapter.confirm repo, issue, users, currentComment, (repo, issue) -> 118 | adapter.comment repo, issue, "Confirmation received, deploying ...", -> 119 | deploy "#{id}#deploy" 120 | , (repo, issue, user) -> 121 | adapter.finish repo, issue, "Deployment cancelled by @#{user}", yes 122 | , (repo, issue) -> 123 | delay 15000, delayDeploy, id 124 | 125 | delay 15000, delayDeploy, id 126 | else 127 | adapter.comment repo, issue, 'Deploying ...', -> 128 | deploy "#{id}#deploy" 129 | 130 | 131 | --------------------------------------------------------------------------------