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)
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
5268
5369merge_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
5672dry_run=false
5773bump_patch_version=false
5874enable_auto_merge=false
5975no_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++ ))
75116done
76117
77118if [ $# -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
90138fi
91139
@@ -99,22 +147,49 @@ if [ "$dry_run" = true ] && [ "$enable_auto_merge" = true ]; then
99147 exit 1
100148fi
101149
102- # Parse positional args, skipping flags
150+ # Parse positional args, skipping flags and their values
103151positional_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++ ))
108161done
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
119194fi
120195
@@ -134,12 +209,79 @@ if [[ ! " ${merge_methods[*]} " =~ ${merge_method} ]]; then
134209 exit 1
135210fi
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
141216fi
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+
143285echo " Searching for PRs matching: \" $pr_title_pattern \" "
144286echo " "
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
352500echo " ========================================"
353501echo " Summary:"
0 commit comments