From 841849cb916b7bb247b9100964fa8d64b8409850 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Jul 2026 23:49:47 +0000 Subject: [PATCH 1/3] feat: consolidate auto-merge workflows and add eligibility action + tests - Rewrite auto-merge.yml: consolidate enable/disable (pull_request) and new merge-on-gate-pass (check_run: All Gates Passed) + bot-approve jobs - Stub auto-merge-on-ci.yml (deprecated workflow_run/AGI smoke trigger) - Create .github/actions/check-auto-merge-eligibility/action.yml composite action with draft/fork/label/conflict/review eligibility checks - Add merge_queue rule to main-default-automation.ruleset.json - Update .github/workflows/README.md with auto-merge policy and new secrets - Update .github/DEFAULT_GITHUB_AUTOMATION.md with label setup instructions - Add tests/test_auto_merge_workflow.py (35 tests, all passing) --- .github/DEFAULT_GITHUB_AUTOMATION.md | 19 + .../check-auto-merge-eligibility/action.yml | 113 +++++ .../main-default-automation.ruleset.json | 16 + .github/workflows/README.md | 30 ++ .github/workflows/auto-merge-on-ci.yml | 107 ++--- .github/workflows/auto-merge.yml | 394 ++++++++++++++++-- tests/test_auto_merge_workflow.py | 394 ++++++++++++++++++ 7 files changed, 958 insertions(+), 115 deletions(-) create mode 100644 .github/actions/check-auto-merge-eligibility/action.yml create mode 100644 tests/test_auto_merge_workflow.py diff --git a/.github/DEFAULT_GITHUB_AUTOMATION.md b/.github/DEFAULT_GITHUB_AUTOMATION.md index 3a821df58..fb300a878 100644 --- a/.github/DEFAULT_GITHUB_AUTOMATION.md +++ b/.github/DEFAULT_GITHUB_AUTOMATION.md @@ -29,6 +29,25 @@ This repository uses a default GitHub automation baseline for quality, safety, a - Workflow: `.github/workflows/ruleset-json-validation.yml` - Verifies `.github/rulesets/*.json` structure and required status-check context. +8. **Auto-merge label automation** + - Workflow: `.github/workflows/auto-merge.yml` + - Labels `auto-merge` or `autofix` on a PR arm GitHub native auto-merge (squash). + - When `Merge Gate / All Gates Passed` check succeeds, the `merge-on-gate-pass` job + validates eligibility and calls the merge API directly. + - Bot-authored PRs can be automatically approved when `AUTO_MERGE_BOT_APPROVE=true` + (repository variable) and `AUTO_MERGE_APPROVE_TOKEN` (PAT secret) are configured. + - Required labels (create manually or via the CLI): + ```bash + gh label create auto-merge --color 0075ca --description "Squash-merge when all CI gates pass" + gh label create autofix --color e4e669 --description "Auto-merge for automated fix PRs" + ``` + - Human-authored PRs always require at least one human approval regardless of labels. + +9. **Dependabot auto-merge** + - Workflow: `.github/workflows/dependabot-automerge.yml` + - Auto-approves and enables squash-merge for Dependabot patch and minor-dev bumps. + - Major version bumps always require manual review. + ## One-time GitHub settings (manual) In repository settings, enable branch protection (or rulesets) on `main` with these minimum requirements: diff --git a/.github/actions/check-auto-merge-eligibility/action.yml b/.github/actions/check-auto-merge-eligibility/action.yml new file mode 100644 index 000000000..dd70bbc98 --- /dev/null +++ b/.github/actions/check-auto-merge-eligibility/action.yml @@ -0,0 +1,113 @@ +name: Check Auto-Merge Eligibility +description: > + Validates all eligibility criteria for automatic PR merging and outputs + whether the PR is eligible together with a human-readable reason. + Checks: not draft, targets main, not a fork, has auto-merge/autofix label, + not blocked/conflicted, no CHANGES_REQUESTED reviews, has an APPROVED review. + +inputs: + pr-number: + description: Pull request number to evaluate + required: true + github-token: + description: > + GitHub token with pull-requests:read and checks:read scope. + Defaults to the built-in GITHUB_TOKEN. + required: false + default: ${{ github.token }} + +outputs: + eligible: + description: "'true' if the PR meets all auto-merge criteria, 'false' otherwise" + value: ${{ steps.check.outputs.eligible }} + reason: + description: > + Human-readable explanation of the eligibility decision. + When eligible=true this is a success message; otherwise it lists failures. + value: ${{ steps.check.outputs.reason }} + +runs: + using: composite + steps: + - name: Evaluate PR eligibility + id: check + shell: bash + env: + GH_TOKEN: ${{ inputs.github-token }} + PR_NUMBER: ${{ inputs.pr-number }} + run: | + set -euo pipefail + + # --------------------------------------------------------------------------- + # Fetch PR metadata via the GitHub CLI. + # --------------------------------------------------------------------------- + pr_json="$(gh pr view "$PR_NUMBER" \ + --json number,isDraft,baseRefName,headRepository,labels,mergeStateStatus,reviewDecision,state \ + 2>&1)" || { + echo "eligible=false" >> "$GITHUB_OUTPUT" + printf 'reason=Failed to fetch PR data: %s\n' "${pr_json}" >> "$GITHUB_OUTPUT" + exit 0 + } + + # --------------------------------------------------------------------------- + # Extract fields via jq. + # --------------------------------------------------------------------------- + is_draft="$(jq -r '.isDraft' <<< "$pr_json")" + base_ref="$(jq -r '.baseRefName' <<< "$pr_json")" + head_repo="$(jq -r '.headRepository.nameWithOwner // empty' <<< "$pr_json")" + pr_state="$(jq -r '.state' <<< "$pr_json")" + merge_state="$(jq -r '.mergeStateStatus // empty' <<< "$pr_json")" + review_decision="$(jq -r '.reviewDecision // empty' <<< "$pr_json")" + has_label="$(jq -r ' + [.labels[].name] | any(. == "auto-merge" or . == "autofix") + ' <<< "$pr_json")" + + current_repo="${GITHUB_REPOSITORY:-}" + + # --------------------------------------------------------------------------- + # Accumulate failures. + # --------------------------------------------------------------------------- + FAILURES=() + + [[ "$pr_state" != "OPEN" ]] && \ + FAILURES+=("PR state is '${pr_state}' (requires OPEN)") + + [[ "$is_draft" == "true" ]] && \ + FAILURES+=("PR is a draft") + + [[ "$base_ref" != "main" ]] && \ + FAILURES+=("base branch is '${base_ref}' (requires 'main')") + + if [[ -n "$current_repo" && -n "$head_repo" && "$head_repo" != "$current_repo" ]]; then + FAILURES+=("PR is from a fork (${head_repo}); fork PRs are not eligible") + fi + + [[ "$has_label" != "true" ]] && \ + FAILURES+=("missing 'auto-merge' or 'autofix' label") + + case "$merge_state" in + DIRTY) FAILURES+=("merge conflicts detected (mergeStateStatus=DIRTY)") ;; + BEHIND) FAILURES+=("branch is behind base branch (mergeStateStatus=BEHIND)") ;; + BLOCKED) FAILURES+=("merge is blocked by required status checks (mergeStateStatus=BLOCKED)") ;; + esac + + if [[ "$review_decision" == "CHANGES_REQUESTED" ]]; then + FAILURES+=("has CHANGES_REQUESTED review") + elif [[ "$review_decision" != "APPROVED" ]]; then + FAILURES+=("awaiting required approval (reviewDecision='${review_decision}')") + fi + + # --------------------------------------------------------------------------- + # Emit outputs. + # --------------------------------------------------------------------------- + if (( ${#FAILURES[@]} == 0 )); then + echo "eligible=true" >> "$GITHUB_OUTPUT" + echo "reason=All eligibility checks passed." >> "$GITHUB_OUTPUT" + echo "✅ PR #${PR_NUMBER} is eligible for auto-merge." + else + echo "eligible=false" >> "$GITHUB_OUTPUT" + joined="$(printf '%s; ' "${FAILURES[@]}")" + joined="${joined%; }" # trim trailing '; ' + printf 'reason=%s\n' "${joined}" >> "$GITHUB_OUTPUT" + echo "❌ PR #${PR_NUMBER} is NOT eligible: ${joined}" + fi diff --git a/.github/rulesets/main-default-automation.ruleset.json b/.github/rulesets/main-default-automation.ruleset.json index 07ebbeab0..fd93f5f4f 100644 --- a/.github/rulesets/main-default-automation.ruleset.json +++ b/.github/rulesets/main-default-automation.ruleset.json @@ -32,6 +32,22 @@ } ] } + }, + { + // Merge queue: prevents the thundering-herd problem when many + // auto-merge labeled PRs all become mergeable simultaneously. + // Requires enabling the merge queue in repository Settings → General + // → Pull Requests → "Allow merge queue" (UI-only setting). + "type": "merge_queue", + "parameters": { + "check_response_timeout_minutes": 60, + "grouping_strategy": "ALLGREEN", + "max_entries_to_build": 5, + "max_entries_to_merge": 5, + "merge_method": "SQUASH", + "min_entries_to_merge": 1, + "min_entries_to_merge_wait_minutes": 5 + } } // Optional hardening rules (enable when policy-ready): diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 153d1f0e0..0904036d9 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -146,6 +146,34 @@ This directory contains all GitHub Actions workflows for the **Aria** repository - **Support-only lanes (not required for merge):** `ci.yml`, `pr-tests.yml`, and `ci-pipeline.yml` - **Workflow hygiene checks remain active:** `workflow-validation.yml` and `actionlint.yml` for workflow/config changes +### Auto-Merge Policy + +Auto-merge is controlled by `.github/workflows/auto-merge.yml` (consolidated from the old `auto-merge.yml` + `auto-merge-on-ci.yml`). + +| Job | Trigger | Behaviour | +|-----|---------|-----------| +| `enable` | `pull_request` labeled `auto-merge` or `autofix` | Arms GitHub native auto-merge (squash) and posts an informational comment | +| `disable` | `pull_request` last matching label removed | Disables native auto-merge | +| `merge-on-gate-pass` | `check_run` completed for `All Gates Passed` | Evaluates all associated PRs: checks label, draft, fork, conflicts, and review state; squash-merges eligible PRs and posts results | +| `bot-approve` | `pull_request` with auto-merge label (gated by `AUTO_MERGE_BOT_APPROVE == 'true'`) | Approves PRs authored by bot actors listed in `BOT_APPROVE_ALLOWLIST` using `AUTO_MERGE_APPROVE_TOKEN` | + +**Eligibility requirements** (enforced by `merge-on-gate-pass` and codified in `.github/actions/check-auto-merge-eligibility/action.yml`): +- PR is not a draft +- PR targets `main` +- PR is from the same repo (forks are always skipped) +- PR has `auto-merge` or `autofix` label +- Merge state is not `DIRTY` / `BEHIND` / `BLOCKED` +- No `CHANGES_REQUESTED` reviews +- At least one `APPROVED` review + +**Bot-approve actor allowlist** (`BOT_APPROVE_ALLOWLIST` env var in `auto-merge.yml`): +- `github-actions[bot]` +- `copilot-swe-agent[bot]` + +Human-authored PRs **always** require a real human approval regardless of the `AUTO_MERGE_BOT_APPROVE` setting. + +**Deprecated:** `auto-merge-on-ci.yml` is now a manual-only stub. All auto-merge logic is in `auto-merge.yml`. + --- ## Required Secrets & Variables @@ -161,6 +189,8 @@ Configure these under **Settings → Secrets and variables → Actions**. | `OPENAI_API_KEY` | Secret | `ci-pipeline.yml`, `pr-test-summary-comment.yml` (optional) | OpenAI API access for provider integration tests and AI-generated PR workflow summaries (falls back to deterministic summary when unset) | | `AZURE_OPENAI_ENDPOINT` | Secret | `ci-pipeline.yml` (optional) | Azure OpenAI endpoint URL | | `AZURE_OPENAI_API_KEY` | Secret | `ci-pipeline.yml` (optional) | Azure OpenAI API key | +| `AUTO_MERGE_APPROVE_TOKEN` | Secret | `auto-merge.yml` (bot-approve job) | PAT with `pull-requests:write` scope issued by a separate identity; required for automated approval of bot-authored PRs. Never use `GITHUB_TOKEN` — it cannot approve its own PRs. | +| `AUTO_MERGE_BOT_APPROVE` | Variable | `auto-merge.yml` (bot-approve job) | Set to `'true'` to enable automated approval of PRs authored by accounts in `BOT_APPROVE_ALLOWLIST`. Defaults to off. Human-authored PRs always require a human approval. | > 🔐 Never log secret values. Always pass secrets via `env:` blocks scoped to the smallest possible step. diff --git a/.github/workflows/auto-merge-on-ci.yml b/.github/workflows/auto-merge-on-ci.yml index fda338362..4d54da2c6 100644 --- a/.github/workflows/auto-merge-on-ci.yml +++ b/.github/workflows/auto-merge-on-ci.yml @@ -1,78 +1,45 @@ -name: Auto-merge on CI success +# ============================================================================= +# NOTICE: This workflow has been consolidated into auto-merge.yml +# ============================================================================= +# The CI-triggered merge path previously in this file (watching the "AGI smoke" +# workflow_run event) has been replaced by the `merge-on-gate-pass` job in +# `.github/workflows/auto-merge.yml`, which watches the canonical +# `Merge Gate / All Gates Passed` check_run instead. +# +# Key improvements in the new path: +# • Triggers on `check_run` (reliable PR association) instead of +# `workflow_run` (pull_requests field is often empty for human PRs). +# • Watches the real merge gate, not a single smoke workflow. +# • Full eligibility checks: label, draft, fork, conflicts, review state. +# • Writes step summary and posts PR comments on result. +# +# This file is intentionally left as a manual-only no-op so it does not +# consume CI minutes. Delete or repurpose it as needed. +# ============================================================================= + +name: Auto-merge on CI success (deprecated — see auto-merge.yml) on: - workflow_run: - workflows: ["AGI smoke"] - types: [completed] + workflow_dispatch: + inputs: + notice: + description: > + This workflow is a stub. Auto-merge logic has moved to auto-merge.yml + (merge-on-gate-pass job). Trigger that workflow instead. + required: false + default: 'See auto-merge.yml' permissions: - contents: write - pull-requests: write - issues: write - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" - HARDEN_RUNNER_SHA: 0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.2 - GITHUB_SCRIPT_SHA: 60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + contents: read jobs: - auto-merge: - name: Auto-merge PRs labeled 'auto-merge' + notice: + name: Deprecated — logic moved to auto-merge.yml runs-on: ubuntu-latest + timeout-minutes: 1 steps: - - name: Harden runner - uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 - with: - egress-policy: audit - disable-sudo: true - - - name: Auto-merge PRs labeled 'auto-merge' - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const run = context.payload.workflow_run; - if (!run) return; - if (run.conclusion !== 'success') { - core.info(`Workflow run conclusion: ${run.conclusion}; skipping`); - return; - } - const prs = run.pull_requests || []; - if (!prs.length) { - core.info('No pull requests associated; skipping.'); - return; - } - for (const pr of prs) { - const prNumber = pr.number; - const { data: labels } = await github.rest.issues.listLabelsOnIssue({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber - }); - const labelNames = labels.map(l => l.name); - if (!labelNames.includes('auto-merge')) { - core.info(`PR #${prNumber} not labeled auto-merge; skipping`); - continue; - } - const { data: prData } = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: prNumber - }); - // If mergeable is 'unknown', the API may still be calculating; guard defensively - if (prData.mergeable === false) { - core.info(`PR #${prNumber} not mergeable; skipping`); - continue; - } - try { - await github.rest.pulls.merge({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: prNumber, - merge_method: 'squash' - }); - core.info(`Merged PR #${prNumber}`); - } catch (err) { - core.info(`Failed to merge PR #${prNumber}: ${err}`); - } - } + - name: Print notice + run: | + echo "This workflow is a stub." + echo "Auto-merge on CI success logic is now in .github/workflows/auto-merge.yml" + echo "(merge-on-gate-pass job, triggered by check_run: All Gates Passed)." diff --git a/.github/workflows/auto-merge.yml b/.github/workflows/auto-merge.yml index 113ec1ce9..0fe00090e 100644 --- a/.github/workflows/auto-merge.yml +++ b/.github/workflows/auto-merge.yml @@ -1,45 +1,70 @@ +# ============================================================================= +# Auto Merge — Label-triggered and gate-triggered PR merge automation +# ============================================================================= +# Purpose: +# • enable — arms GitHub native auto-merge when `auto-merge` or +# `autofix` label is added; posts an informational +# comment explaining what must pass before merge fires. +# • disable — disables native auto-merge when the last matching +# label is removed. +# • merge-on-gate-pass — fires when `Merge Gate / All Gates Passed` check +# succeeds; for each associated PR with the label and +# a clean eligibility check, calls the merge API. +# • bot-approve — if the `AUTO_MERGE_BOT_APPROVE` repository variable +# is 'true', approves PRs authored by bot accounts in +# BOT_APPROVE_ALLOWLIST using a dedicated PAT so they +# clear the required-reviewer gate. Human-authored PRs +# always require a real human approval. +# +# Merge strategy : squash (consistent with dependabot-automerge.yml). +# Fork PRs : always skipped — GITHUB_TOKEN is read-only for forks. +# Defaults : clarifying-question defaults used where not explicitly set: +# bot-approve scope = bot actors only; merge strategy = squash; +# fork PRs = skip; merge queue = enabled in ruleset JSON. +# ============================================================================= + +name: Auto Merge + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled] + check_run: + types: [completed] + +# Use PR number for pull_request events; check_run ID for check_run events. +# cancel-in-progress: false prevents a late enable/disable from clobbering a +# concurrent merge attempt. concurrency: + group: >- + auto-merge-${{ + github.event.pull_request.number + || github.event.check_run.id + }} cancel-in-progress: false - group: auto-merge-${{ github.event.pull_request.number }} + +permissions: + contents: read env: AUTO_MERGE_LABEL: auto-merge AUTOFIX_LABEL: autofix - HARDEN_RUNNER_SHA: 0634a2670c59f64b4a01f0f96f84700a4088b9f0 + HARDEN_RUNNER_SHA: 0634a2670c59f64b4a01f0f96f84700a4088b9f0 # step-security/harden-runner v2.12.2 + GITHUB_SCRIPT_SHA: 60a0d83039c74a4aee543508d2ffcb1c3799cdea # actions/github-script v7.0.1 + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + # Space-separated list of bot login names eligible for automated approval. + # Only used when AUTO_MERGE_BOT_APPROVE variable == 'true'. + BOT_APPROVE_ALLOWLIST: "github-actions[bot] copilot-swe-agent[bot]" jobs: - disable: - if: >- - github.event.pull_request.head.repo.full_name == github.repository && - github.event.action == 'unlabeled' && - (github.event.label.name == 'auto-merge' || github.event.label.name == 'autofix') && - !contains(github.event.pull_request.labels.*.name, 'auto-merge') && - !contains(github.event.pull_request.labels.*.name, 'autofix') - name: Disable Auto-Merge - permissions: - contents: read - issues: write - pull-requests: write - runs-on: ubuntu-latest - steps: - - name: Harden runner - uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 - with: - disable-sudo: true - egress-policy: audit - - - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PR_NUMBER: ${{ github.event.pull_request.number }} - name: Disable auto-merge (no-op safe) - run: | - set -euo pipefail - gh pr merge --disable-auto "$PR_NUMBER" || echo "Auto-merge was already disabled." - timeout-minutes: 5 - + # --------------------------------------------------------------------------- + # Job A — Arm GitHub native auto-merge when a label is added + # --------------------------------------------------------------------------- enable: + name: Enable Auto-Merge if: >- + github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && + github.event.pull_request.draft == false && ( ( github.event.action == 'labeled' && @@ -53,12 +78,12 @@ jobs: ) ) ) - name: Enable Auto-Merge + runs-on: ubuntu-latest + timeout-minutes: 5 permissions: contents: read issues: write pull-requests: write - runs-on: ubuntu-latest steps: - name: Harden runner uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 @@ -66,32 +91,311 @@ jobs: disable-sudo: true egress-policy: audit - - env: + - name: Check if auto-merge already enabled + id: state + env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_NUMBER: ${{ github.event.pull_request.number }} - id: state - name: Check if auto-merge already enabled run: | set -euo pipefail enabled="$(gh pr view "$PR_NUMBER" --json autoMergeRequest --jq '.autoMergeRequest != null')" echo "enabled=$enabled" >> "$GITHUB_OUTPUT" - - env: + - name: Enable auto-merge (squash) + if: steps.state.outputs.enabled != 'true' + env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_NUMBER: ${{ github.event.pull_request.number }} - if: steps.state.outputs.enabled != 'true' - name: Enable auto-merge (squash) run: | set -euo pipefail gh pr merge --auto --squash "$PR_NUMBER" echo "✅ Auto-merge enabled for PR #$PR_NUMBER" + + - name: Post informational comment + if: steps.state.outputs.enabled != 'true' && github.event.action == 'labeled' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + LABEL: ${{ github.event.label.name }} + run: | + set -euo pipefail + gh pr comment "$PR_NUMBER" --body \ + "🤖 **Auto-merge armed** (label: \`${LABEL}\`) + + This PR will be squash-merged automatically once all of the following are true: + - ✅ \`Merge Gate / All Gates Passed\` check succeeds + - ✅ Required reviewer approval is obtained + - ✅ No unresolved review threads remain + - ✅ No merge conflicts + + Remove the \`auto-merge\` or \`autofix\` label to cancel." + + # --------------------------------------------------------------------------- + # Job B — Disable auto-merge when the last matching label is removed + # --------------------------------------------------------------------------- + disable: + name: Disable Auto-Merge + if: >- + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository && + github.event.action == 'unlabeled' && + (github.event.label.name == 'auto-merge' || github.event.label.name == 'autofix') && + !contains(github.event.pull_request.labels.*.name, 'auto-merge') && + !contains(github.event.pull_request.labels.*.name, 'autofix') + runs-on: ubuntu-latest timeout-minutes: 5 + permissions: + contents: read + issues: write + pull-requests: write + steps: + - name: Harden runner + uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 + with: + disable-sudo: true + egress-policy: audit -name: Auto Merge + - name: Disable auto-merge (no-op safe) + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + gh pr merge --disable-auto "$PR_NUMBER" || echo "Auto-merge was already disabled." -on: - pull_request: - types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled] + # --------------------------------------------------------------------------- + # Job C — Merge labeled PRs when the canonical merge gate passes + # --------------------------------------------------------------------------- + merge-on-gate-pass: + name: Merge on Gate Pass + if: >- + github.event_name == 'check_run' && + github.event.check_run.name == 'All Gates Passed' && + github.event.check_run.conclusion == 'success' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + pull-requests: write + issues: write + steps: + - name: Harden runner + uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 + with: + egress-policy: audit + disable-sudo: true -permissions: - contents: read + - name: Merge eligible PRs + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const checkRun = context.payload.check_run; + const prs = checkRun.pull_requests || []; + + if (!prs.length) { + core.info('No pull requests associated with this check run; skipping.'); + return; + } + + const summaryRows = []; + + for (const pr of prs) { + const prNumber = pr.number; + core.info(`Evaluating PR #${prNumber} for auto-merge.`); + + // Fetch full PR data (labels, state, merge flags, head repo). + const { data: prData } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + }); + + // --- Eligibility checks --- + + const labelNames = prData.labels.map(l => l.name); + const hasLabel = labelNames.includes('auto-merge') || labelNames.includes('autofix'); + if (!hasLabel) { + core.info(`PR #${prNumber}: no auto-merge/autofix label — skipping.`); + summaryRows.push(`| #${prNumber} | ⊘ skipped (no label) |`); + continue; + } + + if (prData.draft) { + core.info(`PR #${prNumber}: draft — skipping.`); + summaryRows.push(`| #${prNumber} | ⊘ skipped (draft) |`); + continue; + } + + if (prData.state !== 'open') { + core.info(`PR #${prNumber}: not open (${prData.state}) — skipping.`); + summaryRows.push(`| #${prNumber} | ⊘ skipped (${prData.state}) |`); + continue; + } + + // Guard against fork PRs — GITHUB_TOKEN cannot merge across forks. + const headRepo = prData.head.repo ? prData.head.repo.full_name : ''; + const thisRepo = context.repo.owner + '/' + context.repo.repo; + if (headRepo !== thisRepo) { + core.info(`PR #${prNumber}: fork PR (${headRepo}) — skipping.`); + summaryRows.push(`| #${prNumber} | ⊘ skipped (fork) |`); + continue; + } + + // mergeable can be null/'unknown' while GitHub calculates; skip if false. + if (prData.mergeable === false) { + core.info(`PR #${prNumber}: not mergeable (conflicts or branch behind) — skipping.`); + summaryRows.push(`| #${prNumber} | ❌ not mergeable |`); + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: '⚠️ Auto-merge gate passed but the PR cannot be merged (merge conflict or branch behind base). Please resolve conflicts and re-run checks.', + }); + continue; + } + + // Check for blocking/approved reviews. + const { data: reviews } = await github.rest.pulls.listReviews({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + }); + + // Build latest review state per reviewer (COMMENTED doesn't update decision). + const latestByReviewer = {}; + for (const review of reviews) { + if (review.state !== 'COMMENTED') { + latestByReviewer[review.user.login] = review.state; + } + } + const reviewStates = Object.values(latestByReviewer); + const hasChangesRequested = reviewStates.some(s => s === 'CHANGES_REQUESTED'); + const hasApproval = reviewStates.some(s => s === 'APPROVED'); + + if (hasChangesRequested) { + core.info(`PR #${prNumber}: CHANGES_REQUESTED review present — skipping.`); + summaryRows.push(`| #${prNumber} | ❌ changes requested |`); + continue; + } + + if (!hasApproval) { + core.info(`PR #${prNumber}: no approved review yet — skipping.`); + summaryRows.push(`| #${prNumber} | ⏳ awaiting approval |`); + continue; + } + + // Attempt the squash merge. + try { + await github.rest.pulls.merge({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + merge_method: 'squash', + }); + core.info(`✅ Merged PR #${prNumber}.`); + summaryRows.push(`| #${prNumber} | ✅ merged |`); + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: '✅ Auto-merged via `Merge Gate / All Gates Passed` gate.', + }); + } catch (err) { + core.error(`Failed to merge PR #${prNumber}: ${err}`); + summaryRows.push(`| #${prNumber} | ❌ merge failed — ${err.message} |`); + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: `❌ Auto-merge failed: ${err.message}`, + }); + } + } + + // Write step summary. + const tableHeader = '| PR | Result |\n|-----|--------|'; + const tableBody = summaryRows.length ? summaryRows.join('\n') : '| — | _No PRs evaluated._ |'; + await core.summary + .addHeading('Auto-Merge Results', 2) + .addRaw(`${tableHeader}\n${tableBody}`) + .write(); + + # --------------------------------------------------------------------------- + # Job D — Approve bot-authored PRs (gated by AUTO_MERGE_BOT_APPROVE variable) + # --------------------------------------------------------------------------- + bot-approve: + name: Bot-Approve PR + if: >- + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository && + github.event.pull_request.draft == false && + vars.AUTO_MERGE_BOT_APPROVE == 'true' && + ( + contains(github.event.pull_request.labels.*.name, 'auto-merge') || + contains(github.event.pull_request.labels.*.name, 'autofix') + ) + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + pull-requests: write + steps: + - name: Harden runner + uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 + with: + disable-sudo: true + egress-policy: audit + + - name: Approve if bot-authored PR + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea + env: + BOT_APPROVE_ALLOWLIST: ${{ env.BOT_APPROVE_ALLOWLIST }} + # AUTO_MERGE_APPROVE_TOKEN must be a PAT with pull-requests:write scope + # issued by an identity different from the PR author so the approval + # is not self-approved. GITHUB_TOKEN cannot approve its own PRs. + HAS_APPROVE_TOKEN: ${{ secrets.AUTO_MERGE_APPROVE_TOKEN != '' }} + with: + github-token: ${{ secrets.AUTO_MERGE_APPROVE_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const allowlist = (process.env.BOT_APPROVE_ALLOWLIST || '').split(/\s+/).filter(Boolean); + const author = context.payload.pull_request.user.login; + + const isAllowed = allowlist.some(entry => entry === author); + if (!isAllowed) { + core.info(`PR author '${author}' is not in BOT_APPROVE_ALLOWLIST; skipping.`); + return; + } + + const hasToken = process.env.HAS_APPROVE_TOKEN === 'true'; + if (!hasToken) { + core.warning( + 'AUTO_MERGE_APPROVE_TOKEN secret is not set. ' + + 'A dedicated PAT is required to approve PRs from a separate identity. ' + + 'Skipping bot-approve.' + ); + return; + } + + const prNumber = context.payload.pull_request.number; + + // Check if already approved to avoid duplicate review events. + const { data: reviews } = await github.rest.pulls.listReviews({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + }); + const alreadyApproved = reviews.some(r => r.state === 'APPROVED'); + if (alreadyApproved) { + core.info(`PR #${prNumber} already has an approval; skipping.`); + return; + } + + await github.rest.pulls.createReview({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + event: 'APPROVE', + body: `🤖 Automated approval for bot-authored PR (author: @${author}).`, + }); + core.info(`✅ Approved PR #${prNumber} (author: ${author}).`); diff --git a/tests/test_auto_merge_workflow.py b/tests/test_auto_merge_workflow.py new file mode 100644 index 000000000..dd011519e --- /dev/null +++ b/tests/test_auto_merge_workflow.py @@ -0,0 +1,394 @@ +"""Tests for the consolidated auto-merge workflow and eligibility composite action. + +Covers: +- Consolidated auto-merge.yml has the correct dual trigger (pull_request + check_run). +- merge-on-gate-pass job filters on the canonical check name and conclusion. +- enable / disable jobs guard against forks and drafts. +- bot-approve job is gated by the AUTO_MERGE_BOT_APPROVE variable. +- auto-merge-on-ci.yml is a stub (no longer watches 'AGI smoke' workflow_run). +- check-auto-merge-eligibility composite action has required inputs and outputs. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml + +pytestmark = pytest.mark.unit + +REPO_ROOT = Path(__file__).resolve().parents[1] +WORKFLOWS_DIR = REPO_ROOT / ".github" / "workflows" +ACTIONS_DIR = REPO_ROOT / ".github" / "actions" + + +def _load_yaml(path: Path) -> dict: + return yaml.safe_load(path.read_text(encoding="utf-8")) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _load_auto_merge() -> dict: + return _load_yaml(WORKFLOWS_DIR / "auto-merge.yml") + + +def _load_auto_merge_on_ci() -> dict: + return _load_yaml(WORKFLOWS_DIR / "auto-merge-on-ci.yml") + + +def _load_eligibility_action() -> dict: + return _load_yaml(ACTIONS_DIR / "check-auto-merge-eligibility" / "action.yml") + + +# --------------------------------------------------------------------------- +# auto-merge.yml — triggers +# --------------------------------------------------------------------------- + + +def _get_triggers(wf: dict) -> dict: + """Return the workflow trigger mapping, handling the YAML 'on' → True quirk. + + PyYAML 5.x parses the bare YAML key ``on`` as boolean ``True`` (YAML 1.1 + core schema); GitHub Actions YAML uses ``on`` as the trigger block key. + """ + return wf.get(True, wf.get("on", {})) + + +# --------------------------------------------------------------------------- +# auto-merge.yml — triggers +# --------------------------------------------------------------------------- + + +def test_auto_merge_has_pull_request_trigger() -> None: + wf = _load_auto_merge() + assert "pull_request" in _get_triggers(wf), "auto-merge.yml must trigger on pull_request events" + + +def test_auto_merge_has_check_run_trigger() -> None: + wf = _load_auto_merge() + assert "check_run" in _get_triggers(wf), ( + "auto-merge.yml must trigger on check_run events so it fires when " + "'All Gates Passed' completes" + ) + + +def test_auto_merge_check_run_trigger_on_completed() -> None: + wf = _load_auto_merge() + check_run = _get_triggers(wf)["check_run"] + assert "completed" in check_run.get("types", []), ( + "check_run trigger must include the 'completed' type" + ) + + +def test_auto_merge_pull_request_trigger_includes_labeled_unlabeled() -> None: + wf = _load_auto_merge() + pr_types = _get_triggers(wf)["pull_request"].get("types", []) + assert "labeled" in pr_types, "pull_request trigger must include 'labeled'" + assert "unlabeled" in pr_types, "pull_request trigger must include 'unlabeled'" + + +# --------------------------------------------------------------------------- +# auto-merge.yml — jobs present +# --------------------------------------------------------------------------- + + +def test_auto_merge_has_enable_job() -> None: + wf = _load_auto_merge() + assert "enable" in wf["jobs"], "auto-merge.yml must have an 'enable' job" + + +def test_auto_merge_has_disable_job() -> None: + wf = _load_auto_merge() + assert "disable" in wf["jobs"], "auto-merge.yml must have a 'disable' job" + + +def test_auto_merge_has_merge_on_gate_pass_job() -> None: + wf = _load_auto_merge() + assert "merge-on-gate-pass" in wf["jobs"], ( + "auto-merge.yml must have a 'merge-on-gate-pass' job" + ) + + +def test_auto_merge_has_bot_approve_job() -> None: + wf = _load_auto_merge() + assert "bot-approve" in wf["jobs"], "auto-merge.yml must have a 'bot-approve' job" + + +# --------------------------------------------------------------------------- +# merge-on-gate-pass — correct check filtering +# --------------------------------------------------------------------------- + + +def test_merge_on_gate_pass_filters_check_run_event() -> None: + wf = _load_auto_merge() + job_if = wf["jobs"]["merge-on-gate-pass"].get("if", "") + assert "github.event_name == 'check_run'" in job_if, ( + "merge-on-gate-pass must filter on github.event_name == 'check_run'" + ) + + +def test_merge_on_gate_pass_filters_all_gates_passed_name() -> None: + wf = _load_auto_merge() + job_if = wf["jobs"]["merge-on-gate-pass"].get("if", "") + assert "All Gates Passed" in job_if, ( + "merge-on-gate-pass must filter on check_run.name == 'All Gates Passed' " + "(the canonical fan-in job name from merge-gate.yml)" + ) + + +def test_merge_on_gate_pass_filters_success_conclusion() -> None: + wf = _load_auto_merge() + job_if = wf["jobs"]["merge-on-gate-pass"].get("if", "") + assert "success" in job_if, ( + "merge-on-gate-pass must only fire when the check_run conclusion is 'success'" + ) + + +def test_merge_on_gate_pass_has_write_permissions() -> None: + wf = _load_auto_merge() + perms = wf["jobs"]["merge-on-gate-pass"].get("permissions", {}) + assert perms.get("contents") == "write", ( + "merge-on-gate-pass needs contents:write to perform merges" + ) + assert perms.get("pull-requests") == "write", ( + "merge-on-gate-pass needs pull-requests:write to post comments" + ) + + +# --------------------------------------------------------------------------- +# enable job — fork and draft guards +# --------------------------------------------------------------------------- + + +def test_enable_job_guards_against_forks() -> None: + wf = _load_auto_merge() + job_if = wf["jobs"]["enable"].get("if", "") + assert "head.repo.full_name == github.repository" in job_if, ( + "enable job must guard against fork PRs by checking " + "head.repo.full_name == github.repository" + ) + + +def test_enable_job_guards_against_drafts() -> None: + wf = _load_auto_merge() + job_if = wf["jobs"]["enable"].get("if", "") + assert "draft" in job_if, ( + "enable job must guard against draft PRs" + ) + + +def test_enable_job_fires_on_pull_request_event() -> None: + wf = _load_auto_merge() + job_if = wf["jobs"]["enable"].get("if", "") + assert "github.event_name == 'pull_request'" in job_if, ( + "enable job must check github.event_name == 'pull_request' to avoid " + "running on check_run events" + ) + + +# --------------------------------------------------------------------------- +# disable job — fork guard and label conditions +# --------------------------------------------------------------------------- + + +def test_disable_job_guards_against_forks() -> None: + wf = _load_auto_merge() + job_if = wf["jobs"]["disable"].get("if", "") + assert "head.repo.full_name == github.repository" in job_if, ( + "disable job must guard against fork PRs" + ) + + +def test_disable_job_requires_unlabeled_action() -> None: + wf = _load_auto_merge() + job_if = wf["jobs"]["disable"].get("if", "") + assert "unlabeled" in job_if, ( + "disable job must check for the 'unlabeled' action" + ) + + +def test_disable_job_checks_no_remaining_label() -> None: + wf = _load_auto_merge() + job_if = wf["jobs"]["disable"].get("if", "") + # Must verify both labels are absent before disabling + assert "auto-merge" in job_if, ( + "disable job must check that 'auto-merge' label is no longer present" + ) + assert "autofix" in job_if, ( + "disable job must check that 'autofix' label is no longer present" + ) + + +# --------------------------------------------------------------------------- +# bot-approve job — variable guard and actor allowlist +# --------------------------------------------------------------------------- + + +def test_bot_approve_gated_by_variable() -> None: + wf = _load_auto_merge() + job_if = wf["jobs"]["bot-approve"].get("if", "") + assert "AUTO_MERGE_BOT_APPROVE" in job_if, ( + "bot-approve job must be gated by the AUTO_MERGE_BOT_APPROVE variable" + ) + + +def test_bot_approve_guards_against_forks() -> None: + wf = _load_auto_merge() + job_if = wf["jobs"]["bot-approve"].get("if", "") + assert "head.repo.full_name == github.repository" in job_if, ( + "bot-approve must guard against fork PRs" + ) + + +def test_bot_approve_skips_drafts() -> None: + wf = _load_auto_merge() + job_if = wf["jobs"]["bot-approve"].get("if", "") + assert "draft" in job_if, ( + "bot-approve must skip draft PRs" + ) + + +def test_bot_approve_allowlist_defined_in_env() -> None: + wf = _load_auto_merge() + env = wf.get("env", {}) + assert "BOT_APPROVE_ALLOWLIST" in env, ( + "BOT_APPROVE_ALLOWLIST must be declared in the workflow-level env block" + ) + allowlist = env["BOT_APPROVE_ALLOWLIST"] + assert "github-actions[bot]" in allowlist, ( + "github-actions[bot] must be in BOT_APPROVE_ALLOWLIST" + ) + assert "copilot-swe-agent[bot]" in allowlist, ( + "copilot-swe-agent[bot] must be in BOT_APPROVE_ALLOWLIST" + ) + + +# --------------------------------------------------------------------------- +# auto-merge-on-ci.yml — is now a stub, not watching AGI smoke +# --------------------------------------------------------------------------- + + +def test_auto_merge_on_ci_no_longer_watches_agi_smoke() -> None: + wf = _load_auto_merge_on_ci() + triggers = wf.get("on", {}) + # The old trigger was workflow_run: ["AGI smoke"]. + # The stub should NOT have a workflow_run trigger pointing at AGI smoke. + if "workflow_run" in triggers: + watched = triggers["workflow_run"].get("workflows", []) + assert "AGI smoke" not in watched, ( + "auto-merge-on-ci.yml must no longer watch 'AGI smoke' via workflow_run; " + "that logic has moved to merge-on-gate-pass in auto-merge.yml" + ) + + +def test_auto_merge_on_ci_stub_has_no_automatic_trigger() -> None: + """The stub must not fire automatically to avoid consuming CI minutes.""" + wf = _load_auto_merge_on_ci() + triggers = wf.get("on", {}) + automatic_keys = {"push", "pull_request", "workflow_run", "schedule", "check_run", "check_suite"} + active = automatic_keys & set(triggers.keys()) + assert not active, ( + f"auto-merge-on-ci.yml stub must not have automatic triggers; found: {active}. " + "The workflow should only be manually dispatchable (workflow_dispatch)." + ) + + +# --------------------------------------------------------------------------- +# check-auto-merge-eligibility composite action +# --------------------------------------------------------------------------- + + +def test_eligibility_action_exists() -> None: + action_path = ACTIONS_DIR / "check-auto-merge-eligibility" / "action.yml" + assert action_path.exists(), ( + "check-auto-merge-eligibility/action.yml must exist" + ) + + +def test_eligibility_action_has_pr_number_input() -> None: + action = _load_eligibility_action() + inputs = action.get("inputs", {}) + assert "pr-number" in inputs, ( + "eligibility action must define a 'pr-number' input" + ) + assert inputs["pr-number"].get("required") is True, ( + "'pr-number' input must be required" + ) + + +def test_eligibility_action_has_github_token_input() -> None: + action = _load_eligibility_action() + inputs = action.get("inputs", {}) + assert "github-token" in inputs, ( + "eligibility action must define a 'github-token' input" + ) + + +def test_eligibility_action_outputs_eligible() -> None: + action = _load_eligibility_action() + outputs = action.get("outputs", {}) + assert "eligible" in outputs, ( + "eligibility action must output 'eligible'" + ) + + +def test_eligibility_action_outputs_reason() -> None: + action = _load_eligibility_action() + outputs = action.get("outputs", {}) + assert "reason" in outputs, ( + "eligibility action must output 'reason'" + ) + + +def test_eligibility_action_is_composite() -> None: + action = _load_eligibility_action() + assert action.get("runs", {}).get("using") == "composite", ( + "eligibility action must use 'composite' runner" + ) + + +def test_eligibility_action_checks_draft() -> None: + action_path = ACTIONS_DIR / "check-auto-merge-eligibility" / "action.yml" + content = action_path.read_text(encoding="utf-8") + assert "draft" in content.lower(), ( + "eligibility action script must check for draft status" + ) + + +def test_eligibility_action_checks_base_branch() -> None: + action_path = ACTIONS_DIR / "check-auto-merge-eligibility" / "action.yml" + content = action_path.read_text(encoding="utf-8") + assert "main" in content, ( + "eligibility action must verify the PR targets 'main'" + ) + + +def test_eligibility_action_checks_fork() -> None: + action_path = ACTIONS_DIR / "check-auto-merge-eligibility" / "action.yml" + content = action_path.read_text(encoding="utf-8") + assert "fork" in content.lower(), ( + "eligibility action must guard against fork PRs" + ) + + +def test_eligibility_action_checks_label() -> None: + action_path = ACTIONS_DIR / "check-auto-merge-eligibility" / "action.yml" + content = action_path.read_text(encoding="utf-8") + assert "auto-merge" in content, ( + "eligibility action must verify the 'auto-merge' label" + ) + assert "autofix" in content, ( + "eligibility action must verify the 'autofix' label" + ) + + +def test_eligibility_action_checks_changes_requested() -> None: + action_path = ACTIONS_DIR / "check-auto-merge-eligibility" / "action.yml" + content = action_path.read_text(encoding="utf-8") + assert "CHANGES_REQUESTED" in content, ( + "eligibility action must block on CHANGES_REQUESTED reviews" + ) From d9ec2c289b268da870974c19c1c8eff22ef2bdaf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Jul 2026 23:51:39 +0000 Subject: [PATCH 2/3] fix: address code review feedback on auto-merge implementation - Fix concurrency group: use explicit != '' guard for PR number to avoid falsy coalesce when pull_request.number is 0 - Fix fork guard: explicit null check for prData.head.repo (deleted forks) - Fix mergeable check: only merge when mergeable===true (not just !false); distinguish null (pending) from false (conflict/behind) in log/comment - Fix review ordering: sort reviews by submitted_at before building latestByReviewer map to ensure chronological precedence - Fix eligibility action error output: sanitize gh CLI stderr (strip ANSI codes, truncate to 120 chars) before writing to GITHUB_OUTPUT - Fix test file: remove duplicate section header above _get_triggers helper --- .../check-auto-merge-eligibility/action.yml | 4 +- .github/workflows/auto-merge.yml | 42 ++++++++++++------- tests/test_auto_merge_workflow.py | 2 +- 3 files changed, 31 insertions(+), 17 deletions(-) diff --git a/.github/actions/check-auto-merge-eligibility/action.yml b/.github/actions/check-auto-merge-eligibility/action.yml index dd70bbc98..2a300f21e 100644 --- a/.github/actions/check-auto-merge-eligibility/action.yml +++ b/.github/actions/check-auto-merge-eligibility/action.yml @@ -44,8 +44,10 @@ runs: pr_json="$(gh pr view "$PR_NUMBER" \ --json number,isDraft,baseRefName,headRepository,labels,mergeStateStatus,reviewDecision,state \ 2>&1)" || { + # Sanitize raw gh output: strip ANSI codes, limit to first 120 chars. + sanitized="$(printf '%s' "${pr_json}" | head -n1 | sed 's/\x1b\[[0-9;]*m//g' | cut -c1-120)" echo "eligible=false" >> "$GITHUB_OUTPUT" - printf 'reason=Failed to fetch PR data: %s\n' "${pr_json}" >> "$GITHUB_OUTPUT" + printf 'reason=Failed to fetch PR data: %s\n' "${sanitized}" >> "$GITHUB_OUTPUT" exit 0 } diff --git a/.github/workflows/auto-merge.yml b/.github/workflows/auto-merge.yml index 0fe00090e..18b4934e0 100644 --- a/.github/workflows/auto-merge.yml +++ b/.github/workflows/auto-merge.yml @@ -37,7 +37,7 @@ on: concurrency: group: >- auto-merge-${{ - github.event.pull_request.number + github.event.pull_request.number != '' && github.event.pull_request.number || github.event.check_run.id }} cancel-in-progress: false @@ -234,24 +234,32 @@ jobs: } // Guard against fork PRs — GITHUB_TOKEN cannot merge across forks. - const headRepo = prData.head.repo ? prData.head.repo.full_name : ''; + // prData.head.repo is null when the fork has been deleted. const thisRepo = context.repo.owner + '/' + context.repo.repo; - if (headRepo !== thisRepo) { - core.info(`PR #${prNumber}: fork PR (${headRepo}) — skipping.`); + if (!prData.head.repo || prData.head.repo.full_name !== thisRepo) { + const headRepo = prData.head.repo ? prData.head.repo.full_name : '(deleted fork)'; + core.info(`PR #${prNumber}: fork or deleted-fork PR (${headRepo}) — skipping.`); summaryRows.push(`| #${prNumber} | ⊘ skipped (fork) |`); continue; } - // mergeable can be null/'unknown' while GitHub calculates; skip if false. - if (prData.mergeable === false) { - core.info(`PR #${prNumber}: not mergeable (conflicts or branch behind) — skipping.`); - summaryRows.push(`| #${prNumber} | ❌ not mergeable |`); - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - body: '⚠️ Auto-merge gate passed but the PR cannot be merged (merge conflict or branch behind base). Please resolve conflicts and re-run checks.', - }); + // mergeable can be null (still calculating) or false (conflict/behind). + // Only proceed when explicitly true to avoid merging in an unknown state. + if (prData.mergeable !== true) { + if (prData.mergeable === false) { + core.info(`PR #${prNumber}: not mergeable (conflicts or branch behind) — skipping.`); + summaryRows.push(`| #${prNumber} | ❌ not mergeable |`); + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: '⚠️ Auto-merge gate passed but the PR cannot be merged (merge conflict or branch behind base). Please resolve conflicts and re-run checks.', + }); + } else { + // null — GitHub is still calculating mergeability; skip this run. + core.info(`PR #${prNumber}: mergeability unknown (still calculating) — skipping.`); + summaryRows.push(`| #${prNumber} | ⏳ mergeability pending |`); + } continue; } @@ -263,8 +271,12 @@ jobs: }); // Build latest review state per reviewer (COMMENTED doesn't update decision). + // Sort by submission time to ensure later reviews overwrite earlier ones. + const sortedReviews = [...reviews].sort( + (a, b) => new Date(a.submitted_at).getTime() - new Date(b.submitted_at).getTime() + ); const latestByReviewer = {}; - for (const review of reviews) { + for (const review of sortedReviews) { if (review.state !== 'COMMENTED') { latestByReviewer[review.user.login] = review.state; } diff --git a/tests/test_auto_merge_workflow.py b/tests/test_auto_merge_workflow.py index dd011519e..b850a0525 100644 --- a/tests/test_auto_merge_workflow.py +++ b/tests/test_auto_merge_workflow.py @@ -45,7 +45,7 @@ def _load_eligibility_action() -> dict: # --------------------------------------------------------------------------- -# auto-merge.yml — triggers +# Helpers — trigger extraction # --------------------------------------------------------------------------- From 2963096abd998a47dcac6644b3edd6ed7dd6a654 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Jul 2026 23:53:03 +0000 Subject: [PATCH 3/3] fix: address final code review round on auto-merge implementation - Revert concurrency group to simple || (PR number is numeric, != '' is wrong) - Use context.payload.repository.full_name for fork guard (avoids string concat) - Replace cut -c1-120 with bash substring ${var:0:120} in eligibility action (character-aware, avoids splitting multi-byte UTF-8 on some systems) - Consolidate test assert message for All Gates Passed onto one line --- .github/actions/check-auto-merge-eligibility/action.yml | 7 +++++-- .github/workflows/auto-merge.yml | 4 ++-- tests/test_auto_merge_workflow.py | 3 +-- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/actions/check-auto-merge-eligibility/action.yml b/.github/actions/check-auto-merge-eligibility/action.yml index 2a300f21e..32e66ede8 100644 --- a/.github/actions/check-auto-merge-eligibility/action.yml +++ b/.github/actions/check-auto-merge-eligibility/action.yml @@ -44,8 +44,11 @@ runs: pr_json="$(gh pr view "$PR_NUMBER" \ --json number,isDraft,baseRefName,headRepository,labels,mergeStateStatus,reviewDecision,state \ 2>&1)" || { - # Sanitize raw gh output: strip ANSI codes, limit to first 120 chars. - sanitized="$(printf '%s' "${pr_json}" | head -n1 | sed 's/\x1b\[[0-9;]*m//g' | cut -c1-120)" + # Sanitize raw gh output: strip ANSI escape codes, keep first line, + # truncate using bash substring (character-aware, avoids splitting + # multi-byte UTF-8 sequences that cut -c can mishandle on some systems). + first_line="$(printf '%s' "${pr_json}" | head -n1 | sed 's/\x1b\[[0-9;]*m//g')" + sanitized="${first_line:0:120}" echo "eligible=false" >> "$GITHUB_OUTPUT" printf 'reason=Failed to fetch PR data: %s\n' "${sanitized}" >> "$GITHUB_OUTPUT" exit 0 diff --git a/.github/workflows/auto-merge.yml b/.github/workflows/auto-merge.yml index 18b4934e0..d1ed80fb0 100644 --- a/.github/workflows/auto-merge.yml +++ b/.github/workflows/auto-merge.yml @@ -37,7 +37,7 @@ on: concurrency: group: >- auto-merge-${{ - github.event.pull_request.number != '' && github.event.pull_request.number + github.event.pull_request.number || github.event.check_run.id }} cancel-in-progress: false @@ -235,7 +235,7 @@ jobs: // Guard against fork PRs — GITHUB_TOKEN cannot merge across forks. // prData.head.repo is null when the fork has been deleted. - const thisRepo = context.repo.owner + '/' + context.repo.repo; + const thisRepo = context.payload.repository.full_name; if (!prData.head.repo || prData.head.repo.full_name !== thisRepo) { const headRepo = prData.head.repo ? prData.head.repo.full_name : '(deleted fork)'; core.info(`PR #${prNumber}: fork or deleted-fork PR (${headRepo}) — skipping.`); diff --git a/tests/test_auto_merge_workflow.py b/tests/test_auto_merge_workflow.py index b850a0525..089681afa 100644 --- a/tests/test_auto_merge_workflow.py +++ b/tests/test_auto_merge_workflow.py @@ -135,8 +135,7 @@ def test_merge_on_gate_pass_filters_all_gates_passed_name() -> None: wf = _load_auto_merge() job_if = wf["jobs"]["merge-on-gate-pass"].get("if", "") assert "All Gates Passed" in job_if, ( - "merge-on-gate-pass must filter on check_run.name == 'All Gates Passed' " - "(the canonical fan-in job name from merge-gate.yml)" + "merge-on-gate-pass must filter on check_run.name == 'All Gates Passed' (the canonical fan-in job name from merge-gate.yml)" )