UNPKG

@buildo/hophop

Version:

A minimal tool to accelerate the GitHub workflow from the command line.

266 lines (247 loc) 8.98 kB
import { compact, find, last, includes } from 'lodash'; import path from 'path'; import R from 'ramda'; import fs from 'fs'; import promisify from 'es6-promisify'; import inquirer from 'inquirer'; import { rl, exec, execF, currBranch, issuesLabelMapper, slugify, gh_helper, gh_me, issuec, cmdoutc, log, error, gh_token_file_name, enterpriseRepos } from '../utils'; import { toggl_start } from '../toggl'; async function gh_create_issue() { const { repo } = await gh_helper(); const title = await rl.question({ message: 'Title:' }); const createIssue = promisify(repo.issue.bind(repo)); const created = await createIssue({ title: title }); log(created.html_url); await exec('open "' + created.html_url + '"'); return created; } async function gh_fetch_issue(issueNumber) { const { repo_name, client } = await gh_helper(); const issueRef = await client.issue(repo_name, issueNumber); try { const issue = await promisify(issueRef.info.bind(issueRef))(); return issue; } catch (e) { if (e.statusCode === 404) return null; } } // Retrieve the folder name of the project function getProjectFolderName(currentPath) { const findGitFolderRecursively = (projectPath, previousFolder) => { const dirs = fs.readdirSync(projectPath).filter((file) => fs.statSync(path.join(projectPath, file)).isDirectory()); const gitMatch = find(dirs, d => d === '.git'); if (gitMatch) { const gitFolderPath = path.join(projectPath, '.git'); if (fs.lstatSync(gitFolderPath).isDirectory()) { return previousFolder; } } const parentFolder = path.join(projectPath, '../'); if (parentFolder === '/') { process.exit(0); } return findGitFolderRecursively(path.join(projectPath, '../'), last(compact(projectPath.split('/')))); }; return findGitFolderRecursively(currentPath, last(currentPath.split('/'))).replace(/[ -]/g, '_'); } async function gh_fetch_issues(issuesOptions = { page: 1 }, allIssues = []) { const { repo, client } = await gh_helper(); if (!issuesOptions.getFilter) { if (await rl.yesOrNoQuestion(`Show only issues assigned to you?`, 'y') !== 'n') { const me = await gh_me(client); issuesOptions.getFilter = (i) => { return i.assignee && i.assignee.id === me.id; }; } else { issuesOptions.getFilter = () => { return () => true; }; } } const issues = await promisify(repo.issues.bind(repo))(issuesOptions); const filteredIssues = issues .filter((i) => !i.pull_request) .filter(issuesOptions.getFilter); let issueQuestion; if (filteredIssues && filteredIssues.length > 0) { const issuesChoices = issuesLabelMapper(filteredIssues) .concat( new inquirer.Separator(), { name: 'Create new issue.', value: 'y' }, { name: 'Show older issue.', value: 'm' }); issueQuestion = { message: 'Which issue?', type: 'list', choices: issuesChoices }; } else { issueQuestion = { message: 'No issues found. Create a new one? (y/n)', default: 'y' }; } const _allIssues = allIssues.concat(filteredIssues); // FIXME: don't display `n` hint if there aren't anymore issues const issueno = await rl.question(issueQuestion); switch (issueno) { case '': process.exit(1); break; case 'y': return gh_create_issue(); case 'm': issuesOptions.page = issuesOptions.page + 1; return await gh_fetch_issues(issuesOptions, _allIssues); default: const issue = R.find((i) => i.number === issueno)(_allIssues); if (!issue) { log(`error: issue #${issueno} does not exist`); process.exit(1); } return issue; } } async function gh_feature({ issueNumber }) { if (await currBranch() !== 'master') { if (await rl.yesOrNoQuestion('You\'re not on master, are you sure you want to continue?', 'n') !== 'y') { process.exit(0); } } let issue; if (includes(['c', 'create'], issueNumber)) { issue = await gh_create_issue(); } else { issue = await (issueNumber ? gh_fetch_issue(issueNumber) : gh_fetch_issues()); } if (issueNumber && !issue) { log(`😱 Issue #${issueNumber} does not exist`); process.exit(0); } const inferredBranchPrefix = getProjectFolderName(process.cwd()); const branchPrefixAnswer = await rl.question({message: `Branch prefix? ('n' to leave empty)`}, inferredBranchPrefix); const branch_prefix = branchPrefixAnswer !== 'n' ? ((branchPrefixAnswer || inferredBranchPrefix) + '-') : ''; const slug = slugify(issue.title); const branchSuffixAnswer = await rl.question({ message: `Branch suffix? ('n' to leave empty)` }, slug); const branch_suffix = branchSuffixAnswer !== 'n' ? ('-' + (branchSuffixAnswer || slug)) : ''; const branch_name = `${branch_prefix}${issue.number}${branch_suffix}`; await exec(`git checkout -b ${branch_name}`); log(`👍 Created and switched to branch '${branch_name}'`); if (await rl.yesOrNoQuestion(`Start tracking time for issue ${issue.number}?`, 'y') !== 'n') { await toggl_start(); } } async function gh_pr() { const { repo_name, client, repo } = await gh_helper(); const branch = await currBranch(); if (branch === 'master') { log('Cannot create a PR to merge from master to master, switch branch first'); process.exit(0); } let issueno = R.take(2, branch.split('-').map((i) => parseInt(i, 10))).filter((x) => !!x)[0]; if (!issueno) { log('Cannot infer the issue number from the branch name'); log('Open issues:'); // const issues = await promisify(repo.issues.bind(repo))(); // const issuesChoices = issuesLabelMapper(issues.filter((i) => !i.pull_request)); issueno = await rl.question({message: 'Which issue?'}); } else { log(`Inferred issue number ${issueno} from branch name '${branch}'`); } const issueRef = client.issue(repo_name, issueno); const issue = await promisify(issueRef.info.bind(issueRef))(); if (!issue) { log('Invalid issue'); } log(`Issue ` + issuec(`#${issueno}: ${issue.title}`)); const pullRequests = await promisify(repo.prs.bind(repo))(); const pullRequest = R.find((pr) => { return pr.title.replace(/^\[WIP\]/, '').trim().split(' ')[0] === `#${issueno}:`; })(pullRequests); if (!!pullRequest) { log(`Existing pull request ${pullRequest.number}: ${pullRequest.title} ( ${pullRequest.html_url} )`); } else { log('No pull request found for this issue'); } const pushResult = await exec(`git push -u origin ${branch}`); log(cmdoutc(pushResult.stdout + '\n' + pushResult.stderr)); if (!pullRequest) { const isWIP = await rl.yesOrNoQuestion('Is this a WIP pull request?', 'n') === 'y'; const prefix = isWIP ? '[WIP] ' : ''; const json = { title: `${prefix}#${issueno}: ${issue.title} (closes #${issueno})`, body: `Issue #${issueno}`, head: `${repo_name.split('/')[0]}:${branch}`, base: 'master' }; try { const created = await promisify(repo.pr.bind(repo))(json); log('👍 ' + created.html_url); await exec('open "' + created.html_url + '"'); } catch (e) { const customErr = e.body.errors.filter((err) => err.code === 'custom')[0]; if (!!customErr) { log(customErr.message); } else { log('Unexpected error'); } } } else { log('Updated existing pull request'); } } async function gh_commit() { const { repo_name, client, repo } = await gh_helper(); const issues = await promisify(repo.issues.bind(repo))(); const issuesChoices = issuesLabelMapper(issues.filter(i => !i.pull_request)); const issueno = await rl.question({ type: 'list', message: 'Which issue? (number)', choices: issuesChoices }); const issueRef = client.issue(repo_name, issueno); const issue = await promisify(issueRef.info.bind(issueRef))(); if (!issue) { log('Invalid issue'); } const message = `closes #${issueno}: ` + issue.title .replace(/^\s*/g, '') .replace(/\s*$/g, '') .replace('\n', ' ') .replace(/["@]/g, '') .replace(/`/g, '\\`'); if (await rl.yesOrNoQuestion(`Commit with the following message:\n${issuec(message)}?`, 'y') === 'y') { const commitResult = await execF(`git commit -m "${message}"`); log(cmdoutc(commitResult.stdout + '\n' + commitResult.stderr)); } } async function gh_setup({ gheAccountName }) { const { githubURL } = Object.values(enterpriseRepos).filter(({ accountName }) => gheAccountName === accountName )[0] || {}; const url = githubURL || 'github.com'; const token = await rl.question({ message: `Personal access token: (from https://${url}/settings/tokens):` }); if (!token) { error('No token inserted.'); process.exit(0); } const fileName = gh_token_file_name(gheAccountName); fs.writeFileSync(`${process.env.HOME}/${fileName}`, token); } export default { gh_commit, gh_feature, gh_pr, gh_setup };