Skip to content
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 (comma-separated list)| 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 succeed 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 (comma-separated)'
Copy link
Copy Markdown
Contributor

@msugakov msugakov Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: "Authors to filter PRs for auto-merge (comma-separated)" is confusing. How about "Authors (comma-separated) whose PRs will be auto-merged".

Also, please mention what happens when its value is empty. See related #91 (comment)

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'
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the default for dry-run parameter be true? Optional.

Suggested change
default: 'false'
default: 'true'

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)'
Copy link
Copy Markdown
Contributor

@msugakov msugakov Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd suggest something like "Only PRs having these labels will be merged. Multiple labels can be specified as comma-separated, each of them must be present on the PR."

Also, please mention what happens when an empty value is specified. See related #91 (comment)

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 }}"
179 changes: 179 additions & 0 deletions automerge/automerge.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
#!/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>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# test/local-env.sh automerge/automerge.sh <repository> <limit> <label1,label2,...> <allowed-author> <required-checks> <allowed-base-branches>
# test/local-env.sh automerge/automerge.sh <repository> <limit> <label1,label2,...> <allowed-author1,allowed-author2,...> <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 ALLOWED_BASE_BRANCHES
Comment on lines +22 to +24
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm. Since you have check_not_empty on all CLI arguments here, should required: false be changed to required: true in GitHub inputs definitions?


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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd vote for ALLOWED_BASE_BRANCHES to be the complete regex, i.e. include both ^ and $ so that here you only write

Suggested change
if [[ ! "${BASE_BRANCH}" =~ ^(${ALLOWED_BASE_BRANCHES})$ ]]; then
if [[ ! "${BASE_BRANCH}" =~ ${ALLOWED_BASE_BRANCHES} ]]; then

This would make things less surprising should you try using ^ or $ in allowed-base-branches input value.

echo "[DEBUG] PR #${PR_NUMBER} skipped - base branch '${BASE_BRANCH}' not allowed"
continue
Comment on lines +55 to +56
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The default limit=50 does not seem particularly high. It may happen that at some point we would have >50 PRs that pass basic criteria (matching repo, labels, be open and mergeable, not be draft) but that are open against different branches.
The script will start, load 50 PRs and then filter them out. I mean not just this spot but also filtering by status.

Is there any way to randomize the results of gh pr list ... command so that there's at least a chance to get things going in case there are >50 non-eligible PRs?

Alternatively, gh pr list ... can load many more results, potentially all. We should not have more than a couple thousands open PRs (famous last words) total, much less will pass basic criteria.
The we reshuffle those results and process up to limit in one run.

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}"
Comment on lines +73 to +74
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd suggest indenting here.

Suggested change
gh pr merge --repo "${REPOSITORY}" \
--auto --squash "${PR_NUMBER}"
gh pr merge --repo "${REPOSITORY}" \
--auto --squash "${PR_NUMBER}"

echo "[DEBUG] ✓ PR #${PR_NUMBER} - auto-merge enabled"
fi

# Approve only PRs by allowed authors
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know you did not change that logic as compared to the original implementation, but I wonder what is the scenario when we want to enable auto-merge but don't want to approve?

IFS=',' read -r -a ALLOWED_AUTHORS_ARRAY <<< "${ALLOWED_AUTHORS}"
if [[ " ${ALLOWED_AUTHORS_ARRAY[*]} " == *"${AUTHOR}"* ]]; then
Comment on lines +79 to +80
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would this work?

Suggested change
IFS=',' read -r -a ALLOWED_AUTHORS_ARRAY <<< "${ALLOWED_AUTHORS}"
if [[ " ${ALLOWED_AUTHORS_ARRAY[*]} " == *"${AUTHOR}"* ]]; then
if [[ ",${ALLOWED_AUTHORS}," == *",${AUTHOR},"* ]]; 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}"
Comment on lines +84 to +85
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I spotted the older command had || true at the end. Do you know why that was done?

echo "[DEBUG] ✓ PR #${PR_NUMBER} - approved"
fi
else
echo "[DEBUG] x PR #${PR_NUMBER} not approved - author '${AUTHOR}' not in allowed authors"
fi
done
}

# Collects all status checks and checkruns for the PR and returns a boolean indicating if all required checks have passed or been skipped.
# The REQUIRED_CHECKS regex parameter must return at least one check.
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
}
... on StatusContext {
context
state
}
}
}
}
}
}
}
}
}
}
'

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 -r --arg pattern "${REQUIRED_CHECKS}" '
[.[]
| select(
(.name != null and (.name | test($pattern)))
or (.context != null and (.context | test($pattern)))
)
| if .name != null then {conclusion: .conclusion}
else {conclusion: .state}
end
]
| length > 0 and all(.conclusion == "SUCCESS" or .conclusion == "SKIPPED")
'
}

main "$@"
Loading