"""HTTP adapters wiring the controller's callback protocols to ``tools/_claim_runtime``. The discovery / scheduler-prefetch / forgejo_writes / merging modules all take callback functions for Forgejo I/O. Tests inject synthetic callbacks; production wires them via this module's ``build_callbacks(cfg)`` factory. The factory takes a ``RuntimeContext`` (from ``_claim_runtime``) and returns a frozen ``ForgejoCallbacks`` dataclass with every callback the controller needs. The callbacks are thin closures over ``_claim_runtime.get / post / patch / delete``. Path conventions match Forgejo's API: - list PRs: GET /repos/{owner}/{repo}/pulls?state=open - list issues: GET /repos/{owner}/{repo}/issues?state=open&type=issues - list comments: GET /repos/{owner}/{repo}/issues/{n}/comments - post comment: POST /repos/{owner}/{repo}/issues/{n}/comments - get labels: GET /repos/{owner}/{repo}/issues/{n}/labels - add label: POST /repos/{owner}/{repo}/issues/{n}/labels - remove label: DELETE /repos/{owner}/{repo}/issues/{n}/labels/{label_id} - merge PR: POST /repos/{owner}/{repo}/pulls/{n}/merge Forgejo treats issues + PRs interchangeably at the timeline level (labels + comments use issue endpoints for both). """ from __future__ import annotations import logging from dataclasses import dataclass from typing import Any, Protocol from . import forgejo_writes as fw from . import merging as mg from . import discovery as ds logger = logging.getLogger(__name__) class _ClaimRuntime(Protocol): """Just the parts of ``_claim_runtime`` we use. Lets tests inject a fake without importing the real module.""" def get(self, path: str, cfg: Any) -> dict[str, Any]: ... def post(self, path: str, cfg: Any, body: Any) -> dict[str, Any]: ... def delete(self, path: str, cfg: Any) -> dict[str, Any]: ... @dataclass class ForgejoCallbacks: """Bundle of every callback the controller needs. Built by ``build_callbacks``. Each callback is a thin closure over ``_claim_runtime``.""" list_prs: ds.ListPRsCallback list_issues: ds.ListIssuesCallback list_comments: fw.ListCommentsCallback post_comment: fw.PostCommentCallback get_labels: fw.GetLabelsCallback add_label: fw.AddLabelCallback remove_label: fw.RemoveLabelCallback merge_pr: mg.MergeCallback def build_callbacks( cfg: Any, *, runtime: _ClaimRuntime | None = None, ) -> ForgejoCallbacks: """Wire callbacks for a given RuntimeContext. Tests pass a fake ``runtime`` with ``.get / .post / .delete`` methods that return the standard ``{"status": int, "body": ...}`` shape. Production omits ``runtime`` and the wiring uses the real module. """ if runtime is None: import sys from pathlib import Path tools_dir = Path(__file__).resolve().parents[2] if str(tools_dir) not in sys.path: sys.path.insert(0, str(tools_dir)) from tools import _claim_runtime as runtime # type: ignore[no-redef] return ForgejoCallbacks( list_prs=_make_list_prs(cfg, runtime), list_issues=_make_list_issues(cfg, runtime), list_comments=_make_list_comments(cfg, runtime), post_comment=_make_post_comment(cfg, runtime), get_labels=_make_get_labels(cfg, runtime), add_label=_make_add_label(cfg, runtime), remove_label=_make_remove_label(cfg, runtime), merge_pr=_make_merge_pr(cfg, runtime), ) # ─── discovery ─────────────────────────────────────────────────────── def _make_list_prs(cfg, runtime): def list_prs(owner: str, repo: str) -> list[dict]: path = f"/repos/{owner}/{repo}/pulls?state=open" resp = runtime.get(path, cfg) return _list_or_empty(resp, "list_prs") return list_prs def _make_list_issues(cfg, runtime): def list_issues(owner: str, repo: str) -> list[dict]: # Forgejo's /issues endpoint returns both issues + PRs by # default; type=issues filters out PRs (which list_prs handles). path = f"/repos/{owner}/{repo}/issues?state=open&type=issues" resp = runtime.get(path, cfg) return _list_or_empty(resp, "list_issues") return list_issues # ─── status comments ───────────────────────────────────────────────── def _make_list_comments(cfg, runtime): def list_comments(owner: str, repo: str, pr_number: int) -> list[dict]: path = f"/repos/{owner}/{repo}/issues/{int(pr_number)}/comments" resp = runtime.get(path, cfg) return _list_or_empty(resp, "list_comments") return list_comments def _make_post_comment(cfg, runtime): def post_comment( owner: str, repo: str, pr_number: int, body: str, ) -> dict: path = f"/repos/{owner}/{repo}/issues/{int(pr_number)}/comments" resp = runtime.post(path, cfg, {"body": body}) if int(resp.get("status") or 0) not in (200, 201): raise RuntimeError( f"post_comment HTTP {resp.get('status')}: " f"{_summarise_body(resp.get('body'))}" ) body_obj = resp.get("body") or {} if not isinstance(body_obj, dict): return {"id": None} return body_obj return post_comment # ─── labels ────────────────────────────────────────────────────────── def _make_get_labels(cfg, runtime): def get_labels(owner: str, repo: str, pr_number: int) -> list[dict]: path = f"/repos/{owner}/{repo}/issues/{int(pr_number)}/labels" resp = runtime.get(path, cfg) return _list_or_empty(resp, "get_labels") return get_labels def _make_add_label(cfg, runtime): def add_label( owner: str, repo: str, pr_number: int, label_name: str, ) -> bool: path = f"/repos/{owner}/{repo}/issues/{int(pr_number)}/labels" # Forgejo accepts {"labels": ["label_name"]} on POST. resp = runtime.post(path, cfg, {"labels": [label_name]}) return int(resp.get("status") or 0) in (200, 201) return add_label def _make_remove_label(cfg, runtime): def remove_label( owner: str, repo: str, pr_number: int, label_name: str, ) -> bool: # Forgejo's DELETE-by-name endpoint: # DELETE /repos/{owner}/{repo}/issues/{n}/labels/{label_name} # (some Forgejo deployments use label_id; if so, the caller # would need to look it up first via get_labels) from urllib.parse import quote path = ( f"/repos/{owner}/{repo}/issues/{int(pr_number)}/" f"labels/{quote(label_name, safe='')}" ) resp = runtime.delete(path, cfg) return int(resp.get("status") or 0) in (200, 204, 404) return remove_label # ─── merge ─────────────────────────────────────────────────────────── def _make_merge_pr(cfg, runtime): def merge_pr(owner: str, repo: str, pr_number: int) -> mg.MergeResponse: path = f"/repos/{owner}/{repo}/pulls/{int(pr_number)}/merge" try: resp = runtime.post(path, cfg, {"Do": "merge"}) except Exception as exc: # noqa: BLE001 — bubble as transient # Synthetic 503 — the merging handler treats this as a # retryable failure. return mg.MergeResponse( status_code=503, error_message=f"merge HTTP call raised: {exc}", ) status = int(resp.get("status") or 0) body = resp.get("body") err = _summarise_body(body) if status >= 400 else None # For 404, try to fetch the PR state to distinguish # externally-merged vs externally-closed. If the PR fetch # itself fails (5xx / 404 / other), leave pr_state=None # so the merging handler defaults to ABANDONED conservatively. pr_state: str | None = None if status == 404: try: pr_resp = runtime.get( f"/repos/{owner}/{repo}/pulls/{int(pr_number)}", cfg, ) if int(pr_resp.get("status") or 0) == 200: pr_body = pr_resp.get("body") or {} if isinstance(pr_body, dict): if pr_body.get("merged") is True: pr_state = "merged" elif pr_body.get("state") == "closed": pr_state = "closed" else: pr_state = "open" except Exception: # Couldn't determine; leave None → handler defaults # to ABANDONED per its conservative policy. pass return mg.MergeResponse( status_code=status, pr_state=pr_state, error_message=err, ) return merge_pr # ─── helpers ───────────────────────────────────────────────────────── def _list_or_empty(resp: dict, action: str) -> list[dict]: """Normalize a list-GET response into a list of dicts.""" status = int(resp.get("status") or 0) if status != 200: logger.warning("%s: HTTP %s; treating as empty", action, status) return [] body = resp.get("body") if not isinstance(body, list): return [] return [item for item in body if isinstance(item, dict)] def _summarise_body(body: Any) -> str: """Truncate a Forgejo error body to fit in a one-line error.""" if body is None: return "" s = str(body) if len(s) <= 200: return s return s[:200] + f"… ({len(s)} chars)" __all__ = ["ForgejoCallbacks", "build_callbacks"]