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