diff --git a/.forgejo/workflows/branch-cleanup.yaml b/.forgejo/workflows/branch-cleanup.yaml new file mode 100644 index 0000000..afdaee7 --- /dev/null +++ b/.forgejo/workflows/branch-cleanup.yaml @@ -0,0 +1,41 @@ +# Automated Branch Cleanup +# +# Deletes remote branches that have been merged into main and are older +# than a cutoff (default 30 days). Detects both fast-forward and +# squash-merged branches via the Forgejo API. +# +# Runs on a schedule (~every 10 days) and can be triggered manually +# with a custom cutoff for testing. + +name: Branch Cleanup + +on: + schedule: + # Approximately every 10 days: 1st, 11th, 21st of each month at 06:00 UTC + - cron: '0 6 1,11,21 * *' + workflow_dispatch: + inputs: + cutoff: + description: 'Delete branches older than N days' + required: false + default: '30' + type: string + +jobs: + cleanup: + runs-on: k8s + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Run branch cleanup + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + CUTOFF="${{ inputs.cutoff || '30' }}" + echo "Running branch cleanup with cutoff=${CUTOFF} days..." + uv run --script mise-tasks/branch-cleanup \ + --remote-only \ + --yes \ + --token "$GITHUB_TOKEN" \ + --cutoff "$CUTOFF" diff --git a/mise-tasks/branch-cleanup b/mise-tasks/branch-cleanup index 72ec073..89265bf 100755 --- a/mise-tasks/branch-cleanup +++ b/mise-tasks/branch-cleanup @@ -9,6 +9,7 @@ #USAGE flag "--yes" help="Skip confirmation prompt" #USAGE flag "--local-only" help="Only clean up local branches" #USAGE flag "--remote-only" help="Only clean up remote branches" +#USAGE flag "--token " help="Forgejo API token (default: read from 1Password)" #USAGE flag "--cutoff " default="30" help="Only delete branches whose HEAD commit is older than N days (default 30)" """Clean up merged branches locally and on the Forgejo remote. @@ -16,9 +17,11 @@ Detects merged branches via two methods: 1. git branch --merged (catches fast-forward merges) 2. Forgejo API (catches squash-merged PRs) -For remote branches, deletes those confirmed merged by either method. -For local branches, deletes those that match a merged remote branch or -are detected as merged by git. +Remote branches are deleted via the Forgejo API. The token is resolved: + 1. --token flag (for CI: pass $GITHUB_TOKEN) + 2. 1Password: op read (for local use, prompts biometric) + +Local branches are deleted via git branch -D. Warns about stale local branches that couldn't be confirmed as merged. @@ -40,7 +43,9 @@ from rich.table import Table PROTECTED_BRANCHES = {"main", "master"} FORGE_API = "https://forge.ops.eblu.me/api/v1" -REPO = "eblume/blumeops" +REPO_OWNER = "eblume" +REPO_NAME = "blumeops" +OP_TOKEN_REF = "op://vg6xf6vvfmoh5hqjjhlhbeoaie/w3663ffnvkewbftncqxtcpeavy/api-token" def run_git(*args: str) -> str: @@ -54,6 +59,25 @@ def run_git(*args: str) -> str: return result.stdout.strip() +def resolve_token(explicit_token: str | None, console: Console) -> str: + """Resolve Forgejo API token: explicit flag > 1Password.""" + if explicit_token: + return explicit_token + console.print("[dim]Reading Forgejo API token from 1Password...[/dim]") + try: + result = subprocess.run( + ["op", "read", OP_TOKEN_REF], + capture_output=True, + text=True, + check=True, + ) + return result.stdout.strip() + except (subprocess.CalledProcessError, FileNotFoundError) as e: + console.print(f"[red]Failed to read token from 1Password:[/red] {e}") + console.print("[dim]Pass --token explicitly or ensure op CLI is available[/dim]") + raise typer.Exit(1) + + def branch_head_age_days(ref: str) -> int | None: """Get the age in days of the HEAD commit on a branch ref.""" try: @@ -66,6 +90,15 @@ def branch_head_age_days(ref: str) -> int | None: return None +def api_branch_age_days(commit_date_str: str) -> int | None: + """Compute age in days from an ISO date string.""" + try: + commit_date = datetime.fromisoformat(commit_date_str) + return (datetime.now(timezone.utc) - commit_date).days + except (ValueError, TypeError): + return None + + def get_git_merged_local_branches() -> set[str]: """Get local branches that are fully merged into main (fast-forward).""" try: @@ -110,49 +143,52 @@ def get_all_local_branches() -> set[str]: return branches -def get_all_remote_branches() -> set[str]: - """Get all remote branch names (short form, without origin/ prefix).""" - output = run_git("branch", "-r") - branches = set() - for line in output.splitlines(): - name = line.strip() - if " -> " in name: - continue - if not name.startswith("origin/"): - continue - short = name.removeprefix("origin/") - if short not in PROTECTED_BRANCHES: - branches.add(short) +def get_api_branches(client: httpx.Client) -> dict[str, str]: + """Get all remote branches via API. Returns {name: commit_date_iso}.""" + branches: dict[str, str] = {} + page = 1 + limit = 50 + while True: + resp = client.get( + f"{FORGE_API}/repos/{REPO_OWNER}/{REPO_NAME}/branches", + params={"limit": limit, "page": page}, + ) + resp.raise_for_status() + data = resp.json() + if not data: + break + for branch in data: + name = branch["name"] + if name not in PROTECTED_BRANCHES: + date = branch.get("commit", {}).get("timestamp", "") + branches[name] = date + page += 1 return branches -def get_squash_merged_branches(console: Console) -> set[str]: - """Query Forgejo API for branch names from squash-merged PRs.""" +def get_merged_pr_branches(client: httpx.Client, console: Console) -> set[str]: + """Query Forgejo API for branch names from merged PRs.""" merged_branches: set[str] = set() page = 1 limit = 50 - try: - with httpx.Client(timeout=15) as client: - while True: - resp = client.get( - f"{FORGE_API}/repos/{REPO}/pulls", - params={"state": "closed", "limit": limit, "page": page}, - ) - resp.raise_for_status() - prs = resp.json() - if not prs: - break - for pr in prs: - if pr.get("merged"): - ref = pr.get("head", {}).get("ref", "") - if ref and ref not in PROTECTED_BRANCHES: - merged_branches.add(ref) - page += 1 + while True: + resp = client.get( + f"{FORGE_API}/repos/{REPO_OWNER}/{REPO_NAME}/pulls", + params={"state": "closed", "limit": limit, "page": page}, + ) + resp.raise_for_status() + prs = resp.json() + if not prs: + break + for pr in prs: + if pr.get("merged"): + ref = pr.get("head", {}).get("ref", "") + if ref and ref not in PROTECTED_BRANCHES: + merged_branches.add(ref) + page += 1 except httpx.HTTPError as e: - console.print(f"[yellow]Warning:[/yellow] Failed to query Forgejo API: {e}") - console.print("[dim]Falling back to git-only merge detection[/dim]") - + console.print(f"[yellow]Warning:[/yellow] Failed to query merged PRs: {e}") return merged_branches @@ -166,13 +202,16 @@ def delete_local_branch(branch: str) -> tuple[bool, str]: return False, e.stderr.strip() -def delete_remote_branch(branch: str) -> tuple[bool, str]: - """Delete a remote branch via git push. Returns (success, message).""" - try: - run_git("push", "origin", "--delete", branch) +def delete_remote_branch_api( + client: httpx.Client, branch: str, +) -> tuple[bool, str]: + """Delete a remote branch via Forgejo API. Returns (success, message).""" + resp = client.delete( + f"{FORGE_API}/repos/{REPO_OWNER}/{REPO_NAME}/branches/{branch}", + ) + if resp.status_code == 204: return True, "deleted" - except subprocess.CalledProcessError as e: - return False, e.stderr.strip() + return False, f"HTTP {resp.status_code}: {resp.text[:120]}" app = typer.Typer(add_completion=False) @@ -200,158 +239,175 @@ def main( bool, typer.Option("--remote-only", help="Only clean up remote branches"), ] = False, + token: Annotated[ + str | None, + typer.Option("--token", help="Forgejo API token (default: read from 1Password)"), + ] = None, ) -> None: """Delete branches that have been merged into main.""" console = Console() - # Fetch latest remote state - console.print("[dim]Fetching remote branches...[/dim]") - try: - run_git("fetch", "--prune", "origin") - except subprocess.CalledProcessError as e: - console.print(f"[red]Failed to fetch:[/red] {e.stderr}") - raise typer.Exit(1) - - # Gather merge info from both sources - console.print("[dim]Checking Forgejo for squash-merged PRs...[/dim]") - api_merged = get_squash_merged_branches(console) - git_merged_local = get_git_merged_local_branches() - git_merged_remote = get_git_merged_remote_branches() - - # Union of all confirmed-merged branch names - all_confirmed_merged = api_merged | git_merged_local | git_merged_remote - do_local = not remote_only do_remote = not local_only - # Remote: delete if confirmed merged by either method - all_remote = get_all_remote_branches() if do_remote else set() - remote_to_delete = sorted(all_remote & all_confirmed_merged) + # Resolve token (needed for API branch listing and deletion) + api_token = resolve_token(token, console) if do_remote else None - # Local: delete if confirmed merged (git --merged OR matched a merged PR) - all_local = get_all_local_branches() if do_local else set() - local_to_delete = sorted(all_local & all_confirmed_merged) - - # Compute ages for all candidates - all_candidates = sorted(set(local_to_delete) | set(remote_to_delete)) - branch_ages: dict[str, int | None] = {} - for name in all_candidates: - age = None - if name in all_remote: - age = branch_head_age_days(f"origin/{name}") - if age is None and name in all_local: - age = branch_head_age_days(name) - branch_ages[name] = age - - # Apply cutoff filter - local_to_delete = [ - b for b in local_to_delete - if branch_ages.get(b) is not None and branch_ages[b] >= cutoff # type: ignore[operator] - ] - remote_to_delete = [ - b for b in remote_to_delete - if branch_ages.get(b) is not None and branch_ages[b] >= cutoff # type: ignore[operator] - ] - - filtered_candidates = sorted(set(local_to_delete) | set(remote_to_delete)) - - skipped_count = len(all_candidates) - len(filtered_candidates) - - if filtered_candidates: - # Build summary table - table = Table(title=f"Merged branches to delete (older than {cutoff} days)") - table.add_column("Branch") - table.add_column("Age (days)", justify="right") - table.add_column("Method") - table.add_column("Local") - table.add_column("Remote") - - for branch in filtered_candidates: - has_local = branch in local_to_delete - has_remote = branch in remote_to_delete - age = branch_ages.get(branch) - age_str = str(age) if age is not None else "?" - - methods = [] - if branch in git_merged_local or branch in git_merged_remote: - methods.append("git") - if branch in api_merged: - methods.append("api") - method_str = "+".join(methods) - - table.add_row( - branch, - age_str, - method_str, - "[yellow]delete[/yellow]" if has_local else "[dim]-[/dim]", - "[yellow]delete[/yellow]" if has_remote else "[dim]-[/dim]", - ) - - console.print(table) - console.print( - f"\n[bold]{len(local_to_delete)}[/bold] local, " - f"[bold]{len(remote_to_delete)}[/bold] remote branches to delete" - ) - if skipped_count: - console.print(f"[dim]({skipped_count} merged branches skipped — " - f"newer than {cutoff} days)[/dim]") - else: - console.print(f"[green]No merged branches older than " - f"{cutoff} days to clean up.[/green]") - if skipped_count: - console.print(f"[dim]({skipped_count} merged branches skipped — " - f"newer than {cutoff} days)[/dim]") - - # Warn about stale unmerged local branches + # Fetch latest remote state for local branch operations if do_local: - unmerged_local = all_local - all_confirmed_merged - stale_unmerged = [] - for name in sorted(unmerged_local): - age = branch_head_age_days(name) - if age is not None and age >= cutoff: - stale_unmerged.append((name, age)) + console.print("[dim]Fetching remote branches...[/dim]") + try: + run_git("fetch", "--prune", "origin") + except subprocess.CalledProcessError as e: + console.print(f"[yellow]Warning:[/yellow] git fetch failed: {e.stderr}") - if stale_unmerged: - console.print() - warn_table = Table( - title=f"[yellow]Warning:[/yellow] Stale unmerged local branches " - f"(older than {cutoff} days)", - title_style="", - ) - warn_table.add_column("Branch") - warn_table.add_column("Age (days)", justify="right") - for name, age in stale_unmerged: - warn_table.add_row(f"[yellow]{name}[/yellow]", str(age)) - console.print(warn_table) + # Gather merge info + console.print("[dim]Checking Forgejo for squash-merged PRs...[/dim]") + with httpx.Client( + timeout=15, + headers={"Authorization": f"token {api_token}"} if api_token else {}, + ) as client: + api_merged = get_merged_pr_branches(client, console) + git_merged_local = get_git_merged_local_branches() if do_local else set() + git_merged_remote = get_git_merged_remote_branches() if do_local else set() + + # Union of all confirmed-merged branch names + all_confirmed_merged = api_merged | git_merged_local | git_merged_remote + + # Remote branches and ages via API + remote_branch_ages: dict[str, int | None] = {} + if do_remote: + console.print("[dim]Listing remote branches via API...[/dim]") + api_branches = get_api_branches(client) + for name, date_str in api_branches.items(): + remote_branch_ages[name] = api_branch_age_days(date_str) + all_remote = set(api_branches.keys()) + else: + all_remote = set() + + remote_to_delete = sorted(all_remote & all_confirmed_merged) + + # Local branches + all_local = get_all_local_branches() if do_local else set() + local_to_delete = sorted(all_local & all_confirmed_merged) + + # Compute ages for all candidates + all_candidates = sorted(set(local_to_delete) | set(remote_to_delete)) + branch_ages: dict[str, int | None] = {} + for name in all_candidates: + age = remote_branch_ages.get(name) + if age is None and name in all_local: + age = branch_head_age_days(name) + branch_ages[name] = age + + # Apply cutoff filter + local_to_delete = [ + b for b in local_to_delete + if branch_ages.get(b) is not None and branch_ages[b] >= cutoff # type: ignore[operator] + ] + remote_to_delete = [ + b for b in remote_to_delete + if branch_ages.get(b) is not None and branch_ages[b] >= cutoff # type: ignore[operator] + ] + + filtered_candidates = sorted(set(local_to_delete) | set(remote_to_delete)) + skipped_count = len(all_candidates) - len(filtered_candidates) + + if filtered_candidates: + table = Table(title=f"Merged branches to delete (older than {cutoff} days)") + table.add_column("Branch") + table.add_column("Age (days)", justify="right") + table.add_column("Method") + table.add_column("Local") + table.add_column("Remote") + + for branch in filtered_candidates: + has_local = branch in local_to_delete + has_remote = branch in remote_to_delete + age = branch_ages.get(branch) + age_str = str(age) if age is not None else "?" + + methods = [] + if branch in git_merged_local or branch in git_merged_remote: + methods.append("git") + if branch in api_merged: + methods.append("api") + method_str = "+".join(methods) + + table.add_row( + branch, + age_str, + method_str, + "[yellow]delete[/yellow]" if has_local else "[dim]-[/dim]", + "[yellow]delete[/yellow]" if has_remote else "[dim]-[/dim]", + ) + + console.print(table) console.print( - f"[dim]These {len(stale_unmerged)} branches have no merged PR on " - f"Forgejo and are not git-ancestors of main.\n" - f"They may contain work-in-progress — inspect manually before deleting.[/dim]" + f"\n[bold]{len(local_to_delete)}[/bold] local, " + f"[bold]{len(remote_to_delete)}[/bold] remote branches to delete" ) + if skipped_count: + console.print(f"[dim]({skipped_count} merged branches skipped — " + f"newer than {cutoff} days)[/dim]") + else: + console.print(f"[green]No merged branches older than " + f"{cutoff} days to clean up.[/green]") + if skipped_count: + console.print(f"[dim]({skipped_count} merged branches skipped — " + f"newer than {cutoff} days)[/dim]") - if not filtered_candidates: - raise typer.Exit(0) + # Warn about stale unmerged local branches + if do_local: + unmerged_local = all_local - all_confirmed_merged + stale_unmerged = [] + for name in sorted(unmerged_local): + age = branch_head_age_days(name) + if age is not None and age >= cutoff: + stale_unmerged.append((name, age)) - if dry_run: - console.print("\n[dim]Dry run — no branches were deleted.[/dim]") - raise typer.Exit(0) + if stale_unmerged: + console.print() + warn_table = Table( + title=f"[yellow]Warning:[/yellow] Stale unmerged local branches " + f"(older than {cutoff} days)", + title_style="", + ) + warn_table.add_column("Branch") + warn_table.add_column("Age (days)", justify="right") + for name, age in stale_unmerged: + warn_table.add_row(f"[yellow]{name}[/yellow]", str(age)) + console.print(warn_table) + console.print( + f"[dim]These {len(stale_unmerged)} branches have no merged PR on " + f"Forgejo and are not git-ancestors of main.\n" + f"They may contain work-in-progress — inspect manually " + f"before deleting.[/dim]" + ) - # Confirm - if not yes and not typer.confirm("\nProceed with deletion?"): - console.print("[dim]Aborted.[/dim]") - raise typer.Exit(0) + if not filtered_candidates: + raise typer.Exit(0) - # Delete remote branches first (source of truth) - if remote_to_delete: - console.print("\n[bold]Deleting remote branches...[/bold]") - for branch in remote_to_delete: - ok, msg = delete_remote_branch(branch) - if ok: - console.print(f" [green]✓[/green] origin/{branch}") - else: - console.print(f" [red]✗[/red] origin/{branch}: {msg}") + if dry_run: + console.print("\n[dim]Dry run — no branches were deleted.[/dim]") + raise typer.Exit(0) - # Delete local branches + # Confirm + if not yes and not typer.confirm("\nProceed with deletion?"): + console.print("[dim]Aborted.[/dim]") + raise typer.Exit(0) + + # Delete remote branches via API + if remote_to_delete: + console.print("\n[bold]Deleting remote branches...[/bold]") + for branch in remote_to_delete: + ok, msg = delete_remote_branch_api(client, branch) + if ok: + console.print(f" [green]✓[/green] origin/{branch}") + else: + console.print(f" [red]✗[/red] origin/{branch}: {msg}") + + # Delete local branches (outside httpx client context) if local_to_delete: console.print("\n[bold]Deleting local branches...[/bold]") for branch in local_to_delete: