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