diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml index 66b6aa847..b9992f90b 100644 --- a/.github/workflows/dependabot-auto-merge.yml +++ b/.github/workflows/dependabot-auto-merge.yml @@ -3,143 +3,219 @@ name: Dependabot Auto Merge on: workflow_run: workflows: - - CI + - "Build and Push Image" + - "CI" + - "CodeQL" + - "Commit Lint" + - "Copilot" + - "Dependabot Updates" + - "Deploy to Cloudflare Pages" + - "Deploy to Cloudflare Workers" + - "Deploy to EdgeOne Pages" + - "Deploy to Vercel" + - "Sync to Pages and Functions" types: - completed + workflow_dispatch: concurrency: - group: dependabot-auto-merge-${{ github.event.workflow_run.head_branch }} - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch || github.run_id }} + cancel-in-progress: false permissions: contents: write pull-requests: write + checks: read + statuses: read jobs: merge: name: Auto-merge Dependabot PRs - if: ${{ github.event.workflow_run.event == 'pull_request' }} runs-on: ubuntu-latest timeout-minutes: 10 steps: - - name: Find matching Dependabot PR - id: pr - uses: actions/github-script@v9 - with: - script: | - const branch = context.payload.workflow_run.head_branch; - const { owner, repo } = context.repo; - - const { data: pulls } = await github.rest.pulls.list({ - owner, - repo, - state: "open", - head: `${owner}:${branch}`, - per_page: 10, - }); - - const pr = pulls.find((item) => item.user?.login === "dependabot[bot]"); - - if (!pr) { - core.info(`No open Dependabot PR found for branch ${branch}.`); - core.setOutput("should_merge", "false"); - return; - } - - core.setOutput("should_merge", "true"); - core.setOutput("number", String(pr.number)); - - - name: Merge PR when checks pass - if: steps.pr.outputs.should_merge == 'true' + - name: Merge Dependabot PRs when checks pass uses: actions/github-script@v9 with: script: | const owner = context.repo.owner; const repo = context.repo.repo; - const pull_number = Number("${{ steps.pr.outputs.number }}"); - - const isSuccessful = (status) => - status === "SUCCESS" || - status === "SKIPPED" || - status === "NEUTRAL"; + const successfulCheckConclusions = new Set(["success", "skipped", "neutral"]); + const successfulStatusStates = new Set(["success"]); const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - const getPullRequest = async () => { - const { data } = await github.rest.pulls.get({ + + const latestBy = (items, keyOf, timeOf) => { + const latest = new Map(); + for (const item of items) { + const key = keyOf(item); + const itemTime = new Date(timeOf(item) || 0).getTime(); + const existing = latest.get(key); + const existingTime = existing ? new Date(timeOf(existing) || 0).getTime() : -1; + if (!existing || itemTime >= existingTime) { + latest.set(key, item); + } + } + return [...latest.values()]; + }; + + const findCandidatePulls = async () => { + const branch = context.payload.workflow_run?.head_branch; + if (context.eventName === "workflow_run" && branch) { + const { data: pulls } = await github.rest.pulls.list({ + owner, + repo, + state: "open", + head: owner + ":" + branch, + per_page: 10, + }); + + return pulls.filter((pr) => pr.user?.login === "dependabot[bot]"); + } + + const pulls = await github.paginate(github.rest.pulls.list, { owner, repo, - pull_number, + state: "open", + per_page: 100, }); - return data; + + return pulls.filter((pr) => pr.user?.login === "dependabot[bot]"); }; - let pr = await getPullRequest(); + const getMergeablePullRequest = async (pull_number) => { + for (let attempt = 1; attempt <= 6; attempt += 1) { + const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number }); + if (pr.mergeable !== null) { + return pr; + } + core.info("PR #" + pull_number + " mergeability is still being computed; retry " + attempt + "/6."); + await wait(5000); + } - if (pr.user?.login !== "dependabot[bot]") { - core.info(`PR #${pull_number} is no longer a Dependabot PR.`); - return; - } + const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number }); + return pr; + }; - if (pr.draft) { - core.info(`PR #${pull_number} is still a draft.`); - return; - } + const getSignalState = async (sha) => { + const checkRuns = await github.paginate(github.rest.checks.listForRef, { + owner, + repo, + ref: sha, + per_page: 100, + }); - for (let attempt = 1; attempt <= 6 && pr.mergeable === null; attempt += 1) { - core.info(`PR #${pull_number} mergeability is still being computed; retry ${attempt}/6.`); - await wait(5000); - pr = await getPullRequest(); - } + const statuses = await github.paginate(github.rest.repos.listCommitStatusesForRef, { + owner, + repo, + ref: sha, + per_page: 100, + }); - if (pr.mergeable !== true) { - core.info(`PR #${pull_number} is not currently mergeable; skipping auto-merge.`); + const latestChecks = latestBy( + checkRuns.filter((run) => run.name !== "Dependabot Auto Merge"), + (run) => (run.app?.slug || "unknown") + ":" + run.name, + (run) => run.completed_at || run.started_at || run.created_at, + ); + const latestStatuses = latestBy(statuses, (status) => status.context, (status) => status.updated_at || status.created_at); + + const pendingChecks = latestChecks.filter((run) => run.status !== "completed"); + const failedChecks = latestChecks.filter( + (run) => run.status === "completed" && !successfulCheckConclusions.has(String(run.conclusion || "").toLowerCase()), + ); + const failedStatuses = latestStatuses.filter((status) => !successfulStatusStates.has(String(status.state || "").toLowerCase())); + + return { + totalSignals: latestChecks.length + latestStatuses.length, + pendingChecks, + failedChecks, + failedStatuses, + }; + }; + + const { data: repository } = await github.rest.repos.get({ owner, repo }); + const mergeMethods = []; + if (repository.allow_merge_commit) mergeMethods.push("merge"); + if (repository.allow_squash_merge) mergeMethods.push("squash"); + if (repository.allow_rebase_merge) mergeMethods.push("rebase"); + + if (mergeMethods.length === 0) { + core.info("This repository has no enabled pull request merge methods."); return; } - const { data: checks } = await github.rest.checks.listForRef({ - owner, - repo, - ref: pr.head.sha, - }); - - const requiredChecks = ["Lint", "Test and Coverage", "Type Check"]; - const checkMap = new Map(checks.check_runs.map((run) => [run.name, run])); - const missing = requiredChecks.filter((name) => !checkMap.has(name)); - if (missing.length > 0) { - core.info(`PR #${pull_number} is missing checks: ${missing.join(", ")}.`); + const pulls = await findCandidatePulls(); + if (pulls.length === 0) { + core.info("No open Dependabot PRs to evaluate."); return; } - const failed = requiredChecks.filter((name) => { - const run = checkMap.get(name); - return run.status !== "completed" || !isSuccessful(String(run.conclusion).toUpperCase()); - }); + for (const candidate of pulls) { + const pull_number = candidate.number; + const pr = await getMergeablePullRequest(pull_number); - if (failed.length > 0) { - core.info(`PR #${pull_number} still has pending or failing checks: ${failed.join(", ")}.`); - return; - } + if (pr.user?.login !== "dependabot[bot]") { + core.info("PR #" + pull_number + " is no longer a Dependabot PR."); + continue; + } - try { - await github.rest.pulls.merge({ - owner, - repo, - pull_number, - merge_method: "merge", - }); - } catch (error) { - if (error.status === 405 || error.status === 409) { - core.info(`PR #${pull_number} could not be merged right now: ${error.message}`); - return; + if (pr.state !== "open" || pr.draft) { + core.info("PR #" + pull_number + " is not an open, ready PR."); + continue; } - throw error; - } + if (pr.mergeable !== true) { + core.info("PR #" + pull_number + " is not currently mergeable."); + continue; + } + + const signalState = await getSignalState(pr.head.sha); + if (signalState.totalSignals === 0) { + core.info("PR #" + pull_number + " has no checks or statuses yet; skipping."); + continue; + } + + if (signalState.pendingChecks.length > 0) { + core.info("PR #" + pull_number + " still has pending checks: " + signalState.pendingChecks.map((run) => run.name).join(", ") + "."); + continue; + } + + if (signalState.failedChecks.length > 0 || signalState.failedStatuses.length > 0) { + const failedChecks = signalState.failedChecks.map((run) => run.name + "=" + run.conclusion).join(", "); + const failedStatuses = signalState.failedStatuses.map((status) => status.context + "=" + status.state).join(", "); + core.info("PR #" + pull_number + " is not green. Checks: " + (failedChecks || "none") + ". Statuses: " + (failedStatuses || "none") + "."); + continue; + } + + let merged = false; + let lastError = null; + for (const merge_method of mergeMethods) { + try { + await github.rest.pulls.merge({ owner, repo, pull_number, merge_method }); + core.info("Merged Dependabot PR #" + pull_number + " with " + merge_method + "."); + merged = true; + break; + } catch (error) { + lastError = error; + if (error.status === 403 || error.status === 405 || error.status === 409) { + core.info("Cannot merge PR #" + pull_number + " with " + merge_method + ": " + error.message); + continue; + } + throw error; + } + } - await github.rest.git.deleteRef({ - owner, - repo, - ref: `heads/${pr.head.ref}`, - }); + if (!merged) { + core.info("PR #" + pull_number + " could not be merged: " + (lastError?.message || "unknown error") + "."); + continue; + } - core.info(`Merged Dependabot PR #${pull_number} and deleted ${pr.head.ref}.`); + if (pr.head.repo?.full_name === owner + "/" + repo) { + try { + await github.rest.git.deleteRef({ owner, repo, ref: "heads/" + pr.head.ref }); + core.info("Deleted branch " + pr.head.ref + "."); + } catch (error) { + core.info("Could not delete branch " + pr.head.ref + ": " + error.message); + } + } + }