Skip to content

Reconcile verified agent pull request merges #1

Reconcile verified agent pull request merges

Reconcile verified agent pull request merges #1

name: Reconcile verified agent pull request merges
on:
schedule:
- cron: "*/5 * * * *"
workflow_dispatch:
permissions:
actions: read
contents: write
pull-requests: write
concurrency:
group: reconcile-verified-agent-merges
cancel-in-progress: false
jobs:
reconcile:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Merge eligible exact-head verified pull requests
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3
with:
script: |
const { owner, repo } = context.repo;
const trustedAssociations = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
const pulls = await github.paginate(github.rest.pulls.list, {
owner,
repo,
state: "open",
base: "main",
per_page: 100,
});
const candidates = pulls.filter((pr) => {
const markedSyncBot =
pr.user?.login === "github-actions[bot]" &&
pr.head.ref.startsWith("agent/upstream-sync-") &&
pr.body?.includes("Automated-Upstream-Mike-Sync: true");
return (
!pr.draft &&
pr.head.repo?.full_name === `${owner}/${repo}` &&
pr.head.ref.startsWith("agent/") &&
(trustedAssociations.has(pr.author_association) || markedSyncBot)
);
});
const gateQuery = `
query($owner: String!, $repo: String!, $number: Int!, $after: String) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
state
isDraft
headRefOid
reviewDecision
mergeable
reviewThreads(first: 100, after: $after) {
nodes { isResolved }
pageInfo { hasNextPage endCursor }
}
}
}
}
`;
const readGate = async (number) => {
let after = null;
let pullRequest = null;
let unresolved = false;
do {
const result = await github.graphql(gateQuery, {
owner,
repo,
number,
after,
});
pullRequest = result.repository.pullRequest;
unresolved ||= pullRequest.reviewThreads.nodes.some(
(thread) => !thread.isResolved,
);
after = pullRequest.reviewThreads.pageInfo.hasNextPage
? pullRequest.reviewThreads.pageInfo.endCursor
: null;
} while (after);
return { pullRequest, unresolved };
};
for (const pr of candidates) {
const runs = await github.paginate(
github.rest.actions.listWorkflowRunsForRepo,
{
owner,
repo,
branch: pr.head.ref,
status: "completed",
per_page: 100,
},
);
const verified = runs.some(
(run) =>
run.name === "Baseline verification" &&
run.conclusion === "success" &&
run.head_sha === pr.head.sha &&
(run.event === "pull_request" || run.event === "workflow_dispatch"),
);
if (!verified) {
core.info(`PR #${pr.number} has no successful exact-head Baseline.`);
continue;
}
let gate;
for (let attempt = 1; attempt <= 6; attempt += 1) {
gate = await readGate(pr.number);
if (gate.pullRequest.mergeable !== "UNKNOWN") break;
if (attempt < 6) await new Promise((resolve) => setTimeout(resolve, 10000));
}
const node = gate.pullRequest;
const blocked =
node.state !== "OPEN" ||
node.isDraft ||
node.headRefOid !== pr.head.sha ||
node.reviewDecision === "CHANGES_REQUESTED" ||
node.mergeable !== "MERGEABLE" ||
gate.unresolved;
if (blocked) {
core.info(`PR #${pr.number} still has a review, head, or mergeability blocker.`);
continue;
}
try {
await github.rest.pulls.merge({
owner,
repo,
pull_number: pr.number,
merge_method: "squash",
sha: pr.head.sha,
});
core.notice(`Reconciled and merged PR #${pr.number} at verified head ${pr.head.sha}.`);
} catch (error) {
core.warning(`PR #${pr.number} was eligible but merge failed: ${error.message}`);
}
}