Files
hapi/.github/scripts/auto-merge.cjs
T
weishuandGitHub 7c715843bb ci: merge owner-approved PRs automatically after checks pass (#2217)
* ci: merge owner-approved PRs automatically after checks pass

Approval by the repo owner on a PR should be the only manual step before
merge. The auto-merge workflow squash-merges open PRs the owner approved
at their current head once every required check's latest run on that
head has passed (test, integration, windows-codex-mcp).

Fork PR review events receive no secrets, so a no-op signal workflow
runs on pull_request_review and its completion triggers the privileged
workflow, which runs default-branch code. Merges use AUTO_MERGE_TOKEN, a
user token, so the resulting pushes to main trigger push workflows; the
workflow refuses to merge if that secret is missing.

* ci: load the auto-merge script from main on every trigger

workflow_dispatch checks out the ref chosen by the caller, so a manual
run from a topic branch would execute unmerged merge logic with the
owner's token. Pin the checkout to main.
2026-10-28 13:02:05 +08:00

176 lines
7.8 KiB
JavaScript

// Merges open PRs that the approver approved at their current head once
// every check on that head has passed. Runs from trusted default-branch code.
const DEFAULT_REQUIRED_CHECKS = ['test', 'integration', 'windows-codex-mcp'];
const PASSING_CONCLUSIONS = ['SUCCESS', 'NEUTRAL', 'SKIPPED'];
const MERGEABILITY_ATTEMPTS = 6;
const MERGEABILITY_DELAY_MS = 5000;
const OPEN_PULLS_QUERY = `query($owner: String!, $repo: String!, $cursor: String) {
repository(owner: $owner, name: $repo) {
pullRequests(states: OPEN, first: 100, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes {
number isDraft headRefOid
latestOpinionatedReviews(first: 100) { nodes { author { login } state commit { oid } } }
}
}
}
}`;
const PULL_QUERY = `query($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
number state isDraft mergeable headRefOid
latestOpinionatedReviews(first: 100) { nodes { author { login } state commit { oid } } }
commits(last: 1) {
nodes {
commit {
oid
statusCheckRollup {
contexts(first: 100) {
pageInfo { hasNextPage }
nodes {
__typename
... on CheckRun { databaseId name status conclusion }
... on StatusContext { context state createdAt }
}
}
}
}
}
}
}
}
}`;
function parseList(value) {
const items = (value || '').split(',').map(item => item.trim()).filter(Boolean);
return items.length ? items : null;
}
function approvedAtHead(pull, approver) {
return (pull.latestOpinionatedReviews?.nodes || []).some(review =>
review.author?.login === approver
&& review.state === 'APPROVED'
&& review.commit?.oid === pull.headRefOid
);
}
// Re-runs and repeated triggers leave several runs with one name on a commit;
// only the most recent one describes the current state of that check.
function latestChecks(contexts) {
const latest = new Map();
for (const node of contexts) {
const check = node.__typename === 'StatusContext'
? { key: `status:${node.context}`, name: node.context, order: Date.parse(node.createdAt) || 0, state: statusState(node.state) }
: { key: `check:${node.name}`, name: node.name, order: node.databaseId || 0, state: checkRunState(node) };
const previous = latest.get(check.key);
if (!previous || check.order >= previous.order) latest.set(check.key, check);
}
return [...latest.values()];
}
function checkRunState(run) {
if (run.status !== 'COMPLETED') return 'pending';
return PASSING_CONCLUSIONS.includes(run.conclusion) ? 'pass' : 'fail';
}
function statusState(state) {
if (state === 'SUCCESS') return 'pass';
return state === 'PENDING' || state === 'EXPECTED' ? 'pending' : 'fail';
}
function evaluateChecks(rollup, requiredChecks) {
const contexts = rollup?.contexts;
if (contexts?.pageInfo?.hasNextPage) return { state: 'pending', reason: 'too many checks to evaluate on one page' };
const checks = latestChecks(contexts?.nodes || []);
const failed = checks.filter(check => check.state === 'fail').map(check => check.name);
if (failed.length) return { state: 'fail', reason: `failing checks: ${failed.join(', ')}` };
const names = new Set(checks.filter(check => check.key.startsWith('check:')).map(check => check.name));
const missing = requiredChecks.filter(name => !names.has(name));
if (missing.length) return { state: 'pending', reason: `waiting for required checks: ${missing.join(', ')}` };
const pending = checks.filter(check => check.state === 'pending').map(check => check.name);
if (pending.length) return { state: 'pending', reason: `waiting for checks: ${pending.join(', ')}` };
return { state: 'pass' };
}
function decide(pull, approver, requiredChecks) {
if (!pull || pull.state !== 'OPEN') return { action: 'skip', reason: 'PR is no longer open' };
if (pull.isDraft) return { action: 'skip', reason: 'PR is a draft' };
if (!approvedAtHead(pull, approver)) return { action: 'skip', reason: `no approval from ${approver} at the current head` };
if (pull.mergeable === 'CONFLICTING') return { action: 'skip', reason: 'PR has merge conflicts' };
if (pull.mergeable !== 'MERGEABLE') return { action: 'wait', reason: 'mergeability is still being computed' };
const commit = pull.commits?.nodes?.[0]?.commit;
if (commit?.oid !== pull.headRefOid) return { action: 'wait', reason: 'head commit changed while loading' };
const checks = evaluateChecks(commit.statusCheckRollup, requiredChecks);
if (checks.state === 'fail') return { action: 'skip', reason: checks.reason };
if (checks.state === 'pending') return { action: 'wait', reason: checks.reason };
return { action: 'merge' };
}
async function listOpenPulls(github, owner, repo) {
const pulls = [];
let cursor = null;
do {
const { repository } = await github.graphql(OPEN_PULLS_QUERY, { owner, repo, cursor });
const page = repository.pullRequests;
pulls.push(...page.nodes);
cursor = page.pageInfo.hasNextPage ? page.pageInfo.endCursor : null;
} while (cursor);
return pulls;
}
// GitHub computes mergeability lazily and resets it whenever the base branch
// moves, so a first read (and every read after a merge) is often UNKNOWN.
async function loadDecision(github, request, approver, requiredChecks, sleep) {
for (let attempt = 1; ; attempt++) {
const { repository } = await github.graphql(PULL_QUERY, request);
const pull = repository.pullRequest;
const decision = decide(pull, approver, requiredChecks);
if (pull?.mergeable !== 'UNKNOWN' || attempt >= MERGEABILITY_ATTEMPTS) return { pull, decision };
await sleep(MERGEABILITY_DELAY_MS);
}
}
async function autoMerge({ github, context, core, env = process.env, sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) }) {
const { owner, repo } = context.repo;
const approver = env.AUTO_MERGE_APPROVER || owner;
const requiredChecks = parseList(env.AUTO_MERGE_REQUIRED_CHECKS) || DEFAULT_REQUIRED_CHECKS;
const candidates = (await listOpenPulls(github, owner, repo))
.filter(pull => !pull.isDraft && approvedAtHead(pull, approver));
if (!candidates.length) {
core.info(`No open PRs approved by ${approver} at their current head.`);
return [];
}
// Merges made with GITHUB_TOKEN do not trigger push workflows on the base
// branch, so refuse to merge without the dedicated token.
if (env.AUTO_MERGE_TOKEN_CONFIGURED !== 'true') {
core.setFailed('AUTO_MERGE_TOKEN secret is not configured; approved PRs were not merged.');
return [];
}
const results = [];
for (const { number } of candidates) {
const loaded = await loadDecision(github, { owner, repo, number }, approver, requiredChecks, sleep);
const { pull } = loaded;
let { decision } = loaded;
if (decision.action === 'merge') {
try {
// Pinning the reviewed head refuses the merge if new commits arrived.
await github.rest.pulls.merge({ owner, repo, pull_number: number, merge_method: 'squash', sha: pull.headRefOid });
decision = { action: 'merged' };
} catch (error) {
decision = { action: 'error', reason: error.message };
core.warning(`#${number}: merge failed: ${error.message}`);
}
}
core.info(`#${number}: ${decision.action}${decision.reason ? ` (${decision.reason})` : ''}`);
results.push({ number, ...decision });
}
return results;
}
module.exports = { approvedAtHead, latestChecks, evaluateChecks, decide, autoMerge };