ci/github-script/prepare: identify real base branch (#435596)
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
module.exports = async ({ github, context, core, dry }) => {
|
||||
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
|
||||
@@ -16,7 +17,7 @@ module.exports = async ({ github, context, core, dry }) => {
|
||||
run_id: context.runId,
|
||||
per_page: 100,
|
||||
})
|
||||
).find(({ name }) => name.endsWith('Check / cherry-pick')).html_url +
|
||||
).find(({ name }) => name.endsWith('Check / commits')).html_url +
|
||||
'?pr=' +
|
||||
pull_number
|
||||
|
||||
@@ -137,10 +138,14 @@ module.exports = async ({ github, context, core, dry }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const commits = await github.paginate(github.rest.pulls.listCommits, {
|
||||
...context.repo,
|
||||
pull_number,
|
||||
})
|
||||
// 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))
|
||||
|
||||
@@ -185,38 +190,10 @@ module.exports = async ({ github, context, core, dry }) => {
|
||||
|
||||
// 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')) {
|
||||
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 },
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
await dismissReviews({ github, context, dry })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -336,45 +313,9 @@ module.exports = async ({ github, context, core, dry }) => {
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
// 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 })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const { classify } = require('../supportedBranches.js')
|
||||
const { postReview } = require('./reviews.js')
|
||||
|
||||
module.exports = async ({ github, context, core }) => {
|
||||
module.exports = async ({ github, context, core, dry }) => {
|
||||
const pull_number = context.payload.pull_request.number
|
||||
|
||||
for (const retryInterval of [5, 10, 20, 40, 80]) {
|
||||
@@ -24,6 +25,160 @@ module.exports = async ({ github, context, core }) => {
|
||||
|
||||
const { base, head } = prInfo
|
||||
|
||||
const baseClassification = classify(base.ref)
|
||||
core.setOutput('base', baseClassification)
|
||||
console.log('base classification:', baseClassification)
|
||||
|
||||
const headClassification =
|
||||
base.repo.full_name === head.repo.full_name
|
||||
? classify(head.ref)
|
||||
: // PRs from forks are always considered WIP.
|
||||
{ type: ['wip'] }
|
||||
core.setOutput('head', headClassification)
|
||||
console.log('head classification:', headClassification)
|
||||
|
||||
if (baseClassification.type.includes('channel')) {
|
||||
const { stable, version } = baseClassification
|
||||
const correctBranch = stable ? `release-${version}` : 'master'
|
||||
const body = [
|
||||
'The `nixos-*` and `nixpkgs-*` branches are pushed to by the channel release script and should not be merged into directly.',
|
||||
'',
|
||||
`Please target \`${correctBranch}\` instead.`,
|
||||
].join('\n')
|
||||
|
||||
await postReview({ github, context, core, dry, body })
|
||||
|
||||
throw new Error('The PR targets a channel branch.')
|
||||
}
|
||||
|
||||
if (headClassification.type.includes('wip')) {
|
||||
// In the following, we look at the git history to determine the base branch that
|
||||
// this Pull Request branched off of. This is *supposed* to be the branch that it
|
||||
// merges into, but humans make mistakes. Once that happens we want to error out as
|
||||
// early as possible.
|
||||
|
||||
// To determine the "real base", we are looking at the merge-base of primary development
|
||||
// branches and the head of the PR. The merge-base which results in the least number of
|
||||
// commits between that base and head is the real base. We can query for this via GitHub's
|
||||
// REST API. There can be multiple candidates for the real base with the same number of
|
||||
// commits. In this case we pick the "best" candidate by a fixed ordering of branches,
|
||||
// as defined in ci/supportedBranches.js.
|
||||
//
|
||||
// These requests take a while, when comparing against the wrong release - they need
|
||||
// to look at way more than 10k commits in that case. Thus, we try to minimize the
|
||||
// number of requests across releases:
|
||||
// - First, we look at the primary development branches only: master and release-xx.yy.
|
||||
// The branch with the fewest commits gives us the release this PR belongs to.
|
||||
// - We then compare this number against the relevant staging branches for this release
|
||||
// to find the exact branch that this belongs to.
|
||||
|
||||
// All potential development branches
|
||||
const branches = (
|
||||
await github.paginate(github.rest.repos.listBranches, {
|
||||
...context.repo,
|
||||
per_page: 100,
|
||||
})
|
||||
).map(({ name }) => classify(name))
|
||||
|
||||
// All stable primary development branches from latest to oldest.
|
||||
const releases = branches
|
||||
.filter(({ stable, type }) => type.includes('primary') && stable)
|
||||
.sort((a, b) => b.version.localeCompare(a.version))
|
||||
|
||||
async function mergeBase({ branch, order, version }) {
|
||||
const { data } = await github.rest.repos.compareCommitsWithBasehead({
|
||||
...context.repo,
|
||||
basehead: `${branch}...${head.sha}`,
|
||||
// Pagination for this endpoint is about the commits listed, which we don't care about.
|
||||
per_page: 1,
|
||||
// Taking the second page skips the list of files of this changeset.
|
||||
page: 2,
|
||||
})
|
||||
return {
|
||||
branch,
|
||||
order,
|
||||
version,
|
||||
commits: data.total_commits,
|
||||
sha: data.merge_base_commit.sha,
|
||||
}
|
||||
}
|
||||
|
||||
// Multiple branches can be OK at the same time, if the PR was created of a merge-base,
|
||||
// thus storing as array.
|
||||
let candidates = [await mergeBase(classify('master'))]
|
||||
for (const release of releases) {
|
||||
const nextCandidate = await mergeBase(release)
|
||||
if (candidates[0].commits === nextCandidate.commits)
|
||||
candidates.push(nextCandidate)
|
||||
if (candidates[0].commits > nextCandidate.commits)
|
||||
candidates = [nextCandidate]
|
||||
// The number 10000 is principally arbitrary, but the GitHub API returns this value
|
||||
// when the number of commits exceeds it in reality. The difference between two stable releases
|
||||
// is certainly more than 10k commits, thus this works for us as well: If we're targeting
|
||||
// a wrong release, the number *will* be 10000.
|
||||
if (candidates[0].commits < 10000) break
|
||||
}
|
||||
|
||||
core.info(`This PR is for NixOS ${candidates[0].version}.`)
|
||||
|
||||
// Secondary development branches for the selected version only.
|
||||
const secondary = branches.filter(
|
||||
({ branch, type, version }) =>
|
||||
type.includes('secondary') && version === candidates[0].version,
|
||||
)
|
||||
|
||||
// Make sure that we always check the current target as well, even if its a WIP branch.
|
||||
// If it's not a WIP branch, it was already included in either releases or secondary.
|
||||
if (classify(base.ref).type.includes('wip')) {
|
||||
secondary.push(classify(base.ref))
|
||||
}
|
||||
|
||||
for (const branch of secondary) {
|
||||
const nextCandidate = await mergeBase(branch)
|
||||
if (candidates[0].commits === nextCandidate.commits)
|
||||
candidates.push(nextCandidate)
|
||||
if (candidates[0].commits > nextCandidate.commits)
|
||||
candidates = [nextCandidate]
|
||||
}
|
||||
|
||||
// If the current branch is among the candidates, this is always better than any other,
|
||||
// thus sorting at -1.
|
||||
candidates = candidates
|
||||
.map((candidate) =>
|
||||
candidate.branch === base.ref
|
||||
? { ...candidate, order: -1 }
|
||||
: candidate,
|
||||
)
|
||||
.sort((a, b) => a.order - b.order)
|
||||
|
||||
const best = candidates.at(0)
|
||||
|
||||
core.info('The base branches for this PR are:')
|
||||
core.info(`github: ${base.ref}`)
|
||||
core.info(
|
||||
`candidates: ${candidates.map(({ branch }) => branch).join(',')}`,
|
||||
)
|
||||
core.info(`best candidate: ${best.branch}`)
|
||||
|
||||
if (best.branch !== base.ref) {
|
||||
const current = await mergeBase(classify(base.ref))
|
||||
const body = [
|
||||
`The PR's base branch is set to \`${current.branch}\`, but ${current.commits === 10000 ? 'at least 10000' : current.commits - best.commits} commits from the \`${best.branch}\` branch are included. Make sure you know the [right base branch for your changes](https://github.com/NixOS/nixpkgs/blob/master/CONTRIBUTING.md#branch-conventions), then:`,
|
||||
`- If the changes should go to the \`${best.branch}\` branch, [change the base branch](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/changing-the-base-branch-of-a-pull-request).`,
|
||||
`- If the changes should go to the \`${current.branch}\` branch, rebase your PR onto the correct merge-base:`,
|
||||
' ```bash',
|
||||
` # git rebase --onto $(git merge-base upstream/${current.branch} HEAD) $(git merge-base upstream/${best.branch} HEAD)`,
|
||||
` git rebase --onto ${current.sha} ${best.sha}`,
|
||||
` git push --force-with-lease`,
|
||||
' ```',
|
||||
].join('\n')
|
||||
|
||||
await postReview({ github, context, core, dry, body })
|
||||
|
||||
throw new Error(`The PR contains commits from a different base.`)
|
||||
}
|
||||
}
|
||||
|
||||
let mergedSha, targetSha
|
||||
|
||||
if (prInfo.mergeable) {
|
||||
@@ -39,7 +194,7 @@ module.exports = async ({ github, context, core }) => {
|
||||
} else {
|
||||
core.warning('The PR has a merge conflict.')
|
||||
|
||||
mergedSha = prInfo.head.sha
|
||||
mergedSha = head.sha
|
||||
targetSha = (
|
||||
await github.rest.repos.compareCommitsWithBasehead({
|
||||
...context.repo,
|
||||
@@ -56,18 +211,6 @@ module.exports = async ({ github, context, core }) => {
|
||||
|
||||
core.setOutput('systems', require('../supportedSystems.json'))
|
||||
|
||||
const baseClassification = classify(base.ref)
|
||||
core.setOutput('base', baseClassification)
|
||||
console.log('base classification:', baseClassification)
|
||||
|
||||
const headClassification =
|
||||
base.repo.full_name === head.repo.full_name
|
||||
? classify(head.ref)
|
||||
: // PRs from forks are always considered WIP.
|
||||
{ type: ['wip'] }
|
||||
core.setOutput('head', headClassification)
|
||||
console.log('head classification:', headClassification)
|
||||
|
||||
const files = (
|
||||
await github.paginate(github.rest.pulls.listFiles, {
|
||||
...context.repo,
|
||||
|
||||
85
ci/github-script/reviews.js
Normal file
85
ci/github-script/reviews.js
Normal file
@@ -0,0 +1,85 @@
|
||||
async function dismissReviews({ github, context, dry }) {
|
||||
const pull_number = context.payload.pull_request.number
|
||||
|
||||
if (dry) {
|
||||
return
|
||||
}
|
||||
|
||||
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 good now, thank you!',
|
||||
})
|
||||
}
|
||||
await github.graphql(
|
||||
`mutation($node_id:ID!) {
|
||||
minimizeComment(input: {
|
||||
classifier: RESOLVED,
|
||||
subjectId: $node_id
|
||||
})
|
||||
{ clientMutationId }
|
||||
}`,
|
||||
{ node_id: review.node_id },
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function postReview({ github, context, core, dry, body }) {
|
||||
const pull_number = context.payload.pull_request.number
|
||||
|
||||
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')
|
||||
core.info(body)
|
||||
} else {
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
dismissReviews,
|
||||
postReview,
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { program } from 'commander'
|
||||
import * as core from '@actions/core'
|
||||
import { getOctokit } from '@actions/github'
|
||||
|
||||
async function run(action, owner, repo, pull_number, dry = true) {
|
||||
async function run(action, owner, repo, pull_number, options = {}) {
|
||||
const token = execSync('gh auth token', { encoding: 'utf-8' }).trim()
|
||||
|
||||
const github = getOctokit(token)
|
||||
@@ -35,7 +35,8 @@ async function run(action, owner, repo, pull_number, dry = true) {
|
||||
},
|
||||
},
|
||||
core,
|
||||
dry,
|
||||
dry: true,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -45,9 +46,10 @@ program
|
||||
.argument('<owner>', 'Owner of the GitHub repository to check (Example: NixOS)')
|
||||
.argument('<repo>', 'Name of the GitHub repository to check (Example: nixpkgs)')
|
||||
.argument('<pr>', 'Number of the Pull Request to check')
|
||||
.action(async (owner, repo, pr) => {
|
||||
.option('--no-dry', 'Make actual modifications')
|
||||
.action(async (owner, repo, pr, options) => {
|
||||
const prepare = (await import('./prepare.js')).default
|
||||
run(prepare, owner, repo, pr)
|
||||
run(prepare, owner, repo, pr, options)
|
||||
})
|
||||
|
||||
program
|
||||
@@ -56,9 +58,10 @@ program
|
||||
.argument('<owner>', 'Owner of the GitHub repository to check (Example: NixOS)')
|
||||
.argument('<repo>', 'Name of the GitHub repository to check (Example: nixpkgs)')
|
||||
.argument('<pr>', 'Number of the Pull Request to check')
|
||||
.action(async (owner, repo, pr) => {
|
||||
.option('--no-cherry-picks', 'Do not expect cherry-picks.')
|
||||
.action(async (owner, repo, pr, options) => {
|
||||
const commits = (await import('./commits.js')).default
|
||||
run(commits, owner, repo, pr)
|
||||
run(commits, owner, repo, pr, options)
|
||||
})
|
||||
|
||||
program
|
||||
@@ -74,7 +77,7 @@ program
|
||||
try {
|
||||
process.env.GITHUB_WORKSPACE = tmp
|
||||
process.chdir(tmp)
|
||||
run(labels, owner, repo, pr, options.dry)
|
||||
run(labels, owner, repo, pr, options)
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user