"""Bulk-issue guard: ceiling + discipline wrapper for mass Gitea issue posts. ANTI-BALLOONING guard (Gitea end), built 2026-10-01 after the rebase tranche ballooned to 1,852 per-unit issues via scripts/rebase_issue_factory.py. Any bulk post above the procedural tripwire MUST go through this wrapper: - smoke-first: post ONE unit, verify via API, then unleash the batch - resume-safe: progress file, re-runs skip done keys - retry/backoff: api() retries=5, timeout 60s, 2*(attempt+1) backoff - epic-fold shape: per-FILE epics (never per-unit bulk), members absorbed - dynamic fold-sized bound: posted issues <= epic/file count (one epic per file; units live as member link-lists inside epics, never as issues). The limiting factor is how the batch folds into epics — never a fixed number. BULK_ISSUE_CEILING (50) is a procedural tripwire only: above that many POSTED issues the smoke-first + resume-safe + retry/backoff discipline is mandatory. It caps nothing on its own. PRESERVE-FIRST: auto-label, pipeline-gate, merge-robot, human-only closure untouched. Companion: TRANCHE-TEMPLATE.md §7 (template end). """ import json import os import time import urllib.request BULK_ISSUE_CEILING = 50 # procedural tripwire (matches TRANCHE-TEMPLATE.md §7): # above this many POSTED issues, smoke-first + resume-safe + retry/backoff # discipline is mandatory. This is NOT a cap on units — the substantive # bound is dynamic (see check_ceiling): posted issues <= epic/file count, # units live as member link-lists inside epics, never as issues. API_RETRIES = 5 API_TIMEOUT = 60 def api(method, path, token, base="http://127.0.0.1:3000", payload=None): """Gitea API call with retry + backoff (fold-worker timeout law).""" data = json.dumps(payload).encode() if payload is not None else None last = None for attempt in range(API_RETRIES): try: req = urllib.request.Request( base + path, data=data, method=method, headers={"Authorization": "token " + token, "Content-Type": "application/json"}) with urllib.request.urlopen(req, timeout=API_TIMEOUT) as r: return json.load(r) except Exception as e: # noqa: BLE001 — retry loop must catch every transient failure class; the final raise carries the last error last = e time.sleep(2 * (attempt + 1)) raise RuntimeError("api failed after retries: %r" % (last,)) def check_ceiling(unit_count, epic_map=None, approved=False): """Dynamic fold-sized bound: posted issues <= epic/file count. The limiting factor is how the batch folds into epics (one epic per file; units absorbed as member link-lists), never a fixed number. BULK_ISSUE_CEILING (50) is a procedural tripwire only: above that many POSTED issues, smoke-first + resume-safe + retry/backoff discipline is mandatory. The substantive check: with an epic map, the allowed posted count is len(epic_map) — one issue per epic. Without a map above the tripwire, fold first; without approval, stop. Returns the required epic granularity ('per-file' above the tripwire, 'per-unit-ok' below). This is the check rebase_issue_factory.py never had (1,852 balloon). """ if unit_count <= BULK_ISSUE_CEILING: return "per-unit-ok" if epic_map is None: raise RuntimeError( "tripwire crossed: %d units > %d with no epic map — " "fold into per-FILE epics first (bound = epic count, " "one posted issue per file)" % (unit_count, BULK_ISSUE_CEILING)) bound = len(epic_map) if not approved: raise RuntimeError( "tripwire crossed: %d units > %d — epic map present " "(fold bound: %d posted issues, one per file), " "explicit approval still required" % ( unit_count, BULK_ISSUE_CEILING, bound)) return "per-file"