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}