at master 11 kB view raw
1module.exports = async ({ github, context, core, dry, cherryPicks }) => { 2 const { execFileSync } = require('node:child_process') 3 const { classify } = require('../supportedBranches.js') 4 const withRateLimit = require('./withRateLimit.js') 5 const { dismissReviews, postReview } = require('./reviews.js') 6 7 await withRateLimit({ github, core }, async (stats) => { 8 stats.prs = 1 9 10 const pull_number = context.payload.pull_request.number 11 12 const job_url = 13 context.runId && 14 ( 15 await github.paginate(github.rest.actions.listJobsForWorkflowRun, { 16 ...context.repo, 17 run_id: context.runId, 18 per_page: 100, 19 }) 20 ).find(({ name }) => name.endsWith('Check / commits')).html_url + 21 '?pr=' + 22 pull_number 23 24 async function extract({ sha, commit }) { 25 const noCherryPick = Array.from( 26 commit.message.matchAll(/^Not-cherry-picked-because: (.*)$/gm), 27 ).at(0) 28 29 if (noCherryPick) 30 return { 31 sha, 32 commit, 33 severity: 'important', 34 message: `${sha} is not a cherry-pick, because: ${noCherryPick[1]}. Please review this commit manually.`, 35 type: 'no-cherry-pick', 36 } 37 38 // Using the last line with "cherry" + hash, because a chained backport 39 // can result in multiple of those lines. Only the last one counts. 40 const cherry = Array.from( 41 commit.message.matchAll(/cherry.*([0-9a-f]{40})/g), 42 ).at(-1) 43 44 if (!cherry) 45 return { 46 sha, 47 commit, 48 severity: 'warning', 49 message: `Couldn't locate original commit hash in message of ${sha}.`, 50 type: 'no-commit-hash', 51 } 52 53 const original_sha = cherry[1] 54 55 let branches 56 try { 57 branches = ( 58 await github.request({ 59 // This is an undocumented endpoint to fetch the branches a commit is part of. 60 // There is no equivalent in neither the REST nor the GraphQL API. 61 // The endpoint itself is unlikely to go away, because GitHub uses it to display 62 // the list of branches on the detail page of a commit. 63 url: `https://github.com/${context.repo.owner}/${context.repo.repo}/branch_commits/${original_sha}`, 64 headers: { 65 accept: 'application/json', 66 }, 67 }) 68 ).data.branches 69 .map(({ branch }) => branch) 70 .filter((branch) => classify(branch).type.includes('development')) 71 } catch (e) { 72 // For some unknown reason a 404 error comes back as 500 without any more details in a GitHub Actions runner. 73 // Ignore these to return a regular error message below. 74 if (![404, 500].includes(e.status)) throw e 75 } 76 if (!branches?.length) 77 return { 78 sha, 79 commit, 80 severity: 'error', 81 message: `${original_sha} given in ${sha} not found in any pickable branch.`, 82 } 83 84 return { 85 sha, 86 commit, 87 original_sha, 88 } 89 } 90 91 function diff({ sha, commit, original_sha }) { 92 const diff = execFileSync('git', [ 93 '-C', 94 __dirname, 95 'range-diff', 96 '--no-color', 97 '--ignore-all-space', 98 '--no-notes', 99 // 100 means "any change will be reported"; 0 means "no change will be reported" 100 '--creation-factor=100', 101 `${original_sha}~..${original_sha}`, 102 `${sha}~..${sha}`, 103 ]) 104 .toString() 105 .split('\n') 106 // First line contains commit SHAs, which we'll print separately. 107 .slice(1) 108 // # The output of `git range-diff` is indented with 4 spaces, but we'll control indentation manually. 109 .map((line) => line.replace(/^ {4}/, '')) 110 111 if (!diff.some((line) => line.match(/^[+-]{2}/))) 112 return { 113 sha, 114 commit, 115 severity: 'info', 116 message: `${original_sha} is highly similar to ${sha}.`, 117 } 118 119 const colored_diff = execFileSync('git', [ 120 '-C', 121 __dirname, 122 'range-diff', 123 '--color', 124 '--no-notes', 125 '--creation-factor=100', 126 `${original_sha}~..${original_sha}`, 127 `${sha}~..${sha}`, 128 ]).toString() 129 130 return { 131 sha, 132 commit, 133 diff, 134 colored_diff, 135 severity: 'warning', 136 message: `Difference between ${sha} and original ${original_sha} may warrant inspection.`, 137 type: 'diff', 138 } 139 } 140 141 // For now we short-circuit the list of commits when cherryPicks should not be checked. 142 // This will not run any checks, but still trigger the "dismiss reviews" part below. 143 const commits = !cherryPicks 144 ? [] 145 : await github.paginate(github.rest.pulls.listCommits, { 146 ...context.repo, 147 pull_number, 148 }) 149 150 const extracted = await Promise.all(commits.map(extract)) 151 152 const fetch = extracted 153 .filter(({ severity }) => !severity) 154 .flatMap(({ sha, original_sha }) => [sha, original_sha]) 155 156 if (fetch.length > 0) { 157 // Fetching all commits we need for diff at once is much faster than any other method. 158 execFileSync('git', [ 159 '-C', 160 __dirname, 161 'fetch', 162 '--depth=2', 163 'origin', 164 ...fetch, 165 ]) 166 } 167 168 const results = extracted.map((result) => 169 result.severity ? result : diff(result), 170 ) 171 172 // Log all results without truncation, with better highlighting and all whitespace changes to the job log. 173 results.forEach(({ sha, commit, severity, message, colored_diff }) => { 174 core.startGroup(`Commit ${sha}`) 175 core.info(`Author: ${commit.author.name} ${commit.author.email}`) 176 core.info(`Date: ${new Date(commit.author.date)}`) 177 switch (severity) { 178 case 'error': 179 core.error(message) 180 break 181 case 'warning': 182 core.warning(message) 183 break 184 default: 185 core.info(message) 186 } 187 core.endGroup() 188 if (colored_diff) core.info(colored_diff) 189 }) 190 191 // Only create step summary below in case of warnings or errors. 192 // Also clean up older reviews, when all checks are good now. 193 // An empty results array will always trigger this condition, which is helpful 194 // to clean up reviews created by the prepare step when on the wrong branch. 195 if (results.every(({ severity }) => severity === 'info')) { 196 await dismissReviews({ github, context, dry }) 197 return 198 } 199 200 // In the case of "error" severity, we also fail the job. 201 // Those should be considered blocking and not be dismissable via review. 202 if (results.some(({ severity }) => severity === 'error')) 203 process.exitCode = 1 204 205 core.summary.addRaw( 206 'This report is automatically generated by the `PR / Check / cherry-pick` CI workflow.', 207 true, 208 ) 209 core.summary.addEOL() 210 core.summary.addRaw( 211 "Some of the commits in this PR require the author's and reviewer's attention.", 212 true, 213 ) 214 core.summary.addEOL() 215 216 if (results.some(({ type }) => type === 'no-commit-hash')) { 217 core.summary.addRaw( 218 'Please follow the [backporting guidelines](https://github.com/NixOS/nixpkgs/blob/master/CONTRIBUTING.md#how-to-backport-pull-requests) and cherry-pick with the `-x` flag.', 219 true, 220 ) 221 core.summary.addRaw( 222 'This requires changes to the unstable `master` and `staging` branches first, before backporting them.', 223 true, 224 ) 225 core.summary.addEOL() 226 core.summary.addRaw( 227 'Occasionally, commits are not cherry-picked at all, for example when updating minor versions of packages which have already advanced to the next major on unstable.', 228 true, 229 ) 230 core.summary.addRaw( 231 'These commits can optionally be marked with a `Not-cherry-picked-because: <reason>` footer.', 232 true, 233 ) 234 core.summary.addEOL() 235 } 236 237 if (results.some(({ type }) => type === 'diff')) { 238 core.summary.addRaw( 239 'Sometimes it is not possible to cherry-pick exactly the same patch.', 240 true, 241 ) 242 core.summary.addRaw( 243 'This most frequently happens when resolving merge conflicts.', 244 true, 245 ) 246 core.summary.addRaw( 247 'The range-diff will help to review the resolution of conflicts.', 248 true, 249 ) 250 core.summary.addEOL() 251 } 252 253 core.summary.addRaw( 254 'If you need to merge this PR despite the warnings, please [dismiss](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/reviewing-changes-in-pull-requests/dismissing-a-pull-request-review) this review shortly before merging.', 255 true, 256 ) 257 258 results.forEach(({ severity, message, diff }) => { 259 if (severity === 'info') return 260 261 // The docs for markdown alerts only show examples with markdown blockquote syntax, like this: 262 // > [!WARNING] 263 // > message 264 // However, our testing shows that this also works with a `<blockquote>` html tag, as long as there 265 // is an empty line: 266 // <blockquote> 267 // 268 // [!WARNING] 269 // message 270 // </blockquote> 271 // Whether this is intended or just an implementation detail is unclear. 272 core.summary.addRaw('<blockquote>') 273 core.summary.addRaw( 274 `\n\n[!${{ important: 'IMPORTANT', warning: 'WARNING', error: 'CAUTION' }[severity]}]`, 275 true, 276 ) 277 core.summary.addRaw(`${message}`, true) 278 279 if (diff) { 280 // Limit the output to 10k bytes and remove the last, potentially incomplete line, because GitHub 281 // comments are limited in length. The value of 10k is arbitrary with the assumption, that after 282 // the range-diff becomes a certain size, a reviewer is better off reviewing the regular diff in 283 // GitHub's UI anyway, thus treating the commit as "new" and not cherry-picked. 284 // Note: if multiple commits are close to the limit, this approach could still lead to a comment 285 // that's too long. We think this is unlikely to happen, and so don't deal with it explicitly. 286 const truncated = [] 287 let total_length = 0 288 for (line of diff) { 289 total_length += line.length 290 if (total_length > 10000) { 291 truncated.push('', '[...truncated...]') 292 break 293 } else { 294 truncated.push(line) 295 } 296 } 297 298 core.summary.addRaw('<details><summary>Show diff</summary>') 299 core.summary.addRaw('\n\n``````````diff', true) 300 core.summary.addRaw(truncated.join('\n'), true) 301 core.summary.addRaw('``````````', true) 302 core.summary.addRaw('</details>') 303 } 304 305 core.summary.addRaw('</blockquote>') 306 }) 307 308 if (job_url) 309 core.summary.addRaw( 310 `\n\n_Hint: The full diffs are also available in the [runner logs](${job_url}) with slightly better highlighting._`, 311 ) 312 313 const body = core.summary.stringify() 314 core.summary.write() 315 316 // Posting a review could fail for very long comments. This can only happen with 317 // multiple commits all hitting the truncation limit for the diff. If you ever hit 318 // this case, consider just splitting up those commits into multiple PRs. 319 await postReview({ github, context, core, dry, body }) 320 }) 321}