diff --git a/bin/land.js b/bin/land.js new file mode 100644 index 0000000..8c35e07 --- /dev/null +++ b/bin/land.js @@ -0,0 +1,239 @@ +#!/usr/bin/env node + +'use strict'; + +const childProcess = require('child_process'); +const https = require('https'); +const path = require('path'); +const util = require('util'); +const clipboardy = require('clipboardy'); + +const args = process.argv.slice(2); +const exec = util.promisify(childProcess.exec); +const targetBranch = args[0] || 'master'; +const toolName = path.basename(process.argv[1]); +const toolVersion = require('../package.json').version; + +const help = `\ +This is a Pull Request landing tool that will automatically pick up commits from +the branch adds metadata to them and merge them into the specified branch. + +Usage: ${toolName} [OPTION] + ${toolName} --help + ${toolName} --version + +Options: + --remote-name get name from remote repo + --rebase start interactive rebase of the source branch + --autosquash move commits that begin with squash!/fixup! during rebase + --cherry-pick apply commits from source branch onto + --clipboardy print 'Landed in (...commitsHash)' + --help print this help message and exit + --version print version and exit +`; + +const runGit = async function(options) { + return new Promise((resolve, reject) => { + const git = childProcess.spawn('git', options, { + stdio: 'inherit', + windowsHide: true, + }); + git.on('close', code => { + if (code !== 0) { + reject(new SyntaxError('git exit with exit code !== 0')); + } + resolve(); + }); + }); +}; + +async function commitsHash(count) { + let git; + try { + git = await exec(`git log -${count} --pretty=format:%h`); + } catch (error) { + console.error(error); + process.exit(1); + } + return git.stdout; +} + +async function differCommits() { + let git; + try { + git = await exec(`git rev-list --count ${targetBranch}..HEAD`); + } catch (error) { + console.error(error); + process.exit(1); + } + return parseInt(git.stdout.trim()); +} + +async function currentBranch() { + let git; + try { + git = await exec('git rev-parse --abbrev-ref HEAD'); + } catch (error) { + console.error(error); + process.exit(1); + } + return git.stdout.trim(); +} + +async function httpsGet(sourceBranch, targetBranch) { + const options = { + path: `/search/issues?q=repo:${await getRepoName()}+is:pr+is:open+head:${sourceBranch}+base:${targetBranch}`, + headers: { + 'user-agent': 'metarhia-api-landing-tool', + }, + host: 'api.github.com', + }; + + return new Promise((resolve, reject) => { + https + .get(options, res => { + let data = ''; + res.setEncoding('utf8'); + + res.on('data', chunk => { + data += chunk; + }); + + res.on('end', () => { + resolve(JSON.parse(data).items[0].html_url); + }); + }) + .on('error', err => { + reject(err.message); + }); + }); +} + +async function getRepoName() { + let git, + name = 'origin'; + for (const value of args) { + if (value.startsWith('--remote-name=')) { + name = value.split('=')[1]; + } + } + + try { + git = await exec(`git remote get-url ${name}`); + } catch (error) { + console.error(error); + process.exit(1); + } + + const gitConfig = git.stdout.trim(); + + return gitConfig.slice( + gitConfig.indexOf(':') + 1, + gitConfig.lastIndexOf('.git') + ); +} + +async function gitLog() { + let git; + try { + git = await exec('git log -1 --pretty=format:%B'); + } catch (error) { + console.error(error); + process.exit(1); + } + + return git.stdout.trim(); +} + +if (args.includes('--help')) { + console.log(help); + process.exit(0); +} else if (args.includes('--version')) { + console.log(toolVersion); + process.exit(0); +} + +const onTargetBranch = () => + childProcess.exec(`git checkout ${targetBranch}`, err => { + if (err) { + console.error(err); + process.exit(1); + } + }); + +async function lastCommitHash() { + let git; + try { + git = await exec('git log -1 --pretty=format:%h'); + } catch (error) { + console.error(error); + process.exit(1); + } + return git.stdout; +} + +(async () => { + const sourceBranch = await currentBranch(); + + if (args.includes('--rebase')) { + runGit(['rebase', `origin/${targetBranch}`]); + } + + if (args.includes('--autosquash')) { + runGit(['rebase', '-i', '--autosquash', `HEAD~${await differCommits()}`]); + } + + const commitsCount = await differCommits(); + const differCommitsHash = (await commitsHash(commitsCount)).split('\n'); + const commitsHashArr = new Array(); + + let prUrl, + extendedCommit, + urlRequest = false; + + for (const hash of differCommitsHash) { + childProcess.execSync(`git checkout ${hash}`); + + const gitLogBody = await gitLog(); + + if (!gitLogBody.includes('PR-URL:')) { + if (!urlRequest) { + prUrl = await httpsGet(sourceBranch, targetBranch); + urlRequest = true; + } + + extendedCommit = `${gitLogBody}\n\nPR-URL: ${prUrl}`; + + childProcess.execSync( + `git commit --amend --allow-empty --message='${extendedCommit}'` + ); + + const modifiedCommitsHash = await lastCommitHash(); + + childProcess.execSync(`git checkout ${sourceBranch}`); + + childProcess.execSync(`git replace -f ${hash} ${modifiedCommitsHash}`); + commitsHashArr.push(modifiedCommitsHash); + } + commitsHashArr.push(hash); + childProcess.execSync(`git checkout ${sourceBranch}`); + } + + if (args.includes('--cherry-pick')) { + onTargetBranch(); + childProcess.exec( + `git cherry-pick ${sourceBranch}..${targetBranch}`, + err => { + if (err) { + console.error(err); + process.exit(1); + } + } + ); + } + + if (args.includes('--clipboardy')) { + clipboardy.write(`Landed in ${commitsHashArr.join(', ')}`); + clipboardy.read().then(console.log); + } +})(); diff --git a/package-lock.json b/package-lock.json index c0db1a2..60af61b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -391,15 +391,10 @@ "color-convert": "^1.9.0" } }, - "anymatch": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.0.3.tgz", - "integrity": "sha512-c6IvoeBECQlMVuYUjSwimnhmztImpErfxJzWZhIQinIvQWoGOnB0dLIgifbPHQt5heS6mNlaZG16f06H3C8t1g==", - "dev": true, - "requires": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - } + "arch": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/arch/-/arch-2.1.1.tgz", + "integrity": "sha512-BLM56aPo9vLLFVa8+/+pJLnrZ7QGGTVHWsCwieAWT9o9K8UeGaQbzZbGoabWLOo2ksBCztoXdqBZBplqLDDCSg==" }, "argparse": { "version": "1.0.10", @@ -632,6 +627,18 @@ "is-glob": "^4.0.1", "normalize-path": "^3.0.0", "readdirp": "^3.1.1" + }, + "dependencies": { + "anymatch": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.0.tgz", + "integrity": "sha512-Ozz7l4ixzI7Oxj2+cw+p0tVUt27BpaJ+1+q1TCeANWxHpvyn2+Un+YamBdfKu0uh8xLodGhoa1v7595NhKDAuA==", + "dev": true, + "requires": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + } + } } }, "ci-info": { @@ -655,6 +662,15 @@ "integrity": "sha1-/xnt6Kml5XkyQUewwR8PvLq+1jk=", "dev": true }, + "clipboardy": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-2.1.0.tgz", + "integrity": "sha512-2pzOUxWcLlXWtn+Jd6js3o12TysNOOVes/aQfg+MT/35vrxWzedHlLwyoJpXjsFKWm95BTNEcMGD9+a7mKzZkQ==", + "requires": { + "arch": "^2.1.1", + "execa": "^1.0.0" + } + }, "co": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/co/-/co-3.1.0.tgz", @@ -829,7 +845,6 @@ "version": "6.0.5", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", - "dev": true, "requires": { "nice-try": "^1.0.4", "path-key": "^2.0.1", @@ -938,7 +953,6 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.1.tgz", "integrity": "sha512-1MkrZNvWTKCaigbn+W15elq2BB/L22nqrSY5DKlo3X6+vclJm8Bb5djXJBmEX6fS3+zCh/F4VBK5Z2KxJt4s2Q==", - "dev": true, "requires": { "once": "^1.4.0" } @@ -1242,9 +1256,9 @@ } }, "eslint-utils": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-1.4.0.tgz", - "integrity": "sha512-7ehnzPaP5IIEh1r1tkjuIrxqhNkzUJa9z3R92tLJdZIVdWaczEhr3EbhGtsMrVxi1KeR8qA7Off6SWc5WNQqyQ==", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-1.4.2.tgz", + "integrity": "sha512-eAZS2sEUMlIeCjBeubdj45dmBHQwPHWyBcT1VSYB7o9x9WRRqKxyUoiXlRjyAwzN7YEzHJlYg0NmzDRWx6GP4Q==", "dev": true, "requires": { "eslint-visitor-keys": "^1.0.0" @@ -1307,7 +1321,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz", "integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==", - "dev": true, "requires": { "cross-spawn": "^6.0.0", "get-stream": "^4.0.0", @@ -1468,7 +1481,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", - "dev": true, "requires": { "pump": "^3.0.0" } @@ -1998,8 +2010,7 @@ "is-stream": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", - "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=", - "dev": true + "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=" }, "is-symbol": { "version": "1.0.2", @@ -2040,8 +2051,7 @@ "isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", - "dev": true + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=" }, "js-tokens": { "version": "4.0.0", @@ -2330,8 +2340,7 @@ "nice-try": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", - "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", - "dev": true + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==" }, "normalize-package-data": { "version": "2.4.0", @@ -2366,7 +2375,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz", "integrity": "sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8=", - "dev": true, "requires": { "path-key": "^2.0.0" } @@ -2399,7 +2407,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", - "dev": true, "requires": { "wrappy": "1" } @@ -2448,8 +2455,7 @@ "p-finally": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", - "integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=", - "dev": true + "integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=" }, "p-limit": { "version": "1.3.0", @@ -2523,8 +2529,7 @@ "path-key": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", - "integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=", - "dev": true + "integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=" }, "path-parse": { "version": "1.0.6", @@ -2626,7 +2631,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", - "dev": true, "requires": { "end-of-stream": "^1.1.0", "once": "^1.3.1" @@ -3605,8 +3609,7 @@ "semver": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/semver/-/semver-5.6.0.tgz", - "integrity": "sha512-RS9R6R35NYgQn++fkDWaOmqGoj4Ek9gGs+DPxNUZKuwE183xjJroKvyo1IzVFeXvUrvmALy6FWD5xrdJT25gMg==", - "dev": true + "integrity": "sha512-RS9R6R35NYgQn++fkDWaOmqGoj4Ek9gGs+DPxNUZKuwE183xjJroKvyo1IzVFeXvUrvmALy6FWD5xrdJT25gMg==" }, "semver-compare": { "version": "1.0.0", @@ -3618,7 +3621,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", "integrity": "sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=", - "dev": true, "requires": { "shebang-regex": "^1.0.0" } @@ -3626,8 +3628,7 @@ "shebang-regex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", - "integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=", - "dev": true + "integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=" }, "shellsubstitute": { "version": "1.2.0", @@ -3638,8 +3639,7 @@ "signal-exit": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz", - "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=", - "dev": true + "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=" }, "slash": { "version": "3.0.0", @@ -3776,8 +3776,7 @@ "strip-eof": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", - "integrity": "sha1-u0P/VZim6wXYm1n80SnJgzE2Br8=", - "dev": true + "integrity": "sha1-u0P/VZim6wXYm1n80SnJgzE2Br8=" }, "strip-indent": { "version": "2.0.0", @@ -4240,7 +4239,6 @@ "version": "1.3.1", "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "dev": true, "requires": { "isexe": "^2.0.0" } @@ -4264,8 +4262,7 @@ "wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", - "dev": true + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" }, "write": { "version": "1.0.3", diff --git a/package.json b/package.json index a4ed6c7..ad2b9a5 100644 --- a/package.json +++ b/package.json @@ -45,5 +45,8 @@ "remark-cli": "^7.0.0", "remark-preset-lint-metarhia": "^2.0.0", "remark-validate-links": "^9.0.1" + }, + "dependencies": { + "clipboardy": "^2.1.0" } }