Skip to content

scheduled merge

scheduled merge #194

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}`,
});
}
}