workflows/bot: migrate nixpkgs-merge-bot to GHA (#451168)
This commit is contained in:
2
.github/labeler-development-branches.yml
vendored
2
.github/labeler-development-branches.yml
vendored
@@ -1,4 +1,4 @@
|
|||||||
# This file is used by .github/workflows/labels.yml
|
# This file is used by .github/workflows/bot.yml
|
||||||
# This version is only run for Pull Requests from development branches like staging-next, haskell-updates or python-updates.
|
# This version is only run for Pull Requests from development branches like staging-next, haskell-updates or python-updates.
|
||||||
|
|
||||||
"4.workflow: package set update":
|
"4.workflow: package set update":
|
||||||
|
|||||||
2
.github/labeler-no-sync.yml
vendored
2
.github/labeler-no-sync.yml
vendored
@@ -1,4 +1,4 @@
|
|||||||
# This file is used by .github/workflows/labels.yml
|
# This file is used by .github/workflows/bot.yml
|
||||||
# This version uses `sync-labels: false`, meaning that a non-match will NOT remove the label
|
# This version uses `sync-labels: false`, meaning that a non-match will NOT remove the label
|
||||||
|
|
||||||
# keep-sorted start case=no numeric=yes newline_separated=yes skip_lines=1
|
# keep-sorted start case=no numeric=yes newline_separated=yes skip_lines=1
|
||||||
|
|||||||
2
.github/labeler.yml
vendored
2
.github/labeler.yml
vendored
@@ -1,4 +1,4 @@
|
|||||||
# This file is used by .github/workflows/labels.yml
|
# This file is used by .github/workflows/bot.yml
|
||||||
# This version uses `sync-labels: true`, meaning that a non-match will remove the label
|
# This version uses `sync-labels: true`, meaning that a non-match will remove the label
|
||||||
|
|
||||||
# keep-sorted start case=no numeric=yes newline_separated=yes skip_lines=1
|
# keep-sorted start case=no numeric=yes newline_separated=yes skip_lines=1
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# access to the GitHub API. This means that it should not evaluate user input in
|
# access to the GitHub API. This means that it should not evaluate user input in
|
||||||
# a way that allows code injection.
|
# a way that allows code injection.
|
||||||
|
|
||||||
name: Labels
|
name: Bot
|
||||||
|
|
||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
@@ -21,7 +21,7 @@ on:
|
|||||||
concurrency:
|
concurrency:
|
||||||
# This explicitly avoids using `run_id` for the concurrency key to make sure that only
|
# This explicitly avoids using `run_id` for the concurrency key to make sure that only
|
||||||
# *one* scheduled run can run at a time.
|
# *one* scheduled run can run at a time.
|
||||||
group: labels-${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number }}
|
group: bot-${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number }}
|
||||||
# PR-triggered runs will be cancelled, but scheduled runs will be queued.
|
# PR-triggered runs will be cancelled, but scheduled runs will be queued.
|
||||||
cancel-in-progress: ${{ github.event_name != 'schedule' }}
|
cancel-in-progress: ${{ github.event_name != 'schedule' }}
|
||||||
|
|
||||||
@@ -36,9 +36,13 @@ defaults:
|
|||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
update:
|
run:
|
||||||
runs-on: ubuntu-24.04-arm
|
runs-on: ubuntu-24.04-arm
|
||||||
if: github.event_name != 'schedule' || github.repository_owner == 'NixOS'
|
if: github.event_name != 'schedule' || github.repository_owner == 'NixOS'
|
||||||
|
env:
|
||||||
|
# TODO: Remove after 2026-03-04, when Node 24 becomes the default.
|
||||||
|
# https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
with:
|
with:
|
||||||
@@ -56,6 +60,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
app-id: ${{ vars.NIXPKGS_CI_APP_ID }}
|
app-id: ${{ vars.NIXPKGS_CI_APP_ID }}
|
||||||
private-key: ${{ secrets.NIXPKGS_CI_APP_PRIVATE_KEY }}
|
private-key: ${{ secrets.NIXPKGS_CI_APP_PRIVATE_KEY }}
|
||||||
|
permission-contents: write
|
||||||
permission-issues: write
|
permission-issues: write
|
||||||
permission-pull-requests: write
|
permission-pull-requests: write
|
||||||
|
|
||||||
@@ -64,13 +69,13 @@ jobs:
|
|||||||
GH_TOKEN: ${{ steps.app-token.outputs.token || github.token }}
|
GH_TOKEN: ${{ steps.app-token.outputs.token || github.token }}
|
||||||
run: gh api /rate_limit | jq
|
run: gh api /rate_limit | jq
|
||||||
|
|
||||||
- name: Labels from API data and Eval results
|
- name: Run bot
|
||||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||||
with:
|
with:
|
||||||
github-token: ${{ steps.app-token.outputs.token || github.token }}
|
github-token: ${{ steps.app-token.outputs.token || github.token }}
|
||||||
retries: 3
|
retries: 3
|
||||||
script: |
|
script: |
|
||||||
require('./ci/github-script/labels.js')({
|
require('./ci/github-script/bot.js')({
|
||||||
github,
|
github,
|
||||||
context,
|
context,
|
||||||
core,
|
core,
|
||||||
54
.github/workflows/comment.yml
vendored
Normal file
54
.github/workflows/comment.yml
vendored
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
name: Comment
|
||||||
|
|
||||||
|
on:
|
||||||
|
issue_comment:
|
||||||
|
types: [created]
|
||||||
|
|
||||||
|
# This is used as fallback without app only.
|
||||||
|
# This happens when testing in forks without setting up that app.
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# The `bot` workflow reacts to comments with @NixOS/nixpkgs-merge-bot references, but might only
|
||||||
|
# pick up a comment after up to 10 minutes. To give the user instant feedback, this job adds
|
||||||
|
# a reaction to these comments.
|
||||||
|
react:
|
||||||
|
name: React with eyes
|
||||||
|
runs-on: ubuntu-24.04-arm
|
||||||
|
timeout-minutes: 2
|
||||||
|
if: contains(github.event.comment.body, '@NixOS/nixpkgs-merge-bot merge')
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
sparse-checkout: |
|
||||||
|
ci/github-script
|
||||||
|
|
||||||
|
# Use the GitHub App to make sure the reaction happens with the same user who will later merge.
|
||||||
|
- uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
||||||
|
if: github.event_name != 'pull_request' && vars.NIXPKGS_CI_APP_ID
|
||||||
|
id: app-token
|
||||||
|
with:
|
||||||
|
app-id: ${{ vars.NIXPKGS_CI_APP_ID }}
|
||||||
|
private-key: ${{ secrets.NIXPKGS_CI_APP_PRIVATE_KEY }}
|
||||||
|
permission-pull-requests: write
|
||||||
|
|
||||||
|
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||||
|
with:
|
||||||
|
github-token: ${{ steps.app-token.outputs.token || github.token }}
|
||||||
|
retries: 3
|
||||||
|
script: |
|
||||||
|
const { handleMergeComment } = require('./ci/github-script/merge.js')
|
||||||
|
const { body, node_id } = context.payload.comment
|
||||||
|
|
||||||
|
await handleMergeComment({
|
||||||
|
github,
|
||||||
|
body,
|
||||||
|
node_id,
|
||||||
|
reaction: 'EYES',
|
||||||
|
})
|
||||||
66
.github/workflows/dismissed-review.yml
vendored
66
.github/workflows/dismissed-review.yml
vendored
@@ -1,66 +0,0 @@
|
|||||||
name: Dismissed review
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_run:
|
|
||||||
workflows:
|
|
||||||
- Review dismissed
|
|
||||||
types: [completed]
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: dismissed-review-${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.run_id }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
# The `check-cherry-picks` workflow creates review comments which reviewers
|
|
||||||
# are encouraged to manually dismiss if they're not relevant.
|
|
||||||
# When a CI-generated review is dismissed, this job automatically minimizes
|
|
||||||
# it, preventing it from cluttering the PR.
|
|
||||||
minimize:
|
|
||||||
name: Minimize as resolved
|
|
||||||
runs-on: ubuntu-24.04-arm
|
|
||||||
timeout-minutes: 2
|
|
||||||
steps:
|
|
||||||
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
|
||||||
with:
|
|
||||||
script: |
|
|
||||||
// PRs from forks don't have any PRs associated by default.
|
|
||||||
// Thus, we request the PR number with an API call *to* the fork's repo.
|
|
||||||
// Multiple pull requests can be open from the same head commit, either via
|
|
||||||
// different base branches or head branches.
|
|
||||||
const { head_repository, head_sha, repository } = context.payload.workflow_run
|
|
||||||
await Promise.all(
|
|
||||||
(await github.paginate(github.rest.repos.listPullRequestsAssociatedWithCommit, {
|
|
||||||
owner: head_repository.owner.login,
|
|
||||||
repo: head_repository.name,
|
|
||||||
commit_sha: head_sha
|
|
||||||
}))
|
|
||||||
.filter(pull_request => pull_request.base.repo.id == repository.id)
|
|
||||||
.map(async (pull_request) =>
|
|
||||||
Promise.all(
|
|
||||||
(await github.paginate(github.rest.pulls.listReviews, {
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
pull_number: pull_request.number
|
|
||||||
})).filter(review =>
|
|
||||||
review.user?.login == 'github-actions[bot]' &&
|
|
||||||
review.state == 'DISMISSED'
|
|
||||||
).map(review => github.graphql(`
|
|
||||||
mutation($node_id:ID!) {
|
|
||||||
minimizeComment(input: {
|
|
||||||
classifier: RESOLVED,
|
|
||||||
subjectId: $node_id
|
|
||||||
})
|
|
||||||
{ clientMutationId }
|
|
||||||
}`,
|
|
||||||
{ node_id: review.node_id }
|
|
||||||
))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
6
.github/workflows/pull-request-target.yml
vendored
6
.github/workflows/pull-request-target.yml
vendored
@@ -100,10 +100,10 @@ jobs:
|
|||||||
systems: ${{ needs.prepare.outputs.systems }}
|
systems: ${{ needs.prepare.outputs.systems }}
|
||||||
testVersions: ${{ contains(fromJSON(needs.prepare.outputs.touched), 'pinned') && !contains(fromJSON(needs.prepare.outputs.headBranch).type, 'development') }}
|
testVersions: ${{ contains(fromJSON(needs.prepare.outputs.touched), 'pinned') && !contains(fromJSON(needs.prepare.outputs.headBranch).type, 'development') }}
|
||||||
|
|
||||||
labels:
|
bot:
|
||||||
name: Labels
|
name: Bot
|
||||||
needs: [prepare, eval]
|
needs: [prepare, eval]
|
||||||
uses: ./.github/workflows/labels.yml
|
uses: ./.github/workflows/bot.yml
|
||||||
permissions:
|
permissions:
|
||||||
issues: write
|
issues: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
|||||||
92
.github/workflows/review.yml
vendored
Normal file
92
.github/workflows/review.yml
vendored
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
name: Review
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows:
|
||||||
|
- Reviewed
|
||||||
|
types: [completed]
|
||||||
|
|
||||||
|
# This is used as fallback without app only.
|
||||||
|
# This happens when testing in forks without setting up that app.
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
process:
|
||||||
|
runs-on: ubuntu-24.04-arm
|
||||||
|
timeout-minutes: 2
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
sparse-checkout: |
|
||||||
|
ci/github-script
|
||||||
|
|
||||||
|
# Use the GitHub App to make sure the reaction happens with the same user who will later merge.
|
||||||
|
- uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
||||||
|
if: github.event_name != 'pull_request' && vars.NIXPKGS_CI_APP_ID
|
||||||
|
id: app-token
|
||||||
|
with:
|
||||||
|
app-id: ${{ vars.NIXPKGS_CI_APP_ID }}
|
||||||
|
private-key: ${{ secrets.NIXPKGS_CI_APP_PRIVATE_KEY }}
|
||||||
|
permission-pull-requests: write
|
||||||
|
|
||||||
|
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||||
|
with:
|
||||||
|
github-token: ${{ steps.app-token.outputs.token || github.token }}
|
||||||
|
retries: 3
|
||||||
|
script: |
|
||||||
|
const { handleMergeComment } = require('./ci/github-script/merge.js')
|
||||||
|
|
||||||
|
// PRs from forks don't have any PRs associated by default.
|
||||||
|
// Thus, we request the PR number with an API call *to* the fork's repo.
|
||||||
|
// Multiple pull requests can be open from the same head commit, either via
|
||||||
|
// different base branches or head branches.
|
||||||
|
const { head_repository, head_sha, repository } = context.payload.workflow_run
|
||||||
|
await Promise.all(
|
||||||
|
(await github.paginate(github.rest.repos.listPullRequestsAssociatedWithCommit, {
|
||||||
|
owner: head_repository.owner.login,
|
||||||
|
repo: head_repository.name,
|
||||||
|
commit_sha: head_sha
|
||||||
|
}))
|
||||||
|
.filter(pull_request => pull_request.base.repo.id == repository.id)
|
||||||
|
.map(async (pull_request) =>
|
||||||
|
Promise.all(
|
||||||
|
(await github.paginate(github.rest.pulls.listReviews, {
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
pull_number: pull_request.number
|
||||||
|
})).map(review => {
|
||||||
|
// The `check` workflow creates review comments which reviewers
|
||||||
|
// are encouraged to manually dismiss if they're not relevant.
|
||||||
|
// When a CI-generated review is dismissed, this job automatically minimizes
|
||||||
|
// it, preventing it from cluttering the PR.
|
||||||
|
if (review.user?.login == 'github-actions[bot]' && review.state == 'DISMISSED')
|
||||||
|
return github.graphql(`
|
||||||
|
mutation($node_id:ID!) {
|
||||||
|
minimizeComment(input: {
|
||||||
|
classifier: RESOLVED,
|
||||||
|
subjectId: $node_id
|
||||||
|
})
|
||||||
|
{ clientMutationId }
|
||||||
|
}`,
|
||||||
|
{ node_id: review.node_id }
|
||||||
|
)
|
||||||
|
|
||||||
|
// The `bot` workflow reacts to comments with @NixOS/nixpkgs-merge-bot references, but might only
|
||||||
|
// pick up a comment after up to 10 minutes. To give the user instant feedback, this job adds
|
||||||
|
// a reaction to these comments.
|
||||||
|
return handleMergeComment({
|
||||||
|
github,
|
||||||
|
body: review.body,
|
||||||
|
node_id: review.node_id,
|
||||||
|
reaction: 'EYES',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
name: Review dismissed
|
name: Reviewed
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request_review:
|
pull_request_review:
|
||||||
types: [dismissed]
|
types: [submitted, dismissed]
|
||||||
|
|
||||||
permissions: {}
|
permissions: {}
|
||||||
|
|
||||||
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -58,10 +58,10 @@ jobs:
|
|||||||
|
|
||||||
if (files.some(file => [
|
if (files.some(file => [
|
||||||
'.github/actions/checkout/action.yml',
|
'.github/actions/checkout/action.yml',
|
||||||
|
'.github/workflows/bot.yml',
|
||||||
'.github/workflows/build.yml',
|
'.github/workflows/build.yml',
|
||||||
'.github/workflows/check.yml',
|
'.github/workflows/check.yml',
|
||||||
'.github/workflows/eval.yml',
|
'.github/workflows/eval.yml',
|
||||||
'.github/workflows/labels.yml',
|
|
||||||
'.github/workflows/lint.yml',
|
'.github/workflows/lint.yml',
|
||||||
'.github/workflows/pull-request-target.yml',
|
'.github/workflows/pull-request-target.yml',
|
||||||
'.github/workflows/reviewers.yml',
|
'.github/workflows/reviewers.yml',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ module.exports = async ({ github, context, core, dry }) => {
|
|||||||
const { readFile, writeFile } = require('node:fs/promises')
|
const { readFile, writeFile } = require('node:fs/promises')
|
||||||
const withRateLimit = require('./withRateLimit.js')
|
const withRateLimit = require('./withRateLimit.js')
|
||||||
const { classify } = require('../supportedBranches.js')
|
const { classify } = require('../supportedBranches.js')
|
||||||
|
const { handleMerge } = require('./merge.js')
|
||||||
|
|
||||||
const artifactClient = new DefaultArtifactClient()
|
const artifactClient = new DefaultArtifactClient()
|
||||||
|
|
||||||
@@ -95,7 +96,7 @@ module.exports = async ({ github, context, core, dry }) => {
|
|||||||
return maintainerMaps[branch]
|
return maintainerMaps[branch]
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handlePullRequest({ item, stats }) {
|
async function handlePullRequest({ item, stats, events }) {
|
||||||
const log = (k, v) => core.info(`PR #${item.number} - ${k}: ${v}`)
|
const log = (k, v) => core.info(`PR #${item.number} - ${k}: ${v}`)
|
||||||
|
|
||||||
const pull_number = item.number
|
const pull_number = item.number
|
||||||
@@ -109,6 +110,19 @@ module.exports = async ({ github, context, core, dry }) => {
|
|||||||
})
|
})
|
||||||
).data
|
).data
|
||||||
|
|
||||||
|
const maintainers = await getMaintainerMap(pull_request.base.ref)
|
||||||
|
|
||||||
|
await handleMerge({
|
||||||
|
github,
|
||||||
|
context,
|
||||||
|
core,
|
||||||
|
log,
|
||||||
|
dry,
|
||||||
|
pull_request,
|
||||||
|
events,
|
||||||
|
maintainers,
|
||||||
|
})
|
||||||
|
|
||||||
// When the same change has already been merged to the target branch, a PR will still be
|
// When the same change has already been merged to the target branch, a PR will still be
|
||||||
// open and display the same changes - but will not actually have any effect. This causes
|
// open and display the same changes - but will not actually have any effect. This causes
|
||||||
// strange CI behavior, because the diff of the merge-commit is empty, no rebuilds will
|
// strange CI behavior, because the diff of the merge-commit is empty, no rebuilds will
|
||||||
@@ -305,8 +319,6 @@ module.exports = async ({ github, context, core, dry }) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const maintainers = await getMaintainerMap(pull_request.base.ref)
|
|
||||||
|
|
||||||
Object.assign(prLabels, evalLabels, {
|
Object.assign(prLabels, evalLabels, {
|
||||||
'11.by: package-maintainer':
|
'11.by: package-maintainer':
|
||||||
packages.length &&
|
packages.length &&
|
||||||
@@ -377,9 +389,21 @@ module.exports = async ({ github, context, core, dry }) => {
|
|||||||
|
|
||||||
const itemLabels = {}
|
const itemLabels = {}
|
||||||
|
|
||||||
|
const events = await github.paginate(
|
||||||
|
github.rest.issues.listEventsForTimeline,
|
||||||
|
{
|
||||||
|
...context.repo,
|
||||||
|
issue_number,
|
||||||
|
per_page: 100,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
if (item.pull_request || context.payload.pull_request) {
|
if (item.pull_request || context.payload.pull_request) {
|
||||||
stats.prs++
|
stats.prs++
|
||||||
Object.assign(itemLabels, await handlePullRequest({ item, stats }))
|
Object.assign(
|
||||||
|
itemLabels,
|
||||||
|
await handlePullRequest({ item, stats, events }),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
stats.issues++
|
stats.issues++
|
||||||
if (item.labels.some(({ name }) => name === '4.workflow: auto-close')) {
|
if (item.labels.some(({ name }) => name === '4.workflow: auto-close')) {
|
||||||
@@ -391,13 +415,7 @@ module.exports = async ({ github, context, core, dry }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const latest_event_at = new Date(
|
const latest_event_at = new Date(
|
||||||
(
|
events
|
||||||
await github.paginate(github.rest.issues.listEventsForTimeline, {
|
|
||||||
...context.repo,
|
|
||||||
issue_number,
|
|
||||||
per_page: 100,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.filter(({ event }) =>
|
.filter(({ event }) =>
|
||||||
[
|
[
|
||||||
// These events are hand-picked from:
|
// These events are hand-picked from:
|
||||||
@@ -484,7 +502,7 @@ module.exports = async ({ github, context, core, dry }) => {
|
|||||||
const lastRun = (
|
const lastRun = (
|
||||||
await github.rest.actions.listWorkflowRuns({
|
await github.rest.actions.listWorkflowRuns({
|
||||||
...context.repo,
|
...context.repo,
|
||||||
workflow_id: 'labels.yml',
|
workflow_id: 'bot.yml',
|
||||||
event: 'schedule',
|
event: 'schedule',
|
||||||
status: 'success',
|
status: 'success',
|
||||||
exclude_pull_requests: true,
|
exclude_pull_requests: true,
|
||||||
259
ci/github-script/merge.js
Normal file
259
ci/github-script/merge.js
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
// 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) {
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
|
||||||
|
const packages = files
|
||||||
|
.filter(({ filename }) => filename.startsWith('pkgs/by-name/'))
|
||||||
|
.map(({ filename }) => filename.split('/')[3])
|
||||||
|
|
||||||
|
const eligible = !packages.length
|
||||||
|
? new Set()
|
||||||
|
: packages
|
||||||
|
.map((pkg) => new Set(maintainers[pkg]))
|
||||||
|
.reduce((acc, cur) => acc?.intersection(cur) ?? cur)
|
||||||
|
|
||||||
|
const checklist = {
|
||||||
|
'PR targets one of the allowed branches: master, staging, staging-next.': [
|
||||||
|
'master',
|
||||||
|
'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,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
checklist,
|
||||||
|
eligible,
|
||||||
|
result: Object.values(checklist).every(Boolean),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The merge command must be on a separate line and not within codeblocks or html comments.
|
||||||
|
// Codeblocks can have any number of ` larger than 3 to open/close. We only look at code
|
||||||
|
// blocks that are not indented, because the later regex wouldn't match those anyway.
|
||||||
|
function hasMergeCommand(body) {
|
||||||
|
return (body ?? '')
|
||||||
|
.replace(/<!--.*?-->/gms, '')
|
||||||
|
.replace(/(^`{3,})[^`].*?\1/gms, '')
|
||||||
|
.match(/^@NixOS\/nixpkgs-merge-bot merge\s*$/m)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMergeComment({ github, body, node_id, reaction }) {
|
||||||
|
if (!hasMergeCommand(body)) return
|
||||||
|
|
||||||
|
await github.graphql(
|
||||||
|
`mutation($node_id: ID!, $reaction: ReactionContent!) {
|
||||||
|
addReaction(input: {
|
||||||
|
content: $reaction,
|
||||||
|
subjectId: $node_id
|
||||||
|
})
|
||||||
|
{ clientMutationId }
|
||||||
|
}`,
|
||||||
|
{ node_id, reaction },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMerge({
|
||||||
|
github,
|
||||||
|
context,
|
||||||
|
core,
|
||||||
|
log,
|
||||||
|
dry,
|
||||||
|
pull_request,
|
||||||
|
events,
|
||||||
|
maintainers,
|
||||||
|
}) {
|
||||||
|
const pull_number = pull_request.number
|
||||||
|
|
||||||
|
const { checklist, eligible, result } = await runChecklist({
|
||||||
|
github,
|
||||||
|
context,
|
||||||
|
pull_request,
|
||||||
|
maintainers,
|
||||||
|
})
|
||||||
|
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 }) =>
|
||||||
|
['committed', 'head_ref_force_pushed'].includes(event),
|
||||||
|
) ?? { sha: pull_request.head.sha }
|
||||||
|
const latestSha = latestChange.sha ?? latestChange.commit_id
|
||||||
|
log('latest sha', latestSha)
|
||||||
|
const latestIndex = events.indexOf(latestChange)
|
||||||
|
|
||||||
|
const comments = events.slice(latestIndex + 1).filter(
|
||||||
|
({ event, body, node_id }) =>
|
||||||
|
['commented', 'reviewed'].includes(event) &&
|
||||||
|
hasMergeCommand(body) &&
|
||||||
|
// Ignore comments which had already been responded to by the bot.
|
||||||
|
!events.some(
|
||||||
|
({ event, user, body }) =>
|
||||||
|
['commented'].includes(event) &&
|
||||||
|
// We're only testing this hidden reference, but not the author of the comment.
|
||||||
|
// We'll just assume that nobody creates comments with this marker on purpose.
|
||||||
|
// Additionally checking the author is quite annoying for local debugging.
|
||||||
|
body.match(new RegExp(`^<!-- comment: ${node_id} -->$`, 'm')),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
async function merge() {
|
||||||
|
if (dry) {
|
||||||
|
core.info(`Merging #${pull_number}... (dry)`)
|
||||||
|
return 'Merge completed (dry)'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Using GraphQL's enablePullRequestAutoMerge mutation instead of the REST
|
||||||
|
// /merge endpoint, because the latter doesn't work with Merge Queues.
|
||||||
|
// This mutation works both with and without Merge Queues.
|
||||||
|
// It doesn't work when there are no required status checks for the target branch.
|
||||||
|
// All development branches have these enabled, so this is a non-issue.
|
||||||
|
try {
|
||||||
|
await github.graphql(
|
||||||
|
`mutation($node_id: ID!, $sha: GitObjectID) {
|
||||||
|
enablePullRequestAutoMerge(input: {
|
||||||
|
expectedHeadOid: $sha,
|
||||||
|
pullRequestId: $node_id
|
||||||
|
})
|
||||||
|
{ clientMutationId }
|
||||||
|
}`,
|
||||||
|
{ node_id: pull_request.node_id, sha: latestSha },
|
||||||
|
)
|
||||||
|
return 'Enabled Auto Merge'
|
||||||
|
} catch (e) {
|
||||||
|
log('Auto Merge failed', e.response.errors[0].message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// fallback after this.
|
||||||
|
try {
|
||||||
|
const resp = await github.graphql(
|
||||||
|
`mutation($node_id: ID!, $sha: GitObjectID) {
|
||||||
|
enqueuePullRequest(input: {
|
||||||
|
expectedHeadOid: $sha,
|
||||||
|
pullRequestId: $node_id
|
||||||
|
})
|
||||||
|
{
|
||||||
|
clientMutationId,
|
||||||
|
mergeQueueEntry { mergeQueue { url } }
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{ node_id: pull_request.node_id, sha: latestSha },
|
||||||
|
)
|
||||||
|
return `[Queued](${resp.enqueuePullRequest.mergeQueueEntry.mergeQueue.url}) for merge`
|
||||||
|
} catch (e) {
|
||||||
|
log('Enqueing failed', e.response.errors[0].message)
|
||||||
|
throw new Error(e.response.errors[0].message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const comment of comments) {
|
||||||
|
log('comment', comment.node_id)
|
||||||
|
|
||||||
|
async function react(reaction) {
|
||||||
|
if (dry) {
|
||||||
|
core.info(`Reaction ${reaction} on ${comment.node_id} (dry)`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await handleMergeComment({
|
||||||
|
github,
|
||||||
|
body: comment.body,
|
||||||
|
node_id: comment.node_id,
|
||||||
|
reaction,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isMaintainer(username) {
|
||||||
|
try {
|
||||||
|
return (
|
||||||
|
(
|
||||||
|
await github.rest.teams.getMembershipForUserInOrg({
|
||||||
|
org: context.repo.owner,
|
||||||
|
team_slug: 'nixpkgs-maintainers',
|
||||||
|
username,
|
||||||
|
})
|
||||||
|
).data.state === 'active'
|
||||||
|
)
|
||||||
|
} catch (e) {
|
||||||
|
if (e.status === 404) return false
|
||||||
|
else throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canUseMergeBot = await isMaintainer(comment.user.login)
|
||||||
|
const isEligible = eligible.has(comment.user.id)
|
||||||
|
const canMerge = result && canUseMergeBot && isEligible
|
||||||
|
|
||||||
|
const body = [
|
||||||
|
`<!-- comment: ${comment.node_id} -->`,
|
||||||
|
'',
|
||||||
|
'Requirements to merge this PR:',
|
||||||
|
...Object.entries(checklist).map(
|
||||||
|
([msg, res]) => `- :${res ? 'white_check_mark' : 'x'}: ${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) {
|
||||||
|
await react('ROCKET')
|
||||||
|
try {
|
||||||
|
body.push(`:heavy_check_mark: ${await merge()} (#306934)`)
|
||||||
|
} catch (e) {
|
||||||
|
// Remove the HTML comment with node_id reference to allow retrying this merge on the next run.
|
||||||
|
body.shift()
|
||||||
|
body.push(`:x: Merge failed with: ${e} (#371492)`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await react('THUMBS_DOWN')
|
||||||
|
body.push(':x: Pull Request could not be merged (#305350)')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dry) {
|
||||||
|
core.info(body.join('\n'))
|
||||||
|
} else {
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
...context.repo,
|
||||||
|
issue_number: pull_number,
|
||||||
|
body: body.join('\n'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canMerge) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
handleMerge,
|
||||||
|
handleMergeComment,
|
||||||
|
}
|
||||||
@@ -65,19 +65,19 @@ program
|
|||||||
})
|
})
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('labels')
|
.command('bot')
|
||||||
.description('Manage labels on pull requests.')
|
.description('Run automation on pull requests and issues.')
|
||||||
.argument('<owner>', 'Owner of the GitHub repository to label (Example: NixOS)')
|
.argument('<owner>', 'Owner of the GitHub repository to label (Example: NixOS)')
|
||||||
.argument('<repo>', 'Name of the GitHub repository to label (Example: nixpkgs)')
|
.argument('<repo>', 'Name of the GitHub repository to label (Example: nixpkgs)')
|
||||||
.argument('[pr]', 'Number of the Pull Request to label')
|
.argument('[pr]', 'Number of the Pull Request to label')
|
||||||
.option('--no-dry', 'Make actual modifications')
|
.option('--no-dry', 'Make actual modifications')
|
||||||
.action(async (owner, repo, pr, options) => {
|
.action(async (owner, repo, pr, options) => {
|
||||||
const labels = (await import('./labels.js')).default
|
const bot = (await import('./bot.js')).default
|
||||||
const tmp = mkdtempSync(join(tmpdir(), 'github-script-'))
|
const tmp = mkdtempSync(join(tmpdir(), 'github-script-'))
|
||||||
try {
|
try {
|
||||||
process.env.GITHUB_WORKSPACE = tmp
|
process.env.GITHUB_WORKSPACE = tmp
|
||||||
process.chdir(tmp)
|
process.chdir(tmp)
|
||||||
await run(labels, owner, repo, pr, options)
|
await run(bot, owner, repo, pr, options)
|
||||||
} finally {
|
} finally {
|
||||||
rmSync(tmp, { recursive: true })
|
rmSync(tmp, { recursive: true })
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user