From 7e324254f0962e9c693eea1258f1dcd9f071ff37 Mon Sep 17 00:00:00 2001 From: Xi Xu Date: Fri, 3 Jul 2026 23:03:41 +0800 Subject: [PATCH] ci: add Dependabot auto merge workflow --- .github/workflows/dependabot-auto-merge.yml | 277 ++++++++++++-------- 1 file changed, 168 insertions(+), 109 deletions(-) diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml index 704fd1c..d7d5a8d 100644 --- a/.github/workflows/dependabot-auto-merge.yml +++ b/.github/workflows/dependabot-auto-merge.yml @@ -1,147 +1,206 @@ -name: Dependabot auto-merge +name: Dependabot Auto Merge on: - workflow_run: - workflows: - - CI + pull_request_target: types: - - completed + - opened + - synchronize + - reopened + - ready_for_review + schedule: + - cron: "*/30 * * * *" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: false permissions: - checks: read contents: write pull-requests: write + checks: read statuses: read jobs: merge: - if: ${{ github.event.workflow_run.event == 'pull_request' }} + name: Auto-merge Dependabot PRs runs-on: ubuntu-latest - + timeout-minutes: 10 steps: - - name: Inspect completed PR checks - id: decision - env: - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - GH_TOKEN: ${{ github.token }} - PULL_REQUESTS_JSON: ${{ toJson(github.event.workflow_run.pull_requests) }} - REPOSITORY: ${{ github.repository }} - WORKFLOW_CONCLUSION: ${{ github.event.workflow_run.conclusion }} - run: | - if [ "${WORKFLOW_CONCLUSION}" != "success" ]; then - echo "Completed workflow conclusion is ${WORKFLOW_CONCLUSION}; skipping." - echo "merge=false" >> "${GITHUB_OUTPUT}" - exit 0 - fi + - name: Merge Dependabot PRs when checks pass + uses: actions/github-script@v9 + with: + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; - pr_number="$(python3 - <<'PY' - import json - import os + const successfulCheckConclusions = new Set(["success", "skipped", "neutral"]); + const successfulStatusStates = new Set(["success"]); + const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - pull_requests = json.loads(os.environ["PULL_REQUESTS_JSON"]) - print(pull_requests[0]["number"] if pull_requests else "") - PY - )" + 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()]; + }; - if [ -z "${pr_number}" ]; then - echo "Completed workflow is not associated with a pull request; skipping." - echo "merge=false" >> "${GITHUB_OUTPUT}" - exit 0 - fi + const findCandidatePulls = async () => { + if (context.eventName === "pull_request_target") { + const pr = context.payload.pull_request; + return pr?.user?.login === "dependabot[bot]" && pr?.state === "open" ? [pr] : []; + } - pr_file="${RUNNER_TEMP}/pr.json" - checks_file="${RUNNER_TEMP}/checks.json" + const pulls = await github.paginate(github.rest.pulls.list, { + owner, + repo, + state: "open", + per_page: 100, + }); - gh api "repos/${REPOSITORY}/pulls/${pr_number}" > "${pr_file}" - gh pr view "${pr_number}" \ - --repo "${REPOSITORY}" \ - --json statusCheckRollup \ - > "${checks_file}" + return pulls.filter((pr) => pr.user?.login === "dependabot[bot]"); + }; - python3 - "${pr_file}" "${checks_file}" <<'PY' - import json - import os - import sys + 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); + } - GREEN_CHECK_CONCLUSIONS = {"SUCCESS", "NEUTRAL", "SKIPPED"} - GREEN_STATUS_STATES = {"SUCCESS"} + const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number }); + return pr; + }; + const getSignalState = async (sha) => { + const checkRuns = await github.paginate(github.rest.checks.listForRef, { + owner, + repo, + ref: sha, + per_page: 100, + }); - def set_output(name: str, value: str) -> None: - with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: - output.write(f"{name}={value}\n") + const statuses = await github.paginate(github.rest.repos.listCommitStatusesForRef, { + owner, + repo, + ref: sha, + per_page: 100, + }); + 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); - def skip(reason: str) -> None: - print(reason) - set_output("merge", "false") - raise SystemExit(0) + 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, + }; + }; - with open(sys.argv[1], encoding="utf-8") as pr_json: - pull_request = json.load(pr_json) + 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"); - with open(sys.argv[2], encoding="utf-8") as checks_json: - checks = json.load(checks_json)["statusCheckRollup"] + if (mergeMethods.length === 0) { + core.info("This repository has no enabled pull request merge methods."); + return; + } - repository = os.environ["REPOSITORY"] - default_branch = os.environ["DEFAULT_BRANCH"] + const pulls = await findCandidatePulls(); + if (pulls.length === 0) { + core.info("No open Dependabot PRs to evaluate."); + return; + } - if pull_request["user"]["login"] != "dependabot[bot]": - skip("Pull request author is not Dependabot; skipping.") + for (const candidate of pulls) { + const pull_number = candidate.number; + const pr = await getMergeablePullRequest(pull_number); - if pull_request["state"] != "open": - skip("Pull request is not open; skipping.") + if (pr.user?.login !== "dependabot[bot]") { + core.info("PR #" + pull_number + " is no longer a Dependabot PR."); + continue; + } - if pull_request["draft"]: - skip("Pull request is a draft; skipping.") + if (pr.state !== "open" || pr.draft) { + core.info("PR #" + pull_number + " is not an open, ready PR."); + continue; + } - if pull_request["base"]["repo"]["full_name"] != repository: - skip("Pull request targets a different repository; skipping.") + if (pr.mergeable !== true) { + core.info("PR #" + pull_number + " is not currently mergeable."); + continue; + } - if pull_request["head"]["repo"]["full_name"] != repository: - skip("Pull request comes from a fork; skipping.") + 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 pull_request["base"]["ref"] != default_branch: - skip("Pull request does not target the default branch; skipping.") + if (signalState.pendingChecks.length > 0) { + core.info("PR #" + pull_number + " still has pending checks: " + signalState.pendingChecks.map((run) => run.name).join(", ") + "."); + continue; + } - if not checks: - skip("Pull request has no checks; skipping.") + 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; + } - not_green = [] - for check in checks: - check_type = check.get("__typename") - if check_type == "CheckRun": - name = check.get("name", "") - status = check.get("status") - conclusion = check.get("conclusion") - if status != "COMPLETED" or conclusion not in GREEN_CHECK_CONCLUSIONS: - not_green.append(f"{name}: {status}/{conclusion}") - elif check_type == "StatusContext": - context = check.get("context", "") - state = check.get("state") - if state not in GREEN_STATUS_STATES: - not_green.append(f"{context}: {state}") - else: - not_green.append(f"Unsupported status item: {check_type}") + 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 === 405 || error.status === 409) { + core.info("Cannot merge PR #" + pull_number + " with " + merge_method + ": " + error.message); + continue; + } + throw error; + } + } - if not_green: - skip("Not all pull request checks are green:\n" + "\n".join(not_green)) + if (!merged) { + core.info("PR #" + pull_number + " could not be merged: " + (lastError?.message || "unknown error") + "."); + continue; + } - set_output("merge", "true") - set_output("head_sha", pull_request["head"]["sha"]) - set_output("pr_url", pull_request["html_url"]) - print(f"Dependabot PR #{pull_request['number']} is green and eligible to merge.") - PY - - - name: Squash-merge Dependabot PR - if: ${{ steps.decision.outputs.merge == 'true' }} - env: - GH_TOKEN: ${{ github.token }} - HEAD_SHA: ${{ steps.decision.outputs.head_sha }} - PR_URL: ${{ steps.decision.outputs.pr_url }} - run: | - gh pr merge "${PR_URL}" \ - --squash \ - --delete-branch \ - --match-head-commit "${HEAD_SHA}" + 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); + } + } + }