mirror of
https://github.com/NixOS/nixpkgs.git
synced 2025-11-09 16:18:34 +01:00
The filtered checkout we used before was a nice idea, but only worked for small numbers of commits in a PR. It would fetch the whole history initially, but only fetch objects on demand. This turns out to be much too slow in a PR with 18 commits, regularly hitting the 10 minute timeout, even when running it locally. The new approach uses regular checkouts again. In contrast to the old style, before we switched to the filtered checkout, this only fetches exactly the commits we need - and all of them at once. This is significantly faster than both other approaches, and scales much better. A bigger number of commits doesn't have much of an effect, if any at all.
309 lines
10 KiB
JavaScript
309 lines
10 KiB
JavaScript
module.exports = async function ({ github, context, core, dry }) {
|
|
const { execFileSync } = require('node:child_process')
|
|
const { readFile } = require('node:fs/promises')
|
|
const { join } = require('node:path')
|
|
const { classify } = require('../supportedBranches.js')
|
|
const withRateLimit = require('./withRateLimit.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 == 'Check / cherry-pick').html_url +
|
|
'?pr=' +
|
|
pull_number
|
|
|
|
async function extract({ sha, commit }) {
|
|
// Using the last line with "cherry" + hash, because a chained backport
|
|
// can result in multiple of those lines. Only the last one counts.
|
|
const match = Array.from(
|
|
commit.message.matchAll(/cherry.*([0-9a-f]{40})/g),
|
|
).at(-1)
|
|
|
|
if (!match)
|
|
return {
|
|
sha,
|
|
commit,
|
|
severity: 'warning',
|
|
message: `Couldn't locate original commit hash in message of ${sha}.`,
|
|
}
|
|
|
|
const original_sha = match[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.`,
|
|
}
|
|
}
|
|
|
|
const commits = 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)
|
|
.map(({ sha, original_sha }) => [ sha, original_sha ])
|
|
.flat()
|
|
|
|
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)}`)
|
|
core[severity](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.
|
|
if (results.every(({ severity }) => severity == 'info')) {
|
|
if (!dry) {
|
|
await Promise.all(
|
|
(
|
|
await github.paginate(github.rest.pulls.listReviews, {
|
|
...context.repo,
|
|
pull_number,
|
|
})
|
|
)
|
|
.filter((review) => review.user.login == 'github-actions[bot]')
|
|
.map(async (review) => {
|
|
if (review.state == 'CHANGES_REQUESTED') {
|
|
await github.rest.pulls.dismissReview({
|
|
...context.repo,
|
|
pull_number,
|
|
review_id: review.id,
|
|
message: 'All cherry-picks are good now, thank you!',
|
|
})
|
|
}
|
|
await github.graphql(
|
|
`mutation($node_id:ID!) {
|
|
minimizeComment(input: {
|
|
classifier: RESOLVED,
|
|
subjectId: $node_id
|
|
})
|
|
{ clientMutationId }
|
|
}`,
|
|
{ node_id: review.node_id },
|
|
)
|
|
}),
|
|
)
|
|
}
|
|
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(
|
|
await readFile(join(__dirname, 'check-cherry-picks.md'), 'utf-8'),
|
|
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[!${severity == 'warning' ? 'WARNING' : 'CAUTION'}]`,
|
|
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()
|
|
|
|
const pendingReview = (
|
|
await github.paginate(github.rest.pulls.listReviews, {
|
|
...context.repo,
|
|
pull_number,
|
|
})
|
|
).find(
|
|
(review) =>
|
|
review.user.login == 'github-actions[bot]' &&
|
|
// If a review is still pending, we can just update this instead
|
|
// of posting a new one.
|
|
(review.state == 'CHANGES_REQUESTED' ||
|
|
// No need to post a new review, if an older one with the exact
|
|
// same content had already been dismissed.
|
|
review.body == body),
|
|
)
|
|
|
|
if (dry) {
|
|
if (pendingReview)
|
|
core.info('pending review found: ' + pendingReview.html_url)
|
|
else core.info('no pending review found')
|
|
} else {
|
|
// Either of those two requests 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.
|
|
if (pendingReview) {
|
|
await github.rest.pulls.updateReview({
|
|
...context.repo,
|
|
pull_number,
|
|
review_id: pendingReview.id,
|
|
body,
|
|
})
|
|
} else {
|
|
await github.rest.pulls.createReview({
|
|
...context.repo,
|
|
pull_number,
|
|
event: 'REQUEST_CHANGES',
|
|
body,
|
|
})
|
|
}
|
|
}
|
|
})
|
|
}
|