Files
cleveragents-core/tools/forgejo_audit.py
T
drew 73581e0f19 feat(auto-agents): personal-fork sentinel testing scaffolding
Establishes a fully-isolated test surface for the deterministic
auto-agents pipeline so the existing review / implementation / merge
supervisors never touch sentinel PRs. Achieves isolation by leveraging
what the existing supervisors *already* ignore: PRs in any repo other
than cleveragents/cleveragents-core. The supervisors hard-code their
--owner / --repo flags through the list_prs_* wrappers, so PRs in a
fork are absolutely invisible to them. No agent or prompt change is
required — this is entirely additive.

Env-var refactor (8 tools)
  All eight pipeline tools now read FORGEJO_OWNER / FORGEJO_REPO /
  FORGEJO_API_BASE (and where applicable FORGEJO_ORG /
  FORGEJO_DEFAULT_BRANCH / FORGEJO_MERGE_BOT_EMAILS /
  FORGEJO_PUSH_WHITELIST) at module-import time:

    merge_drive.py, verify_invariant.py, forgejo_audit.py,
    audit_branch_protection.py, flag_stale_prs.py,
    setup_auto_labels.py, setup_branch_protection.py,
    migrate_to_new_driver.py

  Defaults preserve canonical-repo behaviour. Setting the env vars
  retargets the entire stack at a fork:

    export FORGEJO_OWNER=drew
    export FORGEJO_REPO=cleveragents-core
    export FORGEJO_PUSH_WHITELIST=drew

  verify_invariant.py's cursor file is now partitioned by
  <owner>.<repo> so a fork run never clobbers the canonical cursor;
  the default canonical filename is preserved for backward
  compatibility.

tools/setup_test_fork.py (new)
  One-shot fork bootstrap. Identifies the calling user via GET /user,
  creates the fork via POST /repos/{upstream}/forks if absent, applies
  a minimum-viable branch-protection rule on the fork's master, and
  provisions the canonical auto/* label set at the repo scope (forks
  do not inherit org-level labels). Idempotent. --dry-run projects
  what would change without mutating Forgejo.

tools/duplicate_prs_to_fork.py (new)
  Duplicates the N most recent open upstream PRs into the personal
  fork: fetches each head ref, pushes it to the fork as
  tests/sentinel-<N>-<safe-branch>, opens a sentinel PR titled
  '[sentinel #<N>] <upstream-title>' with the auto/sentinel label,
  and assigns to the calling user (or --assignee). Blacklists every
  operational auto/* label so sentinels start automation-clean.
  Flags: --count, --pr <N> (repeatable), --fork-owner, --assignee,
  --workdir, --no-reset-master, --dry-run.

auto/sentinel label
  Added to setup_auto_labels.py so the fork bootstrap creates it.
  Identifies sentinel PRs in the fork.

tools/preflight_phase01.sh
  Banner now surfaces the Forgejo target (FORGEJO_OWNER/FORGEJO_REPO)
  and warns when targeting a non-canonical repo. The harness itself
  was already env-driven via the underlying Python tools.

47 new unit tests
  test_env_var_overrides.py (22 tests) — every tool's env-var
  resolution + canonical defaults + cursor-path partitioning.
  test_setup_test_fork.py (11 tests) — fork bootstrap pure-logic +
  API-mocked happy / dry-run / failure paths.
  test_duplicate_prs_to_fork.py (14 tests) — filter_labels,
  existing_fork_pr, duplicate_one (skip / dry-run / created /
  failed), run() orchestration with mocked git + API.

  tests/auto_agents/ count: 108 -> 155 passing.

AGENTS.md
  New 'Targeting a personal-fork test repo' how-to documenting the
  env-var override pattern and explaining why the existing
  supervisors are absolutely-isolated from a fork without any agent
  change. Operational tooling list extended with setup_test_fork.py
  and duplicate_prs_to_fork.py. auto/* label registry includes the
  new auto/sentinel row.

Verification
  pytest tests/auto_agents: 155/155 passing.
  setup_test_fork.py --dry-run against production: identifies
    drew/cleveragents-core fork (not yet created) and projects the
    full label set + branch-protection create plan.
  duplicate_prs_to_fork.py --dry-run: correctly aborts with a clear
    'fork does not exist; run setup_test_fork.py first' message.

CHANGELOG entry added under [Unreleased] / Added.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-04 11:37:55 -04:00

565 lines
22 KiB
Python
Executable File

#!/usr/bin/env python3
"""Tier 0A — Forgejo behaviour audit (read-only).
Implements the audit described in
``.cursor/plans/improve_auto-agents_pipeline_a31d3945.plan.md`` (Tier 0A).
This script performs every read-only check that does not require creating
test PRs or branches against the production repo. Empirical pre-condition
verification (creating real test PRs to observe ``merge_when_checks_succeed``
behaviour, the ``head_commit_id`` 409 path, and umbrella auto-close) is
documented in the report as a manual follow-up — it intentionally cannot be
automated against a production repo.
Outputs
-------
A structured Markdown report on stdout (or to ``--output PATH``) suitable
for pasting into AGENTS.md under the "Merge invariant" section. Includes:
- Branch protection state for ``master`` (``dismiss_stale_approvals``,
required status contexts, required approvals, push allow-list).
- Canonical merge-bot identity inferred from recent merge commits.
- ``merge_pr.ts`` configuration summary (Do strategy, head_commit_id usage,
merge_when_checks_succeed semantics).
- Pre-condition verification checklist with status (``read-only-pass`` /
``empirical-todo`` / ``unknown``).
Usage
-----
::
# Read-only audit, report to stdout
python3 tools/forgejo_audit.py
# Write the report to a specific path
python3 tools/forgejo_audit.py --output ./audit-report.md
# JSON output for programmatic consumption
python3 tools/forgejo_audit.py --format json
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import urllib.error
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
# ─── Config ─────────────────────────────────────────────────────────────────
REPO_OWNER = os.environ.get("FORGEJO_OWNER", "cleveragents")
REPO_NAME = os.environ.get("FORGEJO_REPO", "cleveragents-core")
API_BASE = os.environ.get(
"FORGEJO_API_BASE", "https://git.cleverthis.com/api/v1"
).rstrip("/")
DEFAULT_BRANCH = os.environ.get("FORGEJO_DEFAULT_BRANCH", "master")
REQUEST_TIMEOUT_SEC = 30
def load_token() -> str:
"""Pull GITEA_TOKEN from environment or .devcontainer/.env / .env."""
token = os.environ.get("GITEA_TOKEN")
if token:
return token
repo_root = Path(__file__).resolve().parent.parent
for candidate in (repo_root / ".devcontainer/.env", repo_root / ".env"):
if candidate.exists():
for line in candidate.read_text().splitlines():
m = re.match(r'^\s*GITEA_TOKEN\s*=\s*"?([^"#\s]+)"?', line)
if m:
return m.group(1)
raise RuntimeError(
"GITEA_TOKEN not found in environment or .devcontainer/.env or .env"
)
def api_get(path: str, token: str) -> Any:
url = f"{API_BASE}{path}" if path.startswith("/") else path
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
try:
with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT_SEC) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
# The branch protection endpoint returns 404 if no rule exists — caller handles.
return {"_http_error": e.code, "_message": e.read().decode("utf-8", "replace")}
# ─── Audit subroutines ─────────────────────────────────────────────────────
def audit_branch_protection(token: str) -> dict[str, Any]:
"""Read ``branch_protections/master`` and summarise the load-bearing fields."""
bp = api_get(
f"/repos/{REPO_OWNER}/{REPO_NAME}/branch_protections/{DEFAULT_BRANCH}",
token,
)
if isinstance(bp, dict) and "_http_error" in bp:
return {
"exists": False,
"http_error": bp["_http_error"],
"message": bp["_message"],
}
# Forgejo's branch protection schema uses snake_case — pull only the fields
# the merge-driver and 2E will actually read.
return {
"exists": True,
"branch_name": bp.get("branch_name"),
"dismiss_stale_approvals": bp.get("dismiss_stale_approvals"),
"required_approvals": bp.get("required_approvals"),
"enable_status_check": bp.get("enable_status_check"),
"status_check_contexts": bp.get("status_check_contexts") or [],
"block_on_official_review_requests": bp.get(
"block_on_official_review_requests"
),
"block_on_outdated_branch": bp.get("block_on_outdated_branch"),
"block_on_rejected_reviews": bp.get("block_on_rejected_reviews"),
"enable_push": bp.get("enable_push"),
"enable_push_whitelist": bp.get("enable_push_whitelist"),
"push_whitelist_usernames": bp.get("push_whitelist_usernames") or [],
"merge_whitelist_usernames": bp.get("merge_whitelist_usernames") or [],
"approvals_whitelist_usernames": bp.get("approvals_whitelist_usernames")
or [],
}
def audit_merge_bot_identity(token: str, sample: int = 50) -> dict[str, Any]:
"""Walk recent master commits and tally distinct committers + their emails.
Most-frequent committer on merge commits is the canonical merge-bot
identity. Any direct push (the merge bot is *not* the committer) shows up
as a separate identity.
"""
commits = api_get(
f"/repos/{REPO_OWNER}/{REPO_NAME}/commits"
f"?sha={DEFAULT_BRANCH}&limit={sample}&page=1",
token,
)
if not isinstance(commits, list):
return {"error": "unable to read recent commits", "raw": commits}
by_email: dict[str, dict[str, Any]] = {}
for c in commits:
commit = c.get("commit", {}) or {}
committer = commit.get("committer", {}) or {}
email = committer.get("email") or ""
if not email:
continue
slot = by_email.setdefault(
email,
{
"email": email,
"name": committer.get("name", ""),
"count": 0,
"sample_subject": (commit.get("message") or "").splitlines()[0]
if commit.get("message")
else "",
},
)
slot["count"] += 1
ranked = sorted(by_email.values(), key=lambda d: -d["count"])
canonical = ranked[0] if ranked else None
return {
"sample_size": sample,
"actual_commits_seen": len(commits) if isinstance(commits, list) else 0,
"canonical_merge_bot": canonical,
"all_committers": ranked,
}
def audit_merge_pr_ts() -> dict[str, Any]:
"""Statically inspect ``merge_pr.ts`` and extract the load-bearing fields."""
path = (
Path(__file__).resolve().parent.parent
/ ".opencode/skills/auto-agents-system/scripts/merge_pr.ts"
)
if not path.exists():
return {"exists": False, "path": str(path)}
src = path.read_text()
findings: dict[str, Any] = {"exists": True, "path": str(path)}
findings["uses_do_rebase"] = bool(re.search(r"Do:\s*'rebase'", src))
findings["uses_do_merge"] = bool(re.search(r"Do:\s*'merge'", src))
findings["uses_merge_when_checks_succeed_true"] = bool(
re.search(r"merge_when_checks_succeed:\s*true", src)
)
findings["uses_merge_when_checks_succeed_false"] = bool(
re.search(r"merge_when_checks_succeed:\s*false", src)
)
findings["passes_head_commit_id"] = bool(
re.search(r"head_commit_id:\s*pr\.head\.sha", src)
)
findings["handles_409"] = "case 409" in src
findings["handles_405"] = "case 405" in src
findings["risk_summary"] = (
"merge_when_checks_succeed=true is set unconditionally; "
"if Forgejo JIT-rebases on auto-merge fire, the merged SHA is untested "
"against current master. Verified empirically in 0A."
)
return findings
def audit_can_create_test_branch(token: str) -> dict[str, Any]:
"""Heuristic check that we have permission to create a test branch.
Round 7 critique #8: the production repo must allow a long-running
``tests/integration-train`` test branch with branch protection mirroring
master.
"""
# We only check that the repo is writable by the token. The actual
# branch-creation step is destructive and is left as a manual follow-up.
repo = api_get(f"/repos/{REPO_OWNER}/{REPO_NAME}", token)
if isinstance(repo, dict) and "_http_error" in repo:
return {"can_check": False, "error": repo}
permissions = (repo or {}).get("permissions", {}) or {}
return {
"can_check": True,
"repo_full_name": (repo or {}).get("full_name"),
"can_admin": permissions.get("admin", False),
"can_push": permissions.get("push", False),
"can_pull": permissions.get("pull", False),
"manual_followup": (
"Confirm manually that a tests/integration-train branch with "
"branch protection mirroring master is acceptable for the 2D test "
"plan. This requires admin access; record outcome in AGENTS.md."
),
}
def audit_repo_settings(token: str) -> dict[str, Any]:
"""Surface repo-level toggles that affect the merge driver."""
repo = api_get(f"/repos/{REPO_OWNER}/{REPO_NAME}", token)
if isinstance(repo, dict) and "_http_error" in repo:
return {"error": repo}
return {
"default_branch": (repo or {}).get("default_branch"),
"default_delete_branch_after_merge": (repo or {}).get(
"default_delete_branch_after_merge"
),
"allow_rebase": (repo or {}).get("allow_rebase"),
"allow_merge_commits": (repo or {}).get("allow_merge_commits"),
"allow_squash_merge": (repo or {}).get("allow_squash_merge"),
"allow_rebase_explicit": (repo or {}).get("allow_rebase_explicit"),
"allow_rebase_update": (repo or {}).get("allow_rebase_update"),
}
# ─── Pre-condition checklist ───────────────────────────────────────────────
PRECONDITIONS: list[dict[str, str]] = [
{
"id": "P1",
"title": "Do='rebase' + merge_when_checks_succeed=false + head_commit_id "
"merges the branch as-is (linear history, no server-side re-rebase).",
"verification": "empirical",
"how": "Create a fresh PR; rebase; capture HEAD SHA; call merge endpoint with "
"those exact params; confirm the merged SHA on master equals the "
"head_commit_id passed.",
},
{
"id": "P2",
"title": "head_commit_id optimistic lock returns HTTP 409 on stale value.",
"verification": "empirical",
"how": "Schedule a merge with a valid head_commit_id; push another commit to "
"the head branch before the merge fires; confirm 409 returned.",
},
{
"id": "P3",
"title": "Originals auto-close as merged when an umbrella train branch merges "
"(via --no-ff, head SHAs reachable in master).",
"verification": "empirical",
"how": "Verified during 2D rollout with a real two-PR train. If Forgejo does "
"not auto-close, the merge driver explicitly PATCHes /pulls/<N> with "
'{state: "closed"} for each constituent.',
},
{
"id": "P4",
"title": "nox -e lint/typecheck/unit_tests/integration_tests/e2e_tests/coverage_report "
"is the complete set of required CI checks.",
"verification": "read-only",
"how": "Cross-check against branch_protections/master.status_check_contexts.",
},
{
"id": "P5",
"title": "DELETE /api/v1/repos/{owner}/{repo}/pulls/{index}/merge cancels a "
"scheduled merge_when_checks_succeed.",
"verification": "empirical",
"how": "Schedule auto-merge on a fresh PR; DELETE the merge endpoint; confirm "
"the schedule is gone (subsequent push does not trigger a merge).",
},
]
# ─── Reporting ──────────────────────────────────────────────────────────────
def render_markdown_report(audit: dict[str, Any]) -> str:
"""Render the audit results as a Markdown report."""
bp = audit["branch_protection"]
bot = audit["merge_bot_identity"]
mp = audit["merge_pr_ts"]
test_branch = audit["test_branch_permissions"]
repo = audit["repo_settings"]
out: list[str] = []
out.append("# Tier 0A — Forgejo Behaviour Audit Report")
out.append("")
out.append(f"_Generated: {audit['generated_at']}_")
out.append(f"_Repo: {REPO_OWNER}/{REPO_NAME}_")
out.append(f"_Tool: tools/forgejo_audit.py_")
out.append("")
out.append("## Branch protection (master)")
out.append("")
if not bp.get("exists"):
out.append(
f"- **No branch protection rule exists on `master`** "
f"(HTTP {bp.get('http_error')}). Driver MUST refuse to start in "
f"this state. Create a rule with required status checks before "
f"shipping 0B."
)
else:
out.append(f"- `dismiss_stale_approvals`: `{bp.get('dismiss_stale_approvals')}`")
out.append(f"- `required_approvals`: `{bp.get('required_approvals')}`")
out.append(f"- `enable_status_check`: `{bp.get('enable_status_check')}`")
out.append(
f"- `status_check_contexts`: "
f"`{bp.get('status_check_contexts')}`"
)
out.append(
f"- `block_on_official_review_requests`: "
f"`{bp.get('block_on_official_review_requests')}`"
)
out.append(
f"- `block_on_outdated_branch`: `{bp.get('block_on_outdated_branch')}`"
)
out.append(f"- `enable_push`: `{bp.get('enable_push')}`")
out.append(
f"- `enable_push_whitelist`: `{bp.get('enable_push_whitelist')}`"
)
out.append(
f"- `push_whitelist_usernames`: "
f"`{bp.get('push_whitelist_usernames')}`"
)
out.append(
f"- `merge_whitelist_usernames`: "
f"`{bp.get('merge_whitelist_usernames')}`"
)
out.append("")
out.append("### 1A decision input (Jeff)")
out.append("")
flag = bp.get("dismiss_stale_approvals") if bp.get("exists") else None
if flag is True:
out.append(
"- `dismiss_stale_approvals` is currently **ON**. The plan recommends "
"flipping to `false` so rebases do not invalidate prior approvals. "
"**Ask Jeff: flip the setting, or build the `reapprove_fallback`?** "
"Record the decision below before 0B can ship."
)
elif flag is False:
out.append(
"- `dismiss_stale_approvals` is already **OFF**. No action needed; "
"0B's throughput targets hold without the reapprove-if-clean-rebase "
"fallback."
)
else:
out.append(
"- `dismiss_stale_approvals` could not be determined "
"(no branch protection rule). Resolve before continuing."
)
out.append("- Jeff's decision (fill in): `pending | flip-to-false | keep-on`")
out.append("- Date recorded: `____-__-__`")
out.append("")
out.append("## Canonical merge-bot identity")
out.append("")
canonical = bot.get("canonical_merge_bot")
if canonical:
out.append(
f"- Most frequent committer in last {bot.get('sample_size')} master "
f"commits: **`{canonical.get('name')}` <{canonical.get('email')}>** "
f"({canonical.get('count')} commits)."
)
out.append(
f"- Sample subject: `{canonical.get('sample_subject', '')[:80]}`"
)
out.append(
"- This identity is the merge-bot. Any commit on master whose "
"committer email differs is a direct push (Path B revert candidate, "
"or unauthorized — 2E alerts)."
)
out.append("")
out.append("- All distinct committers seen:")
for c in bot.get("all_committers", []):
out.append(
f" - `{c['name']}` <{c['email']}> — {c['count']} commits"
)
else:
out.append(
"- No committers found in recent master commits (unexpected). "
"Re-run with a larger sample or investigate."
)
out.append("")
out.append("## merge_pr.ts current behaviour")
out.append("")
if not mp.get("exists"):
out.append("- **`merge_pr.ts` not found** at expected path.")
else:
out.append(f"- Path: `{mp.get('path')}`")
out.append(f"- Uses `Do='rebase'`: `{mp.get('uses_do_rebase')}`")
out.append(f"- Uses `Do='merge'`: `{mp.get('uses_do_merge')}`")
out.append(
f"- Sets `merge_when_checks_succeed=true`: "
f"`{mp.get('uses_merge_when_checks_succeed_true')}`"
)
out.append(
f"- Sets `merge_when_checks_succeed=false`: "
f"`{mp.get('uses_merge_when_checks_succeed_false')}`"
)
out.append(
f"- Passes `head_commit_id` (optimistic lock): "
f"`{mp.get('passes_head_commit_id')}`"
)
out.append(f"- Handles HTTP 409: `{mp.get('handles_409')}`")
out.append(f"- Handles HTTP 405: `{mp.get('handles_405')}`")
out.append("")
out.append(f"- **Risk:** {mp.get('risk_summary')}")
out.append("")
out.append("## Repo-level settings")
out.append("")
if "error" in repo:
out.append(f"- Could not read repo settings: `{repo.get('error')}`")
else:
out.append(f"- `default_branch`: `{repo.get('default_branch')}`")
out.append(
f"- `default_delete_branch_after_merge`: "
f"`{repo.get('default_delete_branch_after_merge')}` "
f"(driver's auto/train/<ts> cleanup depends on this; if false, "
f"0B.5 sweeps abandoned branches > 2 h old)"
)
out.append(f"- `allow_rebase`: `{repo.get('allow_rebase')}`")
out.append(f"- `allow_merge_commits`: `{repo.get('allow_merge_commits')}`")
out.append(f"- `allow_squash_merge`: `{repo.get('allow_squash_merge')}`")
out.append("")
out.append("## Test-branch permissions (round 7 critique #8)")
out.append("")
if test_branch.get("can_check"):
out.append(
f"- Token has admin: `{test_branch.get('can_admin')}`, "
f"push: `{test_branch.get('can_push')}`, "
f"pull: `{test_branch.get('can_pull')}` on "
f"`{test_branch.get('repo_full_name')}`."
)
out.append(f"- {test_branch.get('manual_followup')}")
else:
out.append(
f"- Could not check token permissions: `{test_branch.get('error')}`"
)
out.append("")
out.append("## Pre-condition verification checklist")
out.append("")
out.append(
"Pre-conditions 1, 2, and 4 from the plan are verified as part of 0A; "
"P3 is verified during 2D rollout. The empirical checks (`empirical`) "
"intentionally cannot be automated against a production repo — they "
"are listed here so the operator can run them by hand and record the "
"outcome."
)
out.append("")
for pre in PRECONDITIONS:
out.append(f"- **{pre['id']}** ({pre['verification']}): {pre['title']}")
out.append(f" - How: {pre['how']}")
out.append(f" - Outcome (fill in): `pending | confirmed | deviation`")
out.append(f" - Notes: ____")
out.append("")
out.append("## Auto-generated next steps")
out.append("")
out.append(
"1. Run the empirical pre-condition checks (P1, P2, P5) against the "
"real Forgejo instance with a throwaway PR. Record the outcomes above."
)
out.append(
"2. Get Jeff's `dismiss_stale_approvals` decision and record it in 1A's "
"section above."
)
out.append(
"3. Confirm with Jeff whether a `tests/integration-train` branch with "
"branch protection mirroring master is acceptable; record the outcome."
)
out.append(
"4. Once all pre-conditions are recorded, paste this report into "
"AGENTS.md under the 'Merge invariant' section, then proceed with "
"`setup_labels` (rollout step 3)."
)
out.append("")
return "\n".join(out)
# ─── Main ───────────────────────────────────────────────────────────────────
def run_audit(token: str) -> dict[str, Any]:
return {
"generated_at": datetime.now(timezone.utc).isoformat(),
"repo": f"{REPO_OWNER}/{REPO_NAME}",
"branch_protection": audit_branch_protection(token),
"merge_bot_identity": audit_merge_bot_identity(token),
"merge_pr_ts": audit_merge_pr_ts(),
"test_branch_permissions": audit_can_create_test_branch(token),
"repo_settings": audit_repo_settings(token),
"preconditions": PRECONDITIONS,
}
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="Tier 0A — Forgejo behaviour audit (read-only)."
)
parser.add_argument(
"--format",
choices=("markdown", "json"),
default="markdown",
help="Output format (default: markdown).",
)
parser.add_argument(
"--output",
type=Path,
help="Write report to PATH instead of stdout.",
)
args = parser.parse_args(argv)
try:
token = load_token()
except RuntimeError as e:
print(f"ERROR: {e}", file=sys.stderr)
return 2
audit = run_audit(token)
if args.format == "json":
report = json.dumps(audit, indent=2, sort_keys=True)
else:
report = render_markdown_report(audit)
if args.output:
args.output.write_text(report + ("\n" if not report.endswith("\n") else ""))
print(f"Wrote audit report to {args.output}", file=sys.stderr)
else:
sys.stdout.write(report)
if not report.endswith("\n"):
sys.stdout.write("\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())