diff --git a/.github/workflows/reconcile-verified-agent-merges.yml b/.github/workflows/reconcile-verified-agent-merges.yml new file mode 100644 index 000000000..40f47a756 --- /dev/null +++ b/.github/workflows/reconcile-verified-agent-merges.yml @@ -0,0 +1,145 @@ +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}`); + } + }