From 6beb2676665d0d61b40b4d715ec2ab96a49971b5 Mon Sep 17 00:00:00 2001 From: Valerii Onyshchenko Date: Wed, 25 Feb 2026 11:19:55 +0200 Subject: [PATCH] [TECH-6068] Add autodeleting orphaned branches --- .env.example | 1 + .github/workflows/repo-health-scan.yml | 2 + Makefile | 14 +- README.md | 151 +++++++++++- docker-compose.yaml | 1 + scanner.py | 326 +++++++++++++++++++++---- 6 files changed, 450 insertions(+), 45 deletions(-) diff --git a/.env.example b/.env.example index 312275d..0e3b79e 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,5 @@ GITHUB_ORG=your-org-name GITHUB_TOKEN=ghp_your_token_here OLD_PR_THRESHOLD_DAYS=30 +DELETE_ORPHANED_BRANCHES=false # GITHUB_REPO=specific-repo-name # Optional: scan only this repo instead of entire org diff --git a/.github/workflows/repo-health-scan.yml b/.github/workflows/repo-health-scan.yml index 61256e2..edc7b05 100644 --- a/.github/workflows/repo-health-scan.yml +++ b/.github/workflows/repo-health-scan.yml @@ -61,9 +61,11 @@ jobs: GITHUB_TOKEN: ${{ secrets.SCANNER_PAT }} GITHUB_ORG: ${{ steps.determine-org.outputs.github_org }} OLD_PR_THRESHOLD_DAYS: ${{ github.event.inputs.pr_threshold_days || '30' }} + DELETE_ORPHANED_BRANCHES: ${{ vars.DELETE_ORPHANED_BRANCHES || 'false' }} run: | echo "Starting scan for organization: $GITHUB_ORG" echo "PR threshold: $OLD_PR_THRESHOLD_DAYS days" + echo "Auto-delete branches: $DELETE_ORPHANED_BRANCHES" mkdir -p reports python scanner.py --pretty diff --git a/Makefile b/Makefile index a1795e8..4796828 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build run clean rebuild pretty +.PHONY: build run clean rebuild pretty dbr dpr build: docker compose build --build-arg USER_ID=$$(id -u) --build-arg GROUP_ID=$$(id -g) @@ -15,6 +15,18 @@ scan: build run pretty: build UID=$$(id -u) GID=$$(id -g) docker compose run --rm github-scanner --pretty +# Delete orphaned branches (branches without open PRs) +dbr: build + @echo "⚠️ WARNING: This will DELETE all orphaned branches!" + @read -p "Are you sure? Type 'yes' to continue: " confirm && [ "$$confirm" = "yes" ] || (echo "Aborted." && exit 1) + UID=$$(id -u) GID=$$(id -g) docker compose run --rm github-scanner --delete-branches + +# Delete (close) stale PRs that exceed the threshold +dpr: build + @echo "⚠️ WARNING: This will CLOSE all stale PRs exceeding the threshold!" + @read -p "Are you sure? Type 'yes' to continue: " confirm && [ "$$confirm" = "yes" ] || (echo "Aborted." && exit 1) + UID=$$(id -u) GID=$$(id -g) docker compose run --rm github-scanner --delete-prs + clean: docker compose down rm -rf reports/ diff --git a/README.md b/README.md index 77334a5..ae6e58b 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,12 @@ Dockerized tool to scan GitHub organizations for repository health metrics. Opti - **Orphaned Branch Detection**: Identifies branches without open PRs (includes merged/closed PR branches) - **Closed/Merged PR Branch Tracking**: Identifies branches that still exist after their PRs were closed or merged +- **Automated Cleanup**: Delete orphaned branches and close stale PRs with dedicated commands +- **Auto-Delete Mode**: Automatically delete branches with closed/merged PRs via environment variable +- **Smart Branch Protection**: Automatically excludes standard branches (main, staging, dev, prod, etc.) +- **Archived Repository Handling**: Automatically skips archived repositories from analysis +- **API Error Resilience**: Automatic retry with backoff for transient 403 errors +- **Duplicate Branch Handling**: Deduplicates branches with multiple closed/merged PRs - **Old PR Analysis**: Configurable threshold for identifying stale pull requests - **Pretty-Print Table Format**: Human-friendly table output for stale PRs and orphaned branches - **Discord Integration**: Automated report uploads to Discord via GitHub Actions @@ -26,6 +32,7 @@ Dockerized tool to scan GitHub organizations for repository health metrics. Opti GITHUB_ORG=your-org-name GITHUB_TOKEN=ghp_your_token_here OLD_PR_THRESHOLD_DAYS=30 + DELETE_ORPHANED_BRANCHES=false ``` ### Creating GitHub Token with Minimal Permissions @@ -67,6 +74,8 @@ To create a GitHub Personal Access Token with minimal required permissions: ```bash make scan # Build and run (JSON output) make pretty # Build and run with pretty table output + make dbr # Delete all orphaned branches (requires confirmation) + make dpr # Close all stale PRs (requires confirmation) make fresh # Force rebuild and run make build # Build only make clean # Clean up @@ -119,12 +128,21 @@ make fresh | `GITHUB_TOKEN` | Recommended | - | GitHub personal access token | | `GITHUB_REPO` | No | - | Specific repository name (scans only this repo instead of entire org) | | `OLD_PR_THRESHOLD_DAYS` | No | 30 | Days to consider PRs as "old" | +| `DELETE_ORPHANED_BRANCHES` | No | false | Auto-delete branches with closed/merged PRs during scan | + +**Note:** The scanner automatically excludes: +- Archived repositories (read-only) +- Inactive repositories (no activity in last year) +- Standard branches: `main`, `master`, `develop`, `development`, `dev`, `staging`, `stage`, `prod`, `production`, `test`, `testing`, `qa`, `uat`, `preprod`, `pre-prod`, `release`, `hotfix`, `stable` ### GitHub Token - **Without token**: 60 requests/hour (rate limited) - **With token**: 5000 requests/hour -- **Permissions needed**: `repo` (for private repos) or `public_repo` (for public repos only) +- **Permissions needed**: + - `repo` - Full control of private repositories (required for deletion operations) + - `read:org` - Read organization membership (required for org scanning) + - For read-only scanning of public repos: `public_repo` + `read:org` ## Makefile Commands @@ -132,6 +150,8 @@ make fresh |---------|-------------| | `make scan` | Build and run scanner (JSON output) | | `make pretty` | Build and run with pretty table output | +| `make dbr` | **Delete orphaned branches** (requires confirmation) | +| `make dpr` | **Close stale PRs** (requires confirmation) | | `make build` | Build Docker image | | `make rebuild` | Force rebuild (no cache) | | `make run` | Run existing image | @@ -240,6 +260,55 @@ The Markdown file can be: - Converted to PDF or other formats - Included in documentation or reports +## Cleanup Operations + +### Delete Orphaned Branches (`make dbr`) + +Deletes all branches that don't have open PRs (excluding default and protected branches): + +```bash +make dbr +``` + +**What it does:** +- Scans all repositories for orphaned branches +- Excludes default branches (main, master, etc.) +- Excludes protected branches +- Prompts for confirmation before deletion +- Shows real-time deletion status for each branch + +**⚠️ Warning:** This is a destructive operation. Deleted branches cannot be recovered unless you have local copies. + +### Close Stale PRs (`make dpr`) + +Closes all pull requests that exceed the configured threshold: + +```bash +make dpr +``` + +**What it does:** +- Identifies PRs older than `OLD_PR_THRESHOLD_DAYS` +- Prompts for confirmation before closing +- Closes PRs with status update +- Shows real-time closure status for each PR + +**Note:** Closed PRs can be reopened if needed. + +### Auto-Delete Closed/Merged PR Branches + +Set the `DELETE_ORPHANED_BRANCHES` environment variable to automatically delete branches with closed or merged PRs during scanning: + +```bash +DELETE_ORPHANED_BRANCHES=true make scan +``` + +**What it does:** +- Automatically deletes branches whose PRs have been closed or merged +- Runs during normal scan operation +- No confirmation prompt (use with caution) +- Useful for automated cleanup in CI/CD pipelines + ### Closed/Merged PR Branches Section The report now includes a dedicated section for branches that still exist even though their PRs have been closed or merged: @@ -271,6 +340,36 @@ The workflow automatically uploads the generated Markdown report to Discord: - Includes organization name in the message - Webhook URL is configured in the workflow file +## Command-Line Options + +The scanner supports the following command-line flags: + +```bash +python scanner.py [OPTIONS] + +Options: + -p, --pretty Output human-friendly table format + --delete-branches Delete all orphaned branches + --delete-prs Close all stale PRs exceeding threshold + -h, --help Show help message +``` + +**Examples:** + +```bash +# Generate pretty report +python scanner.py --pretty + +# Delete orphaned branches (with warning) +python scanner.py --delete-branches + +# Close stale PRs (with warning) +python scanner.py --delete-prs + +# Combine operations +python scanner.py --pretty --delete-branches +``` + ## Security - **Docker isolation**: Runs in isolated container environment @@ -279,14 +378,46 @@ The workflow automatically uploads the generated Markdown report to Discord: - **Environment variables**: Secure credential management - **Local reports**: Reports saved to mounted volume only - **No data persistence**: No data stored in container +- **Deletion safeguards**: Confirmation prompts for destructive operations +- **Protected branches**: Never deletes default or protected branches ## Performance - **Optimized API calls**: Reduced API requests for better performance - **Pagination handling**: Automatic handling of large datasets - **Progress indicators**: Real-time feedback for long-running scans -- **Error handling**: Graceful handling of API failures +- **Error handling**: Graceful handling of API failures with automatic retry +- **403 Error Retry**: Automatic 5-second delay and retry for transient access errors - **Rate limit aware**: Respects GitHub API rate limits +- **Batch operations**: Efficient deletion of multiple branches/PRs +- **Smart filtering**: Early exclusion of archived and inactive repositories + +## Best Practices + +### Before Running Cleanup Operations + +1. **Run a scan first**: Always run `make scan` or `make pretty` to see what will be affected +2. **Review the report**: Check the generated report to understand what will be deleted +3. **Backup important branches**: Ensure you have local copies of any branches you might need +4. **Test on a single repo**: Use `GITHUB_REPO=test-repo make dbr` to test on one repository first +5. **Use in CI/CD carefully**: Only enable `DELETE_ORPHANED_BRANCHES=true` if you're confident in the logic + +### Recommended Workflow + +```bash +# 1. Scan and review +make pretty + +# 2. Review the Markdown report +cat reports/scan_*.md + +# 3. If satisfied, run cleanup +make dbr # Delete orphaned branches +make dpr # Close stale PRs + +# 4. Verify results +make pretty +``` ## Troubleshooting @@ -304,3 +435,19 @@ The workflow automatically uploads the generated Markdown report to Discord: - Ensure your GitHub token has access to all repositories in the organization - Private repositories require `repo` scope, public repositories need `public_repo` + +### Deletion Failures + +- Ensure your token has `repo` scope (not just `public_repo`) +- Protected branches cannot be deleted (this is intentional) +- Standard branches (main, staging, dev, prod, etc.) are automatically excluded +- Archived repositories are skipped entirely (read-only) +- Check that branches still exist before attempting deletion +- Verify organization permissions allow branch deletion + +### 403 Errors During Scan + +- The scanner automatically retries 403 errors after a 5-second delay +- Transient 403 errors are common during rapid API requests and are handled automatically +- Persistent 403 errors are silently skipped (usually archived repos or permission issues) +- Check rate limit status if you see many 403 errors: `curl -H "Authorization: token YOUR_TOKEN" https://api.github.com/rate_limit` diff --git a/docker-compose.yaml b/docker-compose.yaml index da04023..91ffaa6 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -10,6 +10,7 @@ services: - GITHUB_ORG=${GITHUB_ORG} - OLD_PR_THRESHOLD_DAYS=${OLD_PR_THRESHOLD_DAYS} - GITHUB_REPO=${GITHUB_REPO} + - DELETE_ORPHANED_BRANCHES=${DELETE_ORPHANED_BRANCHES} volumes: - ./reports:/app/reports networks: diff --git a/scanner.py b/scanner.py index c609c77..3f11f4f 100644 --- a/scanner.py +++ b/scanner.py @@ -13,7 +13,7 @@ from collections import defaultdict class GitHubOrgScanner: - def __init__(self, org_name, token=None, pretty_print=False): + def __init__(self, org_name, token=None, pretty_print=False, delete_branches=False, delete_prs=False): self.org_name = org_name self.base_url = "https://api.github.com" self.headers = {"Accept": "application/vnd.github.v3+json"} @@ -22,12 +22,16 @@ def __init__(self, org_name, token=None, pretty_print=False): self.one_year_ago = datetime.now() - timedelta(days=365) self.old_pr_threshold_days = int(os.environ.get('OLD_PR_THRESHOLD_DAYS', '30')) self.single_repo = os.environ.get('GITHUB_REPO') # If set, scan only this repo + self.delete_orphaned_branches = os.environ.get('DELETE_ORPHANED_BRANCHES', 'false').lower() == 'true' self.report_data = [] self.pretty_print = pretty_print + self.delete_branches = delete_branches + self.delete_prs = delete_prs - def make_request(self, url): - """Make paginated requests to GitHub API.""" + def make_request(self, url, retry_on_403=True): + """Make a request to the GitHub API with pagination support.""" all_items = [] + while url: try: response = requests.get(url, headers=self.headers, timeout=30) @@ -43,7 +47,14 @@ def make_request(self, url): url = link.split('<')[1].split('>')[0] break except requests.exceptions.RequestException as e: - print(f"Request failed: {e}", file=sys.stderr) + # Retry once on 403 errors after a delay + if '403' in str(e) and retry_on_403: + time.sleep(5) + return self.make_request(url, retry_on_403=False) + + # Silently skip 403/404 errors after retry + if '403' not in str(e) and '404' not in str(e): + print(f"Request failed: {e}", file=sys.stderr) break return all_items @@ -147,6 +158,52 @@ def get_closed_merged_pr_branches(self, repo_name): return closed_merged_branches + def delete_branch(self, repo_name, branch_name): + """Delete a branch from a repository.""" + url = f"{self.base_url}/repos/{self.org_name}/{repo_name}/git/refs/heads/{branch_name}" + try: + response = requests.delete(url, headers=self.headers, timeout=30) + if response.status_code == 204: + return True, "Deleted successfully" + elif response.status_code == 404: + return False, "Not found" + elif response.status_code == 422: + # Try to get more details from response + try: + error_data = response.json() + message = error_data.get('message', '') + + # Common 422 reasons + if 'protected' in message.lower(): + return False, "Protected branch" + elif 'required' in message.lower(): + return False, "Required by rules" + elif 'open pull request' in message.lower() or 'reference' in message.lower(): + return False, "Has open PR" + else: + # Return the actual GitHub message + return False, message[:40] if message else "Cannot delete" + except: + return False, "Cannot delete (422)" + elif response.status_code == 403: + return False, "Permission denied" + else: + return False, f"HTTP {response.status_code}" + except requests.exceptions.RequestException as e: + return False, f"Error: {str(e)[:20]}" + + def close_pr(self, repo_name, pr_number): + """Close a pull request.""" + url = f"{self.base_url}/repos/{self.org_name}/{repo_name}/pulls/{pr_number}" + try: + response = requests.patch(url, headers=self.headers, json={"state": "closed"}, timeout=30) + if response.status_code == 200: + return True, "Closed successfully" + else: + return False, f"Failed: {response.status_code}" + except requests.exceptions.RequestException as e: + return False, f"Error: {e}" + def get_default_branch(self, repo_name): """Get the default branch for a repository.""" url = f"{self.base_url}/repos/{self.org_name}/{repo_name}" @@ -216,12 +273,70 @@ def analyze_repo(self, repo): # Filter to only include branches that still exist closed_merged_existing = [b for b in closed_merged_pr_branches if b['branch'] in branch_names] + # Standard branches that should never be deleted + standard_branches = { + 'main', 'master', 'develop', 'development', 'dev', + 'staging', 'stage', 'prod', 'production', + 'test', 'testing', 'qa', 'uat', 'preprod', 'pre-prod', + 'release', 'hotfix', 'stable' + } + + # Deduplicate by branch name (keep the most recent PR for each branch) + # Also exclude standard branches + branch_dict = {} + for b in closed_merged_existing: + branch_name = b['branch'] + + # Skip standard branches + if branch_name.lower() in standard_branches: + continue + + if branch_name not in branch_dict: + branch_dict[branch_name] = b + else: + # Keep the one with the most recent closure + existing_days = branch_dict[branch_name]['days_since_closed'] + if b['days_since_closed'] < existing_days: + branch_dict[branch_name] = b + + closed_merged_unique = list(branch_dict.values()) + + # Delete orphaned branches with closed/merged PRs if enabled + deletion_results = [] + if self.delete_orphaned_branches and closed_merged_unique: + print(f"\n Deleting {len(closed_merged_unique)} orphaned branches with closed/merged PRs...") + for branch_info in closed_merged_unique: + success, msg = self.delete_branch(repo_name, branch_info['branch']) + status = "✓" if success else "✗" + print(f" {status} {branch_info['branch']}: {msg}") + + # Track deletion results + deletion_results.append({ + 'branch': branch_info['branch'], + 'pr_number': branch_info['pr_number'], + 'pr_url': branch_info['pr_url'], + 'user': branch_info['user'], + 'status': branch_info['status'], + 'closed_at': branch_info['closed_at'], + 'days_since_closed': branch_info['days_since_closed'], + 'deletion_success': success, + 'deletion_message': msg + }) + # Get default and protected branches to exclude default_branch = self.get_default_branch(repo_name) protected_branches = self.get_protected_branches(repo_name) - # Calculate orphaned branches (exclude default and protected branches) - excluded_branches = {default_branch} | protected_branches + # Standard branches that should never be deleted + standard_branches = { + 'main', 'master', 'develop', 'development', 'dev', + 'staging', 'stage', 'prod', 'production', + 'test', 'testing', 'qa', 'uat', 'preprod', 'pre-prod', + 'release', 'hotfix', 'stable' + } + + # Calculate orphaned branches (exclude default, protected, and standard branches) + excluded_branches = {default_branch} | protected_branches | standard_branches orphaned_branches = branch_names - open_pr_branches - excluded_branches # Get branch details with authors if pretty print is enabled @@ -229,7 +344,7 @@ def analyze_repo(self, repo): if self.pretty_print: for branch_name in orphaned_branches: # Skip if this branch has a closed/merged PR (we'll show it separately) - if any(b['branch'] == branch_name for b in closed_merged_existing): + if any(b['branch'] == branch_name for b in closed_merged_unique): continue author = self.get_branch_last_commit_author(repo_name, branch_name) orphaned_branch_details.append({ @@ -245,7 +360,8 @@ def analyze_repo(self, repo): 'branches_without_prs_count': len(orphaned_branches), 'stale_branches': list(orphaned_branches), 'orphaned_branch_details': orphaned_branch_details, - 'closed_merged_pr_branches': closed_merged_existing + 'closed_merged_pr_branches': closed_merged_unique if not self.delete_orphaned_branches else [], + 'deleted_branches': deletion_results } def generate_report(self): @@ -272,13 +388,23 @@ def generate_report(self): print(f"Total repos: {len(repos)}") - # Filter active repos + # Filter active repos and exclude archived repos active_repos = [] + archived_count = 0 + inactive_count = 0 + for repo in repos: + if repo.get('archived', False): + archived_count += 1 + continue if self.check_recent_activity(repo): active_repos.append(repo) + else: + inactive_count += 1 - print(f"Active repos (last year): {len(active_repos)}\n") + print(f"Active repos (last year): {len(active_repos)}") + print(f"Archived repos (skipped): {archived_count}") + print(f"Inactive repos (skipped): {inactive_count}\n") if not active_repos: print("No active repositories found.") @@ -318,6 +444,22 @@ def generate_report(self): old_prs = [pr for pr in result['open_prs'] if pr['days_old'] > self.old_pr_threshold_days] if old_prs: print(f" Old PRs (>{self.old_pr_threshold_days}d): {len(old_prs)}") + + # Delete stale PRs if in delete mode + if self.delete_prs: + print(f"\n Closing {len(old_prs)} stale PRs...") + for pr in old_prs: + success, msg = self.close_pr(result['name'], pr['number']) + status = "✓" if success else "✗" + print(f" {status} PR #{pr['number']}: {msg}") + + # Delete orphaned branches if in delete mode + if self.delete_branches and result['branches_without_prs_count'] > 0: + print(f"\n Deleting {result['branches_without_prs_count']} orphaned branches...") + for branch_name in result['stale_branches']: + success, msg = self.delete_branch(result['name'], branch_name) + status = "✓" if success else "✗" + print(f" {status} {branch_name}: {msg}") print() @@ -395,13 +537,33 @@ def collect_table_rows(self, summary): return table_rows def save_markdown_report(self, summary, report_path, timestamp): + """Save a Markdown report with pretty formatting.""" table_rows = self.collect_table_rows(summary) - # Collect closed/merged PR branches + # Collect closed/merged PR branches or deletion results closed_merged_rows = [] + deleted_branches_rows = [] + for repo in summary['repos']: repo_name = repo['name'] + + # Collect deletion results if DELETE_ORPHANED_BRANCHES was enabled + for branch in repo.get('deleted_branches', []): + deleted_branches_rows.append({ + 'repo': repo_name, + 'branch': branch['branch'], + 'pr_number': branch['pr_number'], + 'pr_url': branch['pr_url'], + 'user': branch['user'], + 'status': branch['status'], + 'closed_at': branch['closed_at'], + 'days_since_closed': branch['days_since_closed'], + 'deletion_success': branch['deletion_success'], + 'deletion_message': branch['deletion_message'] + }) + + # Collect branches that still exist (only if not in deletion mode) for branch in repo.get('closed_merged_pr_branches', []): closed_merged_rows.append({ 'repo': repo_name, @@ -430,7 +592,13 @@ def save_markdown_report(self, summary, report_path, timestamp): f.write(f"- **Active repositories (last year):** {summary['active_repos']}\n") f.write(f"- **Repositories with issues:** {summary['repos_with_issues']}\n") f.write(f"- **Total open PRs:** {summary['total_open_prs']}\n") - f.write(f"- **Branches with closed/merged PRs:** {len(closed_merged_rows)}\n\n") + + if deleted_branches_rows: + successful_deletions = sum(1 for b in deleted_branches_rows if b['deletion_success']) + failed_deletions = len(deleted_branches_rows) - successful_deletions + f.write(f"- **Branches deleted:** {successful_deletions} (Failed: {failed_deletions})\n\n") + else: + f.write(f"- **Branches with closed/merged PRs:** {len(closed_merged_rows)}\n\n") # Detailed table f.write("## Stale PRs and Orphaned Branches\n\n") @@ -447,13 +615,30 @@ def save_markdown_report(self, summary, report_path, timestamp): f.write(f"\n**Total items:** {len(table_rows)}\n\n") - # Closed/Merged PR Branches section - f.write("## Branches with Closed/Merged PRs\n\n") - f.write("These branches still exist but their PRs have been closed or merged. Consider deleting them.\n\n") + # Deleted Branches section (if deletion was enabled) + if deleted_branches_rows: + f.write("## Deleted Branches (Closed/Merged PRs)\n\n") + f.write("These branches were automatically deleted because their PRs were closed or merged.\n\n") + + # Markdown table + f.write("| Repository | Branch | PR | User | PR Status | Closed Date | Days Since | Deletion | Link |\n") + f.write("|------------|--------|----|----- |-----------|-------------|------------|----------|------|\n") + + for row in deleted_branches_rows: + status_emoji = "🟣" if row['status'] == 'merged' else "🔴" + deletion_emoji = "✅" if row['deletion_success'] else "❌" + deletion_status = "Deleted" if row['deletion_success'] else f"Failed: {row['deletion_message']}" + f.write(f"| {row['repo']} | {row['branch']} | PR #{row['pr_number']} | {row['user']} | {status_emoji} {row['status'].title()} | {row['closed_at']} | {row['days_since_closed']} days | {deletion_emoji} {deletion_status} | [View]({row['pr_url']}) |\n") + + successful = sum(1 for b in deleted_branches_rows if b['deletion_success']) + failed = len(deleted_branches_rows) - successful + f.write(f"\n**Total:** {len(deleted_branches_rows)} branches ({successful} deleted, {failed} failed)\n\n") - if not closed_merged_rows: - f.write("✅ No branches with closed/merged PRs found.\n\n") - else: + # Closed/Merged PR Branches section (only if deletion was NOT enabled) + elif closed_merged_rows: + f.write("## Branches with Closed/Merged PRs\n\n") + f.write("These branches still exist but their PRs have been closed or merged. Consider deleting them.\n\n") + # Markdown table f.write("| Repository | Branch | PR | User | Status | Closed Date | Days Since | Link |\n") f.write("|------------|--------|----|----- |--------|-------------|------------|------|\n") @@ -463,6 +648,9 @@ def save_markdown_report(self, summary, report_path, timestamp): f.write(f"| {row['repo']} | {row['branch']} | PR #{row['pr_number']} | {row['user']} | {status_emoji} {row['status'].title()} | {row['closed_at']} | {row['days_since_closed']} days | [View]({row['pr_url']}) |\n") f.write(f"\n**Total branches:** {len(closed_merged_rows)}\n\n") + else: + f.write("## Branches with Closed/Merged PRs\n\n") + f.write("✅ No branches with closed/merged PRs found.\n\n") # Repository details f.write("## Repository Details\n\n") @@ -510,33 +698,70 @@ def print_pretty_table(self, summary): print(f"\nTotal items: {len(table_rows)}") - # Print closed/merged PR branches + # Print deleted branches or closed/merged PR branches print(f"\n{'='*100}") - print("BRANCHES WITH CLOSED/MERGED PRs") - print(f"{'='*100}\n") - closed_merged_count = 0 - for repo in summary['repos']: - for branch in repo.get('closed_merged_pr_branches', []): - if closed_merged_count == 0: - # Print header - header = f"{'Repository':<30} | {'Branch':<25} | {'PR':<10} | {'User':<15} | {'Status':<10} | {'Days':<8} | {'Link'}" - print(header) - print("-" * len(header)) - - repo_col = repo['name'][:29] if len(repo['name']) > 29 else repo['name'] - branch_col = branch['branch'][:24] if len(branch['branch']) > 24 else branch['branch'] - user_col = branch['user'][:14] if len(branch['user']) > 14 else branch['user'] - pr_col = f"#{branch['pr_number']}" - status_col = branch['status'].title() - - print(f"{repo_col:<30} | {branch_col:<25} | {pr_col:<10} | {user_col:<15} | {status_col:<10} | {branch['days_since_closed']:<8} | {branch['pr_url']}") - closed_merged_count += 1 + # Check if we have deletion results + deleted_count = sum(len(repo.get('deleted_branches', [])) for repo in summary['repos']) - if closed_merged_count == 0: - print("No branches with closed/merged PRs found.\n") + if deleted_count > 0: + print("DELETED BRANCHES (CLOSED/MERGED PRs)") + print(f"{'='*100}\n") + + total_deleted = 0 + successful = 0 + failed = 0 + + for repo in summary['repos']: + for branch in repo.get('deleted_branches', []): + if total_deleted == 0: + # Print header + header = f"{'Repository':<30} | {'Branch':<25} | {'PR':<10} | {'Status':<10} | {'Deletion':<15} | {'Link'}" + print(header) + print("-" * len(header)) + + repo_col = repo['name'][:29] if len(repo['name']) > 29 else repo['name'] + branch_col = branch['branch'][:24] if len(branch['branch']) > 24 else branch['branch'] + pr_col = f"#{branch['pr_number']}" + status_col = branch['status'].title() + + if branch['deletion_success']: + deletion_col = "✅ Deleted" + successful += 1 + else: + deletion_col = f"❌ {branch['deletion_message'][:10]}" + failed += 1 + + print(f"{repo_col:<30} | {branch_col:<25} | {pr_col:<10} | {status_col:<10} | {deletion_col:<15} | {branch['pr_url']}") + total_deleted += 1 + + print(f"\nTotal: {total_deleted} branches ({successful} deleted, {failed} failed)") else: - print(f"\nTotal branches: {closed_merged_count}") + print("BRANCHES WITH CLOSED/MERGED PRs") + print(f"{'='*100}\n") + + closed_merged_count = 0 + for repo in summary['repos']: + for branch in repo.get('closed_merged_pr_branches', []): + if closed_merged_count == 0: + # Print header + header = f"{'Repository':<30} | {'Branch':<25} | {'PR':<10} | {'User':<15} | {'Status':<10} | {'Days':<8} | {'Link'}" + print(header) + print("-" * len(header)) + + repo_col = repo['name'][:29] if len(repo['name']) > 29 else repo['name'] + branch_col = branch['branch'][:24] if len(branch['branch']) > 24 else branch['branch'] + user_col = branch['user'][:14] if len(branch['user']) > 14 else branch['user'] + pr_col = f"#{branch['pr_number']}" + status_col = branch['status'].title() + + print(f"{repo_col:<30} | {branch_col:<25} | {pr_col:<10} | {user_col:<15} | {status_col:<10} | {branch['days_since_closed']:<8} | {branch['pr_url']}") + closed_merged_count += 1 + + if closed_merged_count == 0: + print("No branches with closed/merged PRs found.\n") + else: + print(f"\nTotal branches: {closed_merged_count}") print(f"{'='*100}\n") @@ -551,6 +776,16 @@ def main(): action='store_true', help='Output a human-friendly table of stale PRs and orphaned branches' ) + parser.add_argument( + '--delete-branches', + action='store_true', + help='Delete all orphaned branches (branches without open PRs)' + ) + parser.add_argument( + '--delete-prs', + action='store_true', + help='Close all stale PRs that exceed the threshold' + ) args = parser.parse_args() org_name = os.environ.get('GITHUB_ORG') @@ -564,7 +799,14 @@ def main(): print("WARNING: No GITHUB_TOKEN provided. API rate limits will be restrictive (60/hour).") print("Provide token for 5000 requests/hour.\n") - scanner = GitHubOrgScanner(org_name, token, pretty_print=args.pretty) + # Warn if deletion modes are enabled + if args.delete_branches: + print("⚠️ WARNING: Branch deletion mode is ENABLED. Orphaned branches will be deleted!\n") + if args.delete_prs: + print("⚠️ WARNING: PR closure mode is ENABLED. Stale PRs will be closed!\n") + + scanner = GitHubOrgScanner(org_name, token, pretty_print=args.pretty, + delete_branches=args.delete_branches, delete_prs=args.delete_prs) scanner.generate_report() if __name__ == "__main__":