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