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.
176 lines
7.8 KiB
JavaScript
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 };
|