Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(prompt): rewrite codebase to use inquirer #2375

Closed
wants to merge 12 commits into from
1 change: 1 addition & 0 deletions .eslintrc.js
Expand Up @@ -52,6 +52,7 @@ module.exports = {
'@typescript-eslint/no-var-requires': 'off',
'@typescript-eslint/no-inferrable-types': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/triple-slash-reference': 'off',

// TODO: enable those rules?
'no-empty': 'off',
Expand Down
6 changes: 2 additions & 4 deletions @commitlint/prompt-cli/cli.js
@@ -1,10 +1,8 @@
#!/usr/bin/env node
const execa = require('execa');
const inquirer = require('inquirer');
const {prompter} = require('@commitlint/prompt');

const _ = undefined;
const prompt = () => prompter(_, commit);

main().catch((err) => {
setTimeout(() => {
throw err;
Expand All @@ -21,7 +19,7 @@ function main() {
process.exit(1);
}
})
.then(() => prompt());
.then(() => prompter(inquirer, commit));
}

function isStageEmpty() {
Expand Down
1 change: 1 addition & 0 deletions @commitlint/prompt-cli/package.json
Expand Up @@ -36,6 +36,7 @@
},
"dependencies": {
"@commitlint/prompt": "^11.0.0",
"inquirer": "^6.5.2",
"execa": "^5.0.0"
},
"gitHead": "cb565dfcca3128380b9b3dc274aedbcae34ce5ca"
Expand Down
6 changes: 4 additions & 2 deletions @commitlint/prompt/package.json
Expand Up @@ -37,15 +37,17 @@
},
"devDependencies": {
"@commitlint/utils": "^11.0.0",
"@commitlint/types": "^11.0.0",
"@commitlint/config-angular": "^11.0.0",
"@types/inquirer": "^6.5.0",
"commitizen": "4.2.2"
},
"dependencies": {
"@commitlint/load": "^11.0.0",
"@commitlint/types": "^11.0.0",
"chalk": "^4.0.0",
"lodash": "^4.17.19",
"throat": "^5.0.0",
"vorpal": "^1.12.0"
"inquirer": "^6.5.2"
},
"gitHead": "cb565dfcca3128380b9b3dc274aedbcae34ce5ca"
}
13 changes: 7 additions & 6 deletions @commitlint/prompt/src/index.ts
@@ -1,17 +1,18 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import vorpal from 'vorpal';
import inquirer from 'inquirer';
import input from './input';

type Commit = (input: string) => void;

/**
* Entry point for commitizen
* @param _ inquirer instance passed by commitizen, unused
* @param cz inquirer instance passed by commitizen
* @param commit callback to execute with complete commit message
* @return generated commit message
*/
export async function prompter(_: unknown, commit: Commit): Promise<void> {
const message = await input(vorpal);
export async function prompter(
cz: typeof inquirer,
commit: Commit
): Promise<void> {
const message = await input(cz.prompt);
commit(message);
}
96 changes: 96 additions & 0 deletions @commitlint/prompt/src/input.test.ts
@@ -0,0 +1,96 @@
import {Answers, PromptModule, QuestionCollection} from 'inquirer';
/// <reference path="./inquirer/inquirer.d.ts" />
Copy link
Contributor Author

@armano2 armano2 Jan 12, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if you have better idea for this, I'm open to suggestions

import input from './input';
import chalk from 'chalk';

jest.mock(
'@commitlint/load',
() => {
return () => require('@commitlint/config-angular');
},
{
virtual: true,
}
);

test('should work with all fields filled', async () => {
const prompt = stub({
'input-custom': {
type: 'fix',
scope: 'test',
subject: 'subject',
body: 'body',
footer: 'footer',
},
});
const message = await input(prompt);
expect(message).toEqual('fix(test): subject\n' + 'body\n' + 'footer');
});
Copy link
Contributor Author

@armano2 armano2 Jan 9, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

those tests actually test validation only, as there is no viable / easy way to write proper end to end tests

most likely we should add more cases here


test('should work without scope', async () => {
const prompt = stub({
'input-custom': {
type: 'fix',
scope: '',
subject: 'subject',
body: 'body',
footer: 'footer',
},
});
const message = await input(prompt);
expect(message).toEqual('fix: subject\n' + 'body\n' + 'footer');
});

test('should fail without type', async () => {
const spy = jest.spyOn(console, 'error').mockImplementation();
const prompt = stub({
'input-custom': {
type: '',
scope: '',
subject: '',
body: '',
footer: '',
},
});
const message = await input(prompt);
expect(message).toEqual('');
expect(console.error).toHaveBeenCalledTimes(1);
expect(console.error).toHaveBeenLastCalledWith(
new Error(`⚠ ${chalk.bold('type')} may not be empty.`)
);
spy.mockRestore();
});
armano2 marked this conversation as resolved.
Show resolved Hide resolved

function stub(config: Record<string, Record<string, unknown>>): PromptModule {
const prompt = async (questions: QuestionCollection): Promise<any> => {
const result: Answers = {};
const resolvedConfig = Array.isArray(questions) ? questions : [questions];
for (const promptConfig of resolvedConfig) {
const configType = promptConfig.type || 'input';
const questions = config[configType];
if (!questions) {
throw new Error(`Unexpected config type: ${configType}`);
}
const answer = questions[promptConfig.name!];
if (answer == null) {
throw new Error(`Unexpected config name: ${promptConfig.name}`);
}
const validate = promptConfig.validate;
if (validate) {
const validationResult = validate(answer, result);
if (validationResult !== true) {
throw new Error(validationResult || undefined);
}
}

result[promptConfig.name!] = answer;
}
return result;
};
prompt.registerPrompt = () => {
return prompt;
};
prompt.restoreDefaultPrompts = () => true;
prompt.prompts = {};
return (prompt as any) as PromptModule;
}
65 changes: 27 additions & 38 deletions @commitlint/prompt/src/input.ts
@@ -1,29 +1,21 @@
import load from '@commitlint/load';
import throat from 'throat';
import {DistinctQuestion, PromptModule} from 'inquirer';

import format from './library/format';
import getPrompt from './library/get-prompt';
import settings from './settings';
import {InputSetting, Prompter, Result} from './library/types';
import {getHasName, getMaxLength, getRules} from './library/utils';
import {InputSetting, Result} from './library/types';

export default input;
import {getHasName, getMaxLength, getRules} from './library/utils';
import InputCustomPrompt from './inquirer/InputCustomPrompt';

/**
* Get user input by interactive prompt based on
* conventional-changelog-lint rules.
* @param prompter
* @return commit message
*/
async function input(prompter: () => Prompter): Promise<string> {
const results: Result = {
type: null,
scope: null,
subject: null,
body: null,
footer: null,
};

export default async function input(prompter: PromptModule): Promise<string> {
const {rules} = await load();
const parts = ['type', 'scope', 'subject', 'body', 'footer'] as const;
const headerParts = ['type', 'scope', 'subject'];
Expand All @@ -33,31 +25,28 @@ async function input(prompter: () => Prompter): Promise<string> {
);
const maxLength = getMaxLength(headerLengthRule);

await Promise.all(
parts.map(
throat(1, async (input) => {
const inputRules = getRules(input, rules);
const inputSettings: InputSetting = settings[input];

if (headerParts.includes(input) && maxLength < Infinity) {
inputSettings.header = {
length: maxLength,
};
}

results[input] = await getPrompt(input, {
rules: inputRules,
settings: inputSettings,
results,
prompter,
});
})
)
).catch((err) => {
try {
const questions: DistinctQuestion<Result>[] = [];
prompter.registerPrompt('input-custom', InputCustomPrompt);

for (const input of parts) {
const inputSettings: InputSetting = settings[input];
const inputRules = getRules(input, rules);
if (headerParts.includes(input) && maxLength < Infinity) {
inputSettings.header = {
length: maxLength,
};
}
const question = getPrompt(input, inputRules, inputSettings);
if (question) {
questions.push(question);
}
}

const results = await prompter<Result>(questions);
return format(results);
} catch (err) {
console.error(err);
return '';
});

// Return the results
return format(results);
}
}
119 changes: 119 additions & 0 deletions @commitlint/prompt/src/inquirer/InputCustomPrompt.ts
@@ -0,0 +1,119 @@
/// <reference path="./inquirer.d.ts" />
import {Interface as ReadlineInterface, Key} from 'readline';

import chalk from 'chalk';
import inquirer from 'inquirer';
import InputPrompt from 'inquirer/lib/prompts/input';
import observe from 'inquirer/lib/utils/events';
import type {Subscription} from 'rxjs/internal/Subscription';

import Answers = inquirer.Answers;
import InputCustomOptions = inquirer.InputCustomOptions;
import SuccessfulPromptStateData = inquirer.prompts.SuccessfulPromptStateData;

interface KeyDescriptor {
value: string;
key: Key;
}

export default class InputCustomPrompt<
TQuestion extends InputCustomOptions = InputCustomOptions
> extends InputPrompt<TQuestion> {
private lineSubscription: Subscription;
private readonly tabCompletion: string[];

constructor(
question: TQuestion,
readLine: ReadlineInterface,
answers: Answers
) {
super(question, readLine, answers);

if (this.opt.log) {
this.rl.write(this.opt.log(answers));
}

if (!this.opt.maxLength) {
this.throwParamError('maxLength');
}

const events = observe(this.rl);
this.lineSubscription = events.keypress.subscribe(
this.onKeyPress2.bind(this)
);
this.tabCompletion = (this.opt.tabCompletion || [])
.map((item) => item.value)
.sort((a, b) => a.localeCompare(b));
}

onEnd(state: SuccessfulPromptStateData): void {
this.lineSubscription.unsubscribe();
super.onEnd(state);
}

/**
* @see https://nodejs.org/api/readline.html#readline_rl_write_data_key
* @see https://nodejs.org/api/readline.html#readline_rl_line
*/
updateLine(line: string): void {
/* eslint-disable @typescript-eslint/ban-ts-comment */
// @ts-ignore
this.rl.line = line;
// @ts-ignore
this.rl.write(null, {ctrl: true, name: 'e'});
}

onKeyPress2(e: KeyDescriptor): void {
if (e.key.name === 'tab' && this.tabCompletion.length > 0) {
let line = this.rl.line.trim();
if (line.length > 0) {
for (const item of this.tabCompletion) {
if (item.startsWith(line) && item !== line) {
line = item;
break;
}
}
}
this.updateLine(line);
}
}

measureInput(input: string): number {
if (this.opt.filter) {
return this.opt.filter(input).length;
}
return input.length;
}

render(error?: string): void {
const answered = this.status === 'answered';

let bottomContent = '';
let message = this.getQuestion();
const length = this.measureInput(this.rl.line);

if (answered) {
message += chalk.cyan(this.answer);
} else if (this.opt.transformer) {
message += this.opt.transformer(this.rl.line, this.answers, {});
}

if (error) {
bottomContent = chalk.red('>> ') + error;
} else if (!answered) {
const maxLength = this.opt.maxLength(this.answers);
if (maxLength < Infinity) {
const lengthRemaining = maxLength - length;
const color =
lengthRemaining <= 5
? chalk.red
: lengthRemaining <= 10
? chalk.yellow
: chalk.grey;
bottomContent = color(`${lengthRemaining} characters left`);
}
}

this.screen.render(message, bottomContent);
}
}