diff --git a/.github/workflows/scheduled_merge.yml b/.github/workflows/scheduled_merge.yml deleted file mode 100644 index 747ebe5f..00000000 --- a/.github/workflows/scheduled_merge.yml +++ /dev/null @@ -1,344 +0,0 @@ -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 = ``; - 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(//); - 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}`, - }); - } - }