@buildo/hophop
Version:
A minimal tool to accelerate the GitHub workflow from the command line.
266 lines (247 loc) • 8.98 kB
JavaScript
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
};