Skip to content

Commit fff1fd1

Browse files
feat: add --bump-patch-version and --no-prompt flags to merge-pull-requests-by-title.sh (#158)
* Initial plan * feat(gh-cli): add --bump-patch-version flag to merge-pull-requests-by-title.sh Co-authored-by: joshjohanning <19912012+joshjohanning@users.noreply.github.com> * feat(gh-cli): add --no-prompt flag to merge-pull-requests-by-title.sh Co-authored-by: joshjohanning <19912012+joshjohanning@users.noreply.github.com> * fix: address pr comments * feat: update README and script comments for --bump-patch-version and --enable-auto-merge options * fix: validate required arguments for merge-pull-requests-by-title.sh * fix: correct jq pattern escaping in merge-pull-requests-by-title.sh --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: joshjohanning <19912012+joshjohanning@users.noreply.github.com> Co-authored-by: Josh Johanning <joshjohanning@github.com>
1 parent 83270f4 commit fff1fd1

2 files changed

Lines changed: 245 additions & 54 deletions

File tree

gh-cli/README.md

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1483,20 +1483,29 @@ Creates a (mostly) empty migration for a given organization repository so that i
14831483

14841484
### merge-pull-requests-by-title.sh
14851485

1486-
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.
1486+
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.
14871487

14881488
```bash
1489-
# Find and merge PRs with exact title match
1489+
# Find and merge PRs with exact title match (prompts for confirmation per PR)
14901490
./merge-pull-requests-by-title.sh repos.txt "chore(deps-dev): bump eslint from 8.0.0 to 9.0.0"
14911491

14921492
# Use wildcard to match partial titles
14931493
./merge-pull-requests-by-title.sh repos.txt "chore(deps-dev): bump eslint*"
14941494

1495-
# With custom commit title
1496-
./merge-pull-requests-by-title.sh repos.txt "chore(deps)*" squash "chore(deps): update dependencies"
1495+
# Merge without confirmation prompt
1496+
./merge-pull-requests-by-title.sh repos.txt "chore(deps)*" --no-prompt
1497+
1498+
# With custom commit title, no confirmation prompt
1499+
./merge-pull-requests-by-title.sh repos.txt "chore(deps)*" squash "chore(deps): update dependencies" --no-prompt
14971500

14981501
# Dry run to preview
1499-
./merge-pull-requests-by-title.sh repos.txt "chore(deps)*" squash "" --dry-run
1502+
./merge-pull-requests-by-title.sh repos.txt "chore(deps)*" --dry-run
1503+
1504+
# Bump npm patch version on matching PR branches and push (run before merging so CI can pass)
1505+
./merge-pull-requests-by-title.sh repos.txt "chore(deps)*" --bump-patch-version
1506+
1507+
# Bump patch version and enable auto-merge (bump, wait for CI, then auto-merge)
1508+
./merge-pull-requests-by-title.sh repos.txt "chore(deps)*" --bump-patch-version --enable-auto-merge
15001509
```
15011510

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

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

Lines changed: 231 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,33 @@
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
@@ -30,45 +39,95 @@
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

3953
merge_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
4256
dry_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")
4361
for 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
4875
done
4976

5077
if [ $# -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
6095
fi
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

67121
if [ "$dry_run" = true ]; then
68122
echo "🔍 DRY RUN MODE - No PRs will be merged"
69123
echo ""
70124
fi
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
73132
if [[ ! " ${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

182352
echo "========================================"
183353
echo "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
185359
echo " ❌ Failed: $fail_count"
186360
echo " ⏭️ Skipped: $skipped_count"
187361
echo " 📭 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"
193367
fi
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

Comments
 (0)