Skip to content

Commit bd5dd16

Browse files
feat: add scripts for merging pull requests by title and from a list, and validate PR titles (#143)
1 parent 4481ab0 commit bd5dd16

File tree

4 files changed

+539
-1
lines changed

4 files changed

+539
-1
lines changed

gh-cli/README.md

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -903,7 +903,7 @@ Output:
903903
🌐 Source URL: https://github.com/joshjohanning-org/export-actions-usage-report
904904
📍 Migration Source: GHEC Source
905905
📊 State: SUCCEEDED
906-
❌ Failure Reason:
906+
❌ Failure Reason:
907907
908908
✅ Migration information retrieved successfully
909909
```
@@ -1429,6 +1429,60 @@ Adds users to an organization team from a CSV input list.
14291429

14301430
Creates a (mostly) empty migration for a given organization repository so that it can create a lock.
14311431

1432+
### merge-pull-requests-by-title.sh
1433+
1434+
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.
1435+
1436+
```bash
1437+
# Find and merge PRs with exact title match
1438+
./merge-pull-requests-by-title.sh repos.txt "chore(deps-dev): bump eslint from 8.0.0 to 9.0.0"
1439+
1440+
# Use wildcard to match partial titles
1441+
./merge-pull-requests-by-title.sh repos.txt "chore(deps-dev): bump eslint*"
1442+
1443+
# With custom commit title
1444+
./merge-pull-requests-by-title.sh repos.txt "chore(deps)*" squash "chore(deps): update dependencies"
1445+
1446+
# Dry run to preview
1447+
./merge-pull-requests-by-title.sh repos.txt "chore(deps)*" squash "" --dry-run
1448+
```
1449+
1450+
Input file format (`repos.txt`):
1451+
1452+
```
1453+
https://github.com/joshjohanning/repo1
1454+
https://github.com/joshjohanning/repo2
1455+
https://github.com/joshjohanning/repo3
1456+
```
1457+
1458+
### merge-pull-requests-from-list.sh
1459+
1460+
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.
1461+
1462+
```bash
1463+
# Basic usage (uses squash merge)
1464+
./merge-pull-requests-from-list.sh prs.txt
1465+
1466+
# Specify merge method
1467+
./merge-pull-requests-from-list.sh prs.txt merge
1468+
1469+
# Custom commit title with template variables
1470+
./merge-pull-requests-from-list.sh prs.txt squash "chore(deps): {title}"
1471+
1472+
# Dry run to preview merges
1473+
./merge-pull-requests-from-list.sh prs.txt squash "" "" --dry-run
1474+
```
1475+
1476+
Input file format (`prs.txt`):
1477+
1478+
```
1479+
https://github.com/joshjohanning/repo1/pull/25
1480+
https://github.com/joshjohanning/repo2/pull/37
1481+
https://github.com/joshjohanning/repo3/pull/43
1482+
```
1483+
1484+
Template variables: `{title}` (PR title), `{number}` (PR number), `{body}` (PR body)
1485+
14321486
### parent-organization-teams.sh
14331487

14341488
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
15991653

16001654
Updates / sets the issue type for an issue. See: [Community Discussions Post](https://github.com/orgs/community/discussions/139933)
16011655

1656+
### validate-pr-titles.sh
1657+
1658+
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.
1659+
1660+
```bash
1661+
./validate-pr-titles.sh prs.txt
1662+
```
1663+
1664+
Input file format (`prs.txt`):
1665+
1666+
```txt
1667+
https://github.com/joshjohanning/repo1/pull/25
1668+
https://github.com/joshjohanning/repo2/pull/37
1669+
https://github.com/joshjohanning/repo3/pull/43
1670+
```
1671+
16021672
### verify-team-membership.sh
16031673

16041674
Simple script to verify that a user is a member of a team
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
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

Comments
 (0)