summaryrefslogtreecommitdiff
path: root/ci/github-script/commits.js
blob: 91cfe00fa58c443c1cba38e2166d5fb0f7f55bcc (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
module.exports = async ({ github, context, core, dry, cherryPicks }) => {
  const { execFileSync } = require('node:child_process')
  const { classify } = require('../supportedBranches.js')
  const withRateLimit = require('./withRateLimit.js')
  const { dismissReviews, postReview } = require('./reviews.js')

  await withRateLimit({ github, core }, async (stats) => {
    stats.prs = 1

    const pull_number = context.payload.pull_request.number

    const job_url =
      context.runId &&
      (
        await github.paginate(github.rest.actions.listJobsForWorkflowRun, {
          ...context.repo,
          run_id: context.runId,
          per_page: 100,
        })
      ).find(({ name }) => name.endsWith('Check / commits')).html_url +
        '?pr=' +
        pull_number

    async function extract({ sha, commit }) {
      const noCherryPick = Array.from(
        commit.message.matchAll(/^Not-cherry-picked-because: (.*)$/gm),
      ).at(0)

      if (noCherryPick)
        return {
          sha,
          commit,
          severity: 'important',
          message: `${sha} is not a cherry-pick, because: ${noCherryPick[1]}. Please review this commit manually.`,
          type: 'no-cherry-pick',
        }

      // Using the last line with "cherry" + hash, because a chained backport
      // can result in multiple of those lines. Only the last one counts.
      const cherry = Array.from(
        commit.message.matchAll(/cherry.*([0-9a-f]{40})/g),
      ).at(-1)

      if (!cherry)
        return {
          sha,
          commit,
          severity: 'warning',
          message: `Couldn't locate the cherry-picked commit's hash in the commit message of ${sha}.`,
          type: 'no-commit-hash',
        }

      const original_sha = cherry[1]

      let branches
      try {
        branches = (
          await github.request({
            // This is an undocumented endpoint to fetch the branches a commit is part of.
            // There is no equivalent in neither the REST nor the GraphQL API.
            // The endpoint itself is unlikely to go away, because GitHub uses it to display
            // the list of branches on the detail page of a commit.
            url: `https://github.com/${context.repo.owner}/${context.repo.repo}/branch_commits/${original_sha}`,
            headers: {
              accept: 'application/json',
            },
          })
        ).data.branches
          .map(({ branch }) => branch)
          .filter((branch) => classify(branch).type.includes('development'))
      } catch (e) {
        // For some unknown reason a 404 error comes back as 500 without any more details in a GitHub Actions runner.
        // Ignore these to return a regular error message below.
        if (![404, 500].includes(e.status)) throw e
      }
      if (!branches?.length)
        return {
          sha,
          commit,
          severity: 'error',
          message: `${original_sha} given in ${sha} not found in any pickable branch.`,
        }

      return {
        sha,
        commit,
        original_sha,
      }
    }

    function diff({ sha, commit, original_sha }) {
      const diff = execFileSync('git', [
        '-C',
        __dirname,
        'range-diff',
        '--no-color',
        '--ignore-all-space',
        '--no-notes',
        // 100 means "any change will be reported"; 0 means "no change will be reported"
        '--creation-factor=100',
        `${original_sha}~..${original_sha}`,
        `${sha}~..${sha}`,
      ])
        .toString()
        .split('\n')
        // First line contains commit SHAs, which we'll print separately.
        .slice(1)
        // # The output of `git range-diff` is indented with 4 spaces, but we'll control indentation manually.
        .map((line) => line.replace(/^ {4}/, ''))

      if (!diff.some((line) => line.match(/^[+-]{2}/)))
        return {
          sha,
          commit,
          severity: 'info',
          message: `✔ ${original_sha} is highly similar to ${sha}.`,
        }

      const colored_diff = execFileSync('git', [
        '-C',
        __dirname,
        'range-diff',
        '--color',
        '--no-notes',
        '--creation-factor=100',
        `${original_sha}~..${original_sha}`,
        `${sha}~..${sha}`,
      ]).toString()

      return {
        sha,
        commit,
        diff,
        colored_diff,
        severity: 'warning',
        message: `Difference between ${sha} and original ${original_sha} may warrant inspection.`,
        type: 'diff',
      }
    }

    // For now we short-circuit the list of commits when cherryPicks should not be checked.
    // This will not run any checks, but still trigger the "dismiss reviews" part below.
    const commits = !cherryPicks
      ? []
      : await github.paginate(github.rest.pulls.listCommits, {
          ...context.repo,
          pull_number,
        })

    const extracted = await Promise.all(commits.map(extract))

    const fetch = extracted
      .filter(({ severity }) => !severity)
      .flatMap(({ sha, original_sha }) => [sha, original_sha])

    if (fetch.length > 0) {
      // Fetching all commits we need for diff at once is much faster than any other method.
      execFileSync('git', [
        '-C',
        __dirname,
        'fetch',
        '--depth=2',
        'origin',
        ...fetch,
      ])
    }

    const results = extracted.map((result) =>
      result.severity ? result : diff(result),
    )

    // Log all results without truncation, with better highlighting and all whitespace changes to the job log.
    results.forEach(({ sha, commit, severity, message, colored_diff }) => {
      core.startGroup(`Commit ${sha}`)
      core.info(`Author: ${commit.author.name} ${commit.author.email}`)
      core.info(`Date: ${new Date(commit.author.date)}`)
      switch (severity) {
        case 'error':
          core.error(message)
          break
        case 'warning':
          core.warning(message)
          break
        default:
          core.info(message)
      }
      core.endGroup()
      if (colored_diff) core.info(colored_diff)
    })

    // Only create step summary below in case of warnings or errors.
    // Also clean up older reviews, when all checks are good now.
    // An empty results array will always trigger this condition, which is helpful
    // to clean up reviews created by the prepare step when on the wrong branch.
    if (results.every(({ severity }) => severity === 'info')) {
      await dismissReviews({ github, context, dry })
      return
    }

    // In the case of "error" severity, we also fail the job.
    // Those should be considered blocking and not be dismissable via review.
    if (results.some(({ severity }) => severity === 'error'))
      process.exitCode = 1

    core.summary.addRaw(
      'This report is automatically generated by the `PR / Check / cherry-pick` CI workflow.',
      true,
    )
    core.summary.addEOL()
    core.summary.addRaw(
      "Some of the commits in this PR require the author's and reviewer's attention.",
      true,
    )
    core.summary.addEOL()

    if (results.some(({ type }) => type === 'no-commit-hash')) {
      core.summary.addRaw(
        '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.',
        true,
      )
      core.summary.addRaw(
        'This requires changes to the unstable `master` and `staging` branches first, before backporting them.',
        true,
      )
      core.summary.addEOL()
      core.summary.addRaw(
        '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.',
        true,
      )
      core.summary.addRaw(
        'These commits can optionally be marked with a `Not-cherry-picked-because: <reason>` footer.',
        true,
      )
      core.summary.addEOL()
    }

    if (results.some(({ type }) => type === 'diff')) {
      core.summary.addRaw(
        'Sometimes it is not possible to cherry-pick exactly the same patch.',
        true,
      )
      core.summary.addRaw(
        'This most frequently happens when resolving merge conflicts.',
        true,
      )
      core.summary.addRaw(
        'The range-diff will help to review the resolution of conflicts.',
        true,
      )
      core.summary.addEOL()
    }

    core.summary.addRaw(
      '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.',
      true,
    )

    results.forEach(({ severity, message, diff }) => {
      if (severity === 'info') return

      // The docs for markdown alerts only show examples with markdown blockquote syntax, like this:
      //   > [!WARNING]
      //   > message
      // However, our testing shows that this also works with a `<blockquote>` html tag, as long as there
      // is an empty line:
      //   <blockquote>
      //
      //   [!WARNING]
      //   message
      //   </blockquote>
      // Whether this is intended or just an implementation detail is unclear.
      core.summary.addRaw('<blockquote>')
      core.summary.addRaw(
        `\n\n[!${{ important: 'IMPORTANT', warning: 'WARNING', error: 'CAUTION' }[severity]}]`,
        true,
      )
      core.summary.addRaw(`${message}`, true)

      if (diff) {
        // Limit the output to 10k bytes and remove the last, potentially incomplete line, because GitHub
        // comments are limited in length. The value of 10k is arbitrary with the assumption, that after
        // the range-diff becomes a certain size, a reviewer is better off reviewing the regular diff in
        // GitHub's UI anyway, thus treating the commit as "new" and not cherry-picked.
        // Note: if multiple commits are close to the limit, this approach could still lead to a comment
        // that's too long. We think this is unlikely to happen, and so don't deal with it explicitly.
        const truncated = []
        let total_length = 0
        for (line of diff) {
          total_length += line.length
          if (total_length > 10000) {
            truncated.push('', '[...truncated...]')
            break
          } else {
            truncated.push(line)
          }
        }

        core.summary.addRaw('<details><summary>Show diff</summary>')
        core.summary.addRaw('\n\n``````````diff', true)
        core.summary.addRaw(truncated.join('\n'), true)
        core.summary.addRaw('``````````', true)
        core.summary.addRaw('</details>')
      }

      core.summary.addRaw('</blockquote>')
    })

    if (job_url)
      core.summary.addRaw(
        `\n\n_Hint: The full diffs are also available in the [runner logs](${job_url}) with slightly better highlighting._`,
      )

    const body = core.summary.stringify()
    core.summary.write()

    // Posting a review could fail for very long comments. This can only happen with
    // multiple commits all hitting the truncation limit for the diff. If you ever hit
    // this case, consider just splitting up those commits into multiple PRs.
    await postReview({ github, context, core, dry, body })
  })
}