scheduled merge #194
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
| name: scheduled merge | |
| on: | |
| issue_comment: | |
| types: [created] | |
| schedule: | |
| - cron: "*/5 * * * *" | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| issues: write | |
| jobs: | |
| schedule: | |
| if: github.event_name == 'issue_comment' && (contains(github.event.comment.body, '/schedule-merge') || contains(github.event.comment.body, '/unschedule-merge')) | |
| runs-on: ubuntu-latest | |
| concurrency: | |
| group: schedule-${{ github.event.issue.number }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-python@v5 | |
| with: | |
| python-version: 3.x | |
| - name: Extract schedule command | |
| id: command | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const body = context.payload.comment?.body || ''; | |
| if (!context.payload.issue?.pull_request) { | |
| core.setOutput('found', 'false'); | |
| return; | |
| } | |
| const cancelMatch = body.match(/^\s*\/unschedule-merge\s*$/im); | |
| if (cancelMatch) { | |
| core.setOutput('found', 'true'); | |
| core.setOutput('action', 'cancel'); | |
| core.setOutput('pr', String(context.payload.issue.number)); | |
| core.setOutput('commenter', context.payload.comment.user.login); | |
| return; | |
| } | |
| const scheduleMatch = body.match(/^\s*\/schedule-merge\s+([^\n\r]+)\s*$/im); | |
| if (!scheduleMatch) { | |
| core.setOutput('found', 'false'); | |
| return; | |
| } | |
| core.setOutput('found', 'true'); | |
| core.setOutput('action', 'schedule'); | |
| core.setOutput('raw', scheduleMatch[1].trim()); | |
| core.setOutput('pr', String(context.payload.issue.number)); | |
| core.setOutput('commenter', context.payload.comment.user.login); | |
| - name: Check commenter permissions | |
| id: perms | |
| if: steps.command.outputs.found == 'true' | |
| uses: actions/github-script@v7 | |
| env: | |
| USERNAME: ${{ steps.command.outputs.commenter }} | |
| with: | |
| script: | | |
| const username = process.env.USERNAME; | |
| let permission = 'none'; | |
| try { | |
| const response = await github.rest.repos.getCollaboratorPermissionLevel({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| username, | |
| }); | |
| permission = response.data.permission || 'none'; | |
| } catch (error) { | |
| permission = 'none'; | |
| } | |
| const allowed = ['admin', 'maintain', 'write'].includes(permission); | |
| core.setOutput('allowed', allowed ? 'true' : 'false'); | |
| core.setOutput('permission', permission); | |
| - name: Parse schedule time | |
| id: parse | |
| if: steps.command.outputs.found == 'true' && steps.command.outputs.action == 'schedule' | |
| run: python .github/scripts/parse_schedule.py | |
| env: | |
| RAW: ${{ steps.command.outputs.raw }} | |
| - name: Reject unauthorized user | |
| if: steps.command.outputs.found == 'true' && steps.perms.outputs.allowed != 'true' | |
| uses: actions/github-script@v7 | |
| env: | |
| PR_NUMBER: ${{ steps.command.outputs.pr }} | |
| PERMISSION: ${{ steps.perms.outputs.permission }} | |
| with: | |
| script: | | |
| const prNumber = Number(process.env.PR_NUMBER); | |
| const permission = process.env.PERMISSION || 'none'; | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: prNumber, | |
| body: `Scheduling is limited to maintainers (write/maintain/admin). Current permission: ${permission}.`, | |
| }); | |
| - name: Report invalid schedule time | |
| if: steps.command.outputs.found == 'true' && steps.command.outputs.action == 'schedule' && steps.perms.outputs.allowed == 'true' && steps.parse.outputs.valid != 'true' | |
| uses: actions/github-script@v7 | |
| env: | |
| PR_NUMBER: ${{ steps.command.outputs.pr }} | |
| ERROR: ${{ steps.parse.outputs.error }} | |
| with: | |
| script: | | |
| const prNumber = Number(process.env.PR_NUMBER); | |
| const error = process.env.ERROR || 'Invalid schedule time.'; | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: prNumber, | |
| body: `${error}\n\nUsage: \`/schedule-merge YYYY-MM-DD HH:MM CT\` (example: \`/schedule-merge 2026-01-28 09:00 CT\`).`, | |
| }); | |
| - name: Record schedule | |
| if: steps.command.outputs.found == 'true' && steps.command.outputs.action == 'schedule' && steps.perms.outputs.allowed == 'true' && steps.parse.outputs.valid == 'true' | |
| uses: actions/github-script@v7 | |
| env: | |
| PR_NUMBER: ${{ steps.command.outputs.pr }} | |
| UTC_TIME: ${{ steps.parse.outputs.utc }} | |
| DISPLAY_TIME: ${{ steps.parse.outputs.display }} | |
| with: | |
| script: | | |
| const prNumber = Number(process.env.PR_NUMBER); | |
| const utcTime = process.env.UTC_TIME; | |
| const displayTime = process.env.DISPLAY_TIME; | |
| const label = 'scheduled-merge'; | |
| try { | |
| await github.rest.issues.getLabel({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| name: label, | |
| }); | |
| } catch (error) { | |
| if (error.status === 404) { | |
| await github.rest.issues.createLabel({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| name: label, | |
| color: '0e8a16', | |
| description: 'PRs scheduled for automatic merge', | |
| }); | |
| } else { | |
| throw error; | |
| } | |
| } | |
| await github.rest.issues.addLabels({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: prNumber, | |
| labels: [label], | |
| }); | |
| const marker = `<!-- scheduled-merge: ${utcTime} -->`; | |
| const body = `${marker}\nScheduled merge for ${displayTime} (UTC ${utcTime}).\n\nTo reschedule, comment again with a new time. To cancel, comment \`/unschedule-merge\`.`; | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: prNumber, | |
| body, | |
| }); | |
| - name: Cancel scheduled merge | |
| if: steps.command.outputs.found == 'true' && steps.command.outputs.action == 'cancel' && steps.perms.outputs.allowed == 'true' | |
| uses: actions/github-script@v7 | |
| env: | |
| PR_NUMBER: ${{ steps.command.outputs.pr }} | |
| with: | |
| script: | | |
| const prNumber = Number(process.env.PR_NUMBER); | |
| const label = 'scheduled-merge'; | |
| try { | |
| await github.rest.issues.removeLabel({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: prNumber, | |
| name: label, | |
| }); | |
| } catch (error) { | |
| if (error.status !== 404) { | |
| throw error; | |
| } | |
| } | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: prNumber, | |
| body: 'Scheduled merge has been cancelled.', | |
| }); | |
| merge: | |
| if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Merge scheduled pull requests | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const { owner, repo } = context.repo; | |
| const now = new Date(); | |
| const issues = await github.paginate(github.rest.issues.listForRepo, { | |
| owner, | |
| repo, | |
| state: 'open', | |
| labels: 'scheduled-merge', | |
| per_page: 100, | |
| }); | |
| for (const issue of issues) { | |
| if (!issue.pull_request) { | |
| continue; | |
| } | |
| const comments = await github.paginate(github.rest.issues.listComments, { | |
| owner, | |
| repo, | |
| issue_number: issue.number, | |
| per_page: 100, | |
| }); | |
| const isWorkflowComment = (comment) => { | |
| if (comment.user?.login === 'github-actions[bot]') { | |
| return true; | |
| } | |
| const app = comment.performed_via_github_app; | |
| if (app && app.slug === 'github-actions') { | |
| return true; | |
| } | |
| return false; | |
| }; | |
| const scheduledComments = comments | |
| .map((comment) => { | |
| if (!isWorkflowComment(comment)) { | |
| return null; | |
| } | |
| const match = comment.body?.match(/<!-- scheduled-merge: (\S+) -->/); | |
| if (!match) { | |
| return null; | |
| } | |
| return { | |
| utc: match[1], | |
| created_at: comment.created_at, | |
| }; | |
| }) | |
| .filter(Boolean) | |
| .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); | |
| if (scheduledComments.length === 0) { | |
| await github.rest.issues.removeLabel({ | |
| owner, | |
| repo, | |
| issue_number: issue.number, | |
| name: 'scheduled-merge', | |
| }); | |
| await github.rest.issues.createComment({ | |
| owner, | |
| repo, | |
| issue_number: issue.number, | |
| body: 'Scheduled merge label removed because no schedule marker was found. Comment again to reschedule.', | |
| }); | |
| continue; | |
| } | |
| const scheduled = scheduledComments[0]; | |
| const scheduledAt = new Date(scheduled.utc); | |
| if (Number.isNaN(scheduledAt.getTime())) { | |
| await github.rest.issues.removeLabel({ | |
| owner, | |
| repo, | |
| issue_number: issue.number, | |
| name: 'scheduled-merge', | |
| }); | |
| await github.rest.issues.createComment({ | |
| owner, | |
| repo, | |
| issue_number: issue.number, | |
| body: `Scheduled merge cleared because the stored time was invalid (${scheduled.utc}). Comment again to reschedule.`, | |
| }); | |
| continue; | |
| } | |
| if (now < scheduledAt) { | |
| continue; | |
| } | |
| const pr = await github.rest.pulls.get({ | |
| owner, | |
| repo, | |
| pull_number: issue.number, | |
| }); | |
| if (pr.data.draft) { | |
| continue; | |
| } | |
| if (pr.data.mergeable === false) { | |
| await github.rest.issues.removeLabel({ | |
| owner, | |
| repo, | |
| issue_number: issue.number, | |
| name: 'scheduled-merge', | |
| }); | |
| await github.rest.issues.createComment({ | |
| owner, | |
| repo, | |
| issue_number: issue.number, | |
| body: 'Scheduled merge failed: PR has merge conflicts or is not mergeable. Please resolve and reschedule.', | |
| }); | |
| continue; | |
| } | |
| try { | |
| await github.rest.pulls.merge({ | |
| owner, | |
| repo, | |
| pull_number: issue.number, | |
| merge_method: 'merge', | |
| sha: pr.data.head.sha, | |
| }); | |
| await github.rest.issues.removeLabel({ | |
| owner, | |
| repo, | |
| issue_number: issue.number, | |
| name: 'scheduled-merge', | |
| }); | |
| await github.rest.issues.createComment({ | |
| owner, | |
| repo, | |
| issue_number: issue.number, | |
| body: `Merged automatically at ${now.toISOString()}.`, | |
| }); | |
| } catch (error) { | |
| await github.rest.issues.removeLabel({ | |
| owner, | |
| repo, | |
| issue_number: issue.number, | |
| name: 'scheduled-merge', | |
| }); | |
| await github.rest.issues.createComment({ | |
| owner, | |
| repo, | |
| issue_number: issue.number, | |
| body: `Scheduled merge failed and was cleared. ${error.message || error}`, | |
| }); | |
| } | |
| } |