Auto-merge PR #1539
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # SPDX-FileCopyrightText: Copyright (c) 2023-present NVIDIA CORPORATION & AFFILIATES. | |
| # All rights reserved. | |
| # SPDX-License-Identifier: BSD-3-Clause | |
| # Auto-merge PRs when all CI checks pass and the "enable-auto-merge" label is present | |
| name: Auto-merge PR | |
| on: | |
| pull_request: | |
| types: | |
| - labeled | |
| pull_request_review: | |
| types: | |
| - submitted | |
| workflow_run: | |
| workflows: ["Build"] # Triggers when entire Build workflow completes (all jobs including clang-build-23 and dynamic-type-meson) | |
| types: | |
| - completed | |
| repository_dispatch: | |
| types: | |
| - nvfuser-ci-success | |
| jobs: | |
| auto-merge: | |
| name: Auto-merge PR | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| statuses: read | |
| checks: read | |
| # Only run if conditions are met based on event type | |
| if: | | |
| (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'enable-auto-merge')) || | |
| (github.event_name == 'pull_request_review' && github.event.review.state == 'approved' && contains(github.event.pull_request.labels.*.name, 'enable-auto-merge')) || | |
| (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.pull_requests != null && github.event.workflow_run.pull_requests[0].number != null) || | |
| (github.event_name == 'repository_dispatch' && github.event.action == 'nvfuser-ci-success') | |
| steps: | |
| - name: Get PR details | |
| id: pr | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| let pr; | |
| if (context.payload.pull_request) { | |
| pr = context.payload.pull_request; | |
| } else if (context.eventName === 'repository_dispatch') { | |
| // For repository_dispatch events, get PR number from client_payload | |
| const pr_number = context.payload.client_payload.pr_number; | |
| if (!pr_number) { | |
| core.info('No PR number in repository_dispatch payload'); | |
| return { should_skip: true }; | |
| } | |
| const { data } = await github.rest.pulls.get({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: pr_number, | |
| }); | |
| pr = data; | |
| } else if (context.eventName === 'workflow_run') { | |
| // For workflow_run events, get PR from payload | |
| const prNumber = context.payload.workflow_run.pull_requests[0]?.number; | |
| if (!prNumber) { | |
| core.info('No PR associated with this workflow run'); | |
| return { should_skip: true }; | |
| } | |
| const { data } = await github.rest.pulls.get({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: prNumber, | |
| }); | |
| pr = data; | |
| } else { | |
| core.info('Unexpected event type, skipping'); | |
| return { should_skip: true }; | |
| } | |
| // Defensive checks: skip if PR is from a fork | |
| if (pr.head.repo.full_name !== pr.base.repo.full_name) { | |
| core.info(`PR is from fork (${pr.head.repo.full_name}), auto-merge only works for same-repo PRs`); | |
| return { should_skip: true }; | |
| } | |
| // Defensive checks: skip if PR is closed or draft | |
| if (pr.state === 'closed') { | |
| core.info('PR is closed, skipping auto-merge checks'); | |
| return { should_skip: true }; | |
| } | |
| if (pr.draft === true) { | |
| core.info('PR is a draft, skipping auto-merge checks'); | |
| return { should_skip: true }; | |
| } | |
| // Check if PR has the enable-auto-merge label | |
| const hasLabel = pr.labels.some(label => label.name === 'enable-auto-merge'); | |
| if (!hasLabel) { | |
| core.info('PR does not have enable-auto-merge label'); | |
| return { should_skip: true }; | |
| } | |
| return { | |
| should_skip: false, | |
| number: pr.number, | |
| sha: pr.head.sha, | |
| mergeable: pr.mergeable, | |
| mergeable_state: pr.mergeable_state, | |
| }; | |
| - name: Check all conditions for auto-merge | |
| id: check | |
| if: fromJSON(steps.pr.outputs.result).should_skip == false | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const prData = ${{ steps.pr.outputs.result }}; | |
| if (!prData || prData.should_skip) { | |
| core.info('PR data unavailable or should skip'); | |
| return { ready: false }; | |
| } | |
| const pr_number = prData.number; | |
| const sha = prData.sha; | |
| core.info(`Checking PR #${pr_number} at commit ${sha}`); | |
| // Initialize check results | |
| const checks = { | |
| internal_ci_passed: false, | |
| internal_ci_reason: '', | |
| no_failed_checks: false, | |
| no_failed_checks_reason: '', | |
| pr_mergeable: false, | |
| pr_mergeable_reason: '', | |
| pr_mergeable_state: '', | |
| }; | |
| // 1. Get all commit statuses (for nvfuser-ci and other status checks) | |
| const allStatuses = await github.paginate(github.rest.repos.listCommitStatusesForRef, { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| ref: sha, | |
| }); | |
| core.info(`Found ${allStatuses.length} commit statuses`); | |
| // Group statuses by context and keep only the most recent status for each context | |
| // This handles cases where statuses are updated multiple times | |
| const statuses = Object.values( | |
| allStatuses.reduce((acc, status) => { | |
| const existing = acc[status.context]; | |
| // Keep the status with the latest created_at or updated_at time | |
| const statusTime = status.updated_at || status.created_at || ''; | |
| const existingTime = existing ? (existing.updated_at || existing.created_at || '') : ''; | |
| // Use ID as tiebreaker for timestamp collisions (higher ID = newer) | |
| if (!existing || statusTime > existingTime || (statusTime === existingTime && status.id > existing.id)) { | |
| acc[status.context] = status; | |
| } | |
| return acc; | |
| }, {}) | |
| ); | |
| core.info(`After deduplication: ${statuses.length} unique commit statuses`); | |
| // Log all unique commit statuses for debugging | |
| core.info('Unique commit statuses:'); | |
| statuses.forEach(s => { | |
| core.info(` - ${s.context}: ${s.state} (updated: ${s.updated_at || s.created_at})`); | |
| }); | |
| // Check for nvfuser-ci status | |
| const nvfuserCiStatus = statuses.find(s => s.context === 'nvfuser-ci'); | |
| if (!nvfuserCiStatus) { | |
| checks.internal_ci_passed = false; | |
| checks.internal_ci_reason = 'nvfuser-ci status not found'; | |
| core.info('⏸️ Auto-merge paused: nvfuser-ci status not found'); | |
| } else if (nvfuserCiStatus.state === 'pending') { | |
| checks.internal_ci_passed = false; | |
| checks.internal_ci_reason = 'pending'; | |
| core.info('nvfuser-ci is still pending'); | |
| } else if (nvfuserCiStatus.state !== 'success') { | |
| checks.internal_ci_passed = false; | |
| checks.internal_ci_reason = nvfuserCiStatus.state; | |
| core.info(`⏸️ Auto-merge paused: nvfuser-ci status is ${nvfuserCiStatus.state}`); | |
| } else { | |
| checks.internal_ci_passed = true; | |
| core.info('✅ nvfuser-ci is success'); | |
| } | |
| // Check for any failed or error statuses | |
| const failedStatuses = statuses.filter(s => | |
| s.state === 'failure' || s.state === 'error' | |
| ); | |
| if (failedStatuses.length > 0) { | |
| core.info(`❌ Found ${failedStatuses.length} failed commit statuses:`); | |
| failedStatuses.forEach(s => core.info(` - ${s.context}: ${s.state}`)); | |
| } else { | |
| core.info('✅ No failed commit statuses'); | |
| } | |
| // 2. Get all check runs | |
| const checkRunsData = await github.paginate(github.rest.checks.listForRef, { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| ref: sha, | |
| }); | |
| const checkRuns = { check_runs: checkRunsData }; | |
| core.info(`Found ${checkRuns.check_runs.length} check runs`); | |
| // Group check runs by name and keep only the most recent run for each check | |
| // This handles cases where checks are cancelled and rerun | |
| const latestCheckRuns = Object.values( | |
| checkRuns.check_runs.reduce((acc, check) => { | |
| const existing = acc[check.name]; | |
| // Keep the check with the latest completed_at time, or started_at if not completed | |
| const checkTime = check.completed_at || check.started_at || ''; | |
| const existingTime = existing ? (existing.completed_at || existing.started_at || '') : ''; | |
| // Use ID as tiebreaker for timestamp collisions (higher ID = newer) | |
| if (!existing || checkTime > existingTime || (checkTime === existingTime && check.id > existing.id)) { | |
| acc[check.name] = check; | |
| } | |
| return acc; | |
| }, {}) | |
| ); | |
| core.info(`After deduplication: ${latestCheckRuns.length} unique check runs`); | |
| // Log check runs grouped by status for debugging | |
| const checksByStatus = latestCheckRuns.reduce((acc, check) => { | |
| const key = check.status === 'completed' ? check.conclusion : check.status; | |
| if (!acc[key]) acc[key] = []; | |
| acc[key].push(check.name); | |
| return acc; | |
| }, {}); | |
| core.info('Check runs by status:'); | |
| Object.entries(checksByStatus).forEach(([status, checks]) => { | |
| core.info(` ${status}: ${checks.length} checks`); | |
| checks.forEach(name => core.info(` - ${name}`)); | |
| }); | |
| // Check for any failed or error check runs (excluding this workflow itself) | |
| // Note: context.job is the job ID, but check names use the job name | |
| const failedChecks = latestCheckRuns.filter(check => | |
| check.name !== 'Auto-merge PR' && ( | |
| check.conclusion === 'failure' || | |
| check.conclusion === 'cancelled' || | |
| check.conclusion === 'timed_out' || | |
| check.status !== 'completed' | |
| ) | |
| ); | |
| if (failedStatuses.length > 0 || failedChecks.length > 0) { | |
| checks.no_failed_checks = false; | |
| const failedNames = []; | |
| if (failedStatuses.length > 0) { | |
| failedNames.push(...failedStatuses.map(s => s.context)); | |
| } | |
| if (failedChecks.length > 0) { | |
| failedNames.push(...failedChecks.map(c => c.name)); | |
| } | |
| checks.no_failed_checks_reason = failedNames.join(', '); | |
| core.info(`❌ Found ${failedChecks.length} failed/pending check runs:`); | |
| failedChecks.forEach(c => { | |
| const reason = c.status !== 'completed' ? c.status : c.conclusion; | |
| core.info(` - ${c.name}: ${reason}`); | |
| }); | |
| core.info(`⏸️ Auto-merge paused: Failed checks: ${checks.no_failed_checks_reason}`); | |
| } else { | |
| checks.no_failed_checks = true; | |
| core.info('✅ No failed or pending check runs'); | |
| } | |
| // 3. Get combined status (this checks required checks) | |
| const { data: combinedStatus } = await github.rest.repos.getCombinedStatusForRef({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| ref: sha, | |
| }); | |
| core.info(`Combined status state: ${combinedStatus.state}`); | |
| // 4. Check if PR is mergeable (includes branch protection rules like approvals) | |
| // Note: GitHub computes mergeable status asynchronously, so we may need to refetch | |
| // if the initial data was null/undefined. Use cached data if available. | |
| let pr; | |
| if (prData.mergeable !== null && prData.mergeable !== undefined) { | |
| // Use cached PR data to avoid redundant API call | |
| pr = { | |
| mergeable: prData.mergeable, | |
| mergeable_state: prData.mergeable_state, | |
| }; | |
| core.info('Using cached PR mergeable status'); | |
| } else { | |
| // Refetch if mergeable status wasn't computed yet | |
| const response = await github.rest.pulls.get({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: pr_number, | |
| }); | |
| pr = response.data; | |
| core.info('Refetched PR to get mergeable status'); | |
| } | |
| // Store mergeable_state for display in status comment | |
| checks.pr_mergeable_state = pr.mergeable_state; | |
| if (pr.mergeable === false) { | |
| checks.pr_mergeable = false; | |
| checks.pr_mergeable_reason = 'has merge conflicts'; | |
| core.warning('⚠️ Auto-merge blocked: PR is not mergeable (conflicts or other issues)'); | |
| } else if (pr.mergeable_state !== 'clean' && pr.mergeable_state !== 'unstable') { | |
| checks.pr_mergeable = false; | |
| checks.pr_mergeable_reason = pr.mergeable_state; | |
| core.info(`⏸️ Auto-merge paused: PR mergeable_state is ${pr.mergeable_state}`); | |
| } else { | |
| checks.pr_mergeable = true; | |
| core.info('✅ PR is mergeable'); | |
| } | |
| // Determine if ready to merge | |
| const ready = checks.internal_ci_passed && | |
| checks.no_failed_checks && | |
| checks.pr_mergeable; | |
| return { | |
| ready: ready, | |
| pr_number: pr_number, | |
| checks: checks, | |
| }; | |
| - name: Update auto-merge status comment | |
| if: always() && fromJSON(steps.pr.outputs.result).should_skip == false | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const prData = ${{ steps.pr.outputs.result }}; | |
| if (!prData || prData.should_skip) { | |
| core.info('PR data unavailable or should skip'); | |
| return; | |
| } | |
| const pr_number = prData.number; | |
| // Get check results from previous step | |
| const checkResult = ${{ steps.check.outputs.result }}; | |
| // Build status based on structured check results | |
| if (!checkResult || !checkResult.checks) { | |
| core.info('Check step was skipped or did not complete - status comment will not be updated'); | |
| return; | |
| } | |
| const checks = checkResult.checks; | |
| const statusLines = []; | |
| // Internal CI | |
| if (checks.internal_ci_passed) { | |
| statusLines.push('✅ Internal CI is finished'); | |
| } else { | |
| const reason = checks.internal_ci_reason ? ` (${checks.internal_ci_reason})` : ''; | |
| statusLines.push(`❌ Internal CI is finished${reason}`); | |
| } | |
| // No failed checks | |
| if (checks.no_failed_checks) { | |
| statusLines.push('✅ No failed checks'); | |
| } else { | |
| const reason = checks.no_failed_checks_reason ? ` (${checks.no_failed_checks_reason})` : ''; | |
| statusLines.push(`❌ No failed checks${reason}`); | |
| } | |
| // PR mergeable | |
| if (checks.pr_mergeable) { | |
| statusLines.push('✅ PR is mergeable'); | |
| } else { | |
| const reason = checks.pr_mergeable_reason ? ` (${checks.pr_mergeable_reason})` : ''; | |
| statusLines.push(`❌ PR is mergeable${reason}`); | |
| } | |
| // Show mergeable_state for transparency | |
| if (checks.pr_mergeable_state) { | |
| statusLines.push(`ℹ️ PR mergeable_state: \`${checks.pr_mergeable_state}\``); | |
| } | |
| const statusText = statusLines.join('\n'); | |
| // Find the comment with placeholders | |
| const comments = await github.paginate(github.rest.issues.listComments, { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: pr_number, | |
| }); | |
| const placeholder_start = '<!-- BEGIN AUTO MERGE PLACEHOLDER -->'; | |
| const placeholder_end = '<!-- END AUTO MERGE PLACEHOLDER -->'; | |
| const targetComment = comments.find(comment => | |
| comment.body.includes(placeholder_start) && | |
| comment.body.includes(placeholder_end) | |
| ); | |
| if (!targetComment) { | |
| core.info('No comment with auto-merge placeholders found'); | |
| return; | |
| } | |
| // Update the comment content between placeholders | |
| const beforePlaceholder = targetComment.body.substring( | |
| 0, | |
| targetComment.body.indexOf(placeholder_start) + placeholder_start.length | |
| ); | |
| const afterPlaceholder = targetComment.body.substring( | |
| targetComment.body.indexOf(placeholder_end) | |
| ); | |
| const newBody = `${beforePlaceholder}\n### Auto-merge Status\n${statusText}\n${afterPlaceholder}`; | |
| await github.rest.issues.updateComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| comment_id: targetComment.id, | |
| body: newBody, | |
| }); | |
| core.info('✅ Updated auto-merge status comment'); | |
| - name: Merge PR | |
| if: steps.check.outputs.result != '' && fromJSON(steps.check.outputs.result).ready == true | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const checkResult = ${{ steps.check.outputs.result }}; | |
| if (!checkResult || !checkResult.pr_number) { | |
| core.info('⏸️ Auto-merge paused: Check results unavailable for merge'); | |
| return; | |
| } | |
| const pr_number = checkResult.pr_number; | |
| core.info(`All conditions met. Merging PR #${pr_number}`); | |
| try { | |
| await github.rest.pulls.merge({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: pr_number, | |
| merge_method: 'squash', | |
| }); | |
| core.info(`✅ Successfully merged PR #${pr_number}`); | |
| // Remove the label after successful merge to prevent stale state | |
| try { | |
| await github.rest.issues.removeLabel({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: pr_number, | |
| name: 'enable-auto-merge', | |
| }); | |
| core.info('Removed enable-auto-merge label'); | |
| } catch (labelError) { | |
| // Label might already be removed, ignore error | |
| core.info(`Could not remove label (may already be removed): ${labelError.message}`); | |
| } | |
| } catch (error) { | |
| core.setFailed(`Failed to merge PR: ${error.message}`); | |
| // Notify maintainers about the merge failure | |
| try { | |
| const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: pr_number, | |
| body: `⚠️ Auto-merge failed. Please review and merge manually.\n\nWorkflow run: ${runUrl}\n\ncc @xwang233`, | |
| }); | |
| core.info('Posted merge failure notification comment'); | |
| } catch (commentError) { | |
| core.error(`Failed to post notification comment: ${commentError.message}`); | |
| } | |
| } |