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