diff --git a/automerge/README.md b/automerge/README.md index db8b333..60c4327 100644 --- a/automerge/README.md +++ b/automerge/README.md @@ -1,23 +1,27 @@ # Auto-merge Action -This composite action enables auto-merge for eligible pull requests based on specified labels. +This composite action enables auto-merge for eligible pull requests based on specified labels. Defaults to approving Dependabot PRs with green CI against all branches. ## Features -- Finds PRs with specified label (default: `auto-merge`) +- Finds PRs with specified label (default: `auto-merge`) for the allowed base branches - Verifies PRs are in mergeable state (non-draft) -- Checks that all status checks have passed +- Checks that required status checks have passed - Enables auto-merge with squash strategy -- Auto-approves Dependabot PRs +- Auto-approves PRs for allowed author ## Inputs | Input | Description | Required | Default | |-------|-------------|----------|---------| +| `allowed-authors` | Authors to filter PRs for auto-merge (regex)| No | `app/dependabot` | +| `allowed-base-branches` | Allowed base branches for auto-merge (regex) | No | `.*` | +| `dry-run` | Whether to dry-run the auto-merge | No | `false` | | `github-token` | GitHub token with permissions to merge PRs and approve reviews (`contents: write` and `pull-requests: write` permissions) | Yes | - | +| `labels` | Labels to filter PRs for auto-merge (comma-separated `and` logic) | No | `auto-merge` | +| `limit` | Maximum number of PRs to process per run | No | `50` | | `repository` | Repository in owner/repo format | No | `${{ github.repository }}` | -| `label` | Label to filter PRs for auto-merge | No | `auto-merge` | -| `limit` | Maximum number of PRs to process | No | `50` | +| `required-checks` | Required checks to pass for auto-merge (regex) | No | `.*` | ## Usage diff --git a/automerge/action.yml b/automerge/action.yml index d106e18..9026485 100644 --- a/automerge/action.yml +++ b/automerge/action.yml @@ -2,21 +2,37 @@ name: Auto-merge PRs description: Enable auto-merge for eligible PRs with specified labels inputs: + allowed-authors: + description: 'Authors to filter PRs for auto-merge (regex)' + required: false + default: 'app/dependabot' + allowed-base-branches: + description: 'Allowed base branches for auto-merge (regex)' + required: false + default: '.*' + dry-run: + description: 'Whether to dry-run the auto-merge' + required: false + default: 'false' github-token: description: 'GitHub token with permissions to merge PRs and approve reviews' required: true - repository: - description: 'Repository in owner/repo format' - required: false - default: ${{ github.repository }} - label: - description: 'Label to filter PRs for auto-merge' + labels: + description: 'Labels to filter PRs for auto-merge (comma-separated `and` logic)' required: false default: 'auto-merge' limit: description: 'Maximum number of PRs to process per run.' required: false default: '50' + repository: + description: 'Repository in owner/repo format' + required: false + default: ${{ github.repository }} + required-checks: + description: 'Required checks to pass for auto-merge (regex)' + required: false + default: '.*' runs: using: composite @@ -25,74 +41,15 @@ runs: shell: bash env: GH_TOKEN: ${{ inputs.github-token }} + DRY_RUN: ${{ inputs.dry-run }} run: | set -euo pipefail - # Extract repo owner and name - IFS='/' read -r OWNER REPO <<< "${{ inputs.repository }}" - - echo "::notice::Querying PRs with '${{ inputs.label }}' label in ${{ inputs.repository }}" - - # Get all PRs with auto-merge labels (non-draft, mergeable only) - PR_DATA=$(gh pr list \ - --repo "${{ inputs.repository }}" \ - --label "${{ inputs.label }}" \ - --draft=false \ - --state open \ - --limit "${{ inputs.limit }}" \ - --json number,mergeable,author \ - --jq ".[] | select(.mergeable == \"MERGEABLE\") | {number, author: .author.login}") - - if [[ -z "$PR_DATA" ]]; then - echo "::notice::No eligible PRs found with '${{ inputs.label }}' label" - exit 0 - fi - - # Process each PR - echo "$PR_DATA" | jq -c '.' | while read -r PR_JSON; do - PR_NUMBER=$(echo "$PR_JSON" | jq -r '.number') - AUTHOR=$(echo "$PR_JSON" | jq -r '.author') - - echo "::notice::Processing PR #$PR_NUMBER (author=$AUTHOR)" - - # Check if all checks have passed using GraphQL statusCheckRollup - STATUS=$(gh api graphql -F owner="$OWNER" -F repo="$REPO" -F number="$PR_NUMBER" -f query=" - query(\$owner: String!, \$repo: String!, \$number: Int!) { - repository(owner: \$owner, name: \$repo) { - pullRequest(number: \$number) { - commits(last: 1) { - nodes { - commit { - statusCheckRollup { - state - } - } - } - } - } - } - } - " --jq ".data.repository.pullRequest.commits.nodes[0].commit.statusCheckRollup.state" || echo "null") - - echo "::notice::PR #$PR_NUMBER status check rollup: $STATUS" - - # Only proceed if all checks passed - if [[ "$STATUS" != "SUCCESS" ]]; then - echo "::warning::Skipping PR #$PR_NUMBER - checks not passed (status: $STATUS)" - continue - fi - - # Enable auto-merge for all PRs with the label - echo "::notice::Enabling auto-merge for PR #$PR_NUMBER" - gh pr merge --repo "${{ inputs.repository }}" \ - --auto --squash "$PR_NUMBER" - - # Auto-approve only Dependabot PRs - if [[ "$AUTHOR" == "app/dependabot" ]]; then - echo "::notice::Approving Dependabot PR #$PR_NUMBER" - gh pr review --repo "${{ inputs.repository }}" \ - --approve "$PR_NUMBER" || true - fi - - echo "::notice::✓ Auto-merge enabled for PR #$PR_NUMBER" - done + "${GITHUB_ACTION_PATH}/../common/common.sh" \ + "${GITHUB_ACTION_PATH}/automerge.sh" \ + "${{ inputs.repository }}" \ + "${{ inputs.limit }}" \ + "${{ inputs.labels }}" \ + "${{ inputs.allowed-authors }}" \ + "${{ inputs.required-checks }}" \ + "${{ inputs.allowed-base-branches }}" diff --git a/automerge/automerge.sh b/automerge/automerge.sh new file mode 100755 index 0000000..41ffda7 --- /dev/null +++ b/automerge/automerge.sh @@ -0,0 +1,169 @@ +#!/bin/bash +# +# Enables auto-merge for eligible PRs with specified labels. +# PRs can be filtered by labels, base branches, and allowed author. +# Required status checks must pass for auto-merge to be enabled. +# +# Local run: +# +# test/local-env.sh automerge/automerge.sh +# + +set -euo pipefail + +function main() { + REPOSITORY="${1:-}" + LIMIT="${2:-}" + LABELS="${3:-}" + ALLOWED_AUTHORS="${4:-}" + REQUIRED_CHECKS="${5:-}" + ALLOWED_BASE_BRANCHES="${6:-}" + + check_not_empty \ + DRY_RUN GH_TOKEN \ + REPOSITORY LIMIT LABELS ALLOWED_AUTHORS REQUIRED_CHECKS + + gh_log notice "Querying PRs with '${LABELS}' label(s) in ${REPOSITORY}, allowed authors: ${ALLOWED_AUTHORS}, required checks: ${REQUIRED_CHECKS}, allowed base branches: ${ALLOWED_BASE_BRANCHES}" + gh_log notice "DRY_RUN: ${DRY_RUN}" + + # Extract repo owner and name + IFS='/' read -r OWNER REPO <<< "${REPOSITORY}" + + # Get all PRs with auto-merge labels (non-draft, mergeable only) + PR_DATA=$(gh pr list \ + --repo "${REPOSITORY}" \ + --label "${LABELS}" \ + --draft=false \ + --state open \ + --limit "${LIMIT}" \ + --json number,mergeable,author,baseRefName \ + --jq ".[] | select(.mergeable == \"MERGEABLE\") | {number, author: .author.login, baseRefName: .baseRefName}") + + if [[ -z "${PR_DATA}" ]]; then + gh_log notice "No eligible PRs found with '${LABELS}' labels" + exit 0 + fi + + # Process each PR + echo "${PR_DATA}" | jq -c '.' | while read -r PR_JSON; do + PR_NUMBER=$(echo "$PR_JSON" | jq -r '.number') + AUTHOR=$(echo "$PR_JSON" | jq -r '.author') + BASE_BRANCH=$(echo "$PR_JSON" | jq -r '.baseRefName') + + echo "[DEBUG] PR #${PR_NUMBER} - author='${AUTHOR}', base branch='${BASE_BRANCH}'" + if [[ ! "${BASE_BRANCH}" =~ ^(${ALLOWED_BASE_BRANCHES})$ ]]; then + echo "[DEBUG] PR #${PR_NUMBER} skipped - base branch '${BASE_BRANCH}' not allowed" + continue + fi + + STATUS="$(get_combined_success_status "${PR_NUMBER}")" + + # Only proceed if the required checks have passed + if [[ "${STATUS}" == "true" ]]; then + echo "[DEBUG] ✓ PR #${PR_NUMBER} - all required checks passed or skipped" + else + echo "[DEBUG] x PR #${PR_NUMBER} skipped - not all required checks passed or skipped" + continue + fi + + # Enable auto-merge for all PRs with the label(s) + if [[ "${DRY_RUN}" == "true" ]]; then + echo "[DEBUG] ✓ PR #${PR_NUMBER} - would have enabled auto-merge [DRY RUN]" + else + gh pr merge --repo "${REPOSITORY}" \ + --auto --squash "${PR_NUMBER}" + echo "[DEBUG] ✓ PR #${PR_NUMBER} - auto-merge enabled" + fi + + # Approve only PRs by allowed authors + if [[ "${AUTHOR}" =~ ^(${ALLOWED_AUTHORS})$ ]]; then + if [[ "${DRY_RUN}" == "true" ]]; then + echo "[DEBUG] ✓ PR #${PR_NUMBER} - would have approved [DRY RUN]" + else + gh pr review --repo "${REPOSITORY}" \ + --approve "${PR_NUMBER}" + echo "[DEBUG] ✓ PR #${PR_NUMBER} - approved" + fi + else + echo "[DEBUG] x PR #${PR_NUMBER} not approved - author '${AUTHOR}' not in allowed authors" + fi + done +} + +function get_combined_success_status() { + PAGE_SIZE=100 + CURSOR="" + NODES_JSON='[]' + + # shellcheck disable=SC2016 + QUERY=' + query($owner: String!, $repo: String!, $number: Int!, $first: Int!, $after: String) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + commits(last: 1) { + nodes { + commit { + statusCheckRollup { + contexts(first: $first, after: $after) { + pageInfo { + hasNextPage + endCursor + } + nodes { + ... on CheckRun { + name + status + conclusion + } + } + } + } + } + } + } + } + } + } + ' + + while true; do + ARGS=( + graphql + -F owner="$OWNER" + -F repo="$REPO" + -F number="$PR_NUMBER" + -F first="$PAGE_SIZE" + -f query="$QUERY" + ) + if [[ -n "${CURSOR}" ]]; then + ARGS+=(-F after="${CURSOR}") + fi + + RESP=$(gh api "${ARGS[@]}") + + PAGE_NODES=$(echo "${RESP}" | jq '.data.repository.pullRequest.commits.nodes[0].commit.statusCheckRollup.contexts.nodes // []') + NODES_JSON=$(jq -n --argjson acc "${NODES_JSON}" --argjson page "${PAGE_NODES}" '$acc + $page') + + HAS_NEXT=$(echo "${RESP}" | jq -r '.data.repository.pullRequest.commits.nodes[0].commit.statusCheckRollup.contexts.pageInfo.hasNextPage // false') + if [[ "${HAS_NEXT}" != "true" ]]; then + break + fi + CURSOR=$(echo "${RESP}" | jq -r '.data.repository.pullRequest.commits.nodes[0].commit.statusCheckRollup.contexts.pageInfo.endCursor // empty') + if [[ -z "${CURSOR}" ]]; then + gh_log error "Pagination indicated hasNextPage but endCursor is empty" + exit 1 + fi + done + + echo "$NODES_JSON" | jq ' + map(select( + .name != null + and (.name | test("'"${REQUIRED_CHECKS}"'")) + )) + | { + ALL_SUCCESS: (length > 0 and all(.conclusion == "SUCCESS" or .conclusion == "SKIPPED")) + } | .ALL_SUCCESS + ' +} + +main "$@"