Files
cleveragents-core/tools/controller/master/forgejo_http.py
T
drew a21466add2 feat(controller): Phase 4 metadata-hygiene + round-2 adversarial fixes
Five deterministic, idempotent Phase 4 checks:
  1. completed_not_closed — close linked issues on MERGED
  2. closing_keyword_fixup — add Closes #N to PR bodies
  3. label_sync_from_issue — copy Priority/Type/MoSCoW labels
  4. state_label_inference — sync State/* label to current_state
  5. milestone_assignment — copy milestone from linked issue

All default-off via CONTROLLER_METADATA_HYGIENE_ENABLED +
per-check granular env flags. Dry-run mode shares the grooming
CONTROLLER_GROOMING_DRY_RUN flag.

Round-1 fixes (applied before this commit):
- False-positive idempotency lock (executed=True on skip)
- Unbounded MERGED scan → LEFT JOIN candidate query
- Duplicate _classify_forgejo_status → import from forgejo_writes
- Bare-ref regex too broad ([#42](url) misread) → add [ lookbehind
- Wrong audit stage → 'metadata_hygiene'

Round-2 adversarial fixes (3 architect, 4 principal, 7 test engineer):
- completed_not_closed: executed=True only when ALL refs close;
  partial success writes executed=False so remaining issues retry
- milestone_assignment: was calling get_pr_details (hits /pulls/,
  returns 404 for plain issues) → now uses get_issue_state
  (/issues/{n}) so milestone fetch works for all issue types
- label_sync failure path: write executed=False audit row for
  observability; pre-fix left no audit trail for persistent failures
- _BARE_REF_RE: add ( to lookbehind to exclude (#42) link destinations
- state_label_inference: re-read current_state inside inner session to
  avoid stale-snapshot spurious label writes across session boundaries
- _last_synced_state: add decision_id DESC tiebreaker for same-second
  wall-clock rows
- dry-run completed_not_closed: separate early-return path to avoid
  inflating completed_not_closed_executed counter

71 tests (54 round-1 + 17 round-2):
- TestCompletedNotClosedPartialSuccess (3) — partial/zero/full success
- TestLabelSyncAdjustLabelsFailure (2) — failure audit + retry
- TestStateLabelAdjustLabelsFailure (2) — no executed=1 on failure
- TestStateLabelInferenceTerminalWorkflows (3) — MERGED/ABANDONED sync
- TestLastSyncedStateDryRunThenReal (2) — dry-run → real-run
- TestClosingKeywordFixupBareRefAlreadyCovered (2) — candidates subtraction
- TestErrorPathHandlingRound2 (3) — label_sync + state_label errors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 17:41:49 -04:00

916 lines
34 KiB
Python

"""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
from . import prefetch as pf
from . import reconciliation as rec
from .ci_rerun import CIRerunCallback, make_ci_rerun_callback
from .ci_run_status import GetActionTasksCallback
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 patch(self, path: str, cfg: Any, body: Any) -> dict[str, Any]: ...
def delete(self, path: str, cfg: Any) -> dict[str, Any]: ...
# get_ci_status(owner, repo, head_sha) → Forgejo combined-status dict
# (``{"state": "...", "statuses": [...]}``) or None on fetch failure.
from collections.abc import Callable as _Callable
GetCIStatusCallback = _Callable[[str, str, str], dict | None]
# get_failure_logs(owner, repo, head_sha) → concatenated CI job-log
# text for the failing jobs (empty string when none / unreachable).
GetFailureLogsCallback = _Callable[[str, str, str], str]
# Phase 4 metadata-hygiene PATCH callbacks (2026-05-25). Both PATCH
# Forgejo's unified issues endpoint:
# PATCH /repos/{owner}/{repo}/issues/{n} body={"<field>": <value>}
# Return the raw ``{"status": int, "body": ...}`` shape so the per-
# check orchestrator can dispatch on the error-handling matrix (200 =
# success, 404 = treat as no-op, 422 = stuck, 5xx/429 = retry).
# patch_pr_body(owner, repo, pr_number, body) → dict
PatchPRBodyCallback = _Callable[[str, str, int, str], dict]
# patch_pr_milestone(owner, repo, pr_number, milestone_id) → dict
# ``milestone_id`` may be ``None`` to clear the assignment, or an int
# to set it.
PatchPRMilestoneCallback = _Callable[[str, str, int, "int | None"], dict]
@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
# PATCH /issues/{n} {"state": ...} — used by grooming's close path
# (Phase 0 grooming plan; orchestration lives in forgejo_writes.close_issue).
patch_pr_state: fw.PatchPRStateCallback
# PATCH /issues/{n} {"body": ...} — Phase 4 metadata-hygiene
# (closing-keyword fixup adds ``Closes #N`` to the PR body).
patch_pr_body: "PatchPRBodyCallback"
# PATCH /issues/{n} {"milestone": ...} — Phase 4 metadata-hygiene
# (milestone assignment copies milestone from linked issue).
patch_pr_milestone: "PatchPRMilestoneCallback"
merge_pr: mg.MergeCallback
# Reconciliation callbacks (Phase 1g):
get_pr_state: rec.GetPRStateCallback
get_issue_state: rec.GetIssueStateCallback
# CI status callback (Phase 1k++++ trial — wired to ci_status_poll):
get_ci_status: "GetCIStatusCallback"
# CI job-log fetcher (CI-freshness gate + ci_status_poll — gives
# the infra-vs-real classifier the real log text; Forgejo status
# descriptions are generic and never carry the error):
get_failure_logs: "GetFailureLogsCallback"
# Unified full-log fetcher (tools/_ci_logs.get_ci_logs) — every job
# of the run, full untruncated logs, one cache. Feeds the
# implementer/reviewer ci_summary so raw_log_excerpt is populated:
get_ci_logs: "pf.GetCILogsCallback"
# CI-rerun callback (CI-freshness gate — empty-commit push to
# re-trigger CI; Forgejo 15.0.2 has no Actions rerun API):
trigger_ci_rerun: "CIRerunCallback"
# Actions-task fetcher — lists workflow tasks for a commit so the
# zombie-CI detector can ask Forgejo directly "is this run still
# running?" (ci_run_status.classify_ci_run active-run check):
get_action_tasks: "GetActionTasksCallback"
# Prefetch callbacks (Phase 1h):
get_pr_details: pf.GetPRDetailsCallback
get_pr_diff: pf.GetPRDiffCallback
list_pr_reviews: pf.ListPRReviewsCallback
list_pr_comments: pf.ListPRCommentsCallback
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),
patch_pr_state=_make_patch_pr_state(cfg, runtime),
patch_pr_body=_make_patch_pr_body(cfg, runtime),
patch_pr_milestone=_make_patch_pr_milestone(cfg, runtime),
merge_pr=_make_merge_pr(cfg, runtime),
get_pr_state=_make_get_pr_state(cfg, runtime),
get_issue_state=_make_get_issue_state(cfg, runtime),
get_pr_details=_make_get_pr_details(cfg, runtime),
get_pr_diff=_make_get_pr_diff(cfg, runtime),
list_pr_reviews=_make_list_pr_reviews(cfg, runtime),
list_pr_comments=_make_list_pr_comments(cfg, runtime),
get_ci_status=_make_get_ci_status(cfg, runtime),
get_failure_logs=_make_get_failure_logs(cfg),
get_ci_logs=_make_get_ci_logs(cfg),
trigger_ci_rerun=_make_trigger_ci_rerun(cfg),
get_action_tasks=_make_get_action_tasks(cfg, runtime),
)
# ─── CI rerun (CI-freshness gate — empty-commit push) ────────────────
def _https_remote_for(cfg: Any) -> str:
"""Derive the ``https://host/owner/repo.git`` clone URL.
Operators can pin it explicitly via ``FORGEJO_HTTPS_REMOTE``;
otherwise it's derived from ``FORGEJO_API_BASE`` (the host) +
``cfg.owner`` / ``cfg.repo``. Mirrors ``merge_drive._derive_git_url``
so a fork-mode controller never pushes to canonical.
"""
import os
pinned = os.environ.get("FORGEJO_HTTPS_REMOTE")
if pinned:
return pinned
api_base = os.environ.get(
"FORGEJO_API_BASE",
"https://git.cleverthis.com/api/v1",
).rstrip("/")
host_base = (
api_base.rsplit("/api/v1", 1)[0] if api_base.endswith("/api/v1") else api_base
)
owner = getattr(cfg, "owner", "") or ""
repo = getattr(cfg, "repo", "") or ""
return f"{host_base}/{owner}/{repo}.git"
def _make_trigger_ci_rerun(cfg: Any) -> "CIRerunCallback":
"""Build the CI-rerun callback (empty-commit push) bound to cfg's
token + the derived HTTPS remote."""
return make_ci_rerun_callback(
https_remote=_https_remote_for(cfg),
token=getattr(cfg, "token", "") or "",
)
# ─── 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_patch_pr_state(cfg, runtime):
"""Build the PATCH-PR-state closure (Phase 0 grooming plan).
Forgejo treats PRs as issues at this endpoint:
PATCH /repos/{owner}/{repo}/issues/{n} body={"state": state}
Returns the raw ``{"status": int, "body": ...}`` shape so the
orchestrator in ``forgejo_writes.close_issue`` can dispatch on the
error-handling matrix (200/404 = success/no-op, 429 = retry,
4xx-other = stuck-after-3, 5xx = retry).
"""
def patch_pr_state(
owner: str,
repo: str,
pr_number: int,
state: str,
) -> dict:
path = f"/repos/{owner}/{repo}/issues/{int(pr_number)}"
return runtime.patch(path, cfg, {"state": state})
return patch_pr_state
def _make_patch_pr_body(cfg, runtime):
"""Build the PATCH-PR-body closure (Phase 4 metadata-hygiene).
Forgejo's unified issues endpoint accepts ``body`` mutations for
both issues and PRs:
PATCH /repos/{owner}/{repo}/issues/{n} body={"body": "..."}
Used by the closing-keyword-fixup tick to add ``Closes #N`` to a
PR body that references issue N without the closing keyword.
"""
def patch_pr_body(
owner: str,
repo: str,
pr_number: int,
body: str,
) -> dict:
path = f"/repos/{owner}/{repo}/issues/{int(pr_number)}"
return runtime.patch(path, cfg, {"body": body})
return patch_pr_body
def _make_patch_pr_milestone(cfg, runtime):
"""Build the PATCH-PR-milestone closure (Phase 4 metadata-hygiene).
Forgejo's unified issues endpoint accepts ``milestone`` mutations
for both issues and PRs:
PATCH /repos/{owner}/{repo}/issues/{n} body={"milestone": <id>}
Pass ``None`` to clear, int milestone-id to set. Used by the
milestone-assignment tick to copy the milestone from a linked
issue onto its PR when the PR has none.
"""
def patch_pr_milestone(
owner: str,
repo: str,
pr_number: int,
milestone_id: int | None,
) -> dict:
path = f"/repos/{owner}/{repo}/issues/{int(pr_number)}"
return runtime.patch(path, cfg, {"milestone": milestone_id})
return patch_pr_milestone
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,
approved_at_sha: str | None = None,
) -> mg.MergeResponse:
path = f"/repos/{owner}/{repo}/pulls/{int(pr_number)}/merge"
# Forgejo's merge endpoint accepts ``head_commit_id``: when set,
# Forgejo refuses the merge (returns 409) if the PR's HEAD has
# advanced past that SHA since approval. This is the race-
# protection invariant the reviewer's approved_at_sha exists to
# enforce. Defensive: only include when caller provides a SHA;
# legacy callers / missing-reviewer-payload paths still merge
# whatever HEAD currently is.
# Rebase-default with a merge fallback ladder (do NOT change
# MergeCallback's signature or merging.py — the ladder is
# entirely contained here):
#
# 1. POST {"Do":"rebase", ...} — linear history, preferred.
# 200 → success.
# 2. on 409 — the rebase-style merge could not apply: a
# conflicting commit OR the head_commit_id race-check
# rejected (HEAD moved past the approved SHA) — OR on 405
# (the repo disabled the rebase merge style) → POST
# {"Do":"merge", ...}. The merge POST carries the SAME
# head_commit_id, so a race rejection 409s the merge too:
# no unreviewed HEAD is ever merged, it just routes on to
# CONFLICT_RESOLVING via rung 3. 200 → success.
# 3. a 2nd 409 (the merge would ALSO conflict) → return
# MergeResponse(409) so the merging handler's existing
# ``merge_base_conflict`` path routes to CONFLICT_RESOLVING.
#
# The conflict-resolver's own deterministic prep mirrors this
# ladder worktree-side (conflict_rebase.prepare_conflict_track).
def _body(do: str) -> dict:
b: dict = {"Do": do}
if approved_at_sha:
b["head_commit_id"] = approved_at_sha
return b
def _post(do: str):
return runtime.post(path, cfg, _body(do))
try:
resp = _post("rebase")
status = int(resp.get("status") or 0)
if status in (409, 405):
if status == 405:
logger.warning(
"merge PR #%s: rebase merge style returned 405 "
"(disabled in repo config) — falling back to a "
"plain merge",
pr_number,
)
resp = _post("merge")
status = int(resp.get("status") or 0)
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}",
)
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
# A 2nd 409 (rebase conflicted, then merge ALSO conflicted) is
# returned as-is: the merging handler's 409 branch fires
# ``merge_base_conflict`` → CONFLICT_RESOLVING, which is exactly
# the right route for a genuinely-conflicting base.
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)"
# ─── reconciliation (Phase 1g) ───────────────────────────────────────
def _make_get_pr_state(cfg, runtime):
def get_pr_state(owner: str, repo: str, pr_number: int) -> dict | None:
path = f"/repos/{owner}/{repo}/pulls/{int(pr_number)}"
resp = runtime.get(path, cfg)
status = int(resp.get("status") or 0)
if status == 404:
return None
if status != 200:
logger.warning(
"get_pr_state: HTTP %s for %s/%s #%d",
status,
owner,
repo,
pr_number,
)
# Treat non-200/404 as "couldn't determine" — raise so the
# reconciliation tick records fetch-failed (rather than
# silently transitioning to STUCK).
raise RuntimeError(f"get_pr_state HTTP {status}")
body = resp.get("body")
if not isinstance(body, dict):
raise RuntimeError("get_pr_state: non-dict body")
return body
return get_pr_state
def _make_get_issue_state(cfg, runtime):
def get_issue_state(owner: str, repo: str, issue_number: int) -> dict | None:
path = f"/repos/{owner}/{repo}/issues/{int(issue_number)}"
resp = runtime.get(path, cfg)
status = int(resp.get("status") or 0)
if status == 404:
return None
if status != 200:
raise RuntimeError(f"get_issue_state HTTP {status}")
body = resp.get("body")
if not isinstance(body, dict):
raise RuntimeError("get_issue_state: non-dict body")
return body
return get_issue_state
# ─── prefetch (Phase 1h) ─────────────────────────────────────────────
def _make_get_pr_details(cfg, runtime):
def get_pr_details(owner: str, repo: str, pr_number: int) -> dict | None:
path = f"/repos/{owner}/{repo}/pulls/{int(pr_number)}"
try:
resp = runtime.get(path, cfg)
except Exception as exc: # noqa: BLE001 — transport failure
logger.warning(
"get_pr_details: transport error for %s/%s #%d: %s",
owner,
repo,
pr_number,
exc,
)
return None
status = int(resp.get("status") or 0)
if status == 404:
return None
if status != 200:
logger.warning(
"get_pr_details: HTTP %s for %s/%s #%d",
status,
owner,
repo,
pr_number,
)
return None
body = resp.get("body")
return body if isinstance(body, dict) else None
return get_pr_details
def _make_get_pr_diff(cfg, runtime):
def get_pr_diff(owner: str, repo: str, pr_number: int) -> str | None:
# Forgejo: GET /repos/{owner}/{repo}/pulls/{n}.diff returns
# the raw unified diff as text/plain.
path = f"/repos/{owner}/{repo}/pulls/{int(pr_number)}.diff"
try:
resp = runtime.get(path, cfg)
except Exception as exc: # noqa: BLE001
logger.warning(
"get_pr_diff: transport error for %s/%s #%d: %s",
owner,
repo,
pr_number,
exc,
)
return None
status = int(resp.get("status") or 0)
if status != 200:
return None
body = resp.get("body")
if isinstance(body, str):
return body
if isinstance(body, (bytes, bytearray)):
try:
return bytes(body).decode("utf-8", errors="replace")
except Exception:
return None
return None
return get_pr_diff
def _make_list_pr_reviews(cfg, runtime):
def list_pr_reviews(owner: str, repo: str, pr_number: int) -> list[dict]:
path = f"/repos/{owner}/{repo}/pulls/{int(pr_number)}/reviews"
resp = runtime.get(path, cfg)
return _list_or_empty(resp, "list_pr_reviews")
return list_pr_reviews
def _make_list_pr_comments(cfg, runtime):
def list_pr_comments(owner: str, repo: str, pr_number: int) -> list[dict]:
# Forgejo: PR comments use the issues endpoint (PRs ARE issues
# at the comment/label level).
path = f"/repos/{owner}/{repo}/issues/{int(pr_number)}/comments"
resp = runtime.get(path, cfg)
return _list_or_empty(resp, "list_pr_comments")
return list_pr_comments
# ─── CI status (Phase 1k++++ trial — wired to ci_status_poll) ───────
def _make_get_ci_status(cfg, runtime):
def get_ci_status(owner: str, repo: str, head_sha: str) -> dict | None:
if not head_sha:
return None
path = f"/repos/{owner}/{repo}/commits/{head_sha}/status"
try:
resp = runtime.get(path, cfg)
except Exception as exc: # noqa: BLE001 — transient
logger.warning(
"get_ci_status: transport error for %s/%s @%s: %s",
owner,
repo,
head_sha[:12],
exc,
)
return None
status = int(resp.get("status") or 0)
if status != 200:
logger.warning(
"get_ci_status: HTTP %s for %s/%s @%s",
status,
owner,
repo,
head_sha[:12],
)
return None
body = resp.get("body")
if not isinstance(body, dict):
return None
# Forgejo's per-gate commit-status entries carry the gate
# result under the key ``status``; the controller's CI code
# (ci_summarize, ci_freshness) reads ``state`` — the key the
# combined object's TOP level also uses, and the shape every
# consumer's docstring documents. Translate so the per-gate
# state is actually visible. Without this every gate reads as
# None and defaults to "pending" — the implementer's ci_summary
# then shows 0 failing gates even when gates genuinely failed.
for s in body.get("statuses") or []:
if isinstance(s, dict) and not s.get("state"):
s["state"] = s.get("status")
return body
return get_ci_status
def _make_get_action_tasks(cfg, runtime):
"""Build the Actions-task fetcher.
Lists workflow tasks for a commit so the zombie-CI detector can ask
Forgejo directly whether a run is still executing. Forgejo's tasks
list is repo-wide; we fetch a page and filter to the commit's
``head_sha`` client-side. An old commit whose tasks have aged off
the page filters to ``[]`` — correctly read as "nothing running."
"""
def get_action_tasks(
owner: str, repo: str, head_sha: str
) -> "list[dict] | None":
if not head_sha:
return None
path = f"/repos/{owner}/{repo}/actions/tasks?limit=50"
try:
resp = runtime.get(path, cfg)
except Exception as exc: # noqa: BLE001 — transient
logger.warning(
"get_action_tasks: transport error for %s/%s @%s: %s",
owner,
repo,
head_sha[:12],
exc,
)
return None
status = int(resp.get("status") or 0)
if status != 200:
logger.warning(
"get_action_tasks: HTTP %s for %s/%s @%s",
status,
owner,
repo,
head_sha[:12],
)
return None
body = resp.get("body")
if not isinstance(body, dict):
return None
runs = body.get("workflow_runs") or body.get("tasks") or []
if not isinstance(runs, list):
return None
return [
t
for t in runs
if isinstance(t, dict) and t.get("head_sha") == head_sha
]
return get_action_tasks
# ─── CI failure logs (CI-freshness gate + ci_status_poll) ────────────
def _make_get_failure_logs(cfg: Any) -> "GetFailureLogsCallback":
"""Build the CI job-log fetcher.
Backs onto ``tools/_ci_logs.get_ci_logs`` — the unified
per-(head_sha) full-log cache. Returns the concatenated FULL log
text of every failing job (newline-separated), or an empty string
when there are no failing jobs / the logs are unreachable. Full
logs (not the old 4000-char tail) — the infra-vs-real classifier
scans for signatures a tail could miss.
The infra-vs-real classifier scans this text for checkout/setup
signatures. ``_ci_logs`` never raises into the caller (every
failure path degrades to a partial payload), and this closure
additionally swallows import/transport errors so a log-fetch
problem can never abort a controller tick — the classifier then
conservatively treats the failure as ``fresh_real``.
"""
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))
def get_failure_logs(owner: str, repo: str, head_sha: str) -> str:
if not head_sha:
return ""
try:
from tools import _ci_logs # type: ignore[import-not-found]
except Exception as exc: # noqa: BLE001
logger.warning("get_failure_logs: _ci_logs import failed: %s", exc)
return ""
# ``_ci_logs`` reads .owner / .repo / .token off the cfg and
# FORGEJO_API_BASE off the env; the session-login path also
# reads .forgejo_url. The controller cfg carries owner/repo/
# token already — build a thin shim adding forgejo_url derived
# the same way the CI-rerun remote is.
import os
api_base = os.environ.get(
"FORGEJO_API_BASE",
"https://git.cleverthis.com/api/v1",
).rstrip("/")
forgejo_url = (
api_base.rsplit("/api/v1", 1)[0]
if api_base.endswith("/api/v1")
else api_base
)
class _LogCfg:
pass
shim = _LogCfg()
shim.owner = owner
shim.repo = repo
shim.token = getattr(cfg, "token", "") or ""
shim.forgejo_url = forgejo_url
shim.request_timeout_s = getattr(cfg, "request_timeout_s", 30)
shim.api_retries = getattr(cfg, "api_retries", 3)
try:
bundle = _ci_logs.get_ci_logs(shim, head_sha)
except Exception as exc: # noqa: BLE001 — never abort a tick
logger.warning(
"get_failure_logs: get_ci_logs raised for %s/%s @%s: %s",
owner,
repo,
head_sha[:12],
exc,
)
return ""
# Concatenate the failing jobs' logs for the infra-vs-real
# classifier. Generous per-job cap guards against a runaway log
# without losing the signal a 4000-char tail used to drop.
failing_states = {
"failure", "failed", "error", "cancelled", "canceled", "timed_out",
}
chunks: list[str] = []
for job in bundle.get("jobs") or []:
if not isinstance(job, dict):
continue
state = str(job.get("state") or "").lower()
log = job.get("log")
if state in failing_states and isinstance(log, str) and log.strip():
chunks.append(log[-200_000:])
return "\n".join(chunks)
return get_failure_logs
def _make_get_ci_logs(cfg: Any) -> "pf.GetCILogsCallback":
"""Build the unified full-log fetcher — every job of a run, full
untruncated logs, one cache (``tools/_ci_logs.get_ci_logs``).
Returns the bundle dict; an empty bundle on any failure so a caller
never has to defend. The session-login + on-disk cache live in
``_ci_logs``; this closure only builds the per-call cfg shim."""
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))
def get_ci_logs(owner: str, repo: str, head_sha: str) -> dict:
empty: dict[str, Any] = {
"schema_version": 1, "head_sha": head_sha or "",
"jobs": [], "partial": False, "completed": True,
}
if not head_sha:
return empty
try:
from tools import _ci_logs # type: ignore[import-not-found]
except Exception as exc: # noqa: BLE001
logger.warning("get_ci_logs: _ci_logs import failed: %s", exc)
return empty
import os
api_base = os.environ.get(
"FORGEJO_API_BASE", "https://git.cleverthis.com/api/v1",
).rstrip("/")
forgejo_url = (
api_base.rsplit("/api/v1", 1)[0]
if api_base.endswith("/api/v1")
else api_base
)
class _LogCfg:
pass
shim = _LogCfg()
shim.owner = owner
shim.repo = repo
shim.token = getattr(cfg, "token", "") or ""
shim.forgejo_url = forgejo_url
shim.request_timeout_s = getattr(cfg, "request_timeout_s", 30)
shim.api_retries = getattr(cfg, "api_retries", 3)
try:
return _ci_logs.get_ci_logs(shim, head_sha)
except Exception as exc: # noqa: BLE001 — never abort a tick
logger.warning(
"get_ci_logs: get_ci_logs raised for %s/%s @%s: %s",
owner, repo, head_sha[:12], exc,
)
return empty
return get_ci_logs
__all__ = [
"CIRerunCallback",
"ForgejoCallbacks",
"GetCIStatusCallback",
"GetFailureLogsCallback",
"build_callbacks",
]