xref: /expo/tools/src/commands/AddChangelog.ts (revision 0f0e2432)
1import { Command } from '@expo/commander';
2import chalk from 'chalk';
3import fs from 'fs-extra';
4import inquirer, { QuestionCollection } from 'inquirer';
5import path from 'path';
6
7import * as Changelogs from '../Changelogs';
8import * as Directories from '../Directories';
9import { formatChangelogEntry } from '../Formatter';
10import logger from '../Logger';
11
12type ActionOptions = {
13  packageNames: string[];
14  // true means that the user didn't use --pull-request or --no-pull-request
15  // unfortunately, we can't change this value
16  pullRequest: number[] | true;
17  author: string[];
18  entry: string;
19  type: string;
20  version: string;
21};
22
23async function checkOrAskForOptions(options: ActionOptions): Promise<ActionOptions> {
24  const lengthValidator = (x: { length: number }) => x.length !== 0;
25  const stringValidator = {
26    filter: (s: string) => s.trim(),
27    validate: lengthValidator,
28  };
29
30  const questions: QuestionCollection[] = [];
31  if (options.packageNames.length === 0) {
32    questions.push({
33      type: 'input',
34      name: 'package',
35      message: 'What are the packages that you want to add a changelog entry?',
36      ...stringValidator,
37      transformer(input) {
38        return input.split(/\s+/g);
39      },
40    });
41  }
42
43  if (options.pullRequest === true) {
44    questions.push({
45      type: 'input',
46      name: 'pullRequest',
47      message: 'What is the pull request number?',
48      filter: (pullRequests) =>
49        pullRequests
50          .split(',')
51          .map((pr) => parseInt(pr, 10))
52          .filter(Boolean),
53      validate: lengthValidator,
54    });
55  }
56
57  if (!options.author.length) {
58    questions.push({
59      type: 'input',
60      name: 'author',
61      message: 'Who is the author?',
62      filter: (authors) =>
63        authors
64          .split(',')
65          .map((author) => author.trim())
66          .filter(Boolean),
67      validate: lengthValidator,
68    });
69  }
70
71  if (!options.entry) {
72    questions.push({
73      type: 'input',
74      name: 'entry',
75      message: 'What is the changelog message?',
76      ...stringValidator,
77    });
78  }
79
80  if (!options.type) {
81    questions.push({
82      type: 'list',
83      name: 'type',
84      message: 'What is the type?',
85      choices: ['bug-fix', 'new-feature', 'breaking-change', 'library-upgrade', 'notice', 'other'],
86    });
87  }
88
89  const promptAnswer = questions.length > 0 ? await inquirer.prompt<ActionOptions>(questions) : {};
90
91  return { ...options, ...promptAnswer };
92}
93
94function toChangeType(type: string): Changelogs.ChangeType | null {
95  switch (type) {
96    case 'bug-fix':
97      return Changelogs.ChangeType.BUG_FIXES;
98    case 'new-feature':
99      return Changelogs.ChangeType.NEW_FEATURES;
100    case 'breaking-change':
101      return Changelogs.ChangeType.BREAKING_CHANGES;
102    case 'library-upgrade':
103      return Changelogs.ChangeType.LIBRARY_UPGRADES;
104    case 'notice':
105      return Changelogs.ChangeType.NOTICES;
106    case 'other':
107      return Changelogs.ChangeType.OTHERS;
108  }
109  return null;
110}
111
112async function action(packageNames: string[], options: ActionOptions) {
113  options.packageNames = packageNames;
114
115  if (!process.env.CI) {
116    options = await checkOrAskForOptions(options);
117  }
118  if (options.packageNames.length === 0) {
119    throw new Error('No packages provided');
120  }
121  if (!options.author.length || !options.entry || !options.type || options.pullRequest === true) {
122    throw new Error(
123      `Must run with --entry <string> --author <string> --pull-request <number> --type <string>`
124    );
125  }
126
127  const type = toChangeType(options.type);
128  if (!type) {
129    throw new Error(`Invalid type: ${chalk.cyan(options.type)}`);
130  }
131
132  for (const packageName of options.packageNames) {
133    const packagePath = path.join(Directories.getPackagesDir(), packageName, 'CHANGELOG.md');
134    if (!(await fs.pathExists(packagePath))) {
135      throw new Error(`Package ${chalk.green(packageName)} doesn't have changelog file.`);
136    }
137
138    const changelog = Changelogs.loadFrom(packagePath);
139
140    const message = options.entry.slice(-1) === '.' ? options.entry : `${options.entry}.`;
141    const insertedEntries = await changelog.insertEntriesAsync(options.version, type, null, [
142      {
143        message,
144        pullRequests: options.pullRequest,
145        authors: options.author,
146      },
147    ]);
148
149    if (insertedEntries.length > 0) {
150      await changelog.saveAsync();
151
152      logger.info(
153        `\n➕ Inserted ${chalk.magenta(options.type)} entry to ${chalk.green(packageName)}:`
154      );
155      insertedEntries.forEach((entry) => {
156        logger.log('  ', formatChangelogEntry(Changelogs.getChangeEntryLabel(entry)));
157      });
158    } else {
159      logger.info(`\n�� Specified entry is already added to ${chalk.green(packageName)} changelog`);
160    }
161  }
162}
163
164export default (program: Command) => {
165  program
166    .command('add-changelog [packageNames...]')
167    .alias('ac')
168    .description('Adds changelog entry to the package.')
169    .option('-e, --entry <string>', 'Change note to put into the changelog.')
170    .option(
171      '-a, --author <string>',
172      "GitHub's user name of someone who made this change. Can be passed multiple times.",
173      (value, previous) => previous.concat(value),
174      []
175    )
176    .option(
177      '-p, --pull-request <number>',
178      'Pull request number. Can be passed multiple times.',
179      (value, previous) => {
180        if (typeof previous === 'boolean') {
181          return [parseInt(value, 10)];
182        }
183
184        return previous.concat(parseInt(value, 10));
185      }
186    )
187    .option(
188      '--no-pull-request',
189      'If changes were pushed directly to the main.',
190      (value, previous) => {
191        // we need to change how no-flag works in commander to be able to pass an array
192        if (!value) {
193          return [];
194        }
195        return previous;
196      }
197    )
198    .option(
199      '-t, --type <string>',
200      'Type of change that determines the section into which the entry should be added. Possible options: bug-fix | new-feature | breaking-change | library-upgrade | notice | other.'
201    )
202    .option(
203      '-v, --version [string]',
204      'Version in which the change was made.',
205      Changelogs.UNPUBLISHED_VERSION_NAME
206    )
207
208    .asyncAction(action);
209};
210