nixpkgs mirror (for testing)
github.com/NixOS/nixpkgs
nix
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}