import chalk from 'chalk'; import path from 'path'; import { ChangelogEntry, UNPUBLISHED_VERSION_NAME } from '../../Changelogs'; import { EXPO_DIR } from '../../Constants'; import { link } from '../../Formatter'; import Git from '../../Git'; import { dispatchWorkflowEventAsync, getClosedIssuesAsync } from '../../GitHubActions'; import logger from '../../Logger'; import { Package } from '../../Packages'; import { Task } from '../../TasksRunner'; import { CommentatorPayload } from '../../commands/CommentatorCommand'; import { CommandOptions, Parcel, TaskArgs } from '../types'; import { selectPackagesToPublish } from './selectPackagesToPublish'; type CommentRowObject = { pkg: Package; version: string; pullRequests: number[]; }; /** * Dispatches GitHub Actions workflow that adds comments to the issues * that were closed by pull requests mentioned in the changelog changes. */ export const commentOnIssuesTask = new Task( { name: 'commentOnIssuesTask', dependsOn: [selectPackagesToPublish], backupable: true, }, async (parcels: Parcel[], options: CommandOptions) => { logger.info('\nπŸ™ Commenting on issues closed by published changes'); const payload = await generatePayloadForCommentatorAsync(parcels, options.tag); if (!payload.length) { logger.log('There are no closed issues to comment on\n'); return; } if (options.dry) { logger.debug('Skipping due to --dry flag'); logManualFallback(payload); return; } if (!process.env.GITHUB_TOKEN) { logger.error( 'Environment variable `%s` must be set to dispatch a commentator workflow', chalk.magenta('GITHUB_TOKEN') ); logManualFallback(payload); return; } const currentBranchName = await Git.getCurrentBranchNameAsync(); // Sometimes we publish from different branches (especially for testing) where comments are not advisable. if (currentBranchName !== 'main') { logger.warn('This feature is disabled on branches other than main'); logManualFallback(payload); return; } // Dispatch commentator workflow on GitHub Actions with stringified and escaped payload. await dispatchWorkflowEventAsync('commentator.yml', currentBranchName, { payload: JSON.stringify(payload).replace(/("|`)/g, '\\$1'), }); logger.success( 'Successfully dispatched commentator action for the following issues: %s', linksToClosedIssues(payload.map(({ issue }) => issue)) ); } ); /** * Generates payload for `expotools commentator` command. */ async function generatePayloadForCommentatorAsync( parcels: Parcel[], tag: string ): Promise { // An object whose key is the issue number and value is an array of rows to put in the comment's body. const commentRows: Record = {}; // An object whose key is the pull request number and value is an array of issues it closes. const closedIssuesRegistry: Record = {}; for (const { pkg, state, changelogChanges } of parcels) { const versionChanges = changelogChanges.versions[UNPUBLISHED_VERSION_NAME]; if (!versionChanges) { continue; } const allEntries = ([] as ChangelogEntry[]).concat(...Object.values(versionChanges)); const allPullRequests = new Set( ([] as number[]).concat(...allEntries.map((entry) => entry.pullRequests ?? [])) ); // Visit all pull requests mentioned in the changelog. for (const pullRequest of allPullRequests) { // Look for closed issues just once per pull request to reduce number of GitHub API calls. if (!closedIssuesRegistry[pullRequest]) { closedIssuesRegistry[pullRequest] = await getClosedIssuesAsync(pullRequest); } const closedIssues = closedIssuesRegistry[pullRequest]; // Visit all issues that have been closed by this pull request. for (const issue of closedIssues) { if (!commentRows[issue]) { commentRows[issue] = []; } // Check if the row for the package already exists. If it does, then just add // another pull request reference into that row instead of creating a new one. // This is to prevent duplicating packages within the comment's body. const existingRowForPackage = commentRows[issue].find((entry) => entry.pkg === pkg); if (existingRowForPackage) { existingRowForPackage.pullRequests.push(pullRequest); } else { commentRows[issue].push({ pkg, version: state.releaseVersion!, pullRequests: [pullRequest], }); } } } } return Object.entries(commentRows).map(([issue, entries]) => { return { issue: +issue, body: generateCommentBody(entries, tag), }; }); } /** * Logs a list of closed issues. We use it as a fallback in several places, so it's extracted. */ function logManualFallback(payload: CommentatorPayload): void { logger.log( 'If necessary, you can still do this manually on the following issues: %s', linksToClosedIssues(payload.map(({ issue }) => issue)) ); } /** * Returns a string with concatenated links to all given issues. */ function linksToClosedIssues(issues: number[]): string { return issues .map((issue) => link(chalk.blue('#' + issue), `https://github.com/expo/expo/issues/${issue}`)) .join(', '); } /** * Generates comment body based on given entries. */ function generateCommentBody(entries: CommentRowObject[], tag: string): string { const rows = entries.map(({ pkg, version, pullRequests }) => { const items = [ linkToNpmPackage(pkg.packageName, version), version, pullRequests.map((pr) => '#' + pr).join(', '), linkToChangelog(pkg), ]; return `| ${items.join(' | ')} |`; }); return ` Some changes in the following packages that may fix this issue have just been published to npm under \`${tag}\` tag πŸš€ | πŸ“¦ Package | πŸ”’ Version | ↖️ Pull requests | πŸ“ Release notes | |:--:|:--:|:--:|:--:| ${rows.join('\n')} If you're using bare workflow you can upgrade them right away. We kindly ask you for some feedbackβ€”even if it works πŸ™ They will become available in managed workflow with the next SDK release πŸ‘€ Happy Coding! πŸŽ‰`; } /** * Returns markdown link to the package on npm. */ function linkToNpmPackage(packageName: string, version: string): string { return `[${packageName}](https://www.npmjs.com/package/${packageName}/v/${version})`; } /** * Returns markdown link to package's changelog. */ function linkToChangelog(pkg: Package): string { const changelogRelativePath = path.relative(EXPO_DIR, pkg.changelogPath); return `[CHANGELOG.md](https://github.com/expo/expo/blob/main/${changelogRelativePath})`; }