Files
timmy-config/bin/enable-branch-protection.py
Step35 1b97aa395d
Some checks failed
Smoke Test / smoke (pull_request) Failing after 19s
Architecture Lint / Linter Tests (pull_request) Successful in 22s
Validate Config / YAML Lint (pull_request) Failing after 15s
Validate Config / JSON Validate (pull_request) Successful in 18s
Validate Config / Python Syntax & Import Check (pull_request) Failing after 57s
Validate Config / Python Test Suite (pull_request) Has been skipped
Validate Config / Shell Script Lint (pull_request) Failing after 1m0s
Validate Config / Cron Syntax Check (pull_request) Successful in 11s
Validate Config / Deploy Script Dry Run (pull_request) Successful in 11s
Validate Config / Playbook Schema Validation (pull_request) Successful in 22s
Architecture Lint / Lint Repository (pull_request) Failing after 22s
PR Checklist / pr-checklist (pull_request) Successful in 4m20s
[AUDIT][RISK] Enforce branch protection — agents merge before review
Add set_branch_protection() and related methods to GiteaClient.

Fixes #482 via three-way enforcement:
1. gitea_client.py: Added get/set/delete_branch_protection() API wrappers
2. bin/enable-branch-protection.py: Idempotent script protects main branches
   of all Timmy_Foundation core repos with:
   ── required_approvals: 1 (at least one human review)
   ── require_status_checks: true (CI must pass)
   ── restrict_merge: true (only admins + reviewers can merge)
3. tests/test_gitea_client_core.py: Added TestBranchProtection suite

Usage: after merging, run:
  bin/enable-branch-protection.py --dry-run  # verify
  bin/enable-branch-protection.py             # apply to all core repos

This prevents agents from merging their own PRs before human review.
2026-04-26 12:00:23 -04:00

141 lines
4.0 KiB
Python
Executable File

#!/usr/bin/env python3
"""
enable-branch-protection — Apply branch protection to Timmy_Foundation repos.
Enforces issue #482: agents must not merge before review.
Applies identical protection rules to the main branch of core repos:
- required_approvals: 1 (at least one human review)
- require_status_checks: true (CI must pass)
- restrict_merge: true (only admins + designated reviewers can merge)
Exit codes:
0 — all requested branches protected (or already protected)
1 — at least one branch could not be protected
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO_ROOT))
from gitea_client import GiteaClient
# Core Timmy Foundation repositories
CORE_REPOS = [
"Timmy_Foundation/the-nexus",
"Timmy_Foundation/timmy-home",
"Timmy_Foundation/timmy-config",
"Timmy_Foundation/hermes-agent",
"Timmy_Foundation/.profile",
"Timmy_Foundation/the-door",
"Timmy_Foundation/turboquant",
]
PROTECTION_RULE = {
"required_approvals": 1,
"require_status_checks": True,
"restrict_merge": True,
"dismiss_stale_reviews": False,
"require_owner_approval": False,
"allow_force_pushes": False,
"allow_deletions": False,
}
def parse_args(argv: list[str]) -> argparse.Namespace:
p = argparse.ArgumentParser(
description="Apply branch protection to core repos (Closes #482)"
)
p.add_argument(
"--branch",
default="main",
help="Branch to protect (default: main)",
)
p.add_argument(
"--repo",
action="append",
help="Limit to specific repo(s); may be repeated. Overrides CORE_REPOS.",
)
p.add_argument(
"--dry-run",
action="store_true",
help="Show what would change without making API calls",
)
p.add_argument(
"--verbose",
action="store_true",
help="Print detailed progress",
)
return p.parse_args(argv)
def protection_matches(current: dict[str, object], desired: dict[str, object]) -> bool:
for key, val in desired.items():
if current.get(key) != val:
return False
return True
def protect_branch(
client: GiteaClient,
repo: str,
branch: str,
dry_run: bool = False,
verbose: bool = False,
) -> tuple[bool, str]:
try:
current = client.get_branch_protection(repo, branch)
if current:
if protection_matches(current, PROTECTION_RULE):
return True, f"{repo}/{branch} already protected"
if dry_run:
return True, f"{repo}/{branch} would be updated (rule differs)"
client.set_branch_protection(repo, branch, **PROTECTION_RULE)
return True, f"{repo}/{branch} protection updated"
else:
if dry_run:
return True, f"+ {repo}/{branch} would be protected"
client.set_branch_protection(repo, branch, **PROTECTION_RULE)
return True, f"{repo}/{branch} now protected"
except Exception as e:
return False, f"{repo}/{branch}: {e}"
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv or sys.argv[1:])
repos = args.repo if args.repo else CORE_REPOS
branch = args.branch
client = GiteaClient()
if not args.dry_run:
try:
client._get("/version")
except Exception as e:
print(f"ERROR: Cannot reach Gitea: {e}", file=sys.stderr)
return 1
all_ok = True
for repo in repos:
ok, msg = protect_branch(client, repo, branch, dry_run=args.dry_run, verbose=args.verbose)
print(msg)
if not ok:
all_ok = False
if all_ok:
print(f"\nDone. {len(repos)} branch{'s' if len(repos)!=1 else ''} checked.")
return 0
else:
print("\nSome branches failed. Review errors above.", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())