|
| 1 | +#!/bin/bash |
| 2 | + |
| 3 | +# Finds and merges pull requests matching a title pattern across multiple repositories |
| 4 | +# |
| 5 | +# Usage: |
| 6 | +# ./merge-pull-requests-by-title.sh <repo_list_file> <pr_title_pattern> [merge_method] [commit_title] [--dry-run] |
| 7 | +# |
| 8 | +# 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 |
| 13 | +# --dry-run - Optional: preview what would be merged without actually merging |
| 14 | +# |
| 15 | +# Examples: |
| 16 | +# # Find and merge PRs with exact title match |
| 17 | +# ./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" |
| 18 | +# |
| 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" |
| 21 | +# |
| 22 | +# # Dry run to preview |
| 23 | +# ./merge-pull-requests-by-title.sh repos.txt "chore(deps)*" squash "" --dry-run |
| 24 | +# |
| 25 | +# Input file format (repos.txt): |
| 26 | +# https://github.com/joshjohanning/repo1 |
| 27 | +# https://github.com/joshjohanning/repo2 |
| 28 | +# https://github.com/joshjohanning/repo3 |
| 29 | +# |
| 30 | +# Notes: |
| 31 | +# - PRs must be open and in a mergeable state |
| 32 | +# - 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) |
| 34 | +# |
| 35 | +# TODO: |
| 36 | +# - Add --delete-branch flag to delete remote branch after merge |
| 37 | +# - Add --bypass flag to bypass branch protection requirements |
| 38 | + |
| 39 | +merge_methods=("merge" "squash" "rebase") |
| 40 | + |
| 41 | +# Check for --dry-run flag anywhere in arguments |
| 42 | +dry_run=false |
| 43 | +for arg in "$@"; do |
| 44 | + if [ "$arg" = "--dry-run" ]; then |
| 45 | + dry_run=true |
| 46 | + break |
| 47 | + fi |
| 48 | +done |
| 49 | + |
| 50 | +if [ $# -lt 2 ]; then |
| 51 | + echo "Usage: $0 <repo_list_file> <pr_title_pattern> [merge_method] [commit_title] [--dry-run]" |
| 52 | + echo "" |
| 53 | + 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" |
| 58 | + echo " --dry-run - Preview what would be merged without actually merging" |
| 59 | + exit 1 |
| 60 | +fi |
| 61 | + |
| 62 | +repo_list_file=$1 |
| 63 | +pr_title_pattern=$2 |
| 64 | +merge_method=${3:-squash} |
| 65 | +commit_title=${4:-} |
| 66 | + |
| 67 | +if [ "$dry_run" = true ]; then |
| 68 | + echo "🔍 DRY RUN MODE - No PRs will be merged" |
| 69 | + echo "" |
| 70 | +fi |
| 71 | + |
| 72 | +# Validate merge method |
| 73 | +if [[ ! " ${merge_methods[*]} " =~ ${merge_method} ]]; then |
| 74 | + echo "Error: merge_method must be one of: ${merge_methods[*]}" |
| 75 | + exit 1 |
| 76 | +fi |
| 77 | + |
| 78 | +# Check if file exists |
| 79 | +if [ ! -f "$repo_list_file" ]; then |
| 80 | + echo "Error: File $repo_list_file does not exist" |
| 81 | + exit 1 |
| 82 | +fi |
| 83 | + |
| 84 | +echo "Searching for PRs matching: \"$pr_title_pattern\"" |
| 85 | +echo "" |
| 86 | + |
| 87 | +success_count=0 |
| 88 | +fail_count=0 |
| 89 | +skipped_count=0 |
| 90 | +not_found_count=0 |
| 91 | + |
| 92 | +while IFS= read -r repo_url || [ -n "$repo_url" ]; do |
| 93 | + # Skip empty lines and comments |
| 94 | + if [ -z "$repo_url" ] || [[ "$repo_url" == \#* ]]; then |
| 95 | + continue |
| 96 | + fi |
| 97 | + |
| 98 | + # Trim whitespace |
| 99 | + repo_url=$(echo "$repo_url" | xargs) |
| 100 | + |
| 101 | + # Parse repo URL: https://github.com/owner/repo |
| 102 | + if [[ "$repo_url" =~ ^https://github\.com/([^/]+)/([^/]+)/?$ ]]; then |
| 103 | + owner="${BASH_REMATCH[1]}" |
| 104 | + repo_name="${BASH_REMATCH[2]}" |
| 105 | + repo="$owner/$repo_name" |
| 106 | + else |
| 107 | + echo "⚠️ Skipping invalid repository URL: $repo_url" |
| 108 | + ((skipped_count++)) |
| 109 | + continue |
| 110 | + fi |
| 111 | + |
| 112 | + echo "Searching: $repo" |
| 113 | + |
| 114 | + # Search for open PRs matching the title pattern |
| 115 | + # Use simple string equality for exact match, regex only if wildcard * is used |
| 116 | + if [[ "$pr_title_pattern" == *"*"* ]]; then |
| 117 | + # Has wildcard - convert to regex (escape special chars, then convert * to .*) |
| 118 | + jq_pattern="$pr_title_pattern" |
| 119 | + jq_pattern="${jq_pattern//\\/\\\\}" |
| 120 | + jq_pattern="${jq_pattern//./\\.}" |
| 121 | + jq_pattern="${jq_pattern//[/\\[}" |
| 122 | + jq_pattern="${jq_pattern//]/\\]}" |
| 123 | + jq_pattern="${jq_pattern//(/\\(}" |
| 124 | + jq_pattern="${jq_pattern//)/\\)}" |
| 125 | + jq_pattern="${jq_pattern//+/\\+}" |
| 126 | + jq_pattern="${jq_pattern//\?/\\?}" |
| 127 | + jq_pattern="${jq_pattern//^/\\^}" |
| 128 | + jq_pattern="${jq_pattern//$/\\$}" |
| 129 | + jq_pattern="${jq_pattern//|/\\|}" |
| 130 | + jq_pattern="${jq_pattern//\*/.*}" |
| 131 | + jq_filter="select(.title | test(\"^\" + \$pattern + \"$\"))" |
| 132 | + else |
| 133 | + # Exact match - use simple string equality |
| 134 | + jq_filter="select(.title == \$pattern)" |
| 135 | + jq_pattern="$pr_title_pattern" |
| 136 | + fi |
| 137 | + |
| 138 | + # Get open PRs and filter by title |
| 139 | + matching_prs=$(gh pr list --repo "$repo" --state open --json number,title,author --limit 100 2>/dev/null | \ |
| 140 | + jq -r --arg pattern "$jq_pattern" ".[] | $jq_filter | \"\(.number)|\(.title)|\(.author.login)\"") |
| 141 | + |
| 142 | + if [ -z "$matching_prs" ]; then |
| 143 | + echo " 📭 No matching PRs found" |
| 144 | + ((not_found_count++)) |
| 145 | + echo "" |
| 146 | + continue |
| 147 | + fi |
| 148 | + |
| 149 | + # Process each matching PR |
| 150 | + while IFS='|' read -r pr_number pr_title pr_author; do |
| 151 | + echo " 📋 Found PR #$pr_number: $pr_title (by $pr_author)" |
| 152 | + |
| 153 | + # Build the merge command |
| 154 | + merge_args=("--$merge_method") |
| 155 | + |
| 156 | + # Apply custom commit title if provided |
| 157 | + if [ -n "$commit_title" ] && [ "$merge_method" != "rebase" ]; then |
| 158 | + merge_args+=("--subject" "$commit_title") |
| 159 | + fi |
| 160 | + |
| 161 | + # Attempt to merge |
| 162 | + if [ "$dry_run" = true ]; then |
| 163 | + echo " 🔍 Would merge $repo#$pr_number with: gh pr merge $pr_number --repo $repo ${merge_args[*]}" |
| 164 | + ((success_count++)) |
| 165 | + elif gh pr merge "$pr_number" --repo "$repo" "${merge_args[@]}"; then |
| 166 | + echo " ✅ Successfully merged $repo#$pr_number" |
| 167 | + ((success_count++)) |
| 168 | + else |
| 169 | + echo " ❌ Failed to merge $repo#$pr_number" |
| 170 | + ((fail_count++)) |
| 171 | + fi |
| 172 | + done <<< "$matching_prs" |
| 173 | + |
| 174 | + echo "" |
| 175 | + |
| 176 | +done < "$repo_list_file" |
| 177 | + |
| 178 | +echo "========================================" |
| 179 | +echo "Summary:" |
| 180 | +echo " ✅ Merged: $success_count" |
| 181 | +echo " ❌ Failed: $fail_count" |
| 182 | +echo " ⏭️ Skipped: $skipped_count" |
| 183 | +echo " 📭 No match: $not_found_count" |
| 184 | +echo "========================================" |
| 185 | + |
| 186 | +if [ "$dry_run" = true ]; then |
| 187 | + echo "" |
| 188 | + echo "🔍 This was a DRY RUN - no PRs were actually merged" |
| 189 | +fi |
0 commit comments