1import assert from 'assert'; 2import fs from 'fs-extra'; 3import semver from 'semver'; 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 // {version -> versionDate} map 39 versionDateMap: Record<string, string>; 40}; 41 42/** 43 * Represents options object that can be passed to `insertEntriesAsync`. 44 */ 45export type InsertOptions = Partial<{ 46 unshift: boolean; 47}>; 48 49/** 50 * Enum with changelog sections that are commonly used by us. 51 */ 52export enum ChangeType { 53 /** 54 * Upgrading vendored libs. 55 */ 56 LIBRARY_UPGRADES = ' 3rd party library updates', 57 58 /** 59 * Changes in the API that may require users to change their code. 60 */ 61 BREAKING_CHANGES = ' Breaking changes', 62 63 /** 64 * New features and non-breaking changes in the API. 65 */ 66 NEW_FEATURES = ' New features', 67 68 /** 69 * Bug fixes and inconsistencies with the documentation. 70 */ 71 BUG_FIXES = ' Bug fixes', 72 73 /** 74 * Changes that users should be aware of as they cause behavior changes in corner cases. 75 */ 76 NOTICES = '⚠️ Notices', 77 78 /** 79 * Anything that doesn't apply to other types. 80 */ 81 OTHERS = ' Others', 82} 83 84/** 85 * Heading name for unpublished changes. 86 */ 87export const UNPUBLISHED_VERSION_NAME = 'Unpublished'; 88 89export const VERSION_EMPTY_PARAGRAPH_TEXT = 90 '_This version does not introduce any user-facing changes._\n'; 91 92/** 93 * Depth of headings that mean the version containing following changes. 94 */ 95const VERSION_HEADING_DEPTH = 2; 96 97/** 98 * Depth of headings that are being recognized as the type of changes (breaking changes, new features of bugfixes). 99 */ 100const CHANGE_TYPE_HEADING_DEPTH = 3; 101 102/** 103 * Depth of the list that can be a group. 104 */ 105const GROUP_LIST_ITEM_DEPTH = 0; 106 107/** 108 * Class representing a changelog. 109 */ 110export class Changelog { 111 filePath: string; 112 tokens: Markdown.Tokens | null = null; 113 114 static textToChangelogEntry(text: string): Required<ChangelogEntry> { 115 const pullRequests = execAll( 116 /\[#\d+\]\(https?:\/\/github\.com\/expo\/expo\/pull\/(\d+)\)/g, 117 text, 118 1 119 ); 120 const authors = execAll(/\[@\w+\]\(https?:\/\/github\.com\/([^/)]+)\)/g, text, 1); 121 122 return { 123 message: text.trim(), 124 pullRequests: pullRequests.map((match) => parseInt(match, 10)), 125 authors, 126 }; 127 } 128 129 constructor(filePath: string) { 130 this.filePath = filePath; 131 } 132 133 /** 134 * Resolves to `true` if changelog file exists, `false` otherwise. 135 */ 136 async fileExistsAsync(): Promise<boolean> { 137 return await fs.pathExists(this.filePath); 138 } 139 140 /** 141 * Lexifies changelog content and returns resulting tokens. 142 */ 143 async getTokensAsync(): Promise<Markdown.Tokens> { 144 if (!this.tokens) { 145 try { 146 const markdown = await fs.readFile(this.filePath, 'utf8'); 147 this.tokens = Markdown.lexify(markdown); 148 } catch { 149 this.tokens = []; 150 } 151 } 152 return this.tokens; 153 } 154 155 /** 156 * Reads versions headers, collects those versions and returns them. 157 */ 158 async getVersionsAsync(): Promise<string[]> { 159 const tokens = await this.getTokensAsync(); 160 161 return tokens 162 .filter((token): token is Markdown.HeadingToken => isVersionToken(token)) 163 .map((token) => parseVersion(token.text)) 164 .filter(Boolean) as string[]; 165 } 166 167 /** 168 * Returns the last version in changelog. 169 */ 170 async getLastPublishedVersionAsync(): Promise<string | null> { 171 const versions = await this.getVersionsAsync(); 172 return versions.find((version) => semver.valid(version)) ?? null; 173 } 174 175 /** 176 * Reads changes between two given versions and returns them in JS object format. 177 * If called without params, then only unpublished changes are returned. 178 */ 179 async getChangesAsync( 180 fromVersion?: string, 181 toVersion: string = UNPUBLISHED_VERSION_NAME 182 ): Promise<ChangelogChanges> { 183 const tokens = await this.getTokensAsync(); 184 const versions: ChangelogChanges['versions'] = {}; 185 const versionDateMap = {}; 186 const changes: ChangelogChanges = { totalCount: 0, versions, versionDateMap }; 187 188 let currentVersion: string | null = null; 189 let currentSection: string | null = null; 190 191 for (let i = 0; i < tokens.length; i++) { 192 const token = tokens[i]; 193 194 if (Markdown.isHeadingToken(token)) { 195 if (token.depth === VERSION_HEADING_DEPTH) { 196 const parsedVersion = parseVersion(token.text); 197 198 if (!parsedVersion) { 199 // Token is not a valid version token. 200 continue; 201 } 202 if (parsedVersion !== toVersion && (!fromVersion || parsedVersion === fromVersion)) { 203 // We've iterated over everything we needed, stop the loop. 204 break; 205 } 206 207 currentVersion = parsedVersion; 208 currentSection = null; 209 210 if (!versions[currentVersion]) { 211 versions[currentVersion] = {} as ChangelogVersionChanges; 212 } 213 214 // version format is `{version} - {date}`. 215 const currentVersionDate = token.text.substring(parsedVersion.length + 3); 216 if (!versionDateMap[currentVersionDate]) { 217 versionDateMap[currentVersion] = currentVersionDate; 218 } 219 } else if (currentVersion && token.depth === CHANGE_TYPE_HEADING_DEPTH) { 220 currentSection = token.text; 221 222 if (!versions[currentVersion][currentSection]) { 223 versions[currentVersion][currentSection] = []; 224 } 225 } 226 continue; 227 } 228 229 if (currentVersion && currentSection && Markdown.isListToken(token)) { 230 for (const item of token.items) { 231 const text = item.tokens.find(Markdown.isTextToken)?.text ?? item.text; 232 233 changes.totalCount++; 234 versions[currentVersion][currentSection].push(Changelog.textToChangelogEntry(text)); 235 } 236 } 237 } 238 return changes; 239 } 240 241 /** 242 * Saves changes that we made in the array of tokens. 243 */ 244 async saveAsync(): Promise<void> { 245 // If tokens where not loaded yet, there is nothing to save. 246 if (!this.tokens) { 247 return; 248 } 249 250 // Parse cached tokens and write result to the file. 251 await fs.outputFile(this.filePath, Markdown.render(this.tokens)); 252 253 // Reset cached tokens as we just modified the file. 254 // We could use an array with new tokens here, but just for safety, let them be reloaded. 255 this.tokens = null; 256 } 257 258 /** 259 * Inserts given entries under specific version, change type and group. 260 * Returns a new array of entries that were successfully inserted (filters out duplicated entries). 261 * Throws an error if given version cannot be find in changelog. 262 */ 263 async insertEntriesAsync( 264 version: string, 265 type: ChangeType | string, 266 group: string | null, 267 entries: (ChangelogEntry | string)[], 268 options: InsertOptions = {} 269 ): Promise<ChangelogEntry[]> { 270 if (entries.length === 0) { 271 return []; 272 } 273 274 const tokens = await this.getTokensAsync(); 275 const sectionIndex = tokens.findIndex((token) => isVersionToken(token, version)); 276 277 if (sectionIndex === -1) { 278 throw new Error(`Version ${version} not found.`); 279 } 280 281 for (let i = sectionIndex + 1; i < tokens.length; i++) { 282 if (isVersionToken(tokens[i])) { 283 // Encountered another version - so given change type isn't in changelog yet. 284 // We create appropriate change type token and insert this version token. 285 const changeTypeToken = Markdown.createHeadingToken(type, CHANGE_TYPE_HEADING_DEPTH); 286 tokens.splice(i, 0, changeTypeToken); 287 // `tokens[i]` is now `changeTypeToken` - so we will jump into `if` below. 288 } 289 if (isChangeTypeToken(tokens[i], type)) { 290 const changeTypeToken = tokens[i] as Markdown.HeadingToken; 291 let list: Markdown.ListToken | null = null; 292 let j = i + 1; 293 294 // Find the first list token between headings and save it under `list` variable. 295 for (; j < tokens.length; j++) { 296 const item = tokens[j]; 297 if (Markdown.isListToken(item)) { 298 list = item; 299 break; 300 } 301 if (Markdown.isHeadingToken(item) && item.depth <= changeTypeToken.depth) { 302 break; 303 } 304 } 305 306 // List not found, create new list token and insert it in place where the loop stopped. 307 if (!list) { 308 list = Markdown.createListToken(); 309 tokens.splice(j, 0, list); 310 } 311 312 // If group name is specified, let's go deeper and find (or create) a list for that group. 313 if (group) { 314 list = findOrCreateGroupList(list, group); 315 } 316 317 const addedEntries: ChangelogEntry[] = []; 318 319 // Iterate over given entries and push them to the list we ended up with. 320 for (const entry of entries) { 321 const entryObject = typeof entry === 'string' ? { message: entry } : entry; 322 const listItemLabel = getChangeEntryLabel(entryObject); 323 324 // Filter out duplicated entries. 325 if (!list.items.some((item) => item.text.trim() === listItemLabel.trim())) { 326 const listItem = Markdown.createListItemToken( 327 listItemLabel, 328 group ? GROUP_LIST_ITEM_DEPTH : 0 329 ); 330 331 if (options.unshift) { 332 list.items.unshift(listItem); 333 } else { 334 list.items.push(listItem); 335 } 336 addedEntries.push(entryObject); 337 } 338 } 339 return addedEntries; 340 } 341 } 342 throw new Error(`Cound't find '${type}' section.`); 343 } 344 345 /** 346 * Inserts an `VERSION_EMPTY_PARAGRAPH_TEXT` version section before first published version. 347 */ 348 async insertEmptyPublishedVersionAsync( 349 version: string, 350 versionDate: string | null 351 ): Promise<boolean> { 352 const tokens = await this.getTokensAsync(); 353 354 const versionIndex = tokens.findIndex((token) => isVersionToken(token, version)); 355 if (versionIndex !== -1) { 356 throw new Error(`Version section ${version} existed.`); 357 } 358 359 const firstPublishedVersionHeadingIndex = tokens.findIndex( 360 (token) => isVersionToken(token) && !isVersionToken(token, UNPUBLISHED_VERSION_NAME) 361 ); 362 363 const dateString = versionDate ?? new Date().toISOString().substring(0, 10); 364 const newSectionTokens = [ 365 Markdown.createHeadingToken(`${version} - ${dateString}`, VERSION_HEADING_DEPTH), 366 { 367 type: Markdown.TokenType.PARAGRAPH, 368 text: VERSION_EMPTY_PARAGRAPH_TEXT, 369 } as Markdown.ParagraphToken, 370 ]; 371 372 // Insert new tokens before first publiushed version header. 373 tokens.splice(firstPublishedVersionHeadingIndex, 0, ...newSectionTokens); 374 return true; 375 } 376 377 /** 378 * Removes an entry under specific version and change type. 379 */ 380 async removeEntryAsync( 381 version: string, 382 type: ChangeType | string, 383 entry: ChangelogEntry | string 384 ): Promise<boolean> { 385 const tokens = await this.getTokensAsync(); 386 387 const versionIndex = tokens.findIndex((token) => isVersionToken(token, version)); 388 if (versionIndex === -1) { 389 throw new Error(`Version ${version} not found.`); 390 } 391 392 const changeTypeIndex = tokens.findIndex( 393 (token, i) => i >= versionIndex && isChangeTypeToken(token, type) 394 ); 395 if (changeTypeIndex === -1) { 396 throw new Error(`Change type ${type} not found.`); 397 } 398 399 const entryText = typeof entry === 'string' ? entry : entry.message; 400 for (let i = changeTypeIndex + 1; i < tokens.length; i++) { 401 if (isVersionToken(tokens[i]) || isChangeTypeToken(tokens[i])) { 402 // Hit other section and stop iteration 403 break; 404 } 405 406 const token = tokens[i]; 407 assert(Markdown.isListToken(token)); 408 409 for (const [itemIndex, item] of token.items.entries()) { 410 const text = (item.tokens.find(Markdown.isTextToken)?.text ?? item.text).trim(); 411 if (text === entryText) { 412 token.items.splice(itemIndex, 1); 413 414 // Remove empty change type section 415 if (token.items.length === 0) { 416 tokens.splice(i, 1); 417 } 418 419 return true; 420 } 421 } 422 } 423 424 return false; 425 } 426 427 /** 428 * Moves an entry from a version section to another. If no `newVersion` section exists, will create one. 429 */ 430 async moveEntryBetweenVersionsAsync( 431 entry: ChangelogEntry | string, 432 type: ChangeType | string, 433 oldVersion: string, 434 newVersion: string, 435 newVersionDate: string | null 436 ): Promise<boolean> { 437 const removed = await this.removeEntryAsync(oldVersion, type, entry); 438 if (!removed) { 439 return false; 440 } 441 442 const tokens = await this.getTokensAsync(); 443 const versionIndex = tokens.findIndex((token) => isVersionToken(token, newVersion)); 444 if (versionIndex === -1) { 445 // if there's no existing version section, create one. 446 const firstPublishedVersionHeadingIndex = tokens.findIndex( 447 (token) => isVersionToken(token) && !isVersionToken(token, UNPUBLISHED_VERSION_NAME) 448 ); 449 450 const dateString = newVersionDate ?? new Date().toISOString().substring(0, 10); 451 const newSectionTokens = [ 452 Markdown.createHeadingToken(`${newVersion} - ${dateString}`, VERSION_HEADING_DEPTH), 453 Markdown.createHeadingToken(String(type), CHANGE_TYPE_HEADING_DEPTH), 454 ]; 455 456 // Insert new tokens before first publiushed version header. 457 tokens.splice(firstPublishedVersionHeadingIndex, 0, ...newSectionTokens); 458 } 459 460 await this.insertEntriesAsync(newVersion, type, null, [entry]); 461 return true; 462 } 463 464 /** 465 * Renames header of unpublished changes to given version and adds new section with unpublished changes on top. 466 */ 467 async cutOffAsync( 468 version: string, 469 types: string[] = [ 470 ChangeType.BREAKING_CHANGES, 471 ChangeType.NEW_FEATURES, 472 ChangeType.BUG_FIXES, 473 ChangeType.OTHERS, 474 ] 475 ): Promise<void> { 476 const tokens = await this.getTokensAsync(); 477 const firstVersionHeadingIndex = tokens.findIndex((token) => isVersionToken(token)); 478 const newSectionTokens = [ 479 Markdown.createHeadingToken(UNPUBLISHED_VERSION_NAME, VERSION_HEADING_DEPTH), 480 ...types.map((type) => Markdown.createHeadingToken(type, CHANGE_TYPE_HEADING_DEPTH)), 481 ]; 482 483 if (firstVersionHeadingIndex !== -1) { 484 // Set version of the first found version header and put current date in YYYY-MM-DD format. 485 const dateStr = new Date().toISOString().substring(0, 10); 486 (tokens[firstVersionHeadingIndex] as Markdown.HeadingToken).text = `${version} — ${dateStr}`; 487 488 // Clean up empty sections. 489 let i = firstVersionHeadingIndex + 1; 490 while (i < tokens.length && !isVersionToken(tokens[i])) { 491 // Remove change type token if its section is empty - when it is followed by another heading token. 492 if (isChangeTypeToken(tokens[i])) { 493 const nextToken = tokens[i + 1]; 494 if (!nextToken || isChangeTypeToken(nextToken) || isVersionToken(nextToken)) { 495 tokens.splice(i, 1); 496 continue; 497 } 498 } 499 i++; 500 } 501 502 // `i` stayed the same after removing empty change type sections, so the entire version is empty. 503 // Let's put an information that this version doesn't contain any user-facing changes. 504 if (i === firstVersionHeadingIndex + 1) { 505 tokens.splice(i, 0, { 506 type: Markdown.TokenType.PARAGRAPH, 507 text: VERSION_EMPTY_PARAGRAPH_TEXT, 508 }); 509 } 510 } 511 512 // Insert new tokens before first version header. 513 tokens.splice(firstVersionHeadingIndex, 0, ...newSectionTokens); 514 } 515 516 render() { 517 if (!this.tokens) { 518 throw new Error('Tokens have not been loaded yet!'); 519 } 520 return Markdown.render(this.tokens); 521 } 522} 523 524/** 525 * Memory based changelog 526 */ 527export class MemChangelog extends Changelog { 528 content: string; 529 530 constructor(content: string) { 531 super(''); 532 this.content = content; 533 } 534 535 async fileExistsAsync(): Promise<boolean> { 536 throw new Error('Unsupported function for MemChangelog.'); 537 } 538 539 async saveAsync(): Promise<void> { 540 throw new Error('Unsupported function for MemChangelog.'); 541 } 542 543 async getTokensAsync(): Promise<Markdown.Tokens> { 544 if (!this.tokens) { 545 try { 546 this.tokens = Markdown.lexify(this.content); 547 } catch { 548 this.tokens = []; 549 } 550 } 551 return this.tokens; 552 } 553} 554 555/** 556 * Convenient method creating `Changelog` instance. 557 */ 558export function loadFrom(path: string): Changelog { 559 return new Changelog(path); 560} 561 562/** 563 * Parses given text and returns the first found semver version, or null if none was found. 564 * If given text equals to unpublished version name then it's returned. 565 */ 566function parseVersion(text: string): string | null { 567 if (text === UNPUBLISHED_VERSION_NAME) { 568 return text; 569 } 570 const match = /(\d+\.\d+\.\d+)([-+][\w\.]+)?/.exec(text); 571 return match?.[0] ?? null; 572} 573 574/** 575 * Parses given text and returns group name if found, null otherwise. 576 */ 577function parseGroup(text: string): string | null { 578 const match = /^\*\*`([@\w\-\/]+)`\*\*/.exec(text.trim()); 579 return match?.[1] ?? null; 580} 581 582/** 583 * Checks whether given token is interpreted as a token with a version. 584 */ 585function isVersionToken(token: Markdown.Token, version?: string): token is Markdown.HeadingToken { 586 return ( 587 Markdown.isHeadingToken(token) && 588 token.depth === VERSION_HEADING_DEPTH && 589 (!version || token.text === version || parseVersion(token.text) === version) 590 ); 591} 592 593/** 594 * Checks whether given token is interpreted as a token with a change type. 595 */ 596function isChangeTypeToken( 597 token: Markdown.Token, 598 changeType?: ChangeType | string 599): token is Markdown.HeadingToken { 600 return ( 601 Markdown.isHeadingToken(token) && 602 token.depth === CHANGE_TYPE_HEADING_DEPTH && 603 (!changeType || token.text === changeType) 604 ); 605} 606 607/** 608 * Checks whether given token is interpreted as a list group. 609 */ 610function isGroupToken(token: Markdown.Token, groupName: string): token is Markdown.ListItemToken { 611 if (Markdown.isListItemToken(token) && token.depth === GROUP_LIST_ITEM_DEPTH) { 612 const firstToken = token.tokens[0]; 613 return Markdown.isTextToken(firstToken) && parseGroup(firstToken.text) === groupName; 614 } 615 return false; 616} 617 618/** 619 * Finds list item that makes a group with given name. 620 */ 621function findOrCreateGroupList(list: Markdown.ListToken, group: string): Markdown.ListToken { 622 let groupListItem = list.items.find((item) => isGroupToken(item, group)) ?? null; 623 624 // Group list item not found, create new list item token and add it at the end. 625 if (!groupListItem) { 626 groupListItem = Markdown.createListItemToken(getGroupLabel(group)); 627 list.items.push(groupListItem); 628 } 629 630 // Find group list among list item tokens. 631 let groupList = groupListItem.tokens.find(Markdown.isListToken); 632 633 if (!groupList) { 634 groupList = Markdown.createListToken(GROUP_LIST_ITEM_DEPTH); 635 groupListItem.tokens.push(groupList); 636 } 637 return groupList; 638} 639 640/** 641 * Stringifies change entry object. 642 */ 643export function getChangeEntryLabel(entry: ChangelogEntry): string { 644 const pullRequests = entry.pullRequests || []; 645 const authors = entry.authors || []; 646 647 if (pullRequests.length + authors.length > 0) { 648 const pullRequestsStr = pullRequests 649 .map((pullRequest) => `[#${pullRequest}](https://github.com/expo/expo/pull/${pullRequest})`) 650 .join(', '); 651 652 const authorsStr = authors 653 .map((author) => `[@${author}](https://github.com/${author})`) 654 .join(', '); 655 656 const pullRequestInformations = `${pullRequestsStr} by ${authorsStr}`.trim(); 657 if (entry.message.includes(pullRequestInformations)) { 658 return entry.message; 659 } else { 660 return `${entry.message} (${pullRequestInformations})`; 661 } 662 } 663 return entry.message; 664} 665 666/** 667 * Converts plain group name to its markdown representation. 668 */ 669function getGroupLabel(groupName: string): string { 670 return `**\`${groupName}\`**`; 671} 672