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