Files
hapi/.github/scripts/auto-merge.test.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

159 lines
7.4 KiB
JavaScript

const assert = require('node:assert/strict');
const { test } = require('node:test');
const { latestChecks, evaluateChecks, decide, autoMerge } = require('./auto-merge.cjs');
const HEAD = 'a'.repeat(40);
const OLD = 'c'.repeat(40);
const REQUIRED = ['test', 'integration'];
function run(name, conclusion, databaseId = 1, status = 'COMPLETED') {
return { __typename: 'CheckRun', databaseId, name, status, conclusion: status === 'COMPLETED' ? conclusion : null };
}
function passingRuns() {
return [run('test', 'SUCCESS', 1), run('integration', 'SUCCESS', 2), run('windows-codex-mcp', 'SUCCESS', 3), run('build', 'SKIPPED', 4)];
}
function approval(login = 'tiann', state = 'APPROVED', oid = HEAD) {
return { author: { login }, state, commit: { oid } };
}
function pull(overrides = {}) {
const { checks = passingRuns(), reviews = [approval()], ...rest } = overrides;
return {
number: 7, state: 'OPEN', isDraft: false, mergeable: 'MERGEABLE', headRefOid: HEAD,
latestOpinionatedReviews: { nodes: reviews },
commits: { nodes: [{ commit: { oid: HEAD, statusCheckRollup: { contexts: { pageInfo: { hasNextPage: false }, nodes: checks } } } }] },
...rest,
};
}
function fakeGithub(pulls, { mergeError } = {}) {
const merges = [];
return {
merges,
async graphql(query, variables) {
if (variables.number === undefined) {
return { repository: { pullRequests: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: pulls } } };
}
return { repository: { pullRequest: pulls.find(item => item.number === variables.number) } };
},
rest: {
pulls: {
async merge(params) {
if (mergeError) throw new Error(mergeError);
merges.push(params);
},
},
},
};
}
function fakeCore() {
return { infos: [], warnings: [], failed: null, info(m) { this.infos.push(m); }, warning(m) { this.warnings.push(m); }, setFailed(m) { this.failed = m; } };
}
const context = { repo: { owner: 'tiann', repo: 'hapi' } };
const env = { AUTO_MERGE_TOKEN_CONFIGURED: 'true' };
test('merges an owner-approved PR whose checks passed, pinned to the reviewed head', async () => {
const github = fakeGithub([pull()]);
const results = await autoMerge({ github, context, core: fakeCore(), env });
assert.deepEqual(results, [{ number: 7, action: 'merged' }]);
assert.deepEqual(github.merges, [{ owner: 'tiann', repo: 'hapi', pull_number: 7, merge_method: 'squash', sha: HEAD }]);
});
test('requires the approver to have approved the current head', () => {
assert.equal(decide(pull({ reviews: [approval('tiann', 'APPROVED', OLD)] }), 'tiann', REQUIRED).action, 'skip');
assert.equal(decide(pull({ reviews: [approval('someone')] }), 'tiann', REQUIRED).action, 'skip');
assert.equal(decide(pull({ reviews: [approval('tiann', 'CHANGES_REQUESTED')] }), 'tiann', REQUIRED).action, 'skip');
});
test('skips drafts and conflicts, and waits while mergeability is unknown', () => {
assert.equal(decide(pull({ isDraft: true }), 'tiann', REQUIRED).action, 'skip');
assert.equal(decide(pull({ mergeable: 'CONFLICTING' }), 'tiann', REQUIRED).action, 'skip');
assert.equal(decide(pull({ mergeable: 'UNKNOWN' }), 'tiann', REQUIRED).action, 'wait');
});
test('waits for missing required checks and for checks still running', () => {
assert.deepEqual(
decide(pull({ checks: [run('test', 'SUCCESS')] }), 'tiann', REQUIRED),
{ action: 'wait', reason: 'waiting for required checks: integration' },
);
assert.deepEqual(
decide(pull({ checks: [...passingRuns(), run('pr-review', null, 9, 'IN_PROGRESS')] }), 'tiann', REQUIRED),
{ action: 'wait', reason: 'waiting for checks: pr-review' },
);
});
test('a failing check blocks the merge even when required checks are missing', () => {
assert.deepEqual(
decide(pull({ checks: [run('test', 'FAILURE')] }), 'tiann', REQUIRED),
{ action: 'skip', reason: 'failing checks: test' },
);
assert.equal(evaluateChecks({ contexts: { nodes: [{ __typename: 'StatusContext', context: 'ci/ext', state: 'ERROR', createdAt: '2026-10-28T00:00:00Z' }] } }, []).state, 'fail');
});
test('only the latest run of each check counts', () => {
const rerunPassed = [run('test', 'CANCELLED', 1), run('integration', 'SUCCESS', 2), run('test', 'SUCCESS', 5)];
assert.equal(decide(pull({ checks: rerunPassed }), 'tiann', REQUIRED).action, 'merge');
const rerunFailed = [run('test', 'SUCCESS', 1), run('integration', 'SUCCESS', 2), run('test', 'FAILURE', 5)];
assert.equal(decide(pull({ checks: rerunFailed }), 'tiann', REQUIRED).action, 'skip');
assert.deepEqual(latestChecks(rerunPassed).map(check => [check.name, check.state]), [['test', 'pass'], ['integration', 'pass']]);
});
test('refuses to merge without the dedicated token but stays quiet with nothing to merge', async () => {
const core = fakeCore();
const github = fakeGithub([pull()]);
assert.deepEqual(await autoMerge({ github, context, core, env: {} }), []);
assert.match(core.failed, /AUTO_MERGE_TOKEN/);
assert.equal(github.merges.length, 0);
const quiet = fakeCore();
await autoMerge({ github: fakeGithub([pull({ reviews: [] })]), context, core: quiet, env: {} });
assert.equal(quiet.failed, null);
});
test('reports a rejected merge and keeps going', async () => {
const core = fakeCore();
const results = await autoMerge({ github: fakeGithub([pull()], { mergeError: 'Head branch was modified' }), context, core, env });
assert.deepEqual(results, [{ number: 7, action: 'error', reason: 'Head branch was modified' }]);
assert.equal(core.warnings.length, 1);
});
function withMergeability(github, states) {
const graphql = github.graphql;
github.graphql = async (query, variables) => {
const data = await graphql(query, variables);
if (variables.number === undefined) return data;
return { repository: { pullRequest: { ...data.repository.pullRequest, mergeable: states.shift() } } };
};
return github;
}
test('re-reads mergeability while GitHub is still computing it', async () => {
const github = withMergeability(fakeGithub([pull()]), ['UNKNOWN', 'UNKNOWN', 'MERGEABLE']);
const sleeps = [];
const results = await autoMerge({ github, context, core: fakeCore(), env, sleep: async ms => sleeps.push(ms) });
assert.deepEqual(results, [{ number: 7, action: 'merged' }]);
assert.equal(sleeps.length, 2);
});
test('stops re-reading mergeability after a bounded number of attempts', async () => {
const github = withMergeability(fakeGithub([pull()]), Array(10).fill('UNKNOWN'));
const sleeps = [];
const results = await autoMerge({ github, context, core: fakeCore(), env, sleep: async ms => sleeps.push(ms) });
assert.deepEqual(results, [{ number: 7, action: 'wait', reason: 'mergeability is still being computed' }]);
assert.equal(sleeps.length, 5);
assert.equal(github.merges.length, 0);
});
test('uses the configured approver and required checks', async () => {
const github = fakeGithub([pull({ reviews: [approval('maintainer')], checks: [run('lint', 'SUCCESS')] })]);
const results = await autoMerge({
github, context, core: fakeCore(),
env: { ...env, AUTO_MERGE_APPROVER: 'maintainer', AUTO_MERGE_REQUIRED_CHECKS: 'lint' },
});
assert.deepEqual(results, [{ number: 7, action: 'merged' }]);
});