├── .gitignore ├── bin └── apply-pr ├── src ├── parse-repo.js ├── RequestError.js ├── validateRef.js ├── run.js └── init.js ├── package.json ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /bin/apply-pr: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | require("../src/init"); 3 | -------------------------------------------------------------------------------- /src/parse-repo.js: -------------------------------------------------------------------------------- 1 | module.exports = function(remote, url) { 2 | var repoPattern = /github.com(?:[:\/])([^\/]+)\/(.+?)(?:\.git)?$/; 3 | var result = repoPattern.exec(url); 4 | if (!result) { 5 | throw new Error("the remote '" + remote+ "' is not configured to a github repository"); 6 | } 7 | return { 8 | owner: result[1], 9 | project: result[2] 10 | }; 11 | }; 12 | -------------------------------------------------------------------------------- /src/RequestError.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var util = require("util"); 3 | var STATUS_CODES = require("http").STATUS_CODES; 4 | function RequestError(url, code) { 5 | this.url = url; 6 | this.code = +code; 7 | this.message = 8 | this.url + " responded with " + this.code + " " + STATUS_CODES[code]; 9 | Error.captureStackTrace(this, RequestError); 10 | } 11 | util.inherits(RequestError, Error); 12 | RequestError.prototype.name = "RequestError"; 13 | module.exports = RequestError; 14 | -------------------------------------------------------------------------------- /src/validateRef.js: -------------------------------------------------------------------------------- 1 | var run = require("./run"); 2 | 3 | exports.branch = function(ref) { 4 | if (ref == null) return Promise.resolve(null); 5 | return run(["git", "check-ref-format", ref + ""]).return(ref).catch(function() { 6 | throw new Error("'" + ref + "' is not a valid branch name"); 7 | }); 8 | }; 9 | 10 | exports.remote = function(ref) { 11 | return run("git remote -v").then(function(remotes) { 12 | if (!remotes.stdout) { 13 | throw new Error("no remotes have been configured for this repository yet"); 14 | } 15 | var isValid = remotes.stdout.split("\n").some(function(line) { 16 | return line.indexOf(ref) === 0; 17 | }); 18 | 19 | if (!isValid) { 20 | throw new Error("'" + ref + "' is not a valid remote name"); 21 | } 22 | }).return(ref); 23 | }; 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "apply-pr", 3 | "version": "1.0.3", 4 | "description": "Command line tool for applying GitHub Pull Requests", 5 | "bin": { 6 | "apply-pr": "bin/apply-pr" 7 | }, 8 | "preferGlobal": true, 9 | "scripts": { 10 | "test": "echo \"Error: no test specified\" && exit 1" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/petkaantonov/apply-pr.git" 15 | }, 16 | "keywords": [ 17 | "git" 18 | ], 19 | "author": "Petka Antonov (https://github.com/petkaantonov)", 20 | "license": "MIT", 21 | "bugs": { 22 | "url": "https://github.com/petkaantonov/apply-pr/issues" 23 | }, 24 | "homepage": "https://github.com/petkaantonov/apply-pr", 25 | "dependencies": { 26 | "bluebird": "^2.9.12", 27 | "core-error-predicates": "^1.0.2", 28 | "cross-spawn": "^0.2.6", 29 | "optimist": "^0.6.1", 30 | "request": "^2.53.0" 31 | }, 32 | "readmeFilename": "README.md", 33 | "files": [ 34 | "bin", 35 | "src" 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Petka Antonov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /src/run.js: -------------------------------------------------------------------------------- 1 | var Promise = require("bluebird"); 2 | var spawn = require("cross-spawn"); 3 | var path = require("path"); 4 | 5 | module.exports = function run(command, options) { 6 | var split = Array.isArray(command) ? command : command.split(" "); 7 | var cmd = split.shift(); 8 | var args = split; 9 | options = Object(options); 10 | var stdin = options.stdin; 11 | var cwd = options.cwd; 12 | var log = !!options.log; 13 | if (typeof stdin !== "string") { 14 | stdin = null; 15 | } 16 | return new Promise(function(resolve, reject) { 17 | function makeResult(e) { 18 | var ret = e instanceof Error ? e : new Error(e + ""); 19 | ret.stdout = out.trim(); 20 | ret.stderr = err.trim(); 21 | return ret; 22 | } 23 | 24 | var out = ""; 25 | var err = ""; 26 | var c = spawn(cmd, args, {stdio: ["pipe", "pipe", "pipe"], cwd: cwd || process.cwd()}); 27 | 28 | if (stdin) { 29 | c.stdin.write(stdin); 30 | c.stdin.end(); 31 | } 32 | 33 | c.stdout.on("data", function(data) { 34 | if (log) process.stdout.write(data.toString()); 35 | out += data; 36 | }); 37 | c.stderr.on("data", function(data) { 38 | if (log) process.stderr.write(data.toString()); 39 | err += data; 40 | }); 41 | 42 | c.on("error", function(err) { 43 | reject(makeResult(err)); 44 | }); 45 | c.on("close", function(code) { 46 | if (code == 0) resolve(makeResult()) 47 | else reject(makeResult(cmd + " " + args.join(" ") + " exited with code: " + code + "\n" + err.trim())); 48 | }) 49 | }); 50 | } 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | #Introduction 2 | 3 | `apply-pr` is a cross-platform command-line tool for applying GitHub Pull Requests 4 | 5 | #Requirements 6 | 7 | Requires io.js or node.js installation and git. 8 | 9 | ``` 10 | npm install -g apply-pr 11 | ``` 12 | 13 | #Why? 14 | 15 | Merging GitHub Pull Requests using the merge pull request button in the web-ui is the most convenient way but it makes the repository history ugly - the original commit will show up in history not when it was actually merged but when the author originally commited it. Additionally 16 | it creates an annoying merge commit which makes the history look non-linear in repository explorer like gitk. 17 | 18 | Copy pasting urls and commands to command line to apply pull requests properly is annoying, when all it takes is the PR number which is easy to type and remember. 19 | 20 | #Usage 21 | 22 | ``` 23 | Apply pull/merge requests. 24 | Usage: apply-pr [OPTIONS] pull-request-id [--] [git am OPTIONS] 25 | 26 | Github authorization token is read from the GITHUB_TOKEN environment variable, 27 | it is not needed for public repositories however. 28 | 29 | Options: 30 | -r, --remote The remote from which the Pull Request is applied. [default: "origin"] 31 | -b, --branch The branch where the Pull Request will be applied to. [default: the current branch] 32 | -t, --timeout Time limit for fetching the pull request patch. [default: 30000] 33 | ``` 34 | 35 | Example: 36 | 37 | ``` 38 | petka@petka-VirtualBox ~/bluebird (3.0) 39 | $ apply-pr 505 40 | ``` 41 | 42 | This would fetch and apply the pull request from https://github.com/petkaantonov/bluebird/pull/505 to the current branch (3.0) 43 | 44 | You may append options for [`git-am(1)`](https://www.kernel.org/pub/software/scm/git/docs/git-am.html) after the double-dash: 45 | 46 | ``` 47 | $ apply-pr 505 -- --whitespace=fix 48 | ``` 49 | 50 | By default nothing is passed as an option to `git am`. 51 | 52 | **Note** If the PR doesn't apply cleanly and you don't want to fix it you can always discard it by typing `git am --abort`. 53 | 54 | #Safety 55 | 56 | apply-pr will instantly abort if the working directory is not clean or if you are in the middle of a merge/rebase/am/cherry-pick. 57 | -------------------------------------------------------------------------------- /src/init.js: -------------------------------------------------------------------------------- 1 | var TIMEOUT = 30000; 2 | var amArgs = []; 3 | var args = require("optimist") 4 | .usage("Apply pull/merge requests.\nUsage: apply-pr [OPTIONS] pull-request-id [--] [git am OPTIONS]\n\n" + 5 | "Github authorization token is read from the GITHUB_TOKEN environment variable, \n" + 6 | "it is not needed for public repositories however.") 7 | 8 | .options('r', { 9 | alias: 'remote', 10 | default: 'origin', 11 | desc: "The remote from which the Pull Request is applied." 12 | }) 13 | .options('b', { 14 | alias: 'branch', 15 | desc: "The branch where the Pull Request will be applied to." 16 | }) 17 | .options('t', { 18 | alias: 'timeout', 19 | default: TIMEOUT, 20 | desc: "Time limit for fetching the pull request patch." 21 | }) 22 | .check(function(argv) { 23 | var posArgs = argv._; 24 | if (!posArgs || posArgs.length < 1) { 25 | throw new Error("Pull Request id is required"); 26 | } 27 | var prId = posArgs[0]; 28 | if ((prId >>> 0) !== prId) { 29 | throw new Error("Pull Request id must be a positive integer"); 30 | } 31 | amArgs = posArgs.slice(1); 32 | }) 33 | .argv; 34 | var Promise = require("bluebird"); 35 | Promise.longStackTraces(); 36 | var path = require("path"); 37 | var request = Promise.promisify(require("request")); 38 | var fs = Promise.promisifyAll(require("fs")); 39 | var authorization = process.env.GITHUB_TOKEN; 40 | var E = require("core-error-predicates"); 41 | var RequestError = require("./RequestError"); 42 | var run = require("./run"); 43 | var util = require("util"); 44 | var parseRepo = require("./parse-repo"); 45 | var validateRef = require("./validateRef"); 46 | 47 | var pullRequestId = (args._[0] >>> 0); 48 | var branch = validateRef.branch(args.branch); 49 | var remote = validateRef.remote(args.remote); 50 | var status = run("git status --porcelain"); 51 | var fullStatus = run("git status"); 52 | Promise.join(status, fullStatus, remote, branch, function(status, fullStatus, remote, branch) { 53 | if (/You are in the middle/i.test(fullStatus.stdout) || status.stdout) { 54 | throw new Error("Your have uncommited/unstaged work in the working directory " + 55 | "or you are in the middle of a merge/rebase/cherry-pick/am. " + 56 | "Run `git status` for more information."); 57 | } 58 | var remoteConfig = run("git config --get remote." + remote + ".url"); 59 | if (branch) { 60 | var branchSwitched = run("git checkout " + branch).catch(function(e) { 61 | if (e.stderr.indexOf("error: pathspec") !== 0) { 62 | throw e; 63 | } 64 | return run("git checkout -b " + branch); 65 | }); 66 | return Promise.join(remoteConfig, branchSwitched).return(remoteConfig); 67 | } 68 | return remoteConfig; 69 | }) 70 | .then(function(remoteConfig) { 71 | var url = remoteConfig.stdout; 72 | if (!url) { 73 | throw new Error("no url configured for the remote '" + remote + "'"); 74 | } 75 | var repo = parseRepo(remote, url); 76 | var pullRequestUrl = util.format("https://github.com/%s/%s/pull/%s.patch", 77 | repo.owner, 78 | repo.project, 79 | pullRequestId); 80 | console.log("Applying Pull Request from:", pullRequestUrl); 81 | return request({ 82 | url: pullRequestUrl, 83 | method: "GET", 84 | headers: { 85 | "User-Agent": repo.owner + "/" + repo.project, 86 | "Authorization": "token " + authorization 87 | } 88 | }) 89 | .timeout(args.timeout) 90 | .spread(function(response, body) { 91 | var code = +response.statusCode; 92 | if (200 <= code && code <= 299) { 93 | return run(["git", "am"].concat(amArgs), {stdin: body, log: true}); 94 | } 95 | throw new RequestError(pullRequestUrl, code); 96 | }); 97 | }) 98 | .catch(E.NetworkError, function(e) { 99 | console.error("Error: unable to connect to the network"); 100 | process.exit(2); 101 | }) 102 | .catch(E.FileNotFoundError, function(e) { 103 | if (e.syscall) { 104 | console.error("Error: " + e.path + " has not been installed"); 105 | } else { 106 | console.error("Error: cannot find file or directory: " + e.path); 107 | } 108 | process.exit(2); 109 | }) 110 | .catch(function(e) { 111 | console.error(e + ""); 112 | process.exit(2); 113 | }); 114 | --------------------------------------------------------------------------------