mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-07 01:21:37 +00:00
* 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.
159 lines
7.4 KiB
JavaScript
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' }]);
|
|
});
|