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