a21466add2
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>
916 lines
34 KiB
Python
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",
|
|
]
|