From e8d5b78c9a6ece0505e20b865155a0f7d36a2d3a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 03:57:32 +0000 Subject: [PATCH 1/7] Initial plan From 48de965e3bf3343e810d02fec9630c53a45f77ae Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 04:01:03 +0000 Subject: [PATCH 2/7] 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> --- gh-cli/README.md | 3 + gh-cli/merge-pull-requests-by-title.sh | 135 ++++++++++++++++++------- 2 files changed, 102 insertions(+), 36 deletions(-) diff --git a/gh-cli/README.md b/gh-cli/README.md index f3c6617..4941192 100644 --- a/gh-cli/README.md +++ b/gh-cli/README.md @@ -1497,6 +1497,9 @@ Finds and merges pull requests matching a title pattern across multiple reposito # Dry run to preview ./merge-pull-requests-by-title.sh repos.txt "chore(deps)*" squash "" --dry-run + +# Bump npm patch version on matching PR branches and push (run before merging so CI can pass) +./merge-pull-requests-by-title.sh repos.txt "chore(deps)*" squash "" --bump-patch-version ``` Input file format (`repos.txt`): diff --git a/gh-cli/merge-pull-requests-by-title.sh b/gh-cli/merge-pull-requests-by-title.sh index c99920b..d171492 100755 --- a/gh-cli/merge-pull-requests-by-title.sh +++ b/gh-cli/merge-pull-requests-by-title.sh @@ -3,14 +3,15 @@ # Finds and merges pull requests matching a title pattern across multiple repositories # # Usage: -# ./merge-pull-requests-by-title.sh [merge_method] [commit_title] [--dry-run] +# ./merge-pull-requests-by-title.sh [merge_method] [commit_title] [--dry-run] [--bump-patch-version] # # Arguments: -# repo_list_file - File with repository URLs (one per line) -# pr_title_pattern - Title pattern to match (exact match or use * for wildcard) -# merge_method - Optional: merge method (merge, squash, rebase) - defaults to squash -# commit_title - Optional: custom commit title for all merged PRs (PR number is auto-appended) -# --dry-run - Optional: preview what would be merged without actually merging +# repo_list_file - File with repository URLs (one per line) +# pr_title_pattern - Title pattern to match (exact match or use * for wildcard) +# merge_method - Optional: merge method (merge, squash, rebase) - defaults to squash +# commit_title - Optional: custom commit title for all merged PRs (PR number is auto-appended) +# --dry-run - Optional: preview what would be merged without actually merging +# --bump-patch-version - Optional: clone each matching PR branch, run npm version patch, commit, and push (mutually exclusive with --dry-run and merge) # # Examples: # # Find and merge PRs with exact title match @@ -22,6 +23,9 @@ # # Dry run to preview # ./merge-pull-requests-by-title.sh repos.txt "chore(deps)*" squash "" --dry-run # +# # Bump patch version on matching PR branches (run before merging so CI can pass) +# ./merge-pull-requests-by-title.sh repos.txt "chore(deps)*" squash "" --bump-patch-version +# # Input file format (repos.txt): # https://github.com/joshjohanning/repo1 # https://github.com/joshjohanning/repo2 @@ -31,6 +35,8 @@ # - PRs must be open and in a mergeable state # - Use * as a wildcard in the title pattern (e.g., "chore(deps)*" matches any title starting with "chore(deps)") # - If multiple PRs match in a repo, all will be listed but only the first will be merged (use --dry-run to preview) +# - --bump-patch-version clones each matching PR branch to a temp dir, bumps the npm patch version, commits, and pushes +# - --bump-patch-version is mutually exclusive with --dry-run # # TODO: # - Add --delete-branch flag to delete remote branch after merge @@ -38,24 +44,32 @@ merge_methods=("merge" "squash" "rebase") -# Check for --dry-run flag anywhere in arguments +# Check for --dry-run and --bump-patch-version flags anywhere in arguments dry_run=false +bump_patch_version=false for arg in "$@"; do if [ "$arg" = "--dry-run" ]; then dry_run=true - break + elif [ "$arg" = "--bump-patch-version" ]; then + bump_patch_version=true fi done if [ $# -lt 2 ]; then - echo "Usage: $0 [merge_method] [commit_title] [--dry-run]" + echo "Usage: $0 [merge_method] [commit_title] [--dry-run] [--bump-patch-version]" echo "" echo "Arguments:" - echo " repo_list_file - File with repository URLs (one per line)" - echo " pr_title_pattern - Title pattern to match (use * for wildcard)" - echo " merge_method - Optional: merge, squash, or rebase (default: squash)" - echo " commit_title - Optional: custom commit title for merged PRs (PR number is auto-appended)" - echo " --dry-run - Preview what would be merged without actually merging" + echo " repo_list_file - File with repository URLs (one per line)" + echo " pr_title_pattern - Title pattern to match (use * for wildcard)" + echo " merge_method - Optional: merge, squash, or rebase (default: squash)" + echo " commit_title - Optional: custom commit title for merged PRs (PR number is auto-appended)" + echo " --dry-run - Preview what would be merged without actually merging" + echo " --bump-patch-version - Bump npm patch version on each matching PR branch and push (mutually exclusive with --dry-run)" + exit 1 +fi + +if [ "$dry_run" = true ] && [ "$bump_patch_version" = true ]; then + echo "Error: --dry-run and --bump-patch-version are mutually exclusive" exit 1 fi @@ -69,6 +83,11 @@ if [ "$dry_run" = true ]; then echo "" fi +if [ "$bump_patch_version" = true ]; then + echo "🔼 BUMP PATCH VERSION MODE - Will bump npm patch version on matching PR branches" + echo "" +fi + # Validate merge method if [[ ! " ${merge_methods[*]} " =~ ${merge_method} ]]; then echo "Error: merge_method must be one of: ${merge_methods[*]}" @@ -137,7 +156,7 @@ while IFS= read -r repo_url || [ -n "$repo_url" ]; do # Get open PRs and filter by title (paginate to get all PRs) matching_prs=$(gh api --paginate "/repos/$repo/pulls?state=open" 2>/dev/null | \ - jq -r --arg pattern "$jq_pattern" ".[] | $jq_filter | \"\(.number)|\(.title)|\(.user.login)\"") + jq -r --arg pattern "$jq_pattern" ".[] | $jq_filter | \"\(.number)|\(.title)|\(.user.login)|\(.head.ref)\"") if [ -z "$matching_prs" ]; then echo " 📭 No matching PRs found" @@ -147,31 +166,66 @@ while IFS= read -r repo_url || [ -n "$repo_url" ]; do fi # Process each matching PR - while IFS='|' read -r pr_number pr_title pr_author; do + while IFS='|' read -r pr_number pr_title pr_author pr_branch; do echo " 📋 Found PR #$pr_number: $pr_title (by $pr_author)" - # Build the merge command - merge_args=("--$merge_method") - - # Always include PR number in commit subject (e.g., "commit message (#123)") - if [ "$merge_method" != "rebase" ]; then - if [ -n "$commit_title" ]; then - merge_args+=("--subject" "$commit_title (#$pr_number)") + if [ "$bump_patch_version" = true ]; then + # Clone to temp dir, bump patch version, commit, and push + tmp_dir=$(mktemp -d) + echo " 🔀 Cloning $repo (branch: $pr_branch) to $tmp_dir" + if ! gh repo clone "$repo" "$tmp_dir" -- --quiet --branch "$pr_branch" 2>&1; then + echo " ❌ Failed to clone $repo" + ((fail_count++)) + rm -rf "$tmp_dir" + continue + fi + new_version=$(cd "$tmp_dir" && npm version patch --no-git-tag-version) + if [ -z "$new_version" ]; then + echo " ❌ Failed to bump version in $repo#$pr_number (is there a package.json?)" + ((fail_count++)) + rm -rf "$tmp_dir" + continue + fi + # Strip leading 'v' if present (npm version returns e.g. "v1.2.3") + new_version="${new_version#v}" + echo " 🔼 Bumped version to $new_version" + if (cd "$tmp_dir" && git add package.json && { git add package-lock.json 2>/dev/null || true; } && git commit -m "chore: bump version to $new_version"); then + if (cd "$tmp_dir" && git push origin "$pr_branch"); then + echo " ✅ Successfully pushed version bump to $repo/$pr_branch" + ((success_count++)) + else + echo " ❌ Failed to push version bump to $repo/$pr_branch" + ((fail_count++)) + fi else - merge_args+=("--subject" "$pr_title (#$pr_number)") + echo " ❌ Failed to commit version bump in $repo#$pr_number" + ((fail_count++)) fi - fi - - # Attempt to merge - if [ "$dry_run" = true ]; then - echo " 🔍 Would merge $repo#$pr_number with: gh pr merge $pr_number --repo $repo ${merge_args[*]}" - ((success_count++)) - elif gh pr merge "$pr_number" --repo "$repo" "${merge_args[@]}"; then - echo " ✅ Successfully merged $repo#$pr_number" - ((success_count++)) + rm -rf "$tmp_dir" else - echo " ❌ Failed to merge $repo#$pr_number" - ((fail_count++)) + # Build the merge command + merge_args=("--$merge_method") + + # Always include PR number in commit subject (e.g., "commit message (#123)") + if [ "$merge_method" != "rebase" ]; then + if [ -n "$commit_title" ]; then + merge_args+=("--subject" "$commit_title (#$pr_number)") + else + merge_args+=("--subject" "$pr_title (#$pr_number)") + fi + fi + + # Attempt to merge + if [ "$dry_run" = true ]; then + echo " 🔍 Would merge $repo#$pr_number with: gh pr merge $pr_number --repo $repo ${merge_args[*]}" + ((success_count++)) + elif gh pr merge "$pr_number" --repo "$repo" "${merge_args[@]}"; then + echo " ✅ Successfully merged $repo#$pr_number" + ((success_count++)) + else + echo " ❌ Failed to merge $repo#$pr_number" + ((fail_count++)) + fi fi done <<< "$matching_prs" @@ -181,7 +235,11 @@ done < "$repo_list_file" echo "========================================" echo "Summary:" -echo " ✅ Merged: $success_count" +if [ "$bump_patch_version" = true ]; then + echo " ✅ Bumped: $success_count" +else + echo " ✅ Merged: $success_count" +fi echo " ❌ Failed: $fail_count" echo " ⏭️ Skipped: $skipped_count" echo " 📭 No match: $not_found_count" @@ -191,3 +249,8 @@ if [ "$dry_run" = true ]; then echo "" echo "🔍 This was a DRY RUN - no PRs were actually merged" fi + +if [ "$bump_patch_version" = true ]; then + echo "" + echo "🔼 Version bumps pushed - wait for CI to pass before merging" +fi From 8a1fd578a2e7369c7ccdb59292ccfd0c71e646fd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:27:06 +0000 Subject: [PATCH 3/7] feat(gh-cli): add --no-prompt flag to merge-pull-requests-by-title.sh Co-authored-by: joshjohanning <19912012+joshjohanning@users.noreply.github.com> --- gh-cli/README.md | 11 +++++--- gh-cli/merge-pull-requests-by-title.sh | 39 ++++++++++++++++++-------- 2 files changed, 35 insertions(+), 15 deletions(-) diff --git a/gh-cli/README.md b/gh-cli/README.md index 4941192..bd5ad7a 100644 --- a/gh-cli/README.md +++ b/gh-cli/README.md @@ -1483,17 +1483,20 @@ Creates a (mostly) empty migration for a given organization repository so that i ### merge-pull-requests-by-title.sh -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. +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. ```bash -# Find and merge PRs with exact title match +# Find and merge PRs with exact title match (prompts for confirmation per PR) ./merge-pull-requests-by-title.sh repos.txt "chore(deps-dev): bump eslint from 8.0.0 to 9.0.0" # Use wildcard to match partial titles ./merge-pull-requests-by-title.sh repos.txt "chore(deps-dev): bump eslint*" -# With custom commit title -./merge-pull-requests-by-title.sh repos.txt "chore(deps)*" squash "chore(deps): update dependencies" +# Merge without confirmation prompt +./merge-pull-requests-by-title.sh repos.txt "chore(deps)*" squash "" --no-prompt + +# With custom commit title, no confirmation prompt +./merge-pull-requests-by-title.sh repos.txt "chore(deps)*" squash "chore(deps): update dependencies" --no-prompt # Dry run to preview ./merge-pull-requests-by-title.sh repos.txt "chore(deps)*" squash "" --dry-run diff --git a/gh-cli/merge-pull-requests-by-title.sh b/gh-cli/merge-pull-requests-by-title.sh index d171492..e5e630c 100755 --- a/gh-cli/merge-pull-requests-by-title.sh +++ b/gh-cli/merge-pull-requests-by-title.sh @@ -3,7 +3,7 @@ # Finds and merges pull requests matching a title pattern across multiple repositories # # Usage: -# ./merge-pull-requests-by-title.sh [merge_method] [commit_title] [--dry-run] [--bump-patch-version] +# ./merge-pull-requests-by-title.sh [merge_method] [commit_title] [--dry-run] [--bump-patch-version] [--no-prompt] # # Arguments: # repo_list_file - File with repository URLs (one per line) @@ -12,13 +12,14 @@ # commit_title - Optional: custom commit title for all merged PRs (PR number is auto-appended) # --dry-run - Optional: preview what would be merged without actually merging # --bump-patch-version - Optional: clone each matching PR branch, run npm version patch, commit, and push (mutually exclusive with --dry-run and merge) +# --no-prompt - Optional: merge without interactive confirmation (default is to prompt before each merge) # # Examples: -# # Find and merge PRs with exact title match +# # Find and merge PRs with exact title match (will prompt for confirmation) # ./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" # -# # With custom commit title -# ./merge-pull-requests-by-title.sh repos.txt "chore(deps-dev): bump eslint*" squash "chore(deps): update eslint dependencies" +# # With custom commit title, no confirmation prompt +# ./merge-pull-requests-by-title.sh repos.txt "chore(deps-dev): bump eslint*" squash "chore(deps): update eslint dependencies" --no-prompt # # # Dry run to preview # ./merge-pull-requests-by-title.sh repos.txt "chore(deps)*" squash "" --dry-run @@ -37,6 +38,7 @@ # - If multiple PRs match in a repo, all will be listed but only the first will be merged (use --dry-run to preview) # - --bump-patch-version clones each matching PR branch to a temp dir, bumps the npm patch version, commits, and pushes # - --bump-patch-version is mutually exclusive with --dry-run +# - By default, merge mode prompts for confirmation before each PR merge; use --no-prompt to skip # # TODO: # - Add --delete-branch flag to delete remote branch after merge @@ -44,19 +46,22 @@ merge_methods=("merge" "squash" "rebase") -# Check for --dry-run and --bump-patch-version flags anywhere in arguments +# Check for --dry-run, --bump-patch-version, and --no-prompt flags anywhere in arguments dry_run=false bump_patch_version=false +no_prompt=false for arg in "$@"; do if [ "$arg" = "--dry-run" ]; then dry_run=true elif [ "$arg" = "--bump-patch-version" ]; then bump_patch_version=true + elif [ "$arg" = "--no-prompt" ]; then + no_prompt=true fi done if [ $# -lt 2 ]; then - echo "Usage: $0 [merge_method] [commit_title] [--dry-run] [--bump-patch-version]" + echo "Usage: $0 [merge_method] [commit_title] [--dry-run] [--bump-patch-version] [--no-prompt]" echo "" echo "Arguments:" echo " repo_list_file - File with repository URLs (one per line)" @@ -65,6 +70,7 @@ if [ $# -lt 2 ]; then echo " commit_title - Optional: custom commit title for merged PRs (PR number is auto-appended)" echo " --dry-run - Preview what would be merged without actually merging" echo " --bump-patch-version - Bump npm patch version on each matching PR branch and push (mutually exclusive with --dry-run)" + echo " --no-prompt - Merge without interactive confirmation (default is to prompt before each merge)" exit 1 fi @@ -219,12 +225,23 @@ while IFS= read -r repo_url || [ -n "$repo_url" ]; do if [ "$dry_run" = true ]; then echo " 🔍 Would merge $repo#$pr_number with: gh pr merge $pr_number --repo $repo ${merge_args[*]}" ((success_count++)) - elif gh pr merge "$pr_number" --repo "$repo" "${merge_args[@]}"; then - echo " ✅ Successfully merged $repo#$pr_number" - ((success_count++)) else - echo " ❌ Failed to merge $repo#$pr_number" - ((fail_count++)) + # Prompt for confirmation unless --no-prompt was passed + if [ "$no_prompt" = false ]; then + read -r -p " ❓ Merge $repo#$pr_number? [y/N] " confirm < /dev/tty + if [[ ! "$confirm" =~ ^[Yy]$ ]]; then + echo " ⏭️ Skipped $repo#$pr_number" + ((skipped_count++)) + continue + fi + fi + if gh pr merge "$pr_number" --repo "$repo" "${merge_args[@]}"; then + echo " ✅ Successfully merged $repo#$pr_number" + ((success_count++)) + else + echo " ❌ Failed to merge $repo#$pr_number" + ((fail_count++)) + fi fi fi done <<< "$matching_prs" From 3f7352fe1bfa4ded683cdb319a470a5f775da89a Mon Sep 17 00:00:00 2001 From: Josh Johanning Date: Wed, 18 Mar 2026 15:10:42 -0500 Subject: [PATCH 4/7] fix: address pr comments --- gh-cli/merge-pull-requests-by-title.sh | 143 +++++++++++++++++++------ 1 file changed, 108 insertions(+), 35 deletions(-) diff --git a/gh-cli/merge-pull-requests-by-title.sh b/gh-cli/merge-pull-requests-by-title.sh index e5e630c..bac1794 100755 --- a/gh-cli/merge-pull-requests-by-title.sh +++ b/gh-cli/merge-pull-requests-by-title.sh @@ -3,16 +3,17 @@ # Finds and merges pull requests matching a title pattern across multiple repositories # # Usage: -# ./merge-pull-requests-by-title.sh [merge_method] [commit_title] [--dry-run] [--bump-patch-version] [--no-prompt] +# ./merge-pull-requests-by-title.sh [merge_method] [commit_title] [--dry-run] [--bump-patch-version] [--enable-auto-merge] [--no-prompt] # # Arguments: -# repo_list_file - File with repository URLs (one per line) -# pr_title_pattern - Title pattern to match (exact match or use * for wildcard) -# merge_method - Optional: merge method (merge, squash, rebase) - defaults to squash -# commit_title - Optional: custom commit title for all merged PRs (PR number is auto-appended) -# --dry-run - Optional: preview what would be merged without actually merging -# --bump-patch-version - Optional: clone each matching PR branch, run npm version patch, commit, and push (mutually exclusive with --dry-run and merge) -# --no-prompt - Optional: merge without interactive confirmation (default is to prompt before each merge) +# repo_list_file - File with repository URLs (one per line) +# pr_title_pattern - Title pattern to match (exact match or use * for wildcard) +# merge_method - Optional: merge method (merge, squash, rebase) - defaults to squash +# commit_title - Optional: custom commit title for all merged PRs (PR number is auto-appended) +# --dry-run - Optional: preview what would be merged without actually merging +# --bump-patch-version - Optional: clone each matching PR branch, run npm version patch, commit, and push (mutually exclusive with --dry-run and merge) +# --enable-auto-merge - Optional: enable auto-merge on matching PRs (can combine with --bump-patch-version) +# --no-prompt - Optional: merge without interactive confirmation (default is to prompt before each merge) # # Examples: # # Find and merge PRs with exact title match (will prompt for confirmation) @@ -21,12 +22,15 @@ # # With custom commit title, no confirmation prompt # ./merge-pull-requests-by-title.sh repos.txt "chore(deps-dev): bump eslint*" squash "chore(deps): update eslint dependencies" --no-prompt # -# # Dry run to preview -# ./merge-pull-requests-by-title.sh repos.txt "chore(deps)*" squash "" --dry-run +# # Dry run to preview (flags can appear anywhere, no need for "" placeholders) +# ./merge-pull-requests-by-title.sh repos.txt "chore(deps)*" --dry-run # # # Bump patch version on matching PR branches (run before merging so CI can pass) # ./merge-pull-requests-by-title.sh repos.txt "chore(deps)*" squash "" --bump-patch-version # +# # Bump patch version and enable auto-merge (bump, wait for CI, then auto-merge) +# ./merge-pull-requests-by-title.sh repos.txt "chore(deps)*" squash "" --bump-patch-version --enable-auto-merge +# # Input file format (repos.txt): # https://github.com/joshjohanning/repo1 # https://github.com/joshjohanning/repo2 @@ -38,6 +42,7 @@ # - If multiple PRs match in a repo, all will be listed but only the first will be merged (use --dry-run to preview) # - --bump-patch-version clones each matching PR branch to a temp dir, bumps the npm patch version, commits, and pushes # - --bump-patch-version is mutually exclusive with --dry-run +# - --enable-auto-merge queues PRs to merge once all required checks pass (does not bypass protections) # - By default, merge mode prompts for confirmation before each PR merge; use --no-prompt to skip # # TODO: @@ -49,28 +54,37 @@ merge_methods=("merge" "squash" "rebase") # Check for --dry-run, --bump-patch-version, and --no-prompt flags anywhere in arguments dry_run=false bump_patch_version=false +enable_auto_merge=false no_prompt=false +valid_flags=("--dry-run" "--bump-patch-version" "--enable-auto-merge" "--no-prompt") for arg in "$@"; do if [ "$arg" = "--dry-run" ]; then dry_run=true elif [ "$arg" = "--bump-patch-version" ]; then bump_patch_version=true + elif [ "$arg" = "--enable-auto-merge" ]; then + enable_auto_merge=true elif [ "$arg" = "--no-prompt" ]; then no_prompt=true + elif [[ "$arg" == --* ]]; then + echo "Error: Unknown flag '$arg'" + echo "Valid flags: ${valid_flags[*]}" + exit 1 fi done if [ $# -lt 2 ]; then - echo "Usage: $0 [merge_method] [commit_title] [--dry-run] [--bump-patch-version] [--no-prompt]" + echo "Usage: $0 [merge_method] [commit_title] [--dry-run] [--bump-patch-version] [--enable-auto-merge] [--no-prompt]" echo "" echo "Arguments:" - echo " repo_list_file - File with repository URLs (one per line)" - echo " pr_title_pattern - Title pattern to match (use * for wildcard)" - echo " merge_method - Optional: merge, squash, or rebase (default: squash)" - echo " commit_title - Optional: custom commit title for merged PRs (PR number is auto-appended)" - echo " --dry-run - Preview what would be merged without actually merging" - echo " --bump-patch-version - Bump npm patch version on each matching PR branch and push (mutually exclusive with --dry-run)" - echo " --no-prompt - Merge without interactive confirmation (default is to prompt before each merge)" + echo " repo_list_file - File with repository URLs (one per line)" + echo " pr_title_pattern - Title pattern to match (use * for wildcard)" + echo " merge_method - Optional: merge, squash, or rebase (default: squash)" + echo " commit_title - Optional: custom commit title for merged PRs (PR number is auto-appended)" + echo " --dry-run - Preview what would be merged without actually merging" + echo " --bump-patch-version - Bump npm patch version on each matching PR branch and push (mutually exclusive with --dry-run)" + echo " --enable-auto-merge - Enable auto-merge on matching PRs (can combine with --bump-patch-version)" + echo " --no-prompt - Merge without interactive confirmation (default is to prompt before each merge)" exit 1 fi @@ -79,10 +93,23 @@ if [ "$dry_run" = true ] && [ "$bump_patch_version" = true ]; then exit 1 fi -repo_list_file=$1 -pr_title_pattern=$2 -merge_method=${3:-squash} -commit_title=${4:-} +if [ "$dry_run" = true ] && [ "$enable_auto_merge" = true ]; then + echo "Error: --dry-run and --enable-auto-merge are mutually exclusive" + exit 1 +fi + +# Parse positional args, skipping flags +positional_args=() +for arg in "$@"; do + if [[ "$arg" != --* ]]; then + positional_args+=("$arg") + fi +done + +repo_list_file=${positional_args[0]} +pr_title_pattern=${positional_args[1]} +merge_method=${positional_args[2]:-squash} +commit_title=${positional_args[3]:-} if [ "$dry_run" = true ]; then echo "🔍 DRY RUN MODE - No PRs will be merged" @@ -153,16 +180,21 @@ while IFS= read -r repo_url || [ -n "$repo_url" ]; do jq_pattern="${jq_pattern//$/\\$}" jq_pattern="${jq_pattern//|/\\|}" jq_pattern="${jq_pattern//\*/.*}" - jq_filter="select(.title | test(\"^\" + \$pattern + \"$\"))" + # Escape backslashes and double quotes for embedding in jq string literal + jq_pattern_escaped="${jq_pattern//\\/\\\\}" + jq_pattern_escaped="${jq_pattern_escaped//\"/\\\"}" + jq_filter="select(.title | test(\"^${jq_pattern_escaped}$\"))" else # Exact match - use simple string equality - jq_filter="select(.title == \$pattern)" - jq_pattern="$pr_title_pattern" + # Escape backslashes and double quotes for embedding in jq string literal + jq_pattern_escaped="${pr_title_pattern//\\/\\\\}" + jq_pattern_escaped="${jq_pattern_escaped//\"/\\\"}" + jq_filter="select(.title == \"${jq_pattern_escaped}\")" fi # Get open PRs and filter by title (paginate to get all PRs) - matching_prs=$(gh api --paginate "/repos/$repo/pulls?state=open" 2>/dev/null | \ - jq -r --arg pattern "$jq_pattern" ".[] | $jq_filter | \"\(.number)|\(.title)|\(.user.login)|\(.head.ref)\"") + matching_prs=$(gh api --paginate "/repos/$repo/pulls?state=open" \ + --jq ".[] | $jq_filter | \"\(.number)|\(.title)|\(.user.login)|\(.head.ref)\"" 2>/dev/null) if [ -z "$matching_prs" ]; then echo " 📭 No matching PRs found" @@ -178,14 +210,15 @@ while IFS= read -r repo_url || [ -n "$repo_url" ]; do if [ "$bump_patch_version" = true ]; then # Clone to temp dir, bump patch version, commit, and push tmp_dir=$(mktemp -d) - echo " 🔀 Cloning $repo (branch: $pr_branch) to $tmp_dir" - if ! gh repo clone "$repo" "$tmp_dir" -- --quiet --branch "$pr_branch" 2>&1; then + clone_dir="$tmp_dir/$repo_name" + echo " 🔀 Cloning $repo (branch: $pr_branch) to $clone_dir" + if ! gh repo clone "$repo" "$clone_dir" -- --quiet --branch "$pr_branch" 2>&1; then echo " ❌ Failed to clone $repo" ((fail_count++)) rm -rf "$tmp_dir" continue fi - new_version=$(cd "$tmp_dir" && npm version patch --no-git-tag-version) + new_version=$(cd "$clone_dir" && npm version patch --no-git-tag-version --ignore-scripts) if [ -z "$new_version" ]; then echo " ❌ Failed to bump version in $repo#$pr_number (is there a package.json?)" ((fail_count++)) @@ -195,10 +228,26 @@ while IFS= read -r repo_url || [ -n "$repo_url" ]; do # Strip leading 'v' if present (npm version returns e.g. "v1.2.3") new_version="${new_version#v}" echo " 🔼 Bumped version to $new_version" - if (cd "$tmp_dir" && git add package.json && { git add package-lock.json 2>/dev/null || true; } && git commit -m "chore: bump version to $new_version"); then - if (cd "$tmp_dir" && git push origin "$pr_branch"); then + 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 + if (cd "$clone_dir" && git push origin "$pr_branch"); then echo " ✅ Successfully pushed version bump to $repo/$pr_branch" ((success_count++)) + # Enable auto-merge if requested + if [ "$enable_auto_merge" = true ]; then + auto_merge_args=("--auto" "--$merge_method") + if [ "$merge_method" != "rebase" ]; then + if [ -n "$commit_title" ]; then + auto_merge_args+=("--subject" "$commit_title (#$pr_number)") + else + auto_merge_args+=("--subject" "$pr_title (#$pr_number)") + fi + fi + if gh pr merge "$pr_number" --repo "$repo" "${auto_merge_args[@]}"; then + echo " 🔄 Auto-merge enabled for $repo#$pr_number" + else + echo " ⚠️ Failed to enable auto-merge for $repo#$pr_number" + fi + fi else echo " ❌ Failed to push version bump to $repo/$pr_branch" ((fail_count++)) @@ -221,13 +270,30 @@ while IFS= read -r repo_url || [ -n "$repo_url" ]; do fi fi - # Attempt to merge + # Check if status checks have failed before attempting merge (skip for auto-merge since it waits for checks) + if [ "$enable_auto_merge" = false ]; then + failed_checks=$(gh pr checks "$pr_number" --repo "$repo" --json "name,state" --jq '[.[] | select(.state == "FAILURE")] | length' 2>/dev/null) + if [ -n "$failed_checks" ] && [ "$failed_checks" -gt 0 ] 2>/dev/null; then + echo " ⚠️ Skipping $repo#$pr_number - $failed_checks status check(s) failed" + ((skipped_count++)) + continue + fi + fi + + # Attempt to merge (or enable auto-merge) + if [ "$enable_auto_merge" = true ]; then + merge_args+=("--auto") + fi if [ "$dry_run" = true ]; then echo " 🔍 Would merge $repo#$pr_number with: gh pr merge $pr_number --repo $repo ${merge_args[*]}" ((success_count++)) else # Prompt for confirmation unless --no-prompt was passed if [ "$no_prompt" = false ]; then + if ! [[ -r /dev/tty ]]; then + echo "Error: No TTY available for interactive prompt - use --no-prompt" + exit 1 + fi read -r -p " ❓ Merge $repo#$pr_number? [y/N] " confirm < /dev/tty if [[ ! "$confirm" =~ ^[Yy]$ ]]; then echo " ⏭️ Skipped $repo#$pr_number" @@ -236,7 +302,11 @@ while IFS= read -r repo_url || [ -n "$repo_url" ]; do fi fi if gh pr merge "$pr_number" --repo "$repo" "${merge_args[@]}"; then - echo " ✅ Successfully merged $repo#$pr_number" + if [ "$enable_auto_merge" = true ]; then + echo " 🔄 Auto-merge enabled for $repo#$pr_number" + else + echo " ✅ Successfully merged $repo#$pr_number" + fi ((success_count++)) else echo " ❌ Failed to merge $repo#$pr_number" @@ -267,7 +337,10 @@ if [ "$dry_run" = true ]; then echo "🔍 This was a DRY RUN - no PRs were actually merged" fi -if [ "$bump_patch_version" = true ]; then +if [ "$bump_patch_version" = true ] && [ "$enable_auto_merge" = true ]; then + echo "" + echo "🔼 Version bumps pushed and auto-merge enabled - PRs will merge once CI passes" +elif [ "$bump_patch_version" = true ]; then echo "" echo "🔼 Version bumps pushed - wait for CI to pass before merging" fi From 5a6202eb9f7ad4ad8761bc4fd292f2532239ee22 Mon Sep 17 00:00:00 2001 From: Josh Johanning Date: Wed, 18 Mar 2026 15:15:16 -0500 Subject: [PATCH 5/7] feat: update README and script comments for --bump-patch-version and --enable-auto-merge options --- gh-cli/README.md | 9 ++++--- gh-cli/merge-pull-requests-by-title.sh | 35 ++++++++++++++++++++------ 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/gh-cli/README.md b/gh-cli/README.md index bd5ad7a..4b70e25 100644 --- a/gh-cli/README.md +++ b/gh-cli/README.md @@ -1493,16 +1493,19 @@ Finds and merges pull requests matching a title pattern across multiple reposito ./merge-pull-requests-by-title.sh repos.txt "chore(deps-dev): bump eslint*" # Merge without confirmation prompt -./merge-pull-requests-by-title.sh repos.txt "chore(deps)*" squash "" --no-prompt +./merge-pull-requests-by-title.sh repos.txt "chore(deps)*" --no-prompt # With custom commit title, no confirmation prompt ./merge-pull-requests-by-title.sh repos.txt "chore(deps)*" squash "chore(deps): update dependencies" --no-prompt # Dry run to preview -./merge-pull-requests-by-title.sh repos.txt "chore(deps)*" squash "" --dry-run +./merge-pull-requests-by-title.sh repos.txt "chore(deps)*" --dry-run # Bump npm patch version on matching PR branches and push (run before merging so CI can pass) -./merge-pull-requests-by-title.sh repos.txt "chore(deps)*" squash "" --bump-patch-version +./merge-pull-requests-by-title.sh repos.txt "chore(deps)*" --bump-patch-version + +# Bump patch version and enable auto-merge (bump, wait for CI, then auto-merge) +./merge-pull-requests-by-title.sh repos.txt "chore(deps)*" --bump-patch-version --enable-auto-merge ``` Input file format (`repos.txt`): diff --git a/gh-cli/merge-pull-requests-by-title.sh b/gh-cli/merge-pull-requests-by-title.sh index bac1794..9f79e34 100755 --- a/gh-cli/merge-pull-requests-by-title.sh +++ b/gh-cli/merge-pull-requests-by-title.sh @@ -11,7 +11,7 @@ # merge_method - Optional: merge method (merge, squash, rebase) - defaults to squash # commit_title - Optional: custom commit title for all merged PRs (PR number is auto-appended) # --dry-run - Optional: preview what would be merged without actually merging -# --bump-patch-version - Optional: clone each matching PR branch, run npm version patch, commit, and push (mutually exclusive with --dry-run and merge) +# --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) # --enable-auto-merge - Optional: enable auto-merge on matching PRs (can combine with --bump-patch-version) # --no-prompt - Optional: merge without interactive confirmation (default is to prompt before each merge) # @@ -26,10 +26,10 @@ # ./merge-pull-requests-by-title.sh repos.txt "chore(deps)*" --dry-run # # # Bump patch version on matching PR branches (run before merging so CI can pass) -# ./merge-pull-requests-by-title.sh repos.txt "chore(deps)*" squash "" --bump-patch-version +# ./merge-pull-requests-by-title.sh repos.txt "chore(deps)*" --bump-patch-version # # # Bump patch version and enable auto-merge (bump, wait for CI, then auto-merge) -# ./merge-pull-requests-by-title.sh repos.txt "chore(deps)*" squash "" --bump-patch-version --enable-auto-merge +# ./merge-pull-requests-by-title.sh repos.txt "chore(deps)*" --bump-patch-version --enable-auto-merge # # Input file format (repos.txt): # https://github.com/joshjohanning/repo1 @@ -41,7 +41,8 @@ # - Use * as a wildcard in the title pattern (e.g., "chore(deps)*" matches any title starting with "chore(deps)") # - If multiple PRs match in a repo, all will be listed but only the first will be merged (use --dry-run to preview) # - --bump-patch-version clones each matching PR branch to a temp dir, bumps the npm patch version, commits, and pushes -# - --bump-patch-version is mutually exclusive with --dry-run +# - --bump-patch-version is mutually exclusive with --dry-run (does not merge unless combined with --enable-auto-merge) +# - --bump-patch-version only works with same-repo PRs (fork-based PRs are skipped) # - --enable-auto-merge queues PRs to merge once all required checks pass (does not bypass protections) # - By default, merge mode prompts for confirmation before each PR merge; use --no-prompt to skip # @@ -193,8 +194,20 @@ while IFS= read -r repo_url || [ -n "$repo_url" ]; do fi # Get open PRs and filter by title (paginate to get all PRs) + api_stderr=$(mktemp) matching_prs=$(gh api --paginate "/repos/$repo/pulls?state=open" \ - --jq ".[] | $jq_filter | \"\(.number)|\(.title)|\(.user.login)|\(.head.ref)\"" 2>/dev/null) + --jq ".[] | $jq_filter | \"\(.number)|\(.title)|\(.user.login)|\(.head.ref)|\(.head.repo.full_name)\"" 2>"$api_stderr") + api_exit=$? + + if [ $api_exit -ne 0 ]; then + api_error=$(cat "$api_stderr") + rm -f "$api_stderr" + echo " ❌ API error for $repo: $api_error" + ((fail_count++)) + echo "" + continue + fi + rm -f "$api_stderr" if [ -z "$matching_prs" ]; then echo " 📭 No matching PRs found" @@ -204,10 +217,17 @@ while IFS= read -r repo_url || [ -n "$repo_url" ]; do fi # Process each matching PR - while IFS='|' read -r pr_number pr_title pr_author pr_branch; do + while IFS='|' read -r pr_number pr_title pr_author pr_branch pr_head_repo; do echo " 📋 Found PR #$pr_number: $pr_title (by $pr_author)" if [ "$bump_patch_version" = true ]; then + # Skip fork-based PRs since we can't push to the head repo + if [ "$pr_head_repo" != "$repo" ]; then + echo " ⚠️ Skipping $repo#$pr_number - fork-based PR ($pr_head_repo), cannot push to branch" + ((skipped_count++)) + continue + fi + # Clone to temp dir, bump patch version, commit, and push tmp_dir=$(mktemp -d) clone_dir="$tmp_dir/$repo_name" @@ -246,6 +266,7 @@ while IFS= read -r repo_url || [ -n "$repo_url" ]; do echo " 🔄 Auto-merge enabled for $repo#$pr_number" else echo " ⚠️ Failed to enable auto-merge for $repo#$pr_number" + ((fail_count++)) fi fi else @@ -290,7 +311,7 @@ while IFS= read -r repo_url || [ -n "$repo_url" ]; do else # Prompt for confirmation unless --no-prompt was passed if [ "$no_prompt" = false ]; then - if ! [[ -r /dev/tty ]]; then + if ! [[ -t 0 ]]; then echo "Error: No TTY available for interactive prompt - use --no-prompt" exit 1 fi From d995d96740f0a3e6cfcf7040f0fd5777cfc12d54 Mon Sep 17 00:00:00 2001 From: Josh Johanning Date: Thu, 19 Mar 2026 22:14:48 -0500 Subject: [PATCH 6/7] fix: validate required arguments for merge-pull-requests-by-title.sh --- gh-cli/merge-pull-requests-by-title.sh | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/gh-cli/merge-pull-requests-by-title.sh b/gh-cli/merge-pull-requests-by-title.sh index 9f79e34..b96b6d5 100755 --- a/gh-cli/merge-pull-requests-by-title.sh +++ b/gh-cli/merge-pull-requests-by-title.sh @@ -39,7 +39,7 @@ # Notes: # - PRs must be open and in a mergeable state # - Use * as a wildcard in the title pattern (e.g., "chore(deps)*" matches any title starting with "chore(deps)") -# - If multiple PRs match in a repo, all will be listed but only the first will be merged (use --dry-run to preview) +# - If multiple PRs match in a repo, all will be processed # - --bump-patch-version clones each matching PR branch to a temp dir, bumps the npm patch version, commits, and pushes # - --bump-patch-version is mutually exclusive with --dry-run (does not merge unless combined with --enable-auto-merge) # - --bump-patch-version only works with same-repo PRs (fork-based PRs are skipped) @@ -112,6 +112,12 @@ pr_title_pattern=${positional_args[1]} merge_method=${positional_args[2]:-squash} commit_title=${positional_args[3]:-} +if [ -z "$repo_list_file" ] || [ -z "$pr_title_pattern" ]; then + echo "Error: repo_list_file and pr_title_pattern are required" + echo "Usage: $0 [merge_method] [commit_title] [flags...]" + exit 1 +fi + if [ "$dry_run" = true ]; then echo "🔍 DRY RUN MODE - No PRs will be merged" echo "" @@ -179,8 +185,8 @@ while IFS= read -r repo_url || [ -n "$repo_url" ]; do jq_pattern="${jq_pattern//\?/\\?}" jq_pattern="${jq_pattern//^/\\^}" jq_pattern="${jq_pattern//$/\\$}" - jq_pattern="${jq_pattern//|/\\|}" - jq_pattern="${jq_pattern//\*/.*}" + jq_pattern="${jq_pattern//|/\\|}" jq_pattern="${jq_pattern//\{/\\{}" + jq_pattern="${jq_pattern//\}/\\}}" jq_pattern="${jq_pattern//\*/.*}" # Escape backslashes and double quotes for embedding in jq string literal jq_pattern_escaped="${jq_pattern//\\/\\\\}" jq_pattern_escaped="${jq_pattern_escaped//\"/\\\"}" @@ -311,7 +317,7 @@ while IFS= read -r repo_url || [ -n "$repo_url" ]; do else # Prompt for confirmation unless --no-prompt was passed if [ "$no_prompt" = false ]; then - if ! [[ -t 0 ]]; then + if ! [[ -t 1 ]] || ! [[ -r /dev/tty ]]; then echo "Error: No TTY available for interactive prompt - use --no-prompt" exit 1 fi From ac9d668eeb92fc567c0d57b9c44af6eaa0388ee9 Mon Sep 17 00:00:00 2001 From: Josh Johanning Date: Thu, 19 Mar 2026 22:16:31 -0500 Subject: [PATCH 7/7] fix: correct jq pattern escaping in merge-pull-requests-by-title.sh --- gh-cli/merge-pull-requests-by-title.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/gh-cli/merge-pull-requests-by-title.sh b/gh-cli/merge-pull-requests-by-title.sh index b96b6d5..c05b19c 100755 --- a/gh-cli/merge-pull-requests-by-title.sh +++ b/gh-cli/merge-pull-requests-by-title.sh @@ -185,8 +185,10 @@ while IFS= read -r repo_url || [ -n "$repo_url" ]; do jq_pattern="${jq_pattern//\?/\\?}" jq_pattern="${jq_pattern//^/\\^}" jq_pattern="${jq_pattern//$/\\$}" - jq_pattern="${jq_pattern//|/\\|}" jq_pattern="${jq_pattern//\{/\\{}" - jq_pattern="${jq_pattern//\}/\\}}" jq_pattern="${jq_pattern//\*/.*}" + jq_pattern="${jq_pattern//|/\\|}" + jq_pattern="${jq_pattern//\{/\\{}" + jq_pattern="${jq_pattern//\}/\\}}" + jq_pattern="${jq_pattern//\*/.*}" # Escape backslashes and double quotes for embedding in jq string literal jq_pattern_escaped="${jq_pattern//\\/\\\\}" jq_pattern_escaped="${jq_pattern_escaped//\"/\\\"}"