From 0c86cac7fdbd6fa0e975e6db5bd694ec8ab7279e Mon Sep 17 00:00:00 2001 From: Josh Johanning Date: Mon, 23 Mar 2026 21:08:03 -0500 Subject: [PATCH 1/7] feat: enhance merge-pull-requests-by-title.sh with owner and topic filtering options --- gh-cli/merge-pull-requests-by-title.sh | 181 ++++++++++++++++++++----- 1 file changed, 150 insertions(+), 31 deletions(-) diff --git a/gh-cli/merge-pull-requests-by-title.sh b/gh-cli/merge-pull-requests-by-title.sh index c05b19c..b63155e 100755 --- a/gh-cli/merge-pull-requests-by-title.sh +++ b/gh-cli/merge-pull-requests-by-title.sh @@ -3,17 +3,24 @@ # 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] [--enable-auto-merge] [--no-prompt] +# ./merge-pull-requests-by-title.sh [merge_method] [commit_title] [flags...] +# ./merge-pull-requests-by-title.sh --owner [--topic ]... [merge_method] [commit_title] [flags...] # -# Arguments: +# Required (one of): # repo_list_file - File with repository URLs (one per line) +# --owner - Search repositories for this user or organization +# +# Required: # 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) +# +# Optional: +# merge_method - Merge method: merge, squash, or rebase (default: squash) +# commit_title - Custom commit title for merged PRs (PR number is auto-appended; defaults to PR title) +# --topic - Filter --owner repositories by topic (can be specified multiple times) +# --dry-run - Preview what would be merged without actually merging +# --bump-patch-version - 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 - Enable auto-merge on matching PRs (can combine with --bump-patch-version) +# --no-prompt - Merge without interactive confirmation (default is to prompt before each merge) # # Examples: # # Find and merge PRs with exact title match (will prompt for confirmation) @@ -31,6 +38,15 @@ # # 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 # +# # Search by owner instead of file list +# ./merge-pull-requests-by-title.sh --owner joshjohanning-org "chore(deps): bump undici*" --no-prompt +# +# # Search by owner and topic +# ./merge-pull-requests-by-title.sh --owner joshjohanning --topic node-action "chore(deps)*" --bump-patch-version +# +# # Search by owner and multiple topics +# ./merge-pull-requests-by-title.sh --owner joshjohanning --topic node-action --topic github-action "chore(deps)*" --dry-run +# # Input file format (repos.txt): # https://github.com/joshjohanning/repo1 # https://github.com/joshjohanning/repo2 @@ -52,13 +68,18 @@ merge_methods=("merge" "squash" "rebase") -# Check for --dry-run, --bump-patch-version, and --no-prompt flags anywhere in arguments +# Check for flags and valued options 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 +owner="" +topics=() +valid_flags=("--dry-run" "--bump-patch-version" "--enable-auto-merge" "--no-prompt" "--owner" "--topic") +args=("$@") +i=0 +while [ $i -lt ${#args[@]} ]; do + arg="${args[$i]}" if [ "$arg" = "--dry-run" ]; then dry_run=true elif [ "$arg" = "--bump-patch-version" ]; then @@ -67,25 +88,48 @@ for arg in "$@"; do enable_auto_merge=true elif [ "$arg" = "--no-prompt" ]; then no_prompt=true + elif [ "$arg" = "--owner" ]; then + ((i++)) + owner="${args[$i]}" + if [ -z "$owner" ]; then + echo "Error: --owner requires a value" + exit 1 + fi + elif [ "$arg" = "--topic" ]; then + ((i++)) + topic_val="${args[$i]}" + if [ -z "$topic_val" ]; then + echo "Error: --topic requires a value" + exit 1 + fi + topics+=("$topic_val") elif [[ "$arg" == --* ]]; then echo "Error: Unknown flag '$arg'" echo "Valid flags: ${valid_flags[*]}" exit 1 fi + ((i++)) done if [ $# -lt 2 ]; then - echo "Usage: $0 [merge_method] [commit_title] [--dry-run] [--bump-patch-version] [--enable-auto-merge] [--no-prompt]" + echo "Usage: $0 [merge_method] [commit_title] [flags...]" + echo " $0 --owner [--topic ]... [merge_method] [commit_title] [flags...]" echo "" - echo "Arguments:" + echo "Required (one of):" echo " repo_list_file - File with repository URLs (one per line)" + echo " --owner - Search repositories for this user or organization" + echo "" + echo "Required:" 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 "" + echo "Optional:" + echo " merge_method - merge, squash, or rebase (default: squash)" + echo " commit_title - Custom commit title for merged PRs (defaults to PR title)" + echo " --topic - Filter --owner repositories by topic (repeatable)" 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 " --bump-patch-version - Bump npm patch version on each matching PR branch and push" 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)" + echo " --no-prompt - Merge without interactive confirmation" exit 1 fi @@ -99,22 +143,45 @@ if [ "$dry_run" = true ] && [ "$enable_auto_merge" = true ]; then exit 1 fi -# Parse positional args, skipping flags +# Parse positional args, skipping flags and their values positional_args=() -for arg in "$@"; do - if [[ "$arg" != --* ]]; then +i=0 +while [ $i -lt ${#args[@]} ]; do + arg="${args[$i]}" + if [ "$arg" = "--owner" ] || [ "$arg" = "--topic" ]; then + ((i++)) # skip the value too + elif [[ "$arg" != --* ]]; then positional_args+=("$arg") fi + ((i++)) done -repo_list_file=${positional_args[0]} -pr_title_pattern=${positional_args[1]} -merge_method=${positional_args[2]:-squash} -commit_title=${positional_args[3]:-} +# When --owner is used, positional args shift (no repo_list_file needed) +if [ -n "$owner" ]; then + pr_title_pattern=${positional_args[0]} + merge_method=${positional_args[1]:-squash} + commit_title=${positional_args[2]:-} +else + repo_list_file=${positional_args[0]} + pr_title_pattern=${positional_args[1]} + merge_method=${positional_args[2]:-squash} + commit_title=${positional_args[3]:-} +fi -if [ -z "$repo_list_file" ] || [ -z "$pr_title_pattern" ]; then - echo "Error: repo_list_file and pr_title_pattern are required" +if [ -z "$pr_title_pattern" ]; then + echo "Error: pr_title_pattern is required" echo "Usage: $0 [merge_method] [commit_title] [flags...]" + echo " $0 --owner [--topic ]... [merge_method] [commit_title] [flags...]" + exit 1 +fi + +if [ -z "$owner" ] && [ -z "$repo_list_file" ]; then + echo "Error: Either repo_list_file or --owner is required" + exit 1 +fi + +if [ ${#topics[@]} -gt 0 ] && [ -z "$owner" ]; then + echo "Error: --topic requires --owner" exit 1 fi @@ -134,12 +201,59 @@ if [[ ! " ${merge_methods[*]} " =~ ${merge_method} ]]; then exit 1 fi -# Check if file exists -if [ ! -f "$repo_list_file" ]; then +# Check if file exists (when using file mode) +if [ -z "$owner" ] && [ ! -f "$repo_list_file" ]; then echo "Error: File $repo_list_file does not exist" exit 1 fi +# Build repo list from --owner/--topic or from file +if [ -n "$owner" ]; then + echo "Fetching repositories for owner: $owner" + if [ ${#topics[@]} -gt 0 ]; then + echo "Filtering by topics: ${topics[*]}" + fi + echo "" + + # Fetch repos from org (or user), optionally filtered by topics + # Try org endpoint first, fall back to user endpoint + # Build jq filter: repos must have ALL specified topics + if [ ${#topics[@]} -gt 0 ]; then + topic_conditions="" + for t in "${topics[@]}"; do + if [ -n "$topic_conditions" ]; then + topic_conditions="$topic_conditions and " + fi + topic_conditions="${topic_conditions}(.topics | index(\"$t\"))" + done + jq_topic_filter="select(.archived == false) | select($topic_conditions) | .html_url" + else + jq_topic_filter="select(.archived == false) | .html_url" + fi + + repo_urls=$(gh api --paginate "/orgs/$owner/repos?per_page=100" \ + --jq ".[] | $jq_topic_filter" 2>/dev/null) + owner_exit=$? + + if [ $owner_exit -ne 0 ] || [ -z "$repo_urls" ]; then + repo_urls=$(gh api --paginate "/users/$owner/repos?per_page=100" \ + --jq ".[] | $jq_topic_filter" 2>/dev/null) + user_exit=$? + fi + + if [ $? -ne 0 ] || [ -z "$repo_urls" ]; then + echo "Error: Failed to fetch repositories for '$owner'" + if [ -n "$repo_urls" ]; then + echo " $repo_urls" + fi + exit 1 + fi + + repo_count=$(echo "$repo_urls" | wc -l | xargs) + echo "Found $repo_count repositories" + echo "" +fi + echo "Searching for PRs matching: \"$pr_title_pattern\"" echo "" @@ -148,6 +262,13 @@ fail_count=0 skipped_count=0 not_found_count=0 +# Determine input source for repo URLs +if [ -n "$owner" ]; then + input_source="$repo_urls" +else + input_source=$(cat "$repo_list_file") +fi + while IFS= read -r repo_url || [ -n "$repo_url" ]; do # Skip empty lines and comments if [ -z "$repo_url" ] || [[ "$repo_url" == \#* ]]; then @@ -186,8 +307,6 @@ 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//\*/.*}" # Escape backslashes and double quotes for embedding in jq string literal jq_pattern_escaped="${jq_pattern//\\/\\\\}" @@ -347,7 +466,7 @@ while IFS= read -r repo_url || [ -n "$repo_url" ]; do echo "" -done < "$repo_list_file" +done <<< "$input_source" echo "========================================" echo "Summary:" From a06c50309a42902efe1d4cba917810d766597058 Mon Sep 17 00:00:00 2001 From: Josh Johanning Date: Mon, 23 Mar 2026 21:25:31 -0500 Subject: [PATCH 2/7] fix: address code review comments --- gh-cli/merge-pull-requests-by-title.sh | 33 ++++++++++++++------------ 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/gh-cli/merge-pull-requests-by-title.sh b/gh-cli/merge-pull-requests-by-title.sh index b63155e..dad55a8 100755 --- a/gh-cli/merge-pull-requests-by-title.sh +++ b/gh-cli/merge-pull-requests-by-title.sh @@ -91,14 +91,14 @@ while [ $i -lt ${#args[@]} ]; do elif [ "$arg" = "--owner" ]; then ((i++)) owner="${args[$i]}" - if [ -z "$owner" ]; then + if [ -z "$owner" ] || [[ "$owner" == --* ]]; then echo "Error: --owner requires a value" exit 1 fi elif [ "$arg" = "--topic" ]; then ((i++)) topic_val="${args[$i]}" - if [ -z "$topic_val" ]; then + if [ -z "$topic_val" ] || [[ "$topic_val" == --* ]]; then echo "Error: --topic requires a value" exit 1 fi @@ -126,9 +126,9 @@ if [ $# -lt 2 ]; then echo " merge_method - merge, squash, or rebase (default: squash)" echo " commit_title - Custom commit title for merged PRs (defaults to PR title)" echo " --topic - Filter --owner repositories by topic (repeatable)" - 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" - echo " --enable-auto-merge - Enable auto-merge on matching PRs (can combine with --bump-patch-version)" + echo " --dry-run - Preview what would be merged (cannot combine with --bump-patch-version or --enable-auto-merge)" + echo " --bump-patch-version - Bump npm patch version on each matching PR branch and push (cannot combine with --dry-run)" + echo " --enable-auto-merge - Enable auto-merge on matching PRs (can combine with --bump-patch-version, cannot combine with --dry-run)" echo " --no-prompt - Merge without interactive confirmation" exit 1 fi @@ -219,6 +219,13 @@ if [ -n "$owner" ]; then # Try org endpoint first, fall back to user endpoint # Build jq filter: repos must have ALL specified topics if [ ${#topics[@]} -gt 0 ]; then + # Validate topic names (alphanumeric and hyphens only) + for t in "${topics[@]}"; do + if ! [[ "$t" =~ ^[a-z0-9][a-z0-9-]*$ ]]; then + echo "Error: Invalid topic '$t' - topics must be lowercase alphanumeric with hyphens" + exit 1 + fi + done topic_conditions="" for t in "${topics[@]}"; do if [ -n "$topic_conditions" ]; then @@ -234,14 +241,15 @@ if [ -n "$owner" ]; then repo_urls=$(gh api --paginate "/orgs/$owner/repos?per_page=100" \ --jq ".[] | $jq_topic_filter" 2>/dev/null) owner_exit=$? + repo_fetch_exit=$owner_exit if [ $owner_exit -ne 0 ] || [ -z "$repo_urls" ]; then repo_urls=$(gh api --paginate "/users/$owner/repos?per_page=100" \ --jq ".[] | $jq_topic_filter" 2>/dev/null) - user_exit=$? + repo_fetch_exit=$? fi - if [ $? -ne 0 ] || [ -z "$repo_urls" ]; then + if [ $repo_fetch_exit -ne 0 ] || [ -z "$repo_urls" ]; then echo "Error: Failed to fetch repositories for '$owner'" if [ -n "$repo_urls" ]; then echo " $repo_urls" @@ -262,13 +270,6 @@ fail_count=0 skipped_count=0 not_found_count=0 -# Determine input source for repo URLs -if [ -n "$owner" ]; then - input_source="$repo_urls" -else - input_source=$(cat "$repo_list_file") -fi - while IFS= read -r repo_url || [ -n "$repo_url" ]; do # Skip empty lines and comments if [ -z "$repo_url" ] || [[ "$repo_url" == \#* ]]; then @@ -307,6 +308,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//\*/.*}" # Escape backslashes and double quotes for embedding in jq string literal jq_pattern_escaped="${jq_pattern//\\/\\\\}" @@ -466,7 +469,7 @@ while IFS= read -r repo_url || [ -n "$repo_url" ]; do echo "" -done <<< "$input_source" +done < <(if [ -n "$owner" ]; then echo "$repo_urls"; else cat "$repo_list_file"; fi) echo "========================================" echo "Summary:" From be414c72de9e76129a09739f53781f91bba169ed Mon Sep 17 00:00:00 2001 From: Josh Johanning Date: Tue, 24 Mar 2026 08:54:14 -0500 Subject: [PATCH 3/7] fix: code review suggestions and better defenses --- gh-cli/merge-pull-requests-by-title.sh | 61 +++++++++++++++++--------- 1 file changed, 41 insertions(+), 20 deletions(-) diff --git a/gh-cli/merge-pull-requests-by-title.sh b/gh-cli/merge-pull-requests-by-title.sh index dad55a8..a0785e3 100755 --- a/gh-cli/merge-pull-requests-by-title.sh +++ b/gh-cli/merge-pull-requests-by-title.sh @@ -73,7 +73,7 @@ dry_run=false bump_patch_version=false enable_auto_merge=false no_prompt=false -owner="" +search_owner="" topics=() valid_flags=("--dry-run" "--bump-patch-version" "--enable-auto-merge" "--no-prompt" "--owner" "--topic") args=("$@") @@ -90,8 +90,8 @@ while [ $i -lt ${#args[@]} ]; do no_prompt=true elif [ "$arg" = "--owner" ]; then ((i++)) - owner="${args[$i]}" - if [ -z "$owner" ] || [[ "$owner" == --* ]]; then + search_owner="${args[$i]}" + if [ -z "$search_owner" ] || [[ "$search_owner" == --* ]]; then echo "Error: --owner requires a value" exit 1 fi @@ -157,7 +157,16 @@ while [ $i -lt ${#args[@]} ]; do done # When --owner is used, positional args shift (no repo_list_file needed) -if [ -n "$owner" ]; then +if [ -n "$search_owner" ]; then + if [ ${#positional_args[@]} -gt 3 ]; then + echo "Error: Too many positional arguments for --owner mode (expected: pr_title_pattern [merge_method] [commit_title])" + exit 1 + fi + # Catch common mistake: passing a file when using --owner + if [ -f "${positional_args[0]}" ]; then + echo "Error: Cannot use both repo_list_file and --owner (first positional arg '${positional_args[0]}' is a file)" + exit 1 + fi pr_title_pattern=${positional_args[0]} merge_method=${positional_args[1]:-squash} commit_title=${positional_args[2]:-} @@ -175,12 +184,12 @@ if [ -z "$pr_title_pattern" ]; then exit 1 fi -if [ -z "$owner" ] && [ -z "$repo_list_file" ]; then +if [ -z "$search_owner" ] && [ -z "$repo_list_file" ]; then echo "Error: Either repo_list_file or --owner is required" exit 1 fi -if [ ${#topics[@]} -gt 0 ] && [ -z "$owner" ]; then +if [ ${#topics[@]} -gt 0 ] && [ -z "$search_owner" ]; then echo "Error: --topic requires --owner" exit 1 fi @@ -202,14 +211,14 @@ if [[ ! " ${merge_methods[*]} " =~ ${merge_method} ]]; then fi # Check if file exists (when using file mode) -if [ -z "$owner" ] && [ ! -f "$repo_list_file" ]; then +if [ -z "$search_owner" ] && [ ! -f "$repo_list_file" ]; then echo "Error: File $repo_list_file does not exist" exit 1 fi # Build repo list from --owner/--topic or from file -if [ -n "$owner" ]; then - echo "Fetching repositories for owner: $owner" +if [ -n "$search_owner" ]; then + echo "Fetching repositories for owner: $search_owner" if [ ${#topics[@]} -gt 0 ]; then echo "Filtering by topics: ${topics[*]}" fi @@ -231,32 +240,37 @@ if [ -n "$owner" ]; then if [ -n "$topic_conditions" ]; then topic_conditions="$topic_conditions and " fi - topic_conditions="${topic_conditions}(.topics | index(\"$t\"))" + topic_conditions="${topic_conditions}((.topics? // []) | index(\"$t\"))" done - jq_topic_filter="select(.archived == false) | select($topic_conditions) | .html_url" + jq_topic_filter="select(.archived == false) | select($topic_conditions) | .full_name" else - jq_topic_filter="select(.archived == false) | .html_url" + jq_topic_filter="select(.archived == false) | .full_name" fi - repo_urls=$(gh api --paginate "/orgs/$owner/repos?per_page=100" \ + repo_urls=$(gh api --paginate "/orgs/$search_owner/repos?per_page=100" \ --jq ".[] | $jq_topic_filter" 2>/dev/null) owner_exit=$? repo_fetch_exit=$owner_exit if [ $owner_exit -ne 0 ] || [ -z "$repo_urls" ]; then - repo_urls=$(gh api --paginate "/users/$owner/repos?per_page=100" \ + repo_urls=$(gh api --paginate "/users/$search_owner/repos?per_page=100" \ --jq ".[] | $jq_topic_filter" 2>/dev/null) repo_fetch_exit=$? fi - if [ $repo_fetch_exit -ne 0 ] || [ -z "$repo_urls" ]; then - echo "Error: Failed to fetch repositories for '$owner'" - if [ -n "$repo_urls" ]; then - echo " $repo_urls" - fi + if [ $repo_fetch_exit -ne 0 ]; then + echo "Error: Failed to fetch repositories for '$search_owner'" exit 1 fi + if [ -z "$repo_urls" ]; then + echo "No repositories found for '$search_owner'" + if [ ${#topics[@]} -gt 0 ]; then + echo " (filtered by topics: ${topics[*]})" + fi + exit 0 + fi + repo_count=$(echo "$repo_urls" | wc -l | xargs) echo "Found $repo_count repositories" echo "" @@ -469,7 +483,14 @@ while IFS= read -r repo_url || [ -n "$repo_url" ]; do echo "" -done < <(if [ -n "$owner" ]; then echo "$repo_urls"; else cat "$repo_list_file"; fi) +done < <(if [ -n "$search_owner" ]; then + # --owner mode: repo_urls contains owner/repo format, convert to URLs + echo "$repo_urls" | while IFS= read -r repo_name; do + echo "https://github.com/$repo_name" + done +else + cat "$repo_list_file" +fi) echo "========================================" echo "Summary:" From 7109f99fc0950069124b3dac1ae45f770d282f0a Mon Sep 17 00:00:00 2001 From: Josh Johanning Date: Tue, 24 Mar 2026 08:54:23 -0500 Subject: [PATCH 4/7] docs: simplify readme entry --- gh-cli/README.md | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/gh-cli/README.md b/gh-cli/README.md index 91cb43f..c42cd0a 100644 --- a/gh-cli/README.md +++ b/gh-cli/README.md @@ -1503,29 +1503,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. By default, prompts for confirmation before each merge; use `--no-prompt` to skip. +Finds and merges pull requests matching a title pattern across multiple repositories. Supports batch merging Dependabot PRs, bumping npm patch versions, and enabling auto-merge. Repositories can be specified via a file list or dynamically via `--owner` with optional `--topic` filtering. ```bash -# 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*" - -# Merge without confirmation prompt +# Merge PRs matching a wildcard title pattern ./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)*" --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) +# Bump patch version and enable auto-merge ./merge-pull-requests-by-title.sh repos.txt "chore(deps)*" --bump-patch-version --enable-auto-merge + +# Search by owner and topic instead of file list +./merge-pull-requests-by-title.sh --owner joshjohanning --topic node-action "chore(deps)*" --dry-run ``` Input file format (`repos.txt`): From 153f9163490b00d26e4236008f212763a24fce02 Mon Sep 17 00:00:00 2001 From: Josh Johanning Date: Tue, 24 Mar 2026 09:08:37 -0500 Subject: [PATCH 5/7] fix: improve error handling and fallback for repository fetching in merge-pull-requests-by-title.sh --- gh-cli/merge-pull-requests-by-title.sh | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/gh-cli/merge-pull-requests-by-title.sh b/gh-cli/merge-pull-requests-by-title.sh index a0785e3..75195ec 100755 --- a/gh-cli/merge-pull-requests-by-title.sh +++ b/gh-cli/merge-pull-requests-by-title.sh @@ -247,23 +247,28 @@ if [ -n "$search_owner" ]; then jq_topic_filter="select(.archived == false) | .full_name" fi - repo_urls=$(gh api --paginate "/orgs/$search_owner/repos?per_page=100" \ - --jq ".[] | $jq_topic_filter" 2>/dev/null) + api_err=$(mktemp) + repo_names=$(gh api --paginate "/orgs/$search_owner/repos?per_page=100" \ + --jq ".[] | $jq_topic_filter" 2>"$api_err") owner_exit=$? repo_fetch_exit=$owner_exit - if [ $owner_exit -ne 0 ] || [ -z "$repo_urls" ]; then - repo_urls=$(gh api --paginate "/users/$search_owner/repos?per_page=100" \ - --jq ".[] | $jq_topic_filter" 2>/dev/null) + # Only fall back to /users/ endpoint if the org endpoint failed (not just empty results) + if [ $owner_exit -ne 0 ]; then + repo_names=$(gh api --paginate "/users/$search_owner/repos?per_page=100" \ + --jq ".[] | $jq_topic_filter" 2>"$api_err") repo_fetch_exit=$? fi if [ $repo_fetch_exit -ne 0 ]; then echo "Error: Failed to fetch repositories for '$search_owner'" + cat "$api_err" 2>/dev/null + rm -f "$api_err" exit 1 fi + rm -f "$api_err" - if [ -z "$repo_urls" ]; then + if [ -z "$repo_names" ]; then echo "No repositories found for '$search_owner'" if [ ${#topics[@]} -gt 0 ]; then echo " (filtered by topics: ${topics[*]})" @@ -271,7 +276,7 @@ if [ -n "$search_owner" ]; then exit 0 fi - repo_count=$(echo "$repo_urls" | wc -l | xargs) + repo_count=$(echo "$repo_names" | wc -l | xargs) echo "Found $repo_count repositories" echo "" fi @@ -484,9 +489,9 @@ while IFS= read -r repo_url || [ -n "$repo_url" ]; do echo "" done < <(if [ -n "$search_owner" ]; then - # --owner mode: repo_urls contains owner/repo format, convert to URLs - echo "$repo_urls" | while IFS= read -r repo_name; do - echo "https://github.com/$repo_name" + # --owner mode: repo_names contains owner/repo format, convert to URLs + echo "$repo_names" | while IFS= read -r name; do + echo "https://github.com/$name" done else cat "$repo_list_file" From fff5bc44177b80b6d7f04fe6d4f024c187382ba7 Mon Sep 17 00:00:00 2001 From: Josh Johanning Date: Tue, 24 Mar 2026 09:30:26 -0500 Subject: [PATCH 6/7] fix: improve error messages and validation in merge-pull-requests-by-title.sh --- gh-cli/merge-pull-requests-by-title.sh | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/gh-cli/merge-pull-requests-by-title.sh b/gh-cli/merge-pull-requests-by-title.sh index 75195ec..b69245e 100755 --- a/gh-cli/merge-pull-requests-by-title.sh +++ b/gh-cli/merge-pull-requests-by-title.sh @@ -162,11 +162,6 @@ if [ -n "$search_owner" ]; then echo "Error: Too many positional arguments for --owner mode (expected: pr_title_pattern [merge_method] [commit_title])" exit 1 fi - # Catch common mistake: passing a file when using --owner - if [ -f "${positional_args[0]}" ]; then - echo "Error: Cannot use both repo_list_file and --owner (first positional arg '${positional_args[0]}' is a file)" - exit 1 - fi pr_title_pattern=${positional_args[0]} merge_method=${positional_args[1]:-squash} commit_title=${positional_args[2]:-} @@ -228,10 +223,12 @@ if [ -n "$search_owner" ]; then # Try org endpoint first, fall back to user endpoint # Build jq filter: repos must have ALL specified topics if [ ${#topics[@]} -gt 0 ]; then - # Validate topic names (alphanumeric and hyphens only) - for t in "${topics[@]}"; do + # Validate and lowercase topic names + for i in "${!topics[@]}"; do + topics[$i]=$(echo "${topics[$i]}" | tr '[:upper:]' '[:lower:]') + t="${topics[$i]}" if ! [[ "$t" =~ ^[a-z0-9][a-z0-9-]*$ ]]; then - echo "Error: Invalid topic '$t' - topics must be lowercase alphanumeric with hyphens" + echo "Error: Invalid topic '$t' - topics must be alphanumeric with hyphens" exit 1 fi done @@ -327,8 +324,6 @@ 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//\*/.*}" # Escape backslashes and double quotes for embedding in jq string literal jq_pattern_escaped="${jq_pattern//\\/\\\\}" From 03b69b797edfe95db4d0b3c6d99402ce1757b6d7 Mon Sep 17 00:00:00 2001 From: Josh Johanning Date: Tue, 24 Mar 2026 11:52:01 -0500 Subject: [PATCH 7/7] fix: add validation for GitHub owner input in merge-pull-requests-by-title.sh --- gh-cli/merge-pull-requests-by-title.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/gh-cli/merge-pull-requests-by-title.sh b/gh-cli/merge-pull-requests-by-title.sh index b69245e..e057714 100755 --- a/gh-cli/merge-pull-requests-by-title.sh +++ b/gh-cli/merge-pull-requests-by-title.sh @@ -95,6 +95,10 @@ while [ $i -lt ${#args[@]} ]; do echo "Error: --owner requires a value" exit 1 fi + if ! [[ "$search_owner" =~ ^[a-zA-Z0-9._-]+$ ]]; then + echo "Error: Invalid owner '$search_owner' - must be a valid GitHub username or organization" + exit 1 + fi elif [ "$arg" = "--topic" ]; then ((i++)) topic_val="${args[$i]}" @@ -324,6 +328,7 @@ 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//\*/.*}" # Escape backslashes and double quotes for embedding in jq string literal jq_pattern_escaped="${jq_pattern//\\/\\\\}"