1import chalk from 'chalk';
2import path from 'path';
3
4import { ChangelogEntry, UNPUBLISHED_VERSION_NAME } from '../../Changelogs';
5import { EXPO_DIR } from '../../Constants';
6import { link } from '../../Formatter';
7import Git from '../../Git';
8import { dispatchWorkflowEventAsync, getClosedIssuesAsync } from '../../GitHubActions';
9import logger from '../../Logger';
10import { Package } from '../../Packages';
11import { Task } from '../../TasksRunner';
12import { CommentatorPayload } from '../../commands/CommentatorCommand';
13import { CommandOptions, Parcel, TaskArgs } from '../types';
14import { selectPackagesToPublish } from './selectPackagesToPublish';
15
16type CommentRowObject = {
17  pkg: Package;
18  version: string;
19  pullRequests: number[];
20};
21
22/**
23 * Dispatches GitHub Actions workflow that adds comments to the issues
24 * that were closed by pull requests mentioned in the changelog changes.
25 */
26export const commentOnIssuesTask = new Task<TaskArgs>(
27  {
28    name: 'commentOnIssuesTask',
29    dependsOn: [selectPackagesToPublish],
30    backupable: true,
31  },
32  async (parcels: Parcel[], options: CommandOptions) => {
33    logger.info('\n�� Commenting on issues closed by published changes');
34
35    const payload = await generatePayloadForCommentatorAsync(parcels, options.tag);
36
37    if (!payload.length) {
38      logger.log('There are no closed issues to comment on\n');
39      return;
40    }
41    if (options.dry) {
42      logger.debug('Skipping due to --dry flag');
43      logManualFallback(payload);
44      return;
45    }
46    if (!process.env.GITHUB_TOKEN) {
47      logger.error(
48        'Environment variable `%s` must be set to dispatch a commentator workflow',
49        chalk.magenta('GITHUB_TOKEN')
50      );
51      logManualFallback(payload);
52      return;
53    }
54
55    const currentBranchName = await Git.getCurrentBranchNameAsync();
56
57    // Sometimes we publish from different branches (especially for testing) where comments are not advisable.
58    if (currentBranchName !== 'main') {
59      logger.warn('This feature is disabled on branches other than main');
60      logManualFallback(payload);
61      return;
62    }
63
64    // Dispatch commentator workflow on GitHub Actions with stringified and escaped payload.
65    await dispatchWorkflowEventAsync('commentator.yml', currentBranchName, {
66      payload: JSON.stringify(payload).replace(/("|`)/g, '\\$1'),
67    });
68    logger.success(
69      'Successfully dispatched commentator action for the following issues: %s',
70      linksToClosedIssues(payload.map(({ issue }) => issue))
71    );
72  }
73);
74
75/**
76 * Generates payload for `expotools commentator` command.
77 */
78async function generatePayloadForCommentatorAsync(
79  parcels: Parcel[],
80  tag: string
81): Promise<CommentatorPayload> {
82  // An object whose key is the issue number and value is an array of rows to put in the comment's body.
83  const commentRows: Record<number, CommentRowObject[]> = {};
84
85  // An object whose key is the pull request number and value is an array of issues it closes.
86  const closedIssuesRegistry: Record<number, number[]> = {};
87
88  for (const { pkg, state, changelogChanges } of parcels) {
89    const versionChanges = changelogChanges.versions[UNPUBLISHED_VERSION_NAME];
90
91    if (!versionChanges) {
92      continue;
93    }
94    const allEntries = ([] as ChangelogEntry[]).concat(...Object.values(versionChanges));
95    const allPullRequests = new Set(
96      ([] as number[]).concat(...allEntries.map((entry) => entry.pullRequests ?? []))
97    );
98
99    // Visit all pull requests mentioned in the changelog.
100    for (const pullRequest of allPullRequests) {
101      // Look for closed issues just once per pull request to reduce number of GitHub API calls.
102      if (!closedIssuesRegistry[pullRequest]) {
103        closedIssuesRegistry[pullRequest] = await getClosedIssuesAsync(pullRequest);
104      }
105      const closedIssues = closedIssuesRegistry[pullRequest];
106
107      // Visit all issues that have been closed by this pull request.
108      for (const issue of closedIssues) {
109        if (!commentRows[issue]) {
110          commentRows[issue] = [];
111        }
112
113        // Check if the row for the package already exists. If it does, then just add
114        // another pull request reference into that row instead of creating a new one.
115        // This is to prevent duplicating packages within the comment's body.
116        const existingRowForPackage = commentRows[issue].find((entry) => entry.pkg === pkg);
117
118        if (existingRowForPackage) {
119          existingRowForPackage.pullRequests.push(pullRequest);
120        } else {
121          commentRows[issue].push({
122            pkg,
123            version: state.releaseVersion!,
124            pullRequests: [pullRequest],
125          });
126        }
127      }
128    }
129  }
130
131  return Object.entries(commentRows).map(([issue, entries]) => {
132    return {
133      issue: +issue,
134      body: generateCommentBody(entries, tag),
135    };
136  });
137}
138
139/**
140 * Logs a list of closed issues. We use it as a fallback in several places, so it's extracted.
141 */
142function logManualFallback(payload: CommentatorPayload): void {
143  logger.log(
144    'If necessary, you can still do this manually on the following issues: %s',
145    linksToClosedIssues(payload.map(({ issue }) => issue))
146  );
147}
148
149/**
150 * Returns a string with concatenated links to all given issues.
151 */
152function linksToClosedIssues(issues: number[]): string {
153  return issues
154    .map((issue) => link(chalk.blue('#' + issue), `https://github.com/expo/expo/issues/${issue}`))
155    .join(', ');
156}
157
158/**
159 * Generates comment body based on given entries.
160 */
161function generateCommentBody(entries: CommentRowObject[], tag: string): string {
162  const rows = entries.map(({ pkg, version, pullRequests }) => {
163    const items = [
164      linkToNpmPackage(pkg.packageName, version),
165      version,
166      pullRequests.map((pr) => '#' + pr).join(', '),
167      linkToChangelog(pkg),
168    ];
169    return `| ${items.join(' | ')} |`;
170  });
171
172  return `<!-- Generated by \`expotools publish\` -->
173Some changes in the following packages that may fix this issue have just been published to npm under \`${tag}\` tag ��
174
175| �� Package | �� Version | ↖️ Pull requests | �� Release notes |
176|:--:|:--:|:--:|:--:|
177${rows.join('\n')}
178
179If you're using bare workflow you can upgrade them right away. We kindly ask you for some feedback—even if it works ��
180
181They will become available in managed workflow with the next SDK release ��
182
183Happy Coding! ��`;
184}
185
186/**
187 * Returns markdown link to the package on npm.
188 */
189function linkToNpmPackage(packageName: string, version: string): string {
190  return `[${packageName}](https://www.npmjs.com/package/${packageName}/v/${version})`;
191}
192
193/**
194 * Returns markdown link to package's changelog.
195 */
196function linkToChangelog(pkg: Package): string {
197  const changelogRelativePath = path.relative(EXPO_DIR, pkg.changelogPath);
198  return `[CHANGELOG.md](https://github.com/expo/expo/blob/main/${changelogRelativePath})`;
199}
200