Skip to content

Commit fb29c05

Browse files
feat: enhance merge-pull-requests-by-title.sh with owner and topic filtering options (#160)
* feat: enhance merge-pull-requests-by-title.sh with owner and topic filtering options * fix: address code review comments * fix: code review suggestions and better defenses * docs: simplify readme entry * fix: improve error handling and fallback for repository fetching in merge-pull-requests-by-title.sh * fix: improve error messages and validation in merge-pull-requests-by-title.sh * fix: add validation for GitHub owner input in merge-pull-requests-by-title.sh
1 parent 832d064 commit fb29c05

2 files changed

Lines changed: 186 additions & 47 deletions

File tree

gh-cli/README.md

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1503,29 +1503,20 @@ Creates a (mostly) empty migration for a given organization repository so that i
15031503

15041504
### merge-pull-requests-by-title.sh
15051505

1506-
Finds and merges pull requests matching a title pattern across multiple repositories. Useful for batch merging Dependabot PRs or other automated PRs with similar titles. By default, prompts for confirmation before each merge; use `--no-prompt` to skip.
1506+
Finds and merges pull requests matching a title pattern across multiple repositories. Supports batch merging Dependabot PRs, bumping npm patch versions, and enabling auto-merge. Repositories can be specified via a file list or dynamically via `--owner` with optional `--topic` filtering.
15071507

15081508
```bash
1509-
# Find and merge PRs with exact title match (prompts for confirmation per PR)
1510-
./merge-pull-requests-by-title.sh repos.txt "chore(deps-dev): bump eslint from 8.0.0 to 9.0.0"
1511-
1512-
# Use wildcard to match partial titles
1513-
./merge-pull-requests-by-title.sh repos.txt "chore(deps-dev): bump eslint*"
1514-
1515-
# Merge without confirmation prompt
1509+
# Merge PRs matching a wildcard title pattern
15161510
./merge-pull-requests-by-title.sh repos.txt "chore(deps)*" --no-prompt
15171511

1518-
# With custom commit title, no confirmation prompt
1519-
./merge-pull-requests-by-title.sh repos.txt "chore(deps)*" squash "chore(deps): update dependencies" --no-prompt
1520-
15211512
# Dry run to preview
15221513
./merge-pull-requests-by-title.sh repos.txt "chore(deps)*" --dry-run
15231514

1524-
# Bump npm patch version on matching PR branches and push (run before merging so CI can pass)
1525-
./merge-pull-requests-by-title.sh repos.txt "chore(deps)*" --bump-patch-version
1526-
1527-
# Bump patch version and enable auto-merge (bump, wait for CI, then auto-merge)
1515+
# Bump patch version and enable auto-merge
15281516
./merge-pull-requests-by-title.sh repos.txt "chore(deps)*" --bump-patch-version --enable-auto-merge
1517+
1518+
# Search by owner and topic instead of file list
1519+
./merge-pull-requests-by-title.sh --owner joshjohanning --topic node-action "chore(deps)*" --dry-run
15291520
```
15301521

15311522
Input file format (`repos.txt`):

gh-cli/merge-pull-requests-by-title.sh

Lines changed: 180 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,24 @@
33
# Finds and merges pull requests matching a title pattern across multiple repositories
44
#
55
# Usage:
6-
# ./merge-pull-requests-by-title.sh <repo_list_file> <pr_title_pattern> [merge_method] [commit_title] [--dry-run] [--bump-patch-version] [--enable-auto-merge] [--no-prompt]
6+
# ./merge-pull-requests-by-title.sh <repo_list_file> <pr_title_pattern> [merge_method] [commit_title] [flags...]
7+
# ./merge-pull-requests-by-title.sh --owner <owner> <pr_title_pattern> [--topic <topic>]... [merge_method] [commit_title] [flags...]
78
#
8-
# Arguments:
9+
# Required (one of):
910
# repo_list_file - File with repository URLs (one per line)
11+
# --owner <owner> - Search repositories for this user or organization
12+
#
13+
# Required:
1014
# pr_title_pattern - Title pattern to match (exact match or use * for wildcard)
11-
# merge_method - Optional: merge method (merge, squash, rebase) - defaults to squash
12-
# commit_title - Optional: custom commit title for all merged PRs (PR number is auto-appended)
13-
# --dry-run - Optional: preview what would be merged without actually merging
14-
# --bump-patch-version - Optional: clone each matching PR branch, run npm version patch, commit, and push (mutually exclusive with --dry-run; does not merge unless combined with --enable-auto-merge)
15-
# --enable-auto-merge - Optional: enable auto-merge on matching PRs (can combine with --bump-patch-version)
16-
# --no-prompt - Optional: merge without interactive confirmation (default is to prompt before each merge)
15+
#
16+
# Optional:
17+
# merge_method - Merge method: merge, squash, or rebase (default: squash)
18+
# commit_title - Custom commit title for merged PRs (PR number is auto-appended; defaults to PR title)
19+
# --topic <topic> - Filter --owner repositories by topic (can be specified multiple times)
20+
# --dry-run - Preview what would be merged without actually merging
21+
# --bump-patch-version - Clone each matching PR branch, run npm version patch, commit, and push (mutually exclusive with --dry-run; does not merge unless combined with --enable-auto-merge)
22+
# --enable-auto-merge - Enable auto-merge on matching PRs (can combine with --bump-patch-version)
23+
# --no-prompt - Merge without interactive confirmation (default is to prompt before each merge)
1724
#
1825
# Examples:
1926
# # Find and merge PRs with exact title match (will prompt for confirmation)
@@ -31,6 +38,15 @@
3138
# # Bump patch version and enable auto-merge (bump, wait for CI, then auto-merge)
3239
# ./merge-pull-requests-by-title.sh repos.txt "chore(deps)*" --bump-patch-version --enable-auto-merge
3340
#
41+
# # Search by owner instead of file list
42+
# ./merge-pull-requests-by-title.sh --owner joshjohanning-org "chore(deps): bump undici*" --no-prompt
43+
#
44+
# # Search by owner and topic
45+
# ./merge-pull-requests-by-title.sh --owner joshjohanning --topic node-action "chore(deps)*" --bump-patch-version
46+
#
47+
# # Search by owner and multiple topics
48+
# ./merge-pull-requests-by-title.sh --owner joshjohanning --topic node-action --topic github-action "chore(deps)*" --dry-run
49+
#
3450
# Input file format (repos.txt):
3551
# https://github.com/joshjohanning/repo1
3652
# https://github.com/joshjohanning/repo2
@@ -52,13 +68,18 @@
5268

5369
merge_methods=("merge" "squash" "rebase")
5470

55-
# Check for --dry-run, --bump-patch-version, and --no-prompt flags anywhere in arguments
71+
# Check for flags and valued options
5672
dry_run=false
5773
bump_patch_version=false
5874
enable_auto_merge=false
5975
no_prompt=false
60-
valid_flags=("--dry-run" "--bump-patch-version" "--enable-auto-merge" "--no-prompt")
61-
for arg in "$@"; do
76+
search_owner=""
77+
topics=()
78+
valid_flags=("--dry-run" "--bump-patch-version" "--enable-auto-merge" "--no-prompt" "--owner" "--topic")
79+
args=("$@")
80+
i=0
81+
while [ $i -lt ${#args[@]} ]; do
82+
arg="${args[$i]}"
6283
if [ "$arg" = "--dry-run" ]; then
6384
dry_run=true
6485
elif [ "$arg" = "--bump-patch-version" ]; then
@@ -67,25 +88,52 @@ for arg in "$@"; do
6788
enable_auto_merge=true
6889
elif [ "$arg" = "--no-prompt" ]; then
6990
no_prompt=true
91+
elif [ "$arg" = "--owner" ]; then
92+
((i++))
93+
search_owner="${args[$i]}"
94+
if [ -z "$search_owner" ] || [[ "$search_owner" == --* ]]; then
95+
echo "Error: --owner requires a value"
96+
exit 1
97+
fi
98+
if ! [[ "$search_owner" =~ ^[a-zA-Z0-9._-]+$ ]]; then
99+
echo "Error: Invalid owner '$search_owner' - must be a valid GitHub username or organization"
100+
exit 1
101+
fi
102+
elif [ "$arg" = "--topic" ]; then
103+
((i++))
104+
topic_val="${args[$i]}"
105+
if [ -z "$topic_val" ] || [[ "$topic_val" == --* ]]; then
106+
echo "Error: --topic requires a value"
107+
exit 1
108+
fi
109+
topics+=("$topic_val")
70110
elif [[ "$arg" == --* ]]; then
71111
echo "Error: Unknown flag '$arg'"
72112
echo "Valid flags: ${valid_flags[*]}"
73113
exit 1
74114
fi
115+
((i++))
75116
done
76117

77118
if [ $# -lt 2 ]; then
78-
echo "Usage: $0 <repo_list_file> <pr_title_pattern> [merge_method] [commit_title] [--dry-run] [--bump-patch-version] [--enable-auto-merge] [--no-prompt]"
119+
echo "Usage: $0 <repo_list_file> <pr_title_pattern> [merge_method] [commit_title] [flags...]"
120+
echo " $0 --owner <owner> <pr_title_pattern> [--topic <topic>]... [merge_method] [commit_title] [flags...]"
79121
echo ""
80-
echo "Arguments:"
122+
echo "Required (one of):"
81123
echo " repo_list_file - File with repository URLs (one per line)"
124+
echo " --owner <owner> - Search repositories for this user or organization"
125+
echo ""
126+
echo "Required:"
82127
echo " pr_title_pattern - Title pattern to match (use * for wildcard)"
83-
echo " merge_method - Optional: merge, squash, or rebase (default: squash)"
84-
echo " commit_title - Optional: custom commit title for merged PRs (PR number is auto-appended)"
85-
echo " --dry-run - Preview what would be merged without actually merging"
86-
echo " --bump-patch-version - Bump npm patch version on each matching PR branch and push (mutually exclusive with --dry-run)"
87-
echo " --enable-auto-merge - Enable auto-merge on matching PRs (can combine with --bump-patch-version)"
88-
echo " --no-prompt - Merge without interactive confirmation (default is to prompt before each merge)"
128+
echo ""
129+
echo "Optional:"
130+
echo " merge_method - merge, squash, or rebase (default: squash)"
131+
echo " commit_title - Custom commit title for merged PRs (defaults to PR title)"
132+
echo " --topic <topic> - Filter --owner repositories by topic (repeatable)"
133+
echo " --dry-run - Preview what would be merged (cannot combine with --bump-patch-version or --enable-auto-merge)"
134+
echo " --bump-patch-version - Bump npm patch version on each matching PR branch and push (cannot combine with --dry-run)"
135+
echo " --enable-auto-merge - Enable auto-merge on matching PRs (can combine with --bump-patch-version, cannot combine with --dry-run)"
136+
echo " --no-prompt - Merge without interactive confirmation"
89137
exit 1
90138
fi
91139

@@ -99,22 +147,49 @@ if [ "$dry_run" = true ] && [ "$enable_auto_merge" = true ]; then
99147
exit 1
100148
fi
101149

102-
# Parse positional args, skipping flags
150+
# Parse positional args, skipping flags and their values
103151
positional_args=()
104-
for arg in "$@"; do
105-
if [[ "$arg" != --* ]]; then
152+
i=0
153+
while [ $i -lt ${#args[@]} ]; do
154+
arg="${args[$i]}"
155+
if [ "$arg" = "--owner" ] || [ "$arg" = "--topic" ]; then
156+
((i++)) # skip the value too
157+
elif [[ "$arg" != --* ]]; then
106158
positional_args+=("$arg")
107159
fi
160+
((i++))
108161
done
109162

110-
repo_list_file=${positional_args[0]}
111-
pr_title_pattern=${positional_args[1]}
112-
merge_method=${positional_args[2]:-squash}
113-
commit_title=${positional_args[3]:-}
163+
# When --owner is used, positional args shift (no repo_list_file needed)
164+
if [ -n "$search_owner" ]; then
165+
if [ ${#positional_args[@]} -gt 3 ]; then
166+
echo "Error: Too many positional arguments for --owner mode (expected: pr_title_pattern [merge_method] [commit_title])"
167+
exit 1
168+
fi
169+
pr_title_pattern=${positional_args[0]}
170+
merge_method=${positional_args[1]:-squash}
171+
commit_title=${positional_args[2]:-}
172+
else
173+
repo_list_file=${positional_args[0]}
174+
pr_title_pattern=${positional_args[1]}
175+
merge_method=${positional_args[2]:-squash}
176+
commit_title=${positional_args[3]:-}
177+
fi
114178

115-
if [ -z "$repo_list_file" ] || [ -z "$pr_title_pattern" ]; then
116-
echo "Error: repo_list_file and pr_title_pattern are required"
179+
if [ -z "$pr_title_pattern" ]; then
180+
echo "Error: pr_title_pattern is required"
117181
echo "Usage: $0 <repo_list_file> <pr_title_pattern> [merge_method] [commit_title] [flags...]"
182+
echo " $0 --owner <owner> <pr_title_pattern> [--topic <topic>]... [merge_method] [commit_title] [flags...]"
183+
exit 1
184+
fi
185+
186+
if [ -z "$search_owner" ] && [ -z "$repo_list_file" ]; then
187+
echo "Error: Either repo_list_file or --owner is required"
188+
exit 1
189+
fi
190+
191+
if [ ${#topics[@]} -gt 0 ] && [ -z "$search_owner" ]; then
192+
echo "Error: --topic requires --owner"
118193
exit 1
119194
fi
120195

@@ -134,12 +209,79 @@ if [[ ! " ${merge_methods[*]} " =~ ${merge_method} ]]; then
134209
exit 1
135210
fi
136211

137-
# Check if file exists
138-
if [ ! -f "$repo_list_file" ]; then
212+
# Check if file exists (when using file mode)
213+
if [ -z "$search_owner" ] && [ ! -f "$repo_list_file" ]; then
139214
echo "Error: File $repo_list_file does not exist"
140215
exit 1
141216
fi
142217

218+
# Build repo list from --owner/--topic or from file
219+
if [ -n "$search_owner" ]; then
220+
echo "Fetching repositories for owner: $search_owner"
221+
if [ ${#topics[@]} -gt 0 ]; then
222+
echo "Filtering by topics: ${topics[*]}"
223+
fi
224+
echo ""
225+
226+
# Fetch repos from org (or user), optionally filtered by topics
227+
# Try org endpoint first, fall back to user endpoint
228+
# Build jq filter: repos must have ALL specified topics
229+
if [ ${#topics[@]} -gt 0 ]; then
230+
# Validate and lowercase topic names
231+
for i in "${!topics[@]}"; do
232+
topics[$i]=$(echo "${topics[$i]}" | tr '[:upper:]' '[:lower:]')
233+
t="${topics[$i]}"
234+
if ! [[ "$t" =~ ^[a-z0-9][a-z0-9-]*$ ]]; then
235+
echo "Error: Invalid topic '$t' - topics must be alphanumeric with hyphens"
236+
exit 1
237+
fi
238+
done
239+
topic_conditions=""
240+
for t in "${topics[@]}"; do
241+
if [ -n "$topic_conditions" ]; then
242+
topic_conditions="$topic_conditions and "
243+
fi
244+
topic_conditions="${topic_conditions}((.topics? // []) | index(\"$t\"))"
245+
done
246+
jq_topic_filter="select(.archived == false) | select($topic_conditions) | .full_name"
247+
else
248+
jq_topic_filter="select(.archived == false) | .full_name"
249+
fi
250+
251+
api_err=$(mktemp)
252+
repo_names=$(gh api --paginate "/orgs/$search_owner/repos?per_page=100" \
253+
--jq ".[] | $jq_topic_filter" 2>"$api_err")
254+
owner_exit=$?
255+
repo_fetch_exit=$owner_exit
256+
257+
# Only fall back to /users/ endpoint if the org endpoint failed (not just empty results)
258+
if [ $owner_exit -ne 0 ]; then
259+
repo_names=$(gh api --paginate "/users/$search_owner/repos?per_page=100" \
260+
--jq ".[] | $jq_topic_filter" 2>"$api_err")
261+
repo_fetch_exit=$?
262+
fi
263+
264+
if [ $repo_fetch_exit -ne 0 ]; then
265+
echo "Error: Failed to fetch repositories for '$search_owner'"
266+
cat "$api_err" 2>/dev/null
267+
rm -f "$api_err"
268+
exit 1
269+
fi
270+
rm -f "$api_err"
271+
272+
if [ -z "$repo_names" ]; then
273+
echo "No repositories found for '$search_owner'"
274+
if [ ${#topics[@]} -gt 0 ]; then
275+
echo " (filtered by topics: ${topics[*]})"
276+
fi
277+
exit 0
278+
fi
279+
280+
repo_count=$(echo "$repo_names" | wc -l | xargs)
281+
echo "Found $repo_count repositories"
282+
echo ""
283+
fi
284+
143285
echo "Searching for PRs matching: \"$pr_title_pattern\""
144286
echo ""
145287

@@ -187,7 +329,6 @@ while IFS= read -r repo_url || [ -n "$repo_url" ]; do
187329
jq_pattern="${jq_pattern//$/\\$}"
188330
jq_pattern="${jq_pattern//|/\\|}"
189331
jq_pattern="${jq_pattern//\{/\\{}"
190-
jq_pattern="${jq_pattern//\}/\\}}"
191332
jq_pattern="${jq_pattern//\*/.*}"
192333
# Escape backslashes and double quotes for embedding in jq string literal
193334
jq_pattern_escaped="${jq_pattern//\\/\\\\}"
@@ -347,7 +488,14 @@ while IFS= read -r repo_url || [ -n "$repo_url" ]; do
347488

348489
echo ""
349490

350-
done < "$repo_list_file"
491+
done < <(if [ -n "$search_owner" ]; then
492+
# --owner mode: repo_names contains owner/repo format, convert to URLs
493+
echo "$repo_names" | while IFS= read -r name; do
494+
echo "https://github.com/$name"
495+
done
496+
else
497+
cat "$repo_list_file"
498+
fi)
351499

352500
echo "========================================"
353501
echo "Summary:"

0 commit comments

Comments
 (0)