Last active
April 4, 2024 20:28
-
-
Save stefcameron/527108410e01dadd58aad81232366902 to your computer and use it in GitHub Desktop.
Auto-merge Dependabot PRs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.