Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a guard job to prevent accidental auto-merging of PRs when cross-version tests fail #10210

Merged
merged 20 commits into from
Nov 15, 2023
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
114 changes: 114 additions & 0 deletions .github/workflows/auto-merge.js
harupy marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
module.exports = async ({ github, context }) => {
const {
repo: { owner, repo },
} = context;

const MERGE_INTERVAL_MS = 5000; // 5 seconds pause after a merge
const MAX_RETRIES = 3;
const RETRY_INTERVAL_MS = 10000; // 10 seconds

async function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

async function logRateLimit() {
const { data: rateLimit } = await github.rest.rateLimit.get();
console.log(`Rate limit remaining: ${rateLimit.resources.core.remaining}`);
console.log(
`Rate limit resets at: ${new Date(rateLimit.resources.core.reset * 1000).toISOString()}`
);
}

async function fetchPullRequestDetails(prNumber) {
for (let i = 0; i < MAX_RETRIES; i++) {
const pullRequest = await github.rest.pulls
.get({
owner,
repo,
pull_number: prNumber,
})
.then((res) => res.data);

if (pullRequest.mergeable !== null) {
return pullRequest;
}

console.log(`Waiting for mergeability calculation for PR #${prNumber}...`);
await sleep(RETRY_INTERVAL_MS);
}
return null;
}

async function isPRApproved(prNumber) {
const { data: reviews } = await github.rest.pulls.listReviews({
owner,
repo,
pull_number: prNumber,
});
return reviews.some((review) => review.state === "APPROVED");
}

async function areAllChecksPassed(sha) {
const { data: checkRuns } = await github.rest.checks.listForRef({
owner,
repo,
ref: sha,
});
return checkRuns.check_runs.every(({ conclusion }) =>
["success", "skipped"].includes(conclusion)
);
}

// Get date from a month ago in ISO format
const oneMonthAgo = new Date();
oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1);
const sinceDate = oneMonthAgo.toISOString();

// List PRs with the "automerge" label created within the last month
const { data: issues } = await github.rest.issues.listForRepo({
owner,
repo,
labels: "automerge",
since: sinceDate,
});

// Filter for pull requests from the list of issues
const pullRequests = issues.filter((issue) => issue.pull_request);

for (const pr of pullRequests) {
const pullRequest = await fetchPullRequestDetails(pr.number);

if (!pullRequest?.mergeable) {
console.log(
`PR #${pr.number} is not mergeable or could not fetch details. Skipping this PR.`
);
await logRateLimit();
continue;
}
TomeHirata marked this conversation as resolved.
Show resolved Hide resolved

if (!(await isPRApproved(pr.number))) {
TomeHirata marked this conversation as resolved.
Show resolved Hide resolved
console.log(`PR #${pr.number} hasn't been approved. Skipping merge.`);
await logRateLimit();
continue;
}

if (await areAllChecksPassed(pullRequest.head.sha)) {
try {
await github.rest.pulls.merge({
owner,
repo,
pull_number: pr.number,
});
console.log(`Merged PR #${pr.number}`);

await sleep(MERGE_INTERVAL_MS);
await logRateLimit();
} catch (error) {
console.log(`Failed to merge PR #${pr.number}. Reason: ${error.message}`);
}
} else {
console.log(`Checks not ready for PR #${pr.number}. Skipping merge.`);
await logRateLimit();
}
}
};
20 changes: 20 additions & 0 deletions .github/workflows/auto-merge.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
name: Automerge

on:
schedule:
- cron: "*/10 * * * *" # Run every 10 minutes

jobs:
merge:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4
- name: Automerge
uses: actions/github-script@v6
with:
github-token: ${{secrets.GITHUB_TOKEN}}
retries: 3
TomeHirata marked this conversation as resolved.
Show resolved Hide resolved
script: |
const script = require('./.github/workflows/auto-merge.js');
await script({github, context});
33 changes: 33 additions & 0 deletions .github/workflows/remove-automerge-label.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: Remove automerge label

on:
pull_request_target:
types:
- synchronize

jobs:
remove-label:
runs-on: ubuntu-latest
harupy marked this conversation as resolved.
Show resolved Hide resolved
if: ${{ !contains(fromJSON('["OWNER", "COLLABORATOR", "MEMBER"]'), github.event.pull_request.author_association )}}
steps:
- uses: actions/checkout@v4
- name: Remove automerge label
uses: actions/github-script@v5
with:
github-token: ${{secrets.GITHUB_TOKEN}}
script: |
const { owner, repo, number: issue_number } = context.issue;
const labelToRemove = "automerge";

// Fetch current labels on the PR
const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ owner, repo, issue_number });

// Check if the label is present
const hasLabel = currentLabels.some(label => label.name === labelToRemove);

if (hasLabel) {
await github.rest.issues.removeLabel({ owner, repo, issue_number, name: labelToRemove });
console.log(`Removed label "${labelToRemove}" from PR #${issue_number}`);
} else {
console.log(`Label "${labelToRemove}" not found on PR #${issue_number}`);
}