mirror of
https://github.com/NixOS/nixpkgs.git
synced 2026-10-02 13:00:23 +00:00
398 lines
13 KiB
TypeScript
398 lines
13 KiB
TypeScript
import { execFileSync } from 'node:child_process'
|
|
import type * as actionsCore from '@actions/core'
|
|
import type { context as actionsContext } from '@actions/github'
|
|
import type { GitHub } from '@actions/github/lib/utils'
|
|
import { dismissReviews, postReview } from './reviews.ts'
|
|
import { classify } from './supportedBranches.ts'
|
|
import withRateLimit from './withRateLimit.js'
|
|
|
|
const dirname = import.meta.dirname
|
|
|
|
type PullRequestCommit = Awaited<
|
|
ReturnType<InstanceType<typeof GitHub>['rest']['pulls']['listCommits']>
|
|
>['data'][number]
|
|
|
|
type CommitDetails = Pick<PullRequestCommit, 'sha' | 'commit'>
|
|
|
|
type CherryPick = CommitDetails & {
|
|
original_sha: string
|
|
severity?: undefined
|
|
}
|
|
|
|
type CheckResult = CommitDetails & {
|
|
diff?: string[]
|
|
colored_diff?: string
|
|
severity: 'important' | 'warning' | 'error' | 'info'
|
|
message: string
|
|
type?: 'no-cherry-pick' | 'no-commit-hash' | 'diff'
|
|
}
|
|
|
|
type ExtractedCommit = CheckResult | CherryPick
|
|
|
|
type CheckCommitsProps = {
|
|
github: InstanceType<typeof GitHub>
|
|
context: typeof actionsContext
|
|
core: typeof actionsCore
|
|
dry: boolean
|
|
cherryPicks: boolean
|
|
}
|
|
|
|
type CommitBranches = {
|
|
branches: {
|
|
branch: string
|
|
}[]
|
|
}
|
|
|
|
type RateLimitStats = {
|
|
prs: number
|
|
}
|
|
|
|
function isCherryPick(result: ExtractedCommit): result is CherryPick {
|
|
return !result.severity
|
|
}
|
|
|
|
function isIgnoredBranchError(error: unknown) {
|
|
return (
|
|
typeof error === 'object' &&
|
|
error !== null &&
|
|
'status' in error &&
|
|
(error.status === 404 || error.status === 500)
|
|
)
|
|
}
|
|
|
|
export default async ({
|
|
github,
|
|
context,
|
|
core,
|
|
dry,
|
|
cherryPicks,
|
|
}: CheckCommitsProps) => {
|
|
const reviewKey = 'check-commits'
|
|
|
|
await withRateLimit({ github, core }, async (stats: RateLimitStats) => {
|
|
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,
|
|
}: CommitDetails): Promise<ExtractedCommit> {
|
|
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: string[] | undefined
|
|
try {
|
|
branches = (
|
|
await github.request<CommitBranches>({
|
|
// 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 (!isIgnoredBranchError(e)) 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 }: CherryPick): CheckResult {
|
|
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(isCherryPick)
|
|
.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, core, dry, reviewKey })
|
|
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 (const 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,
|
|
event: 'REQUEST_CHANGES',
|
|
reviewKey,
|
|
})
|
|
})
|
|
}
|