#!/usr/bin/env python3
"""Open one PR across many repos — a wave — without tripping the title throttle.

THE CONSTRAINT, STATED CORRECTLY
--------------------------------
Forgejo/Gitea does **not** rate-limit PR *creation*. It blocks::

    "magogi-admin" posted 2 similairy named issues in the last hour

That is an **anti-spam rule on TITLE SIMILARITY** — roughly 2 per similar-title
cluster per hour, per account. It is not a throughput cap, and waiting does not
help you go faster: a wave of 34 identically-titled PRs drains at 2/hour (~17
hours), no matter how politely you back off.

**So the fix is to VARY THE TITLE, not to sleep.** Measured 2026-07-17 on the
34-repo fed-resources wave: identical titles → 2 accepted, 32 blocked; distinct
titles → all 34 accepted in minutes, same token, same second. Backing off the
full window paces for a limit that does not exist and converts a minutes-long
wave into an overnight one.

The similarity check is FUZZY. A shared phrase with a per-repo prefix still
clusters — ``mgf-auth: converge fed-resources`` and ``mgf-http: converge
fed-resources`` are "similar". Titles must differ in their *words*, not just in
one token. :func:`vary_titles` therefore rotates whole phrasings and only then
falls back to a per-repo qualifier.

Backoff is kept for the ONE case that is a real rate limit — HTTP 429 — where
waiting is the correct and only remedy.

Usage::

    pr_wave.py --branch chore/my-change --repos mgf-a mgf-b ... \\
               --body-file body.md --titles-file titles.txt [--dry-run]

``--titles-file``: one phrasing per line. They are assigned round-robin and
qualified per-repo when a wave is longer than the list, so a 34-repo wave off 6
phrasings still lands. Read the token from disk; never pass it on argv (it would
land in ``ps`` output and shell history).
"""

from __future__ import annotations

import argparse
import json
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
    from collections.abc import Callable, Sequence

API = "https://codeberg.org/api/v1"
OWNER = "magogi-admin"

#: Substring identifying the title-similarity block (the message is misspelled
#: upstream — match on the stable part, not the typo).
_SIMILAR_MARKER = "similairy named"
_SIMILAR_MARKER_ALT = "similarly named"


def is_title_throttle(message: str) -> bool:
    """True when ``message`` is the title-similarity block.

    Deliberately NOT treated as a rate limit: the remedy is a different title,
    not a wait. Matches both the upstream misspelling and a corrected spelling,
    so a Forgejo typo fix does not silently turn this back into a 17-hour wave.
    """
    low = message.lower()
    return _SIMILAR_MARKER in low or _SIMILAR_MARKER_ALT in low


def vary_titles(repos: Sequence[str], phrasings: Sequence[str]) -> dict[str, str]:
    """Assign each repo a title that will not cluster with its neighbours.

    Rotates whole ``phrasings`` first — genuinely different words are what clears
    the throttle. Once the phrasings are exhausted the repo name is appended as a
    qualifier, which keeps a long wave moving; the rotation means two repos
    sharing a phrasing are far apart in the wave and differ by that qualifier.

    Raises when ``phrasings`` is empty — a wave with no titles is a wave that
    would take 17 hours, and failing loudly beats discovering that at 2/hour.
    """
    if not phrasings:
        raise ValueError("pr_wave: need at least one title phrasing (see --titles-file)")
    out: dict[str, str] = {}
    for i, repo in enumerate(repos):
        phrasing = phrasings[i % len(phrasings)]
        lap = i // len(phrasings)
        out[repo] = phrasing if lap == 0 else f"{repo}: {phrasing}"
    return out


def _post(url: str, payload: dict[str, Any], token: str) -> tuple[int, dict[str, Any]]:
    req = urllib.request.Request(
        url,
        data=json.dumps(payload).encode(),
        headers={"Authorization": f"token {token}", "Content-Type": "application/json"},
        method="POST",
    )
    try:
        with urllib.request.urlopen(req, timeout=30) as resp:
            return resp.status, json.loads(resp.read() or b"{}")
    except urllib.error.HTTPError as exc:
        body = exc.read().decode(errors="replace")
        try:
            return exc.code, json.loads(body or "{}")
        except json.JSONDecodeError:
            return exc.code, {"message": body}


def open_pr(
    repo: str,
    head: str,
    base: str,
    title: str,
    body: str,
    token: str,
    post: Callable[[str, dict[str, Any], str], tuple[int, dict[str, Any]]] = _post,
) -> tuple[int | None, str]:
    """Open one PR. Returns ``(number, "")`` or ``(None, message)``."""
    status, data = post(
        f"{API}/repos/{OWNER}/{repo}/pulls",
        {"head": head, "base": base, "title": title, "body": body},
        token,
    )
    num = data.get("number")
    if status in (200, 201) and isinstance(num, int):
        return num, ""
    return None, str(data.get("message") or f"HTTP {status}")


def run_wave(
    repos: Sequence[str],
    head: str,
    base: str,
    body: str,
    phrasings: Sequence[str],
    token: str,
    post: Callable[[str, dict[str, Any], str], tuple[int, dict[str, Any]]] = _post,
    sleep: Callable[[float], None] = time.sleep,
    retitle_attempts: int = 3,
) -> dict[str, int | str]:
    """Open a PR per repo. Returns ``{repo: number}`` or ``{repo: error}``.

    On the title throttle: **re-title and retry immediately** — the block is about
    the words, so a wait accomplishes nothing. On HTTP 429 (a real rate limit):
    back off, because waiting IS the remedy there. The two are different failures
    and conflating them is what made a minutes-long wave take a night.
    """
    titles = vary_titles(repos, phrasings)
    results: dict[str, int | str] = {}
    for repo in repos:
        title = titles[repo]
        for attempt in range(retitle_attempts):
            num, err = open_pr(repo, head, base, title, body, token, post=post)
            if num is not None:
                results[repo] = num
                break
            if is_title_throttle(err):
                # NOT a rate limit — the words clustered. Re-title, do not sleep.
                title = f"{repo} ({attempt + 2}): {titles[repo]}"
                continue
            if "429" in err or "too many requests" in err.lower():
                sleep(60)
                continue
            results[repo] = err
            break
        else:
            results[repo] = f"title throttle persisted after {retitle_attempts} re-titles"
    return results


def main(argv: Sequence[str] | None = None) -> int:
    ap = argparse.ArgumentParser(
        description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
    )
    ap.add_argument("--branch", required=True, help="head branch (must already be pushed)")
    ap.add_argument("--base", default="main")
    ap.add_argument("--repos", nargs="+", required=True)
    ap.add_argument("--body-file", type=Path, required=True)
    ap.add_argument("--titles-file", type=Path, required=True, help="one phrasing per line")
    ap.add_argument(
        "--token-file",
        type=Path,
        required=True,
        help="file holding the API token (never pass a token on argv — it lands in ps + history)",
    )
    ap.add_argument(
        "--token-line", type=int, default=11, help="1-based line to read the token from"
    )
    ap.add_argument("--dry-run", action="store_true", help="print the assigned titles and exit")
    args = ap.parse_args(argv)

    phrasings = [ln.strip() for ln in args.titles_file.read_text().splitlines() if ln.strip()]
    if args.dry_run:
        for repo, title in vary_titles(args.repos, phrasings).items():
            print(f"{repo:24} {title}")
        return 0

    raw = args.token_file.read_text().splitlines()[args.token_line - 1]
    token = (
        raw.split(":", 1)[-1].split("=", 1)[-1].strip()
        if (":" in raw or "=" in raw)
        else raw.strip()
    )

    results = run_wave(
        args.repos, args.branch, args.base, args.body_file.read_text(), phrasings, token
    )
    ok = {r: v for r, v in results.items() if isinstance(v, int)}
    bad = {r: v for r, v in results.items() if not isinstance(v, int)}
    for repo, num in ok.items():
        print(f"  {repo:24} #{num}")
    for repo, err in bad.items():
        print(f"  {repo:24} ERROR: {err}", file=sys.stderr)
    print(f"\nopened {len(ok)}/{len(args.repos)}")
    return 1 if bad else 0


if __name__ == "__main__":
    raise SystemExit(main())
