From 5a3e43defebf670b53aeae6d5cd98d50dbc07d74 Mon Sep 17 00:00:00 2001 From: Josh Johanning Date: Tue, 23 Dec 2025 10:43:41 -0600 Subject: [PATCH] feat: add scripts for merging pull requests by title and from a list, and validate PR titles --- gh-cli/README.md | 72 ++++++++- gh-cli/merge-pull-requests-by-title.sh | 189 +++++++++++++++++++++++ gh-cli/merge-pull-requests-from-list.sh | 195 ++++++++++++++++++++++++ gh-cli/validate-pr-titles.sh | 84 ++++++++++ 4 files changed, 539 insertions(+), 1 deletion(-) create mode 100755 gh-cli/merge-pull-requests-by-title.sh create mode 100755 gh-cli/merge-pull-requests-from-list.sh create mode 100755 gh-cli/validate-pr-titles.sh diff --git a/gh-cli/README.md b/gh-cli/README.md index 6d79f60..d4e0bb1 100644 --- a/gh-cli/README.md +++ b/gh-cli/README.md @@ -903,7 +903,7 @@ Output: 🌐 Source URL: https://github.com/joshjohanning-org/export-actions-usage-report 📍 Migration Source: GHEC Source 📊 State: SUCCEEDED -❌ Failure Reason: +❌ Failure Reason: ✅ Migration information retrieved successfully ``` @@ -1429,6 +1429,60 @@ Adds users to an organization team from a CSV input list. Creates a (mostly) empty migration for a given organization repository so that it can create a lock. +### 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. + +```bash +# Find and merge PRs with exact title match +./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" + +# Dry run to preview +./merge-pull-requests-by-title.sh repos.txt "chore(deps)*" squash "" --dry-run +``` + +Input file format (`repos.txt`): + +``` +https://github.com/joshjohanning/repo1 +https://github.com/joshjohanning/repo2 +https://github.com/joshjohanning/repo3 +``` + +### merge-pull-requests-from-list.sh + +Merges a list of pull requests from a file containing PR URLs with customizable commit messages. Useful for batch merging similar PRs across multiple repositories (e.g., Dependabot updates). Supports dry-run mode to preview merges. + +```bash +# Basic usage (uses squash merge) +./merge-pull-requests-from-list.sh prs.txt + +# Specify merge method +./merge-pull-requests-from-list.sh prs.txt merge + +# Custom commit title with template variables +./merge-pull-requests-from-list.sh prs.txt squash "chore(deps): {title}" + +# Dry run to preview merges +./merge-pull-requests-from-list.sh prs.txt squash "" "" --dry-run +``` + +Input file format (`prs.txt`): + +``` +https://github.com/joshjohanning/repo1/pull/25 +https://github.com/joshjohanning/repo2/pull/37 +https://github.com/joshjohanning/repo3/pull/43 +``` + +Template variables: `{title}` (PR title), `{number}` (PR number), `{body}` (PR body) + ### parent-organization-teams.sh Sets the parents of teams in an target organization based on existing child/parent relationship on a source organization teams. @@ -1599,6 +1653,22 @@ Adds your account to an organization in an enterprise as an owner, member, or le Updates / sets the issue type for an issue. See: [Community Discussions Post](https://github.com/orgs/community/discussions/139933) +### validate-pr-titles.sh + +Validates that all pull requests in a list have the same title. Useful for checking Dependabot PRs before batch merging to ensure consistency. Shows the majority title and lists any outliers with their URLs. + +```bash +./validate-pr-titles.sh prs.txt +``` + +Input file format (`prs.txt`): + +```txt +https://github.com/joshjohanning/repo1/pull/25 +https://github.com/joshjohanning/repo2/pull/37 +https://github.com/joshjohanning/repo3/pull/43 +``` + ### verify-team-membership.sh Simple script to verify that a user is a member of a team diff --git a/gh-cli/merge-pull-requests-by-title.sh b/gh-cli/merge-pull-requests-by-title.sh new file mode 100755 index 0000000..fc2e9e5 --- /dev/null +++ b/gh-cli/merge-pull-requests-by-title.sh @@ -0,0 +1,189 @@ +#!/bin/bash + +# 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] +# +# 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 +# --dry-run - Optional: preview what would be merged without actually merging +# +# Examples: +# # Find and merge PRs with exact title match +# ./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" +# +# # Dry run to preview +# ./merge-pull-requests-by-title.sh repos.txt "chore(deps)*" squash "" --dry-run +# +# Input file format (repos.txt): +# https://github.com/joshjohanning/repo1 +# https://github.com/joshjohanning/repo2 +# https://github.com/joshjohanning/repo3 +# +# 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) +# +# 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 +dry_run=false +for arg in "$@"; do + if [ "$arg" = "--dry-run" ]; then + dry_run=true + break + fi +done + +if [ $# -lt 2 ]; then + echo "Usage: $0 [merge_method] [commit_title] [--dry-run]" + 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" + echo " --dry-run - Preview what would be merged without actually merging" + exit 1 +fi + +repo_list_file=$1 +pr_title_pattern=$2 +merge_method=${3:-squash} +commit_title=${4:-} + +if [ "$dry_run" = true ]; then + echo "🔍 DRY RUN MODE - No PRs will be merged" + echo "" +fi + +# Validate merge method +if [[ ! " ${merge_methods[*]} " =~ ${merge_method} ]]; then + echo "Error: merge_method must be one of: ${merge_methods[*]}" + exit 1 +fi + +# Check if file exists +if [ ! -f "$repo_list_file" ]; then + echo "Error: File $repo_list_file does not exist" + exit 1 +fi + +echo "Searching for PRs matching: \"$pr_title_pattern\"" +echo "" + +success_count=0 +fail_count=0 +skipped_count=0 +not_found_count=0 + +while IFS= read -r repo_url || [ -n "$repo_url" ]; do + # Skip empty lines and comments + if [ -z "$repo_url" ] || [[ "$repo_url" == \#* ]]; then + continue + fi + + # Trim whitespace + repo_url=$(echo "$repo_url" | xargs) + + # Parse repo URL: https://github.com/owner/repo + if [[ "$repo_url" =~ ^https://github\.com/([^/]+)/([^/]+)/?$ ]]; then + owner="${BASH_REMATCH[1]}" + repo_name="${BASH_REMATCH[2]}" + repo="$owner/$repo_name" + else + echo "⚠️ Skipping invalid repository URL: $repo_url" + ((skipped_count++)) + continue + fi + + echo "Searching: $repo" + + # Search for open PRs matching the title pattern + # Use simple string equality for exact match, regex only if wildcard * is used + if [[ "$pr_title_pattern" == *"*"* ]]; then + # Has wildcard - convert to regex (escape special chars, then convert * to .*) + jq_pattern="$pr_title_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="${jq_pattern//|/\\|}" + jq_pattern="${jq_pattern//\*/.*}" + jq_filter="select(.title | test(\"^\" + \$pattern + \"$\"))" + else + # Exact match - use simple string equality + jq_filter="select(.title == \$pattern)" + jq_pattern="$pr_title_pattern" + fi + + # Get open PRs and filter by title + matching_prs=$(gh pr list --repo "$repo" --state open --json number,title,author --limit 100 2>/dev/null | \ + jq -r --arg pattern "$jq_pattern" ".[] | $jq_filter | \"\(.number)|\(.title)|\(.author.login)\"") + + if [ -z "$matching_prs" ]; then + echo " 📭 No matching PRs found" + ((not_found_count++)) + echo "" + continue + fi + + # Process each matching PR + while IFS='|' read -r pr_number pr_title pr_author; do + echo " 📋 Found PR #$pr_number: $pr_title (by $pr_author)" + + # Build the merge command + merge_args=("--$merge_method") + + # Apply custom commit title if provided + if [ -n "$commit_title" ] && [ "$merge_method" != "rebase" ]; then + merge_args+=("--subject" "$commit_title") + 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 + done <<< "$matching_prs" + + echo "" + +done < "$repo_list_file" + +echo "========================================" +echo "Summary:" +echo " ✅ Merged: $success_count" +echo " ❌ Failed: $fail_count" +echo " ⏭️ Skipped: $skipped_count" +echo " 📭 No match: $not_found_count" +echo "========================================" + +if [ "$dry_run" = true ]; then + echo "" + echo "🔍 This was a DRY RUN - no PRs were actually merged" +fi diff --git a/gh-cli/merge-pull-requests-from-list.sh b/gh-cli/merge-pull-requests-from-list.sh new file mode 100755 index 0000000..43449fb --- /dev/null +++ b/gh-cli/merge-pull-requests-from-list.sh @@ -0,0 +1,195 @@ +#!/bin/bash + +# Merges a list of pull requests from a file containing PR URLs +# +# Usage: +# ./merge-pull-requests-from-list.sh [merge_method] [commit_title] [commit_body] [--dry-run] +# +# Arguments: +# pr_list_file - File with PR URLs (one per line) +# merge_method - Optional: merge method (merge, squash, rebase) - defaults to squash +# commit_title - Optional: custom commit title (use {title} for original PR title, {number} for PR number) +# commit_body - Optional: custom commit body (use {body} for original PR body) +# --dry-run - Optional: preview what would be merged without actually merging +# +# Examples: +# # Basic usage with a PR list file (uses squash merge) +# ./merge-pull-requests-from-list.sh prs.txt +# +# # Specify merge method +# ./merge-pull-requests-from-list.sh prs.txt merge +# ./merge-pull-requests-from-list.sh prs.txt rebase +# +# # Custom commit title (squash/merge only) +# ./merge-pull-requests-from-list.sh prs.txt squash "chore(deps): {title}" +# +# # Custom commit title and body +# ./merge-pull-requests-from-list.sh prs.txt squash "chore(deps): {title}" "Merged via automation" +# +# # Dry run to preview merges +# ./merge-pull-requests-from-list.sh prs.txt squash "" "" --dry-run +# +# Input file format (prs.txt): +# https://github.com/joshjohanning/repo1/pull/25 +# https://github.com/joshjohanning/repo2/pull/37 +# https://github.com/joshjohanning/repo3/pull/43 +# +# Notes: +# - Ensure you have merge permissions on all repositories +# - PRs must be in a mergeable state (approved, checks passed, no conflicts) +# - The script will skip PRs that cannot be merged and continue with the rest +# - Rebase merge method does not support custom commit messages +# +# 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 +dry_run=false +for arg in "$@"; do + if [ "$arg" = "--dry-run" ]; then + dry_run=true + break + fi +done + +if [ $# -lt 1 ]; then + echo "Usage: $0 [merge_method] [commit_title] [commit_body] [--dry-run]" + echo "" + echo "Arguments:" + echo " pr_list_file - File with PR URLs (one per line)" + echo " merge_method - Optional: merge, squash, or rebase (default: squash)" + echo " commit_title - Optional: custom commit title (use {title} for PR title, {number} for PR number)" + echo " commit_body - Optional: custom commit body (use {body} for PR body)" + echo " --dry-run - Preview what would be merged without actually merging" + exit 1 +fi + +pr_list_file=$1 +merge_method=${2:-squash} +commit_title=${3:-} +commit_body=${4:-} + +if [ "$dry_run" = true ]; then + echo "🔍 DRY RUN MODE - No PRs will be merged" + echo "" +fi + +# Validate merge method +if [[ ! " ${merge_methods[*]} " =~ ${merge_method} ]]; then + echo "Error: merge_method must be one of: ${merge_methods[*]}" + exit 1 +fi + +# Check if file exists +if [ ! -f "$pr_list_file" ]; then + echo "Error: File $pr_list_file does not exist" + exit 1 +fi + +# Warn about custom messages with rebase +if [ "$merge_method" = "rebase" ] && { [ -n "$commit_title" ] || [ -n "$commit_body" ]; }; then + echo "Warning: Rebase merge does not support custom commit messages, they will be ignored" +fi + +success_count=0 +fail_count=0 +skipped_count=0 + +while IFS= read -r pr_url || [ -n "$pr_url" ]; do + # Skip empty lines and comments + if [ -z "$pr_url" ] || [[ "$pr_url" == \#* ]]; then + continue + fi + + # Trim whitespace + pr_url=$(echo "$pr_url" | xargs) + + # Parse PR URL: https://github.com/owner/repo/pull/123 + if [[ "$pr_url" =~ ^https://github\.com/([^/]+)/([^/]+)/pull/([0-9]+) ]]; then + owner="${BASH_REMATCH[1]}" + repo="${BASH_REMATCH[2]}" + pr_number="${BASH_REMATCH[3]}" + else + echo "⚠️ Skipping invalid PR URL: $pr_url" + ((skipped_count++)) + continue + fi + + echo "Processing: $owner/$repo#$pr_number" + + # Get PR details for template substitution + pr_info=$(gh api "/repos/$owner/$repo/pulls/$pr_number" 2>/dev/null) + + if [ $? -ne 0 ]; then + echo "❌ Failed to fetch PR info for $owner/$repo#$pr_number" + ((fail_count++)) + continue + fi + + pr_title=$(echo "$pr_info" | jq -r '.title') + pr_body=$(echo "$pr_info" | jq -r '.body // ""') + pr_state=$(echo "$pr_info" | jq -r '.state') + pr_merged=$(echo "$pr_info" | jq -r '.merged') + + # Check if PR is already merged + if [ "$pr_merged" = "true" ]; then + echo "⏭️ Skipping $owner/$repo#$pr_number - already merged" + ((skipped_count++)) + continue + fi + + # Check if PR is closed + if [ "$pr_state" = "closed" ]; then + echo "⏭️ Skipping $owner/$repo#$pr_number - PR is closed" + ((skipped_count++)) + continue + fi + + # Build the merge command + merge_args=("--$merge_method") + + # Apply custom commit title with template substitution + if [ -n "$commit_title" ] && [ "$merge_method" != "rebase" ]; then + final_title="${commit_title//\{title\}/$pr_title}" + final_title="${final_title//\{number\}/$pr_number}" + merge_args+=("--subject" "$final_title") + fi + + # Apply custom commit body with template substitution + if [ -n "$commit_body" ] && [ "$merge_method" != "rebase" ]; then + final_body="${commit_body//\{body\}/$pr_body}" + final_body="${final_body//\{title\}/$pr_title}" + final_body="${final_body//\{number\}/$pr_number}" + merge_args+=("--body" "$final_body") + fi + + # Attempt to merge + if [ "$dry_run" = true ]; then + echo "🔍 Would merge $owner/$repo#$pr_number with: gh pr merge $pr_number --repo $owner/$repo ${merge_args[*]}" + ((success_count++)) + elif gh pr merge "$pr_number" --repo "$owner/$repo" "${merge_args[@]}"; then + echo "✅ Successfully merged $owner/$repo#$pr_number" + ((success_count++)) + else + echo "❌ Failed to merge $owner/$repo#$pr_number" + ((fail_count++)) + fi + + echo "" + +done < "$pr_list_file" + +echo "========================================" +echo "Summary:" +echo " ✅ Merged: $success_count" +echo " ❌ Failed: $fail_count" +echo " ⏭️ Skipped: $skipped_count" +echo "========================================" + +if [ "$dry_run" = true ]; then + echo "" + echo "🔍 This was a DRY RUN - no PRs were actually merged" +fi diff --git a/gh-cli/validate-pr-titles.sh b/gh-cli/validate-pr-titles.sh new file mode 100755 index 0000000..532d1ee --- /dev/null +++ b/gh-cli/validate-pr-titles.sh @@ -0,0 +1,84 @@ +#!/bin/zsh + +# Validates that all PRs in a list have the same title +# +# Usage: ./validate-pr-titles.sh + +if [ $# -lt 1 ]; then + echo "Usage: $0 " + exit 1 +fi + +pr_list_file=$1 + +if [ ! -f "$pr_list_file" ]; then + echo "Error: File $pr_list_file does not exist" + exit 1 +fi + +typeset -A titles +typeset -A title_urls +first_title="" + +while IFS= read -r pr_url || [ -n "$pr_url" ]; do + [ -z "$pr_url" ] || [[ "$pr_url" == \#* ]] && continue + pr_url=$(echo "$pr_url" | xargs) + + if [[ "$pr_url" =~ ^https://github\.com/([^/]+)/([^/]+)/pull/([0-9]+) ]]; then + owner="${match[1]}" + repo="${match[2]}" + pr_number="${match[3]}" + else + echo "⚠️ Invalid URL: $pr_url" + continue + fi + + title=$(gh pr view "$pr_url" --json title --jq '.title' 2>/dev/null) + if [ $? -ne 0 ]; then + echo "❌ Failed to fetch: $pr_url" + continue + fi + + echo "📋 $owner/$repo#$pr_number: $title" + + if [ -z "$first_title" ]; then + first_title="$title" + fi + + titles[$title]=$((${titles[$title]:-0} + 1)) + # Append URL to the list for this title + if [ -z "${title_urls[$title]}" ]; then + title_urls[$title]="$pr_url" + else + title_urls[$title]="${title_urls[$title]}|$pr_url" + fi +done < "$pr_list_file" + +echo "" +echo "========================================" +echo "Title Summary:" + +# Find the majority count +max_count=0 +for title in "${(@k)titles}"; do + if [ ${titles[$title]} -gt $max_count ]; then + max_count=${titles[$title]} + fi +done + +for title in "${(@k)titles}"; do + echo " (${titles[$title]}x) $title" + # Show URLs for non-majority titles + if [ ${titles[$title]} -lt $max_count ]; then + echo "${title_urls[$title]}" | tr '|' '\n' | while read -r url; do + echo " └─ $url" + done + fi +done +echo "========================================" + +if [ ${#titles[@]} -eq 1 ]; then + echo "✅ All PRs have the same title" +else + echo "⚠️ PRs have ${#titles[@]} different titles" +fi