Unverified Commit 99750f21 authored by Wolfgang Walther's avatar Wolfgang Walther Committed by GitHub
Browse files

ci/github-script/merge: various improvements (#457652)

parents 1774ef87 84d6678f
Loading
Loading
Loading
Loading
+4 −2
Original line number Diff line number Diff line
@@ -43,8 +43,10 @@ These issues effectively list PRs the merge bot has interacted with.
To ensure security and a focused utility, the bot adheres to specific limitations:

- The PR targets `master`, `staging`, or `staging-next`.
- The PR only touches files located under `pkgs/by-name/*`.
- The PR is authored by [@r-ryantm](https://nix-community.github.io/nixpkgs-update/r-ryantm/) or a [committer][@NixOS/nixpkgs-committers].
- The PR only touches packages located under `pkgs/by-name/*`.
- The PR is either:
  - authored by a [committer][@NixOS/nixpkgs-committers], or
  - created by [@r-ryantm](https://nix-community.github.io/nixpkgs-update/r-ryantm/).
- The user attempting to merge is a member of [@NixOS/nixpkgs-maintainers].
- The user attempting to merge is a maintainer of all packages touched by the PR.

+104 −57
Original line number Diff line number Diff line
// Caching the list of committers saves API requests when running the bot on the schedule and
// processing many PRs at once.
let committers

async function runChecklist({ github, context, pull_request, maintainers }) {
  const pull_number = pull_request.number

  if (!committers) {
    if (context.eventName === 'pull_request') {
      // We have no chance of getting a token in the pull_request context with the right
      // permissions to access the members endpoint below. Thus, we're pretending to have
      // no committers. This is OK; because this is only for the Test workflow, not for
      // real use.
      committers = new Set()
    } else {
      committers = github
        .paginate(github.rest.teams.listMembersInOrg, {
          org: context.repo.owner,
          team_slug: 'nixpkgs-committers',
          per_page: 100,
        })
        .then((members) => new Set(members.map(({ id }) => id)))
    }
  }

  const files = await github.paginate(github.rest.pulls.listFiles, {
    ...context.repo,
    pull_number,
    per_page: 100,
  })
function runChecklist({
  committers,
  files,
  pull_request,
  log,
  maintainers,
  user,
  userIsMaintainer,
}) {
  const allByName = files.every(({ filename }) =>
    filename.startsWith('pkgs/by-name/'),
  )

  const packages = files
    .filter(({ filename }) => filename.startsWith('pkgs/by-name/'))
@@ -45,19 +27,39 @@ async function runChecklist({ github, context, pull_request, maintainers }) {
      'staging',
      'staging-next',
    ].includes(pull_request.base.ref),
    'PR touches only files in `pkgs/by-name/`.': files.every(({ filename }) =>
      filename.startsWith('pkgs/by-name/'),
    ),
    'PR authored by r-ryantm or committer.':
      pull_request.user.login === 'r-ryantm' ||
      (await committers).has(pull_request.user.id),
    'PR has maintainers eligible for merge.': eligible.size > 0,
    'PR touches only packages in `pkgs/by-name/`.': allByName,
    'PR is at least one of:': {
      'Authored by a committer.': committers.has(pull_request.user.id),
      'Created by r-ryantm.': pull_request.user.login === 'r-ryantm',
    },
  }

  if (user) {
    checklist[
      `${user.login} is a member of [@NixOS/nixpkgs-maintainers](https://github.com/orgs/NixOS/teams/nixpkgs-maintainers).`
    ] = userIsMaintainer
    if (allByName) {
      // We can only determine the below, if all packages are in by-name, since
      // we can't reliably relate changed files to packages outside by-name.
      checklist[`${user.login} is a maintainer of all touched packages.`] =
        eligible.has(user.id)
    }
  } else {
    // This is only used when no user is passed, i.e. for labeling.
    checklist['PR has maintainers eligible to merge.'] = eligible.size > 0
  }

  const result = Object.values(checklist).every((v) =>
    typeof v === 'boolean' ? v : Object.values(v).some(Boolean),
  )

  log('checklist', JSON.stringify(checklist))
  log('eligible', JSON.stringify(Array.from(eligible)))
  log('result', result)

  return {
    checklist,
    eligible,
    result: Object.values(checklist).every(Boolean),
    result,
  }
}

@@ -86,6 +88,10 @@ async function handleMergeComment({ github, body, node_id, reaction }) {
  )
}

// Caching the list of team members saves API requests when running the bot on the schedule and
// processing many PRs at once.
const members = {}

async function handleMerge({
  github,
  context,
@@ -98,15 +104,34 @@ async function handleMerge({
}) {
  const pull_number = pull_request.number

  const { checklist, eligible, result } = await runChecklist({
    github,
    context,
    pull_request,
    maintainers,
  function getTeamMembers(team_slug) {
    if (context.eventName === 'pull_request') {
      // We have no chance of getting a token in the pull_request context with the right
      // permissions to access the members endpoint below. Thus, we're pretending to have
      // no members. This is OK; because this is only for the Test workflow, not for
      // real use.
      return new Set()
    }

    if (!members[team_slug]) {
      members[team_slug] = github
        .paginate(github.rest.teams.listMembersInOrg, {
          org: context.repo.owner,
          team_slug,
          per_page: 100,
        })
        .then((members) => new Set(members.map(({ id }) => id)))
    }

    return members[team_slug]
  }
  const committers = await getTeamMembers('nixpkgs-committers')

  const files = await github.paginate(github.rest.pulls.listFiles, {
    ...context.repo,
    pull_number,
    per_page: 100,
  })
  log('checklist', JSON.stringify(checklist))
  log('eligible', JSON.stringify(Array.from(eligible)))
  log('result', result)

  // Only look through comments *after* the latest (force) push.
  const latestChange = events.findLast(({ event }) =>
@@ -158,6 +183,7 @@ async function handleMerge({
      log('Auto Merge failed', e.response.errors[0].message)
    }

    // TODO: Observe whether the below is true and whether manual enqueue is actually needed.
    // Auto-merge doesn't work if the target branch has already run all CI, in which
    // case the PR must be enqueued explicitly.
    // We now have merge queues enabled on all development branches, thus don't need a
@@ -217,23 +243,36 @@ async function handleMerge({
      }
    }

    const canUseMergeBot = await isMaintainer(comment.user.login)
    const isEligible = eligible.has(comment.user.id)
    const canMerge = result && canUseMergeBot && isEligible
    const { result, checklist } = runChecklist({
      committers,
      files,
      pull_request,
      log,
      maintainers,
      user: comment.user,
      userIsMaintainer: await isMaintainer(comment.user.login),
    })

    const body = [
      `<!-- comment: ${comment.node_id} -->`,
      `@${comment.user.login} wants to merge this PR.`,
      '',
      'Requirements to merge this PR:',
      ...Object.entries(checklist).map(
        ([msg, res]) => `- :${res ? 'white_check_mark' : 'x'}: ${msg}`,
      'Requirements to merge this PR with `@NixOS/nixpkgs-merge-bot merge`:',
      ...Object.entries(checklist).flatMap(([msg, res]) =>
        typeof res === 'boolean'
          ? `- :${res ? 'white_check_mark' : 'x'}: ${msg}`
          : [
              `- :${Object.values(res).some(Boolean) ? 'white_check_mark' : 'x'}: ${msg}`,
              ...Object.entries(res).map(
                ([msg, res]) =>
                  `  - ${res ? ':white_check_mark:' : ':white_large_square:'} ${msg}`,
              ),
            ],
      ),
      `- :${canUseMergeBot ? 'white_check_mark' : 'x'}: ${comment.user.login} can use the merge bot.`,
      `- :${isEligible ? 'white_check_mark' : 'x'}: ${comment.user.login} is eligible to merge changes to the touched packages.`,
      '',
    ]

    if (canMerge) {
    if (result) {
      await react('ROCKET')
      try {
        body.push(`:heavy_check_mark: ${await merge()} (#306934)`)
@@ -257,9 +296,17 @@ async function handleMerge({
      })
    }

    if (canMerge) break
    if (result) break
  }

  const { result } = runChecklist({
    committers,
    files,
    pull_request,
    log,
    maintainers,
  })

  // Returns a boolean, which indicates whether the PR is merge-bot eligible in principle.
  // This is used to set the respective label in bot.js.
  return result