Skip to content
Open
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
107 changes: 32 additions & 75 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:
Comment thread
kurlov marked this conversation as resolved.
description: 'Authors (comma-separated) to filter PRs for auto-merge. Empty list will fail validation.'
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: 'true'
github-token:
description: 'GitHub token with permissions to merge PRs and approve reviews'
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: '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. Empty list will fail validation.'
required: false
default: 'auto-merge'
limit:
description: 'Maximum number of PRs to process per run.'
required: false
default: '50'
repository:
Comment thread
kurlov marked this conversation as resolved.
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 }}"
178 changes: 178 additions & 0 deletions automerge/automerge.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
#!/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-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?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The inputs are not required on the action level (mostly for backwards compatibility) and we provide reasonable defaults there.
In the script however, each of these inputs needs to be defined. If a workflow calling the action does not provide an input, the default from the action's inputs section will be used.

Alternatively, we could provide defaults again in the script (duplicate the defaults from the action definition) or work with named arguments (overhead).

TLDR: For backwards compatibility and simplicity, we should keep the current implementation.

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.

How does the combination of

  default: 'blah'
  required: false

work?

If the caller does not provide the attribute, the default blah value gets passed in, right? That's the point of defaults.

required: false may only play a role when the attribute is specified but a null/empty value is passed. I.e. the caller would explicitly do

steps:
- uses: my-fancy-action
  with:
    blah: null

Is this a backward compatibility scenario to be at all concerned about?


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
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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Counterpoint: Why may it be okay to have the current limit?

  • Generally: Because we go through the list of PRs relatively quickly, so it shouldn't grow too much
  • Dependabot: We don't observe issues with this number, it was fine before, so I assume it will be in the future.
  • MintMaker: Because we go through the list of PRs relatively quickly, so it shouldn't grow too much. 50 may be a good compromise: (3 + 1) branches * 4 types of updates = 16 MintMaker PRs concurrently. Even if we keep release branches for EUS support, we could support 12 branches in parallel.

I think 50 is a good compromise for now, and I would not invest in optimizations with randomization or custom limit implementations. If we encounter >50 PRs, we can revisit this.

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 MintMaker assumption may not hold infinitely true. MintMaker will keep opening PRs against the release branches after we do the last release before EoL but before we tear down the setup.
We don't have a garbage collector or a step to go and close stale MintMaker PRs when sunsetting a version stream.

My main concern at this point is how would we find out when we encounter >50 PRs given that this action is to be executed in cron workflow which does not report its status on a PR?

The same question applies to #91 (comment): how would the engineer find out that gh pr review ... --approve ... command failed?

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
Comment thread
msugakov marked this conversation as resolved.
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 +83 to +84
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?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I assume it was done to continue the workflow if approval fails. I would actually prefer to let it fail (not silently), because only if it breaks, engineers will investigate and implement improvements if necessary.

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