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