Skip to content

Instantly share code, notes, and snippets.

@stefcameron
Last active April 4, 2024 20:28
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save stefcameron/527108410e01dadd58aad81232366902 to your computer and use it in GitHub Desktop.
Save stefcameron/527108410e01dadd58aad81232366902 to your computer and use it in GitHub Desktop.
Auto-merge Dependabot PRs
#!/bin/bash
##
## Lists and optionally merges only Dependabot PRs in a given GitHub repo.
##
## Published at: https://gist.github.com/stefcameron/527108410e01dadd58aad81232366902
##
## Version history:
##
## - 2024/04/04+1527: Update show_help() to use easier syntax.
## - 2023/01/09+1014: Update Dependabot `author.login` from 'dependabot' to 'app/dependabot' per upstream change.
## - 2022/11/24+1319: Make a list of 'do not merge' labels instead of individual variables.
## - 2022/11/24+1306: Also consider pkg-dependency label as a 'do not merge' label, and show name of blocking label.
## - 2022/11/02+1603: When PR is intentionally skipped, show 'Skipped' as reason instead of 'Status'.
## - 2022/10/12+1247:
## - Now using 'X' instead of 'S' to indicate skipped PRs since 'S' is too reminicent of 'Squash'.
## - If there are no bot PRs and -m option is not given, the script now outputs a message stating no bot PRs were found.
## - 2022/09/15+1503: Added support for 'DO-NOT-MERGE' labels to auto-skip PRs.
## - 2022/07/01+1103: Back to using --admin for merging; --auto doesn't work well, doesn't merge for no reason sometimes.
## - 2022/06/30+1337: Fixed another bug in status detection.
## - 2022/06/29+1738: Fixed bug in status detection, added detection of more conclusions/states/statuses.
## - 2022/06/28+1147: Changed comment only, no code.
##
## Dependencies:
##
## - gh (GitHub CLI)
## - jq (JSON Query)
##
# seconds to wait before starting merges
MERGE_WAIT=7
# special PR labels for PRs that should not be merged
doNotMergeLabels='DO-NOT-MERGE pkg-dependency'
# org/repo string (required)
repo=
# if 1, merge PRs; otherwise, list only
merge=0
# if 1, PRs of any status are merged; by default, only merge PRs with SUCCESS status
force=0
# space-delimited list of PRs to skip when merging (if any)
skipList=
# if set, show help and exit
showHelp=
##
## FUNCTIONS
##
#
# Load command line parameters
# @see https://stackoverflow.com/questions/192249/how-do-i-parse-command-line-arguments-in-bash
#
function get_params {
while getopts r:mfs:h option
do
case "$option" in
r)
repo="$OPTARG"
;;
m)
merge=1
;;
f)
force=1
;;
s)
skipList="$OPTARG"
;;
h|\?)
showHelp=1
;;
esac
done
}
#
# Show command line usage help.
#
function show_help {
cat <<EOF
Usage: gh-dependabot-prs -r ORG/REPO [options]
Options:
-r ORG/REPO: Org and name of repo to check, e.g. "focus-trap/tabbable"
-m: Merge all listed PRs excepted filtered ones (if any) from -f. By default,
PRs are listed only. When merging, will wait $MERGE_WAIT seconds before
starting.
-s SKIP_LIST: Space-delimited list of PRs to skip when merging. Ignored if
-m not specified. Example: \`-s "3 5"\` would skip PRs #3 and #5 when merging.
-f: Force merging of auto-skipped PRs. Manually skipped (-s) PRs are never
merged.
-h: Show this help message.
EOF
}
#
# Determines, based on labels, if a PR can be merged.
# @param {string|null} $1 JSON describing the PR including the `labels` array.
# @echoes {label | ''} Label name blocking merge if __NOT__ OK to merge; '' if good to merge.
#
function do_not_merge_label {
typeset pr
typeset label
typeset result
typeset returnValue
pr="$1"
for label in ${doNotMergeLabels}; do
result=$(echo "$pr" | jq ".labels | any(index(\"${label}\"))")
if [ "$result" = 'true' ]; then
returnValue=$label
break
fi
done
echo "$returnValue"
}
#
# Get overall status of a PR.
# @param {string|null} $1 JSON describing the PR including the `statusChecks` array, which
# could be `null` if there was an issue with GitHub and Actions haven't run yet.
# @echoes {'SUCCESS' | 'ERROR' | 'PENDING'} Status rollup.
#
function get_pr_status {
typeset pr
typeset indexes
typeset idx
typeset conclusion
typeset state
typeset status
typeset combinedStatus # overall status of all checks combined
pr="$1"
indexes=$(echo "$pr" | jq 'if .statusChecks == null then empty else .statusChecks[] | .number end' | nl | cut -f1)
combinedStatus='PENDING' # assume pending until (and if, since array may be null/empty) find otherwise
for idx in $indexes; do
conclusion=$(echo "$pr" | jq ".statusChecks[$((idx - 1))].conclusion") # empty string if not used
state=$(echo "$pr" | jq ".statusChecks[$((idx - 1))].state") # jq will return `null` if not found
status=$(echo "$pr" | jq ".statusChecks[$((idx - 1))].status") # jq will return `null` if not found
# NOTE: when jq returns a string, it returns it in quotes, so '"SUCCESS"', not just 'SUCCESS'...
if [ -z "$conclusion" -o "$conclusion" = '"EXPECTED"' -o "$conclusion" = '"QUEUED"' -o "$conclusion" = '"PENDING"' -o "$conclusion" = '"IN_PROGRESS"' ]; then
conclusion='PENDING'
elif [ "$conclusion" = '"ERROR"' -o "$conclusion" = '"FAILURE"' -o "$conclusion" = '"CANCELLED"' ]; then
conclusion='ERROR'
else
conclusion='SUCCESS'
fi
if [ -z "$state" -o "$state" = '"EXPECTED"' -o "$state" = '"QUEUED"' -o "$state" = '"PENDING"' -o "$state" = '"IN_PROGRESS"' ]; then
state='PENDING'
elif [ "$state" = '"ERROR"' -o "$state" = '"FAILURE"' -o "$state" = '"CANCELLED"' ]; then
state='ERROR'
else
state='SUCCESS'
fi
if [ -z "$status" -o "$status" = '"EXPECTED"' -o "$status" = '"QUEUED"' -o "$status" = '"PENDING"' -o "$status" = '"IN_PROGRESS"' ]; then
status='PENDING'
elif [ "$status" = '"ERROR"' -o "$status" = '"FAILURE"' -o "$status" = '"CANCELLED"' ]; then
status='ERROR'
else
status='SUCCESS'
fi
# now normalize all 3 into a single combined status
if [ "$state" = 'ERROR' -o "$status" = 'ERROR' -o "$conclusion" = 'ERROR' ]; then
combinedStatus='ERROR' # ERROR takes precedence
break; # no point looking further
elif [ "$state" = 'PENDING' -o "$status" = 'PENDING' -o "$conclusion" = 'PENDING' ]; then
combinedStatus='PENDING' # PENDING takes precedence
break; # no point looking further
else
combinedStatus='SUCCESS' # assume success
fi
done
echo "$combinedStatus"
}
#
# Main script run.
#
function run_script {
typeset prs
typeset indexes
typeset idx
typeset pr
typeset prNum
typeset prTitle
typeset prStatus # 'SUCCESS' | 'ERROR' | 'PENDING'
typeset prDoNotMergeLabel # label name if blocked | ''
typeset skipped # because of PR status
typeset ignored # because of user manually skipping
typeset botList # space-delimited list of dependabot PR numbers (all found regardless of status)
typeset mergeList # space-delimited list of PR numbers to merge
# JSON array of PR objects with shapes like
# { title: string, number: number, labels: Array<string>, statusChecks: Array<{ conclusion: string, state: string, status: string }> }
# NOTE: for some reason, `--search 'author:dependabot'` doesn't work
# even though `--search 'author:stefcameron'` (for example) does, so
# select `author` in the JSON, and then use built-in jq to filter results
prs=$(gh pr list --repo "$repo" --state open --json title,number,author,labels,statusCheckRollup --jq '[ .[] | select(.author.login == "app/dependabot") | { number: .number, title: .title, labels: [.labels[].name], statusChecks: .statusCheckRollup } ]')
indexes=$(echo "$prs" | jq '.[] | .number' | nl | cut -f1)
for idx in $indexes; do
# JSON object with `title`, `number`, and `statusChecks` properties
pr=$(echo "$prs" | jq ".[$((idx - 1))]")
prNum=$(echo "$pr" | jq '.number')
prTitle=$(echo "$pr" | jq '.title')
prStatus=$(get_pr_status "$pr")
prDoNotMergeLabel=$(do_not_merge_label "$pr")
# NOTE: title will include the start/end quotes ("Title")
echo "$prTitle" | grep '^"\[DEPENDABOT\]: Bump' > /dev/null
isDependabotPr=$?
if [ $isDependabotPr -eq 0 ]; then
botList="$botList $prNum"
skipped=1 # 1 == no == NOT skipped == MERGE it!
ignored=1 # 1 == no == NOT ignored
if [ -n "$skipList" ]; then
# NOTE: we add spaces before and after to make it easier to grep for the
# specific number and not find partial matches, like if PR number is 23,
# we don't want to match on 231, but just 23, so we search for ' 23 '
echo " $skipList " | grep " $prNum " > /dev/null
skipped=$? # 0 if SKIPPED
ignored=$skipped
fi
# NOTE: for some unknown reason, it seems the -a (AND) operator is incompatible
# with `elif` because execution never falls into the block when it should,
# hence why it has been split out like this
if [ $skipped -ne 0 -a $force -ne 1 -a "$prStatus" != 'SUCCESS' ]; then
skipped=0 # SKIP it because we're NOT forcing and PR is NOT successfully verified
fi
if [ $skipped -ne 0 -a $force -ne 1 -a -n "$prDoNotMergeLabel" ]; then
skipped=0 # SKIP it because we're NOT forcing and PR has a 'do not merge' label
fi
if [ $skipped -eq 0 ]; then
if [ -n "$prDoNotMergeLabel" ]; then
echo "- X: $prNum, $prStatus, $prTitle -> Label: ${prDoNotMergeLabel}"
elif [ $ignored -eq 0 ]; then
echo "- X: $prNum, $prStatus, $prTitle -> Skipped"
else
echo "- X: $prNum, $prStatus, $prTitle -> Status"
fi
else
echo "- M: $prNum, $prStatus, $prTitle"
if [ $merge -eq 1 ]; then
mergeList="$mergeList $prNum"
fi
fi
fi
done
if [ $merge -eq 1 ]; then
if [ -n "$mergeList" ]; then
echo "Merging $repo Dependabot PRs in $MERGE_WAIT seconds:$mergeList..."
sleep $MERGE_WAIT
gh-pr-merge "$repo" "$mergeList" --admin
else
echo "There are no Dependabot PRs to merge in $repo"
fi
elif [ -z "$botList" ]; then
echo "No Dependabot PRs found in $repo"
fi
}
##
## MAIN
##
which gh > /dev/null
if [ $? -ne 0 ]; then
echo 'The gh (GitHub CLI) utility is required: https://cli.github.com/'
exit 1
fi
which jq > /dev/null
if [ $? -ne 0 ]; then
echo 'The jq (JSON Query) utility is required: https://stedolan.github.io/jq/'
exit 1
fi
if [ $# -lt 1 ]; then
# no parameters given is an error
show_help
exit 1
fi
get_params "$@"
if [ -n "$showHelp" ]; then
show_help
exit 0
fi
echo "$repo" | grep '\/' > /dev/null
if [ $? -ne 0 ]; then
echo "ERROR: missing ORG in -r parameter '$repo'"
exit 1
fi
run_script
#!/bin/bash
if [ $# -lt 2 ]; then
echo 'Usage: gh-pr-merge ORG/REPO "1 3 4 56 37" [...params]'
echo 'Any additional parameters are passed directly to `gh pr merge`.'
exit 1
fi
repo="$1"
shift
idList="$1"
shift
for id in $idList; do
gh pr review $id --approve
# -s means 'squash & merge'
gh pr merge $id -s --repo "$repo" "$@"
echo 'Pausing for 5 seconds...'
sleep 5 # give GitHub enough time to check for merge conflicts in all PRs still open
done
@stefcameron
Copy link
Author

stefcameron commented Sep 15, 2022

gh-dependabot-prs

I love Dependabot, but I got tired of manually merging Dependabot PRs one by one, and there's no "bulk PR merge" feature on GitHub.

So I wrote this Bash script, partly as a way to learn jq, and partly to solve my problem.

Usage: gh-dependabot-prs -r ORG/REPO [options]

  Options:

  -r ORG/REPO: Org and name of repo to check, e.g. "focus-trap/tabbable"

  -m: Merge all listed PRs excepted filtered ones (if any) from -f. By default,
    PRs are listed only. When merging, will wait 7 seconds before
    starting.

  -s SKIP_LIST: Space-delimited list of PRs to skip when merging. Ignored if
    -m not specified. Example: `-s "3 5"` would skip PRs #3 and #5 when merging.

  -f: Force merging of auto-skipped PRs. Manually skipped (-s) PRs are never
    merged.

  -h: Show this help message.

💬 The script does expect you to be an admin in the repo since gh-pr-merge, while it approves the PR prior to merging, is still given the --admin flag to override any pending checks there might be. Just make sure your branch rules are setup properly, or tweak the script so it doesn't need to use admin mode (and share how you did it, because I had trouble, and didn't really have to solve for that so far).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment