Merge verified agent pull requests #32
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Merge verified agent pull requests | |
| on: | |
| workflow_run: | |
| workflows: ["Baseline verification"] | |
| types: [completed] | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| concurrency: | |
| group: merge-verified-${{ github.event.workflow_run.id }} | |
| cancel-in-progress: false | |
| jobs: | |
| merge: | |
| if: >- | |
| github.event.workflow_run.conclusion == 'success' && | |
| (github.event.workflow_run.event == 'pull_request' || | |
| github.event.workflow_run.event == 'workflow_dispatch') | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| steps: | |
| - name: Verify exact PR head and merge | |
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 | |
| with: | |
| script: | | |
| const run = context.payload.workflow_run; | |
| const { owner, repo } = context.repo; | |
| const resolvePullRequest = async () => { | |
| const linked = run.pull_requests?.[0]; | |
| if (run.event === "pull_request" && linked) { | |
| const { data: pr } = await github.rest.pulls.get({ | |
| owner, | |
| repo, | |
| pull_number: linked.number, | |
| }); | |
| return { pr, verifiedHead: linked.head.sha }; | |
| } | |
| if (run.event !== "workflow_dispatch" || !run.head_branch) { | |
| return null; | |
| } | |
| const candidates = await github.paginate(github.rest.pulls.list, { | |
| owner, | |
| repo, | |
| state: "open", | |
| base: "main", | |
| head: `${owner}:${run.head_branch}`, | |
| per_page: 100, | |
| }); | |
| const matches = candidates.filter( | |
| (pr) => | |
| pr.head.repo?.full_name === `${owner}/${repo}` && | |
| pr.head.ref === run.head_branch && | |
| pr.head.sha === run.head_sha, | |
| ); | |
| if (matches.length !== 1) { | |
| core.info( | |
| `Dispatched Baseline resolved ${matches.length} exact open PRs; expected one.`, | |
| ); | |
| return null; | |
| } | |
| return { pr: matches[0], verifiedHead: run.head_sha }; | |
| }; | |
| const resolved = await resolvePullRequest(); | |
| if (!resolved) { | |
| core.info("No exact pull request is linked to this Baseline run."); | |
| return; | |
| } | |
| const { pr, verifiedHead } = resolved; | |
| const trustedAssociations = new Set(["OWNER", "MEMBER", "COLLABORATOR"]); | |
| const markedSyncBot = | |
| pr.user?.login === "github-actions[bot]" && | |
| pr.head.ref.startsWith("agent/upstream-sync-") && | |
| pr.body?.includes("Automated-Upstream-Mike-Sync: true"); | |
| const eligible = | |
| pr.state === "open" && | |
| !pr.draft && | |
| pr.head.repo?.full_name === `${owner}/${repo}` && | |
| pr.head.ref.startsWith("agent/") && | |
| (trustedAssociations.has(pr.author_association) || markedSyncBot) && | |
| pr.head.sha === verifiedHead; | |
| if (!eligible) { | |
| core.info("PR is not an eligible trusted same-repository agent PR at the verified head."); | |
| return; | |
| } | |
| const query = ` | |
| 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 () => { | |
| let after = null; | |
| let pullRequest = null; | |
| let unresolved = false; | |
| do { | |
| const result = await github.graphql(query, { | |
| owner, | |
| repo, | |
| number: pr.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 }; | |
| }; | |
| let gate; | |
| for (let attempt = 1; attempt <= 6; attempt += 1) { | |
| gate = await readGate(); | |
| if (gate.pullRequest.mergeable !== "UNKNOWN") break; | |
| core.info(`Mergeability is still UNKNOWN (attempt ${attempt}/6).`); | |
| if (attempt < 6) await new Promise((resolve) => setTimeout(resolve, 10000)); | |
| } | |
| const node = gate.pullRequest; | |
| const blocked = | |
| node.state !== "OPEN" || | |
| node.isDraft || | |
| node.headRefOid !== verifiedHead || | |
| node.reviewDecision === "CHANGES_REQUESTED" || | |
| node.mergeable !== "MERGEABLE" || | |
| gate.unresolved; | |
| if (blocked) { | |
| core.info("PR has changed or has a review/merge blocker; it will not be merged."); | |
| return; | |
| } | |
| await github.rest.pulls.merge({ | |
| owner, | |
| repo, | |
| pull_number: pr.number, | |
| merge_method: "squash", | |
| sha: verifiedHead, | |
| }); | |
| core.notice(`Merged PR #${pr.number} immediately after successful final-head Baseline verification.`); |