1import fs from 'fs-extra'; 2import semver from 'semver'; 3import semverRegex from 'semver-regex'; 4 5import * as Markdown from './Markdown'; 6import { execAll } from './Utils'; 7 8/** 9 * Type of the objects representing single changelog entry. 10 */ 11export type ChangelogEntry = { 12 /** 13 * The change note. 14 */ 15 message: string; 16 /** 17 * The pull request number. 18 */ 19 pullRequests?: number[]; 20 /** 21 * GitHub's user names of someones who made this change. 22 */ 23 authors?: string[]; 24}; 25 26/** 27 * Describes changelog entries under specific version. 28 */ 29export type ChangelogVersionChanges = Record<ChangeType, ChangelogEntry[]>; 30 31/** 32 * Type of the objects representing changelog entries. 33 */ 34export type ChangelogChanges = { 35 totalCount: number; 36 versions: Record<string, ChangelogVersionChanges>; 37}; 38 39/** 40 * Represents options object that can be passed to `insertEntriesAsync`. 41 */ 42export type InsertOptions = Partial<{ 43 unshift: boolean; 44}>; 45 46/** 47 * Enum with changelog sections that are commonly used by us. 48 */ 49export enum ChangeType { 50 /** 51 * Upgrading vendored libs. 52 */ 53 LIBRARY_UPGRADES = ' 3rd party library updates', 54 55 /** 56 * Changes in the API that may require users to change their code. 57 */ 58 BREAKING_CHANGES = ' Breaking changes', 59 60 /** 61 * New features and non-breaking changes in the API. 62 */ 63 NEW_FEATURES = ' New features', 64 65 /** 66 * Bug fixes and inconsistencies with the documentation. 67 */ 68 BUG_FIXES = ' Bug fixes', 69 70 /** 71 * Changes that users should be aware of as they cause behavior changes in corner cases. 72 */ 73 NOTICES = '⚠️ Notices', 74 75 /** 76 * Anything that doesn't apply to other types. 77 */ 78 OTHERS = ' Others', 79} 80 81/** 82 * Heading name for unpublished changes. 83 */ 84export const UNPUBLISHED_VERSION_NAME = 'Unpublished'; 85 86export const VERSION_EMPTY_PARAGRAPH_TEXT = 87 '_This version does not introduce any user-facing changes._\n'; 88 89/** 90 * Depth of headings that mean the version containing following changes. 91 */ 92const VERSION_HEADING_DEPTH = 2; 93 94/** 95 * Depth of headings that are being recognized as the type of changes (breaking changes, new features of bugfixes). 96 */ 97const CHANGE_TYPE_HEADING_DEPTH = 3; 98 99/** 100 * Depth of the list that can be a group. 101 */ 102const GROUP_LIST_ITEM_DEPTH = 0; 103 104/** 105 * Class representing a changelog. 106 */ 107export class Changelog { 108 filePath: string; 109 tokens: Markdown.Tokens | null = null; 110 111 static textToChangelogEntry(text: string): Required<ChangelogEntry> { 112 const pullRequests = execAll( 113 /\[#\d+\]\(https?:\/\/github\.com\/expo\/expo\/pull\/(\d+)\)/g, 114 text, 115 1 116 ); 117 const authors = execAll(/\[@\w+\]\(https?:\/\/github\.com\/([^/)]+)\)/g, text, 1); 118 119 return { 120 message: text.trim(), 121 pullRequests: pullRequests.map((match) => parseInt(match, 10)), 122 authors, 123 }; 124 } 125 126 constructor(filePath: string) { 127 this.filePath = filePath; 128 } 129 130 /** 131 * Resolves to `true` if changelog file exists, `false` otherwise. 132 */ 133 async fileExistsAsync(): Promise<boolean> { 134 return await fs.pathExists(this.filePath); 135 } 136 137 /** 138 * Lexifies changelog content and returns resulting tokens. 139 */ 140 async getTokensAsync(): Promise<Markdown.Tokens> { 141 if (!this.tokens) { 142 try { 143 const markdown = await fs.readFile(this.filePath, 'utf8'); 144 this.tokens = Markdown.lexify(markdown); 145 } catch (error) { 146 this.tokens = []; 147 } 148 } 149 return this.tokens; 150 } 151 152 /** 153 * Reads versions headers, collects those versions and returns them. 154 */ 155 async getVersionsAsync(): Promise<string[]> { 156 const tokens = await this.getTokensAsync(); 157 158 return tokens 159 .filter((token): token is Markdown.HeadingToken => isVersionToken(token)) 160 .map((token) => parseVersion(token.text)) 161 .filter(Boolean) as string[]; 162 } 163 164 /** 165 * Returns the last version in changelog. 166 */ 167 async getLastPublishedVersionAsync(): Promise<string | null> { 168 const versions = await this.getVersionsAsync(); 169 return versions.find((version) => semver.valid(version)) ?? null; 170 } 171 172 /** 173 * Reads changes between two given versions and returns them in JS object format. 174 * If called without params, then only unpublished changes are returned. 175 */ 176 async getChangesAsync( 177 fromVersion?: string, 178 toVersion: string = UNPUBLISHED_VERSION_NAME 179 ): Promise<ChangelogChanges> { 180 const tokens = await this.getTokensAsync(); 181 const versions: ChangelogChanges['versions'] = {}; 182 const changes: ChangelogChanges = { totalCount: 0, versions }; 183 184 let currentVersion: string | null = null; 185 let currentSection: string | null = null; 186 187 for (let i = 0; i < tokens.length; i++) { 188 const token = tokens[i]; 189 190 if (Markdown.isHeadingToken(token)) { 191 if (token.depth === VERSION_HEADING_DEPTH) { 192 const parsedVersion = parseVersion(token.text); 193 194 if (!parsedVersion) { 195 // Token is not a valid version token. 196 continue; 197 } 198 if (parsedVersion !== toVersion && (!fromVersion || parsedVersion === fromVersion)) { 199 // We've iterated over everything we needed, stop the loop. 200 break; 201 } 202 203 currentVersion = parsedVersion; 204 currentSection = null; 205 206 if (!versions[currentVersion]) { 207 versions[currentVersion] = {} as ChangelogVersionChanges; 208 } 209 } else if (currentVersion && token.depth === CHANGE_TYPE_HEADING_DEPTH) { 210 currentSection = token.text; 211 212 if (!versions[currentVersion][currentSection]) { 213 versions[currentVersion][currentSection] = []; 214 } 215 } 216 continue; 217 } 218 219 if (currentVersion && currentSection && Markdown.isListToken(token)) { 220 for (const item of token.items) { 221 const text = item.tokens.find(Markdown.isTextToken)?.text ?? item.text; 222 223 changes.totalCount++; 224 versions[currentVersion][currentSection].push(Changelog.textToChangelogEntry(text)); 225 } 226 } 227 } 228 return changes; 229 } 230 231 /** 232 * Saves changes that we made in the array of tokens. 233 */ 234 async saveAsync(): Promise<void> { 235 // If tokens where not loaded yet, there is nothing to save. 236 if (!this.tokens) { 237 return; 238 } 239 240 // Parse cached tokens and write result to the file. 241 await fs.outputFile(this.filePath, Markdown.render(this.tokens)); 242 243 // Reset cached tokens as we just modified the file. 244 // We could use an array with new tokens here, but just for safety, let them be reloaded. 245 this.tokens = null; 246 } 247 248 /** 249 * Inserts given entries under specific version, change type and group. 250 * Returns a new array of entries that were successfully inserted (filters out duplicated entries). 251 * Throws an error if given version cannot be find in changelog. 252 */ 253 async insertEntriesAsync( 254 version: string, 255 type: ChangeType | string, 256 group: string | null, 257 entries: (ChangelogEntry | string)[], 258 options: InsertOptions = {} 259 ): Promise<ChangelogEntry[]> { 260 if (entries.length === 0) { 261 return []; 262 } 263 264 const tokens = await this.getTokensAsync(); 265 const sectionIndex = tokens.findIndex((token) => isVersionToken(token, version)); 266 267 if (sectionIndex === -1) { 268 throw new Error(`Version ${version} not found.`); 269 } 270 271 for (let i = sectionIndex + 1; i < tokens.length; i++) { 272 if (isVersionToken(tokens[i])) { 273 // Encountered another version - so given change type isn't in changelog yet. 274 // We create appropriate change type token and insert this version token. 275 const changeTypeToken = Markdown.createHeadingToken(type, CHANGE_TYPE_HEADING_DEPTH); 276 tokens.splice(i, 0, changeTypeToken); 277 // `tokens[i]` is now `changeTypeToken` - so we will jump into `if` below. 278 } 279 if (isChangeTypeToken(tokens[i], type)) { 280 const changeTypeToken = tokens[i] as Markdown.HeadingToken; 281 let list: Markdown.ListToken | null = null; 282 let j = i + 1; 283 284 // Find the first list token between headings and save it under `list` variable. 285 for (; j < tokens.length; j++) { 286 const item = tokens[j]; 287 if (Markdown.isListToken(item)) { 288 list = item; 289 break; 290 } 291 if (Markdown.isHeadingToken(item) && item.depth <= changeTypeToken.depth) { 292 break; 293 } 294 } 295 296 // List not found, create new list token and insert it in place where the loop stopped. 297 if (!list) { 298 list = Markdown.createListToken(); 299 tokens.splice(j, 0, list); 300 } 301 302 // If group name is specified, let's go deeper and find (or create) a list for that group. 303 if (group) { 304 list = findOrCreateGroupList(list, group); 305 } 306 307 const addedEntries: ChangelogEntry[] = []; 308 309 // Iterate over given entries and push them to the list we ended up with. 310 for (const entry of entries) { 311 const entryObject = typeof entry === 'string' ? { message: entry } : entry; 312 const listItemLabel = getChangeEntryLabel(entryObject); 313 314 // Filter out duplicated entries. 315 if (!list.items.some((item) => item.text.trim() === listItemLabel.trim())) { 316 const listItem = Markdown.createListItemToken( 317 listItemLabel, 318 group ? GROUP_LIST_ITEM_DEPTH : 0 319 ); 320 321 if (options.unshift) { 322 list.items.unshift(listItem); 323 } else { 324 list.items.push(listItem); 325 } 326 addedEntries.push(entryObject); 327 } 328 } 329 return addedEntries; 330 } 331 } 332 throw new Error(`Cound't find '${type}' section.`); 333 } 334 335 /** 336 * Renames header of unpublished changes to given version and adds new section with unpublished changes on top. 337 */ 338 async cutOffAsync( 339 version: string, 340 types: string[] = [ 341 ChangeType.BREAKING_CHANGES, 342 ChangeType.NEW_FEATURES, 343 ChangeType.BUG_FIXES, 344 ChangeType.OTHERS, 345 ] 346 ): Promise<void> { 347 const tokens = await this.getTokensAsync(); 348 const firstVersionHeadingIndex = tokens.findIndex((token) => isVersionToken(token)); 349 const newSectionTokens = [ 350 Markdown.createHeadingToken(UNPUBLISHED_VERSION_NAME, VERSION_HEADING_DEPTH), 351 ...types.map((type) => Markdown.createHeadingToken(type, CHANGE_TYPE_HEADING_DEPTH)), 352 ]; 353 354 if (firstVersionHeadingIndex !== -1) { 355 // Set version of the first found version header and put current date in YYYY-MM-DD format. 356 const dateStr = new Date().toISOString().substring(0, 10); 357 (tokens[firstVersionHeadingIndex] as Markdown.HeadingToken).text = `${version} — ${dateStr}`; 358 359 // Clean up empty sections. 360 let i = firstVersionHeadingIndex + 1; 361 while (i < tokens.length && !isVersionToken(tokens[i])) { 362 // Remove change type token if its section is empty - when it is followed by another heading token. 363 if (isChangeTypeToken(tokens[i])) { 364 const nextToken = tokens[i + 1]; 365 if (!nextToken || isChangeTypeToken(nextToken) || isVersionToken(nextToken)) { 366 tokens.splice(i, 1); 367 continue; 368 } 369 } 370 i++; 371 } 372 373 // `i` stayed the same after removing empty change type sections, so the entire version is empty. 374 // Let's put an information that this version doesn't contain any user-facing changes. 375 if (i === firstVersionHeadingIndex + 1) { 376 tokens.splice(i, 0, { 377 type: Markdown.TokenType.PARAGRAPH, 378 text: VERSION_EMPTY_PARAGRAPH_TEXT, 379 }); 380 } 381 } 382 383 // Insert new tokens before first version header. 384 tokens.splice(firstVersionHeadingIndex, 0, ...newSectionTokens); 385 } 386 387 render() { 388 if (!this.tokens) { 389 throw new Error('Tokens have not been loaded yet!'); 390 } 391 return Markdown.render(this.tokens); 392 } 393} 394 395/** 396 * Convenient method creating `Changelog` instance. 397 */ 398export function loadFrom(path: string): Changelog { 399 return new Changelog(path); 400} 401 402/** 403 * Parses given text and returns the first found semver version, or null if none was found. 404 * If given text equals to unpublished version name then it's returned. 405 */ 406function parseVersion(text: string): string | null { 407 if (text === UNPUBLISHED_VERSION_NAME) { 408 return text; 409 } 410 const match = semverRegex().exec(text); 411 return match?.[0] ?? null; 412} 413 414/** 415 * Parses given text and returns group name if found, null otherwise. 416 */ 417function parseGroup(text: string): string | null { 418 const match = /^\*\*`([@\w\-\/]+)`\*\*/.exec(text.trim()); 419 return match?.[1] ?? null; 420} 421 422/** 423 * Checks whether given token is interpreted as a token with a version. 424 */ 425function isVersionToken(token: Markdown.Token, version?: string): token is Markdown.HeadingToken { 426 return ( 427 Markdown.isHeadingToken(token) && 428 token.depth === VERSION_HEADING_DEPTH && 429 (!version || token.text === version || parseVersion(token.text) === version) 430 ); 431} 432 433/** 434 * Checks whether given token is interpreted as a token with a change type. 435 */ 436function isChangeTypeToken( 437 token: Markdown.Token, 438 changeType?: ChangeType | string 439): token is Markdown.HeadingToken { 440 return ( 441 Markdown.isHeadingToken(token) && 442 token.depth === CHANGE_TYPE_HEADING_DEPTH && 443 (!changeType || token.text === changeType) 444 ); 445} 446 447/** 448 * Checks whether given token is interpreted as a list group. 449 */ 450function isGroupToken(token: Markdown.Token, groupName: string): token is Markdown.ListItemToken { 451 if (Markdown.isListItemToken(token) && token.depth === GROUP_LIST_ITEM_DEPTH) { 452 const firstToken = token.tokens[0]; 453 return Markdown.isTextToken(firstToken) && parseGroup(firstToken.text) === groupName; 454 } 455 return false; 456} 457 458/** 459 * Finds list item that makes a group with given name. 460 */ 461function findOrCreateGroupList(list: Markdown.ListToken, group: string): Markdown.ListToken { 462 let groupListItem = list.items.find((item) => isGroupToken(item, group)) ?? null; 463 464 // Group list item not found, create new list item token and add it at the end. 465 if (!groupListItem) { 466 groupListItem = Markdown.createListItemToken(getGroupLabel(group)); 467 list.items.push(groupListItem); 468 } 469 470 // Find group list among list item tokens. 471 let groupList = groupListItem.tokens.find(Markdown.isListToken); 472 473 if (!groupList) { 474 groupList = Markdown.createListToken(GROUP_LIST_ITEM_DEPTH); 475 groupListItem.tokens.push(groupList); 476 } 477 return groupList; 478} 479 480/** 481 * Stringifies change entry object. 482 */ 483export function getChangeEntryLabel(entry: ChangelogEntry): string { 484 const pullRequests = entry.pullRequests || []; 485 const authors = entry.authors || []; 486 487 if (pullRequests.length + authors.length > 0) { 488 const pullRequestsStr = pullRequests 489 .map((pullRequest) => `[#${pullRequest}](https://github.com/expo/expo/pull/${pullRequest})`) 490 .join(', '); 491 492 const authorsStr = authors 493 .map((author) => `[@${author}](https://github.com/${author})`) 494 .join(', '); 495 496 const pullRequestInformations = `${pullRequestsStr} by ${authorsStr}`.trim(); 497 return `${entry.message} (${pullRequestInformations})`; 498 } 499 return entry.message; 500} 501 502/** 503 * Converts plain group name to its markdown representation. 504 */ 505function getGroupLabel(groupName: string): string { 506 return `**\`${groupName}\`**`; 507} 508