1+ name : Cleanup Merged/Closed PR Branches
2+
3+ on :
4+ schedule :
5+ - cron : ' 0 2 * * 0' # Every Sunday at 2 AM UTC
6+ workflow_dispatch : # Allow manual triggering
7+ inputs :
8+ dry_run :
9+ description : ' Dry run (show what would be deleted without actually deleting)'
10+ required : false
11+ default : ' false'
12+ type : boolean
13+
14+ permissions :
15+ contents : write
16+ pull-requests : read
17+
18+ jobs :
19+ cleanup-branches :
20+ runs-on : ubuntu-latest
21+
22+ steps :
23+ - name : Checkout repository
24+ uses : actions/checkout@v4
25+ with :
26+ fetch-depth : 0 # Need full history to see all branches
27+ token : ${{ secrets.PAT_TOKEN }}
28+
29+ - name : Install GitHub CLI
30+ run : |
31+ curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
32+ && sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg \
33+ && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
34+ && sudo apt update \
35+ && sudo apt install gh -y
36+
37+ - name : Configure git
38+ run : |
39+ git config --global user.email "[email protected] " 40+ git config --global user.name "GitHub Action"
41+
42+ - name : Cleanup merged/closed PR branches
43+ env :
44+ GH_TOKEN : ${{ secrets.PAT_TOKEN }}
45+ run : |
46+ echo "Starting branch cleanup process..."
47+
48+ # Check if this is a dry run
49+ DRY_RUN="${{ github.event.inputs.dry_run || 'false' }}"
50+ if [ "$DRY_RUN" = "true" ]; then
51+ echo "🔍 DRY RUN MODE - No branches will actually be deleted"
52+ echo ""
53+ fi
54+
55+ # Define protected branches and patterns
56+ protected_branches=(
57+ "master"
58+ "main"
59+ )
60+
61+ # Translation branch patterns (any 2-letter combination)
62+ translation_pattern="^[a-zA-Z]{2}$"
63+
64+ # Get all remote branches except protected ones
65+ echo "Fetching all remote branches..."
66+ git fetch --all --prune
67+
68+ # Get list of all remote branches (excluding HEAD)
69+ all_branches=$(git branch -r | grep -v 'HEAD' | sed 's/origin\///' | grep -v '^$')
70+
71+ # Get all open PRs to identify branches with open PRs
72+ echo "Getting list of open PRs..."
73+ open_pr_branches=$(gh pr list --state open --json headRefName --jq '.[].headRefName' | sort | uniq)
74+
75+ echo "Open PR branches:"
76+ echo "$open_pr_branches"
77+ echo ""
78+
79+ deleted_count=0
80+ skipped_count=0
81+
82+ for branch in $all_branches; do
83+ branch=$(echo "$branch" | xargs) # Trim whitespace
84+
85+ # Skip if empty
86+ if [ -z "$branch" ]; then
87+ continue
88+ fi
89+
90+ echo "Checking branch: $branch"
91+
92+ # Check if it's a protected branch
93+ is_protected=false
94+ for protected in "${protected_branches[@]}"; do
95+ if [ "$branch" = "$protected" ]; then
96+ echo " ✓ Skipping protected branch: $branch"
97+ is_protected=true
98+ skipped_count=$((skipped_count + 1))
99+ break
100+ fi
101+ done
102+
103+ if [ "$is_protected" = true ]; then
104+ continue
105+ fi
106+
107+ # Check if it's a translation branch (any 2-letter combination)
108+ # Also protect any branch that starts with 2 letters followed by additional content
109+ if echo "$branch" | grep -Eq "$translation_pattern" || echo "$branch" | grep -Eq "^[a-zA-Z]{2}[_-]"; then
110+ echo " ✓ Skipping translation/language branch: $branch"
111+ skipped_count=$((skipped_count + 1))
112+ continue
113+ fi
114+
115+ # Check if branch has an open PR
116+ if echo "$open_pr_branches" | grep -Fxq "$branch"; then
117+ echo " ✓ Skipping branch with open PR: $branch"
118+ skipped_count=$((skipped_count + 1))
119+ continue
120+ fi
121+
122+ # Check if branch had a PR that was merged or closed
123+ echo " → Checking PR history for branch: $branch"
124+
125+ # Look for PRs from this branch (both merged and closed)
126+ pr_info=$(gh pr list --state all --head "$branch" --json number,state,mergedAt --limit 1)
127+
128+ if [ "$pr_info" != "[]" ]; then
129+ pr_state=$(echo "$pr_info" | jq -r '.[0].state')
130+ pr_number=$(echo "$pr_info" | jq -r '.[0].number')
131+ merged_at=$(echo "$pr_info" | jq -r '.[0].mergedAt')
132+
133+ if [ "$pr_state" = "MERGED" ] || [ "$pr_state" = "CLOSED" ]; then
134+ if [ "$DRY_RUN" = "true" ]; then
135+ echo " 🔍 [DRY RUN] Would delete branch: $branch (PR #$pr_number was $pr_state)"
136+ deleted_count=$((deleted_count + 1))
137+ else
138+ echo " ✗ Deleting branch: $branch (PR #$pr_number was $pr_state)"
139+
140+ # Delete the remote branch
141+ if git push origin --delete "$branch" 2>/dev/null; then
142+ echo " Successfully deleted remote branch: $branch"
143+ deleted_count=$((deleted_count + 1))
144+ else
145+ echo " Failed to delete remote branch: $branch"
146+ fi
147+ fi
148+ else
149+ echo " ✓ Skipping branch with open PR: $branch (PR #$pr_number is $pr_state)"
150+ skipped_count=$((skipped_count + 1))
151+ fi
152+ else
153+ # No PR found for this branch - it might be a stale branch
154+ # Check if branch is older than 30 days and has no recent activity
155+ last_commit_date=$(git log -1 --format="%ct" origin/"$branch" 2>/dev/null || echo "0")
156+
157+ if [ "$last_commit_date" != "0" ] && [ -n "$last_commit_date" ]; then
158+ # Calculate 30 days ago in seconds since epoch
159+ thirty_days_ago=$(($(date +%s) - 30 * 24 * 60 * 60))
160+
161+ if [ "$last_commit_date" -lt "$thirty_days_ago" ]; then
162+ if [ "$DRY_RUN" = "true" ]; then
163+ echo " 🔍 [DRY RUN] Would delete stale branch (no PR, >30 days old): $branch"
164+ deleted_count=$((deleted_count + 1))
165+ else
166+ echo " ✗ Deleting stale branch (no PR, >30 days old): $branch"
167+
168+ if git push origin --delete "$branch" 2>/dev/null; then
169+ echo " Successfully deleted stale branch: $branch"
170+ deleted_count=$((deleted_count + 1))
171+ else
172+ echo " Failed to delete stale branch: $branch"
173+ fi
174+ fi
175+ else
176+ echo " ✓ Skipping recent branch (no PR, <30 days old): $branch"
177+ skipped_count=$((skipped_count + 1))
178+ fi
179+ else
180+ echo " ✓ Skipping branch (cannot determine age): $branch"
181+ skipped_count=$((skipped_count + 1))
182+ fi
183+ fi
184+
185+ echo ""
186+ done
187+
188+ echo "=================================="
189+ echo "Branch cleanup completed!"
190+ if [ "$DRY_RUN" = "true" ]; then
191+ echo "Branches that would be deleted: $deleted_count"
192+ else
193+ echo "Branches deleted: $deleted_count"
194+ fi
195+ echo "Branches skipped: $skipped_count"
196+ echo "=================================="
197+
198+ # Clean up local tracking branches (only if not dry run)
199+ if [ "$DRY_RUN" != "true" ]; then
200+ echo "Cleaning up local tracking branches..."
201+ git remote prune origin
202+ fi
203+
204+ echo "Cleanup process finished."
0 commit comments