xref: /expo/tools/src/commands/MergeChangelogs.ts (revision d7f57c45)
1import { Command } from '@expo/commander';
2import JsonFile from '@expo/json-file';
3import chalk from 'chalk';
4import inquirer from 'inquirer';
5import nullthrows from 'nullthrows';
6import path from 'path';
7import semver from 'semver';
8
9import {
10  Changelog,
11  ChangelogChanges,
12  ChangelogEntry,
13  ChangeType,
14  UNPUBLISHED_VERSION_NAME,
15} from '../Changelogs';
16import { EXPO_DIR } from '../Constants';
17import { stripNonAsciiChars, formatChangelogEntry } from '../Formatter';
18import logger from '../Logger';
19import { getListOfPackagesAsync, Package } from '../Packages';
20import { filterAsync } from '../Utils';
21
22const MAIN_CHANGELOG_PATH = path.join(EXPO_DIR, 'CHANGELOG.md');
23const VERSIONS_FILE_PATH = path.join(EXPO_DIR, 'changelogVersions.json');
24
25type CommandOptions = {
26  cutOff: boolean;
27};
28
29type ChangesMap = Map<Package, ChangelogChanges['versions']>;
30type ChangelogVersions = Record<string, Record<string, string>>;
31
32export default (program: Command) => {
33  program
34    .command('merge-changelogs')
35    .alias('mc')
36    .description('Merges packages changelogs into the root one.')
37    .option(
38      '-c, --cut-off',
39      'Whether to cut off SDK changelog after merging. Works only without --sdk flag.'
40    )
41    .asyncAction(async (options: CommandOptions) => {
42      const mainChangelog = new Changelog(MAIN_CHANGELOG_PATH);
43      const changesMap: ChangesMap = new Map();
44      const versions = await JsonFile.readAsync<ChangelogVersions>(VERSIONS_FILE_PATH);
45      const previousVersion = await mainChangelog.getLastPublishedVersionAsync();
46      const nextVersion = nullthrows(semver.inc(nullthrows(previousVersion), 'major'));
47
48      if (!previousVersion) {
49        throw new Error('Cannot find last published version in SDK changelog.');
50      }
51
52      // Versions object will be used to do cut-off. Make a new field for the next SDK in advance.
53      versions[nextVersion] = { ...versions[previousVersion] };
54
55      logger.info('\n�� Getting a list of packages...');
56
57      // Get public packages that are not explicitly set to `null` in `changelogVersions.json`.
58      const packages = await filterAsync(await getListOfPackagesAsync(), async (pkg) => {
59        return (
60          !pkg.packageJson.private &&
61          versions[previousVersion]?.[pkg.packageName] !== null &&
62          (await pkg.hasChangelogAsync())
63        );
64      });
65
66      // Load changes into `changesMap`.
67      await getChangesFromPackagesAsync(packages, changesMap, versions, previousVersion);
68
69      // Insert entries for packages not bundled in previous SDK.
70      await insertInitialReleasesAsync(
71        mainChangelog,
72        changesMap,
73        versions,
74        previousVersion,
75        nextVersion
76      );
77
78      // Insert updates from previously bundled packages.
79      await insertNewChangelogEntriesAsync(
80        mainChangelog,
81        changesMap,
82        versions,
83        previousVersion,
84        nextVersion
85      );
86
87      if (options.cutOff) {
88        await cutOffMainChangelogAsync(mainChangelog, versions, nextVersion);
89      }
90
91      logger.info('\n�� Saving SDK changelog...');
92
93      await mainChangelog.saveAsync();
94
95      logger.success('\n✅ Successfully merged changelog entries.');
96    });
97};
98
99/**
100 * Gets changes in packages changelogs as of the version bundled in previous SDK version.
101 */
102async function getChangesFromPackagesAsync(
103  packages: Package[],
104  changesMap: ChangesMap,
105  versions: ChangelogVersions,
106  previousVersion: string
107): Promise<void> {
108  logger.info('\n�� Gathering changelog entries from packages...');
109
110  await Promise.all(
111    packages.map(async (pkg) => {
112      const changelog = new Changelog(pkg.changelogPath);
113      const fromVersion = versions[previousVersion]?.[pkg.packageName];
114      const changes = await changelog.getChangesAsync(fromVersion);
115
116      if (changes.totalCount > 0) {
117        changesMap.set(pkg, changes.versions);
118      }
119    })
120  );
121}
122
123/**
124 * Inserts initial package releases at the beginning of new features.
125 */
126async function insertInitialReleasesAsync(
127  mainChangelog: Changelog,
128  changesMap: ChangesMap,
129  versions: ChangelogVersions,
130  previousVersion: string,
131  nextVersion: string
132): Promise<void> {
133  for (const pkg of changesMap.keys()) {
134    // Get version of the package in previous SDK.
135    const fromVersion = versions[previousVersion]?.[pkg.packageName];
136
137    // The package wasn't bundled in SDK yet.
138    if (!fromVersion) {
139      // Delete the package from the map, no need to handle them again in further functions.
140      changesMap.delete(pkg);
141
142      if (!(await promptToMakeInitialReleaseAsync(pkg.packageName))) {
143        continue;
144      }
145
146      // Update versions object with the local version.
147      versions[nextVersion][pkg.packageName] = pkg.packageVersion;
148
149      // Unshift initial release entry instead of grouped entries.
150      await mainChangelog.insertEntriesAsync(
151        UNPUBLISHED_VERSION_NAME,
152        ChangeType.NEW_FEATURES,
153        null,
154        [`Initial release of **\`${pkg.packageName}\`** ��`],
155        {
156          unshift: true,
157        }
158      );
159      logger.info(`\n�� Inserted initial release of ${chalk.green(pkg.packageName)}`);
160    }
161  }
162}
163
164/**
165 * Inserts new changelog entries made as of previous SDK.
166 */
167async function insertNewChangelogEntriesAsync(
168  mainChangelog: Changelog,
169  changesMap: ChangesMap,
170  versions: ChangelogVersions,
171  previousVersion: string,
172  nextVersion: string
173): Promise<void> {
174  for (const [pkg, changes] of changesMap) {
175    // Sort versions so we keep the order of changelog entries from oldest to newest.
176    const packageVersions = Object.keys(changes).sort(sortVersionsAsc);
177
178    // Get version of the package in previous SDK.
179    const fromVersion = versions[previousVersion]?.[pkg.packageName];
180
181    // Update versions object with the local version.
182    versions[nextVersion][pkg.packageName] = pkg.packageVersion;
183
184    const insertedEntries: Record<string, ChangelogEntry[]> = {};
185    let entriesCount = 0;
186
187    for (const packageVersion of packageVersions) {
188      for (const type in changes[packageVersion]) {
189        const entries = await mainChangelog.insertEntriesAsync(
190          UNPUBLISHED_VERSION_NAME,
191          type,
192          pkg.packageName,
193          changes[packageVersion][type]
194        );
195
196        if (entries.length > 0) {
197          insertedEntries[type] = entries;
198          entriesCount += entries.length;
199        }
200      }
201    }
202
203    if (entriesCount === 0) {
204      continue;
205    }
206
207    // Package was already bundled within previous version.
208    logger.info(
209      `\n�� Inserted ${chalk.green(pkg.packageName)} entries as of ${chalk.yellow(fromVersion)}`
210    );
211
212    for (const [type, entries] of Object.entries(insertedEntries)) {
213      logger.log('  ', chalk.magenta(stripNonAsciiChars(type).trim() + ':'));
214      entries.forEach((entry) => {
215        logger.log('    ', formatChangelogEntry(entry.message));
216      });
217    }
218  }
219}
220
221/**
222 * Cuts off changelog for the new SDK and updates file with changelog versions.
223 */
224async function cutOffMainChangelogAsync(
225  mainChangelog: Changelog,
226  versions: ChangelogVersions,
227  nextVersion: string
228): Promise<void> {
229  logger.info(`\n✂️  Cutting off changelog for SDK ${chalk.cyan(nextVersion)}...`);
230
231  await mainChangelog.cutOffAsync(nextVersion, [
232    ChangeType.LIBRARY_UPGRADES,
233    ChangeType.BREAKING_CHANGES,
234    ChangeType.NEW_FEATURES,
235    ChangeType.BUG_FIXES,
236  ]);
237
238  logger.info('\n�� Saving new changelog versions...');
239
240  // Create a new versions object with keys in descending order.
241  const newVersions = Object.keys(versions)
242    .sort((a, b) => sortVersionsAsc(b, a))
243    .reduce((acc, version) => {
244      acc[version] = versions[version];
245      return acc;
246    }, {});
247
248  // Update `changelogVersions.json` with keys being sorted in descending order.
249  await JsonFile.writeAsync(VERSIONS_FILE_PATH, newVersions);
250}
251
252/**
253 * Comparator that sorts versions in ascending order with unpublished version being the last.
254 */
255function sortVersionsAsc(a: string, b: string): number {
256  return a === UNPUBLISHED_VERSION_NAME
257    ? 1
258    : b === UNPUBLISHED_VERSION_NAME
259    ? -1
260    : semver.compare(a, b);
261}
262
263/**
264 * Prompts the user whether to make initial release of given package.
265 */
266async function promptToMakeInitialReleaseAsync(packageName: string): Promise<boolean> {
267  logger.log();
268  const { confirm } = await inquirer.prompt([
269    {
270      type: 'confirm',
271      name: 'confirm',
272      default: true,
273      prefix: '❔',
274      message: `${chalk.green(packageName)} wasn't bundled in SDK yet. Do you want to include it?`,
275    },
276  ]);
277  return confirm;
278}
279