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]
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]
77#
88# Arguments:
9- # repo_list_file - File with repository URLs (one per line)
10- # 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
9+ # repo_list_file - File with repository URLs (one per line)
10+ # 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)
1417#
1518# Examples:
16- # # Find and merge PRs with exact title match
19+ # # Find and merge PRs with exact title match (will prompt for confirmation)
1720# ./merge-pull-requests-by-title.sh repos.txt "chore(deps-dev): bump eslint-plugin-jest from 29.5.0 to 29.9.0 in the eslint group"
1821#
19- # # With custom commit title
20- # ./merge-pull-requests-by-title.sh repos.txt "chore(deps-dev): bump eslint*" squash "chore(deps): update eslint dependencies"
22+ # # With custom commit title, no confirmation prompt
23+ # ./merge-pull-requests-by-title.sh repos.txt "chore(deps-dev): bump eslint*" squash "chore(deps): update eslint dependencies" --no-prompt
2124#
22- # # Dry run to preview
23- # ./merge-pull-requests-by-title.sh repos.txt "chore(deps)*" squash "" --dry-run
25+ # # Dry run to preview (flags can appear anywhere, no need for "" placeholders)
26+ # ./merge-pull-requests-by-title.sh repos.txt "chore(deps)*" --dry-run
27+ #
28+ # # Bump patch version on matching PR branches (run before merging so CI can pass)
29+ # ./merge-pull-requests-by-title.sh repos.txt "chore(deps)*" --bump-patch-version
30+ #
31+ # # Bump patch version and enable auto-merge (bump, wait for CI, then auto-merge)
32+ # ./merge-pull-requests-by-title.sh repos.txt "chore(deps)*" --bump-patch-version --enable-auto-merge
2433#
2534# Input file format (repos.txt):
2635# https://github.com/joshjohanning/repo1
3039# Notes:
3140# - PRs must be open and in a mergeable state
3241# - Use * as a wildcard in the title pattern (e.g., "chore(deps)*" matches any title starting with "chore(deps)")
33- # - If multiple PRs match in a repo, all will be listed but only the first will be merged (use --dry-run to preview)
42+ # - If multiple PRs match in a repo, all will be processed
43+ # - --bump-patch-version clones each matching PR branch to a temp dir, bumps the npm patch version, commits, and pushes
44+ # - --bump-patch-version is mutually exclusive with --dry-run (does not merge unless combined with --enable-auto-merge)
45+ # - --bump-patch-version only works with same-repo PRs (fork-based PRs are skipped)
46+ # - --enable-auto-merge queues PRs to merge once all required checks pass (does not bypass protections)
47+ # - By default, merge mode prompts for confirmation before each PR merge; use --no-prompt to skip
3448#
3549# TODO:
3650# - Add --delete-branch flag to delete remote branch after merge
3751# - Add --bypass flag to bypass branch protection requirements
3852
3953merge_methods=(" merge" " squash" " rebase" )
4054
41- # Check for --dry-run flag anywhere in arguments
55+ # Check for --dry-run, --bump-patch-version, and --no-prompt flags anywhere in arguments
4256dry_run=false
57+ bump_patch_version=false
58+ enable_auto_merge=false
59+ no_prompt=false
60+ valid_flags=(" --dry-run" " --bump-patch-version" " --enable-auto-merge" " --no-prompt" )
4361for arg in " $@ " ; do
4462 if [ " $arg " = " --dry-run" ]; then
4563 dry_run=true
46- break
64+ elif [ " $arg " = " --bump-patch-version" ]; then
65+ bump_patch_version=true
66+ elif [ " $arg " = " --enable-auto-merge" ]; then
67+ enable_auto_merge=true
68+ elif [ " $arg " = " --no-prompt" ]; then
69+ no_prompt=true
70+ elif [[ " $arg " == --* ]]; then
71+ echo " Error: Unknown flag '$arg '"
72+ echo " Valid flags: ${valid_flags[*]} "
73+ exit 1
4774 fi
4875done
4976
5077if [ $# -lt 2 ]; then
51- echo " Usage: $0 <repo_list_file> <pr_title_pattern> [merge_method] [commit_title] [--dry-run]"
78+ echo " Usage: $0 <repo_list_file> <pr_title_pattern> [merge_method] [commit_title] [--dry-run] [--bump-patch-version] [--enable-auto-merge] [--no-prompt] "
5279 echo " "
5380 echo " Arguments:"
54- echo " repo_list_file - File with repository URLs (one per line)"
55- echo " pr_title_pattern - Title pattern to match (use * for wildcard)"
56- echo " merge_method - Optional: merge, squash, or rebase (default: squash)"
57- echo " commit_title - Optional: custom commit title for merged PRs (PR number is auto-appended)"
58- echo " --dry-run - Preview what would be merged without actually merging"
81+ echo " repo_list_file - File with repository URLs (one per line)"
82+ 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)"
89+ exit 1
90+ fi
91+
92+ if [ " $dry_run " = true ] && [ " $bump_patch_version " = true ]; then
93+ echo " Error: --dry-run and --bump-patch-version are mutually exclusive"
5994 exit 1
6095fi
6196
62- repo_list_file=$1
63- pr_title_pattern=$2
64- merge_method=${3:- squash}
65- commit_title=${4:- }
97+ if [ " $dry_run " = true ] && [ " $enable_auto_merge " = true ]; then
98+ echo " Error: --dry-run and --enable-auto-merge are mutually exclusive"
99+ exit 1
100+ fi
101+
102+ # Parse positional args, skipping flags
103+ positional_args=()
104+ for arg in " $@ " ; do
105+ if [[ " $arg " != --* ]]; then
106+ positional_args+=(" $arg " )
107+ fi
108+ done
109+
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]:- }
114+
115+ if [ -z " $repo_list_file " ] || [ -z " $pr_title_pattern " ]; then
116+ echo " Error: repo_list_file and pr_title_pattern are required"
117+ echo " Usage: $0 <repo_list_file> <pr_title_pattern> [merge_method] [commit_title] [flags...]"
118+ exit 1
119+ fi
66120
67121if [ " $dry_run " = true ]; then
68122 echo " 🔍 DRY RUN MODE - No PRs will be merged"
69123 echo " "
70124fi
71125
126+ if [ " $bump_patch_version " = true ]; then
127+ echo " 🔼 BUMP PATCH VERSION MODE - Will bump npm patch version on matching PR branches"
128+ echo " "
129+ fi
130+
72131# Validate merge method
73132if [[ ! " ${merge_methods[*]} " =~ ${merge_method} ]]; then
74133 echo " Error: merge_method must be one of: ${merge_methods[*]} "
@@ -127,17 +186,36 @@ while IFS= read -r repo_url || [ -n "$repo_url" ]; do
127186 jq_pattern=" ${jq_pattern// ^/ \\ ^} "
128187 jq_pattern=" ${jq_pattern// $/ \\ $} "
129188 jq_pattern=" ${jq_pattern// |/ \\ |} "
189+ jq_pattern=" ${jq_pattern// \{ / \\ {} "
190+ jq_pattern=" ${jq_pattern// \} / \\ } }"
130191 jq_pattern=" ${jq_pattern// \* / .* } "
131- jq_filter=" select(.title | test(\" ^\" + \$ pattern + \" $\" ))"
192+ # Escape backslashes and double quotes for embedding in jq string literal
193+ jq_pattern_escaped=" ${jq_pattern// \\ / \\\\ } "
194+ jq_pattern_escaped=" ${jq_pattern_escaped// \" / \\\" } "
195+ jq_filter=" select(.title | test(\" ^${jq_pattern_escaped} $\" ))"
132196 else
133197 # Exact match - use simple string equality
134- jq_filter=" select(.title == \$ pattern)"
135- jq_pattern=" $pr_title_pattern "
198+ # Escape backslashes and double quotes for embedding in jq string literal
199+ jq_pattern_escaped=" ${pr_title_pattern// \\ / \\\\ } "
200+ jq_pattern_escaped=" ${jq_pattern_escaped// \" / \\\" } "
201+ jq_filter=" select(.title == \" ${jq_pattern_escaped} \" )"
136202 fi
137203
138204 # Get open PRs and filter by title (paginate to get all PRs)
139- matching_prs=$( gh api --paginate " /repos/$repo /pulls?state=open" 2> /dev/null | \
140- jq -r --arg pattern " $jq_pattern " " .[] | $jq_filter | \" \(.number)|\(.title)|\(.user.login)\" " )
205+ api_stderr=$( mktemp)
206+ matching_prs=$( gh api --paginate " /repos/$repo /pulls?state=open" \
207+ --jq " .[] | $jq_filter | \" \(.number)|\(.title)|\(.user.login)|\(.head.ref)|\(.head.repo.full_name)\" " 2> " $api_stderr " )
208+ api_exit=$?
209+
210+ if [ $api_exit -ne 0 ]; then
211+ api_error=$( cat " $api_stderr " )
212+ rm -f " $api_stderr "
213+ echo " ❌ API error for $repo : $api_error "
214+ (( fail_count++ ))
215+ echo " "
216+ continue
217+ fi
218+ rm -f " $api_stderr "
141219
142220 if [ -z " $matching_prs " ]; then
143221 echo " 📭 No matching PRs found"
@@ -147,31 +225,123 @@ while IFS= read -r repo_url || [ -n "$repo_url" ]; do
147225 fi
148226
149227 # Process each matching PR
150- while IFS=' |' read -r pr_number pr_title pr_author; do
228+ while IFS=' |' read -r pr_number pr_title pr_author pr_branch pr_head_repo ; do
151229 echo " 📋 Found PR #$pr_number : $pr_title (by $pr_author )"
152230
153- # Build the merge command
154- merge_args=(" --$merge_method " )
231+ if [ " $bump_patch_version " = true ]; then
232+ # Skip fork-based PRs since we can't push to the head repo
233+ if [ " $pr_head_repo " != " $repo " ]; then
234+ echo " ⚠️ Skipping $repo #$pr_number - fork-based PR ($pr_head_repo ), cannot push to branch"
235+ (( skipped_count++ ))
236+ continue
237+ fi
155238
156- # Always include PR number in commit subject (e.g., "commit message (#123)")
157- if [ " $merge_method " != " rebase" ]; then
158- if [ -n " $commit_title " ]; then
159- merge_args+=(" --subject" " $commit_title (#$pr_number )" )
239+ # Clone to temp dir, bump patch version, commit, and push
240+ tmp_dir=$( mktemp -d)
241+ clone_dir=" $tmp_dir /$repo_name "
242+ echo " 🔀 Cloning $repo (branch: $pr_branch ) to $clone_dir "
243+ if ! gh repo clone " $repo " " $clone_dir " -- --quiet --branch " $pr_branch " 2>&1 ; then
244+ echo " ❌ Failed to clone $repo "
245+ (( fail_count++ ))
246+ rm -rf " $tmp_dir "
247+ continue
248+ fi
249+ new_version=$( cd " $clone_dir " && npm version patch --no-git-tag-version --ignore-scripts)
250+ if [ -z " $new_version " ]; then
251+ echo " ❌ Failed to bump version in $repo #$pr_number (is there a package.json?)"
252+ (( fail_count++ ))
253+ rm -rf " $tmp_dir "
254+ continue
255+ fi
256+ # Strip leading 'v' if present (npm version returns e.g. "v1.2.3")
257+ new_version=" ${new_version# v} "
258+ echo " 🔼 Bumped version to $new_version "
259+ if (cd " $clone_dir " && git add package.json && { git add package-lock.json 2> /dev/null || true ; } && git commit -m " chore: bump version to $new_version " ); then
260+ if (cd " $clone_dir " && git push origin " $pr_branch " ); then
261+ echo " ✅ Successfully pushed version bump to $repo /$pr_branch "
262+ (( success_count++ ))
263+ # Enable auto-merge if requested
264+ if [ " $enable_auto_merge " = true ]; then
265+ auto_merge_args=(" --auto" " --$merge_method " )
266+ if [ " $merge_method " != " rebase" ]; then
267+ if [ -n " $commit_title " ]; then
268+ auto_merge_args+=(" --subject" " $commit_title (#$pr_number )" )
269+ else
270+ auto_merge_args+=(" --subject" " $pr_title (#$pr_number )" )
271+ fi
272+ fi
273+ if gh pr merge " $pr_number " --repo " $repo " " ${auto_merge_args[@]} " ; then
274+ echo " 🔄 Auto-merge enabled for $repo #$pr_number "
275+ else
276+ echo " ⚠️ Failed to enable auto-merge for $repo #$pr_number "
277+ (( fail_count++ ))
278+ fi
279+ fi
280+ else
281+ echo " ❌ Failed to push version bump to $repo /$pr_branch "
282+ (( fail_count++ ))
283+ fi
160284 else
161- merge_args+=(" --subject" " $pr_title (#$pr_number )" )
285+ echo " ❌ Failed to commit version bump in $repo #$pr_number "
286+ (( fail_count++ ))
162287 fi
163- fi
164-
165- # Attempt to merge
166- if [ " $dry_run " = true ]; then
167- echo " 🔍 Would merge $repo #$pr_number with: gh pr merge $pr_number --repo $repo ${merge_args[*]} "
168- (( success_count++ ))
169- elif gh pr merge " $pr_number " --repo " $repo " " ${merge_args[@]} " ; then
170- echo " ✅ Successfully merged $repo #$pr_number "
171- (( success_count++ ))
288+ rm -rf " $tmp_dir "
172289 else
173- echo " ❌ Failed to merge $repo #$pr_number "
174- (( fail_count++ ))
290+ # Build the merge command
291+ merge_args=(" --$merge_method " )
292+
293+ # Always include PR number in commit subject (e.g., "commit message (#123)")
294+ if [ " $merge_method " != " rebase" ]; then
295+ if [ -n " $commit_title " ]; then
296+ merge_args+=(" --subject" " $commit_title (#$pr_number )" )
297+ else
298+ merge_args+=(" --subject" " $pr_title (#$pr_number )" )
299+ fi
300+ fi
301+
302+ # Check if status checks have failed before attempting merge (skip for auto-merge since it waits for checks)
303+ if [ " $enable_auto_merge " = false ]; then
304+ failed_checks=$( gh pr checks " $pr_number " --repo " $repo " --json " name,state" --jq ' [.[] | select(.state == "FAILURE")] | length' 2> /dev/null)
305+ if [ -n " $failed_checks " ] && [ " $failed_checks " -gt 0 ] 2> /dev/null; then
306+ echo " ⚠️ Skipping $repo #$pr_number - $failed_checks status check(s) failed"
307+ (( skipped_count++ ))
308+ continue
309+ fi
310+ fi
311+
312+ # Attempt to merge (or enable auto-merge)
313+ if [ " $enable_auto_merge " = true ]; then
314+ merge_args+=(" --auto" )
315+ fi
316+ if [ " $dry_run " = true ]; then
317+ echo " 🔍 Would merge $repo #$pr_number with: gh pr merge $pr_number --repo $repo ${merge_args[*]} "
318+ (( success_count++ ))
319+ else
320+ # Prompt for confirmation unless --no-prompt was passed
321+ if [ " $no_prompt " = false ]; then
322+ if ! [[ -t 1 ]] || ! [[ -r /dev/tty ]]; then
323+ echo " Error: No TTY available for interactive prompt - use --no-prompt"
324+ exit 1
325+ fi
326+ read -r -p " ❓ Merge $repo #$pr_number ? [y/N] " confirm < /dev/tty
327+ if [[ ! " $confirm " =~ ^[Yy]$ ]]; then
328+ echo " ⏭️ Skipped $repo #$pr_number "
329+ (( skipped_count++ ))
330+ continue
331+ fi
332+ fi
333+ if gh pr merge " $pr_number " --repo " $repo " " ${merge_args[@]} " ; then
334+ if [ " $enable_auto_merge " = true ]; then
335+ echo " 🔄 Auto-merge enabled for $repo #$pr_number "
336+ else
337+ echo " ✅ Successfully merged $repo #$pr_number "
338+ fi
339+ (( success_count++ ))
340+ else
341+ echo " ❌ Failed to merge $repo #$pr_number "
342+ (( fail_count++ ))
343+ fi
344+ fi
175345 fi
176346 done <<< " $matching_prs"
177347
@@ -181,7 +351,11 @@ done < "$repo_list_file"
181351
182352echo " ========================================"
183353echo " Summary:"
184- echo " ✅ Merged: $success_count "
354+ if [ " $bump_patch_version " = true ]; then
355+ echo " ✅ Bumped: $success_count "
356+ else
357+ echo " ✅ Merged: $success_count "
358+ fi
185359echo " ❌ Failed: $fail_count "
186360echo " ⏭️ Skipped: $skipped_count "
187361echo " 📭 No match: $not_found_count "
@@ -191,3 +365,11 @@ if [ "$dry_run" = true ]; then
191365 echo " "
192366 echo " 🔍 This was a DRY RUN - no PRs were actually merged"
193367fi
368+
369+ if [ " $bump_patch_version " = true ] && [ " $enable_auto_merge " = true ]; then
370+ echo " "
371+ echo " 🔼 Version bumps pushed and auto-merge enabled - PRs will merge once CI passes"
372+ elif [ " $bump_patch_version " = true ]; then
373+ echo " "
374+ echo " 🔼 Version bumps pushed - wait for CI to pass before merging"
375+ fi
0 commit comments