Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions automerge/README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
105 changes: 31 additions & 74 deletions automerge/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 }}"
169 changes: 169 additions & 0 deletions automerge/automerge.sh
Original file line number Diff line number Diff line change
@@ -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 <repository> <limit> <label1,label2,...> <allowed-author> <required-checks> <allowed-base-branches>
#

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 "$@"
Loading