Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions gh-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1483,20 +1483,29 @@ 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)*" --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)*" --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`):
Expand Down
270 changes: 222 additions & 48 deletions gh-cli/merge-pull-requests-by-title.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,33 @@
# Finds and merges pull requests matching a title pattern across multiple repositories
#
# Usage:
# ./merge-pull-requests-by-title.sh <repo_list_file> <pr_title_pattern> [merge_method] [commit_title] [--dry-run]
# ./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]
#
# 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; 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)
#
# 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
# # 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)*" --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):
# https://github.com/joshjohanning/repo1
Expand All @@ -31,44 +40,88 @@
# - 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 (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
#
# TODO:
# - Add --delete-branch flag to delete remote branch after merge
# - Add --bypass flag to bypass branch protection requirements

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

# Check for --dry-run flag anywhere in arguments
# 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
break
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 <repo_list_file> <pr_title_pattern> [merge_method] [commit_title] [--dry-run]"
echo "Usage: $0 <repo_list_file> <pr_title_pattern> [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 " 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

if [ "$dry_run" = true ] && [ "$bump_patch_version" = true ]; then
echo "Error: --dry-run and --bump-patch-version are mutually exclusive"
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]:-}
Comment on lines +110 to +113

if [ "$dry_run" = true ]; then
echo "🔍 DRY RUN MODE - No PRs will be merged"
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[*]}"
Expand Down Expand Up @@ -128,16 +181,33 @@ 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)\"")
api_stderr=$(mktemp)
matching_prs=$(gh api --paginate "/repos/$repo/pulls?state=open" \
--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"
Expand All @@ -147,31 +217,123 @@ 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 pr_head_repo; do
echo " 📋 Found PR #$pr_number: $pr_title (by $pr_author)"

# Build the merge command
merge_args=("--$merge_method")
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

# 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)")
# Clone to temp dir, bump patch version, commit, and push
tmp_dir=$(mktemp -d)
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 "$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++))
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 "$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"
((fail_count++))
fi
fi
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

# 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 ! [[ -t 0 ]]; then
echo "Error: No TTY available for interactive prompt - use --no-prompt"
Comment on lines +314 to +315
exit 1
fi
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
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"
((fail_count++))
fi
fi
fi
done <<< "$matching_prs"

Expand All @@ -181,7 +343,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"
Expand All @@ -191,3 +357,11 @@ if [ "$dry_run" = true ]; then
echo ""
echo "🔍 This was a DRY RUN - no PRs were actually merged"
fi

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
Loading