xref: /expo/tools/src/Changelogs.ts (revision 43fd9ed3)
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