Files
cleveragents-core/tools/_review_fetch.py
T
drew 0bc734c020 style: ruff format the controller-state-machine branch (288 files)
Applies `ruff format` to the accumulated formatting debt on this branch.
Formatting-only — no behavioral changes. Required for CI/lint's format
gate (`nox -s format -- --check`), which the branch was failing on 288
tracked files that drifted from ruff's canonical style.

In-progress WIP files are intentionally excluded so this commit stays a
clean formatting-only diff.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 00:09:17 -04:00

982 lines
40 KiB
Python

"""Forgejo fetchers for the review dispatcher.
Extracted from ``_review_pipeline.py`` so the pipeline driver can stay
under the project's 500-line per-file budget. Every entry point in
this module is a pure HTTP-wrapper that:
- routes through :mod:`_claim_runtime`'s retry-aware ``get`` helper;
- swallows its own exceptions (the dispatcher's review prompt falls
back to a "Pre-fetched X unavailable" section on any error);
- returns ``(items, completed)`` on listing endpoints so callers can
thread a ``data_complete`` signal into the worker prompt.
What's here:
- :func:`_api_get_paginated` -- the shared paginator every list-style
fetcher uses (reviews, comments, commits, statuses).
- Per-resource fetchers: :func:`fetch_pr_details`,
:func:`fetch_ci_status`, :func:`fetch_existing_reviews`,
:func:`fetch_pr_comments`, :func:`fetch_linked_issues`,
:func:`fetch_pr_commits`, :func:`fetch_ci_check_detail`.
- :func:`parse_linked_issue_numbers` / :func:`_annotate_linked_issue`
-- linked-issue parsing + dispatcher-side resolution annotation.
- :func:`count_active_request_changes` -- shared Tier 1F counting
helper used by both the prompt-time existing-reviews section and
the dispatcher's defensive escalation guard.
- :func:`_redact` / :func:`_truncate_for_prompt` -- small primitives
shared with :mod:`_review_views`.
This module deliberately holds no prompt-section markers or section
templates -- those live with :mod:`_review_views` so the section
templates are co-located with the slim helpers that feed them.
"""
from __future__ import annotations
import logging
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any, overload
_TOOLS_DIR = str(Path(__file__).resolve().parent)
if _TOOLS_DIR not in sys.path:
sys.path.insert(0, _TOOLS_DIR)
from _loader import load_sibling as _load_sibling # noqa: E402 type: ignore[import-not-found]
_claim_runtime = _load_sibling("_claim_runtime", "_claim_runtime.py")
_logger = logging.getLogger("review_pipeline")
# Cap on the per-page list result we retrieve. Forgejo enforces 50 as
# the hard upper bound, which we honour. If a PR has >50 reviews or
# >50 comments we paginate.
_PAGE_SIZE = 50
# Hard cap on pagination — defensive: an unexpected server-side bug
# returning duplicate pages must not turn into an infinite loop.
# Bumped 2026-05-17 from 20 (1000-comment ceiling) to 50 (2500-
# comment ceiling) for headroom on heavy PRs (PR #30 hit 390
# comments; future PRs may grow). The walk terminates naturally on
# the first short/empty page, so the cost for small PRs is zero.
_MAX_PAGES = 50
# Cap on the unresolved-detail string we stamp into the prompt-visible
# placeholder. Cuts giant tracebacks / network exception strings down
# to a budgeted size; the elided suffix below preserves an explicit
# truncation marker so an operator reading the JSON can tell head from
# whole.
_UNRESOLVED_DETAIL_MAX_CHARS = 200
_UNRESOLVED_DETAIL_TRUNCATION_SUFFIX = "...[truncated]"
# Module-load invariant: if a future tweak shrinks MAX below the
# suffix length the truncated head would be empty, silently degrading
# to a marker-only string. Catch that at import time rather than at
# the next operator-visible regression.
assert _UNRESOLVED_DETAIL_MAX_CHARS > len(_UNRESOLVED_DETAIL_TRUNCATION_SUFFIX), (
f"_UNRESOLVED_DETAIL_MAX_CHARS ({_UNRESOLVED_DETAIL_MAX_CHARS}) must "
f"exceed the truncation suffix length "
f"({len(_UNRESOLVED_DETAIL_TRUNCATION_SUFFIX)}) so the truncated "
"value retains at least one character of head content"
)
# Canonical values for the dispatcher-stamped ``link.state`` field. The
# section template + worker prompt key off these strings, so any
# expansion / rename should land here first. Pass-through of raw
# upstream Forgejo states (e.g. "locked", "archived") still happens in
# :func:`_annotate_linked_issue`'s catch-all branch — those flow
# through verbatim so a future Forgejo extension is visible to the
# worker rather than silently coerced.
LINK_STATE_OPEN = "open"
LINK_STATE_CLOSED_MERGED = "closed-merged"
LINK_STATE_CLOSED_WITHOUT_MERGE = "closed-without-merge"
LINK_STATE_UNRESOLVED = "unresolved"
LINK_STATE_UNKNOWN = "unknown"
LINK_STATES_CANONICAL = (
LINK_STATE_OPEN,
LINK_STATE_CLOSED_MERGED,
LINK_STATE_CLOSED_WITHOUT_MERGE,
LINK_STATE_UNRESOLVED,
LINK_STATE_UNKNOWN,
)
# Per-process dedup set for the 4xx INFO log. Without this, every
# polling cycle of every sentinel-fork PR (whose ``Closes #N`` keeps
# pointing at an upstream-only issue number) emits a fresh INFO line,
# producing roughly one log entry per (cycle x reference) — high
# enough volume to drown real signals in operator logs. Keying on
# ``(owner, repo, number, status)`` means each truly-distinct
# unresolved reference logs once and stays quiet thereafter for the
# lifetime of the dispatcher process.
_LOGGED_UNRESOLVED_4XX: set[tuple[str, str, int, int]] = set()
# Reason tokens stamped into the unresolved placeholder. The slim
# helper, the section template, and the test surface all consume
# these literally, so they live here as named constants instead of
# string literals scattered across modules.
UNRESOLVED_REASON_NOT_FOUND = "not-found"
UNRESOLVED_REASON_FETCH_ERROR = "fetch-error"
# Tokens are munged into archive keys / header slots / log identifiers
# by ``str.replace("-", "_")`` in three call sites. A future token with
# any other special character (``.``, ``/``, whitespace, uppercase,
# leading/trailing dash) would silently produce broken keys:
# ``unresolved_link_count_500.upstream`` is not a valid Python
# identifier the operator can grep for the same way they grep
# ``unresolved_link_count_not_found``; ``unresolved_link_count_foo_``
# (from a ``foo-`` trailing-dash token) is a valid identifier but
# breaks the operator's ``-_`` mapping convention. The constraint
# below pins the contract: lowercase ASCII letters, digits, and
# internal dashes only, starting and ending with a letter or digit.
# Validated at instance construction
# (:meth:`UnresolvedReason.__post_init__`) so a future maintainer
# adding a malformed token gets caught at module-import time rather
# than at the next operator-visible regression.
_UNRESOLVED_REASON_TOKEN_RE = re.compile(r"^[a-z]([a-z0-9-]*[a-z0-9])?$")
@dataclass(frozen=True)
class UnresolvedReason:
"""Registry entry describing one unresolved-reference reason token.
Each instance is the **single source of truth** for everything the
pipeline needs to know about that reason: the literal token stamped
into the placeholder JSON, whether seeing it should flip
``data_complete`` (and thereby trigger the dispatcher's APPROVED →
COMMENT defensive downgrade), and the reason-specific prose body
the section preamble injects when one or more entries of this kind
are present in the slim payload.
Three things consume each instance:
- ``_review_views.build_linked_issues_section`` iterates the
registry to count instances by token (single pass over the slim
list) and to drive the preamble paragraph generator.
- ``_review_views._build_unresolved_note`` renders one paragraph
per reason whose count is non-zero, templating the
"DOES / DOES NOT defensively downgrade" sentence off
:attr:`triggers_defensive_downgrade`.
- The 4xx INFO log dedup path keys off
:data:`UNRESOLVED_REASON_NOT_FOUND` and
:data:`UNRESOLVED_REASON_FETCH_ERROR` directly because the
log-time decision pre-dates the slim payload (we know the
reason at fetch time, before the prose pass).
Adding a future reason — e.g. ``quota-exceeded`` for a
rate-limit response — is genuinely a single-file edit: append a
new constant + ``UnresolvedReason`` instance to
:data:`KNOWN_UNRESOLVED_REASONS` and (if the reason should fire
the dispatcher's defensive downgrade) flip the bool. The section
builder picks it up automatically; the prose body comes from the
instance's ``prose_body`` field.
Token format constraint
-----------------------
:attr:`token` MUST match :data:`_UNRESOLVED_REASON_TOKEN_RE`
(``^[a-z][a-z0-9-]*$``) — lowercase ASCII letters, digits, and
dashes only, starting with a letter. The constraint is validated
in :meth:`__post_init__` so a malformed token raises
``ValueError`` at module import time rather than producing
broken downstream archive keys. Three call sites
(``_review_finalize`` archive-key flatten, ``_review_views``
section header, the 4xx log dedup) munge the token via
``str.replace("-", "_")`` to derive a Python-identifier-shaped
name, so any other special character would silently corrupt
those derived names.
"""
token: str
triggers_defensive_downgrade: bool
prose_body: str
def __post_init__(self) -> None:
"""Validate :attr:`token` against the documented format
constraint at instance-construction time. Frozen dataclasses
do not support assignment in ``__post_init__``, so we only
validate (no normalisation); a malformed token must be fixed
at the call site."""
if not _UNRESOLVED_REASON_TOKEN_RE.fullmatch(self.token):
raise ValueError(
f"UnresolvedReason.token={self.token!r} violates the "
"format constraint "
f"{_UNRESOLVED_REASON_TOKEN_RE.pattern!r}: tokens must be "
"lowercase ASCII letters, digits, and dashes only, "
"starting with a letter. Three downstream sites munge the "
"token via str.replace('-', '_') to derive a Python "
"identifier (archive key / header slot / log dedup id); "
"other characters would silently corrupt those names."
)
# Registry tuple the section builder iterates over so that adding a
# future reason token only requires landing the new constant + a new
# ``UnresolvedReason`` entry here. The section builder's count loop,
# the preamble prose generator, and the documentation surface all
# read fields off the dataclass — there is no parallel hardcoded list
# anywhere in the package.
KNOWN_UNRESOLVED_REASONS: tuple[UnresolvedReason, ...] = (
UnresolvedReason(
token=UNRESOLVED_REASON_NOT_FOUND,
triggers_defensive_downgrade=False,
prose_body=(
"a 4xx response (404 most commonly, also 403 / 410). The "
"implementer's PR body references an issue that does not "
"exist in this repository's tracker (typo, deleted issue, "
"cross-repo reference, or a fork-mode quirk where the "
"upstream issue number is not mirrored locally). Surface "
"the broken link in your review body as a quality concern."
),
),
UnresolvedReason(
token=UNRESOLVED_REASON_FETCH_ERROR,
triggers_defensive_downgrade=True,
prose_body=(
"a 5xx response, a runtime / network exception, or a "
"malformed body. The issue may genuinely exist on the "
"tracker but the dispatcher could not retrieve it. Mention "
"the failure in your review body so the operator "
"understands why the next cycle will re-attempt."
),
),
)
assert {r.token for r in KNOWN_UNRESOLVED_REASONS} == {
UNRESOLVED_REASON_NOT_FOUND,
UNRESOLVED_REASON_FETCH_ERROR,
}, "KNOWN_UNRESOLVED_REASONS drifted from constant set"
# Per-repo policy for ``ISSUES CLOSED: #N`` / ``Closes #N`` resolution
# during reviewer + implementer pre-fetch. Three modes:
#
# - ``strict`` (default, prod-shaped repos): every reference is
# resolved; a ``not-found`` (4xx) result is rendered as a quality
# concern the worker is expected to surface; a ``fetch-error`` (5xx
# / runtime) trips the dispatcher's defensive APPROVED→COMMENT
# downgrade as it always has.
# - ``informational-on-not-found`` (fork / test repos that harvest
# commits from an upstream with its own issue tracker): a
# ``not-found`` result is rendered with an explicit policy note
# telling the worker "this repo's policy is to treat upstream-only
# references as informational, not blocking." The link is still
# shown so the worker has full context; only the "must reject"
# instruction is removed. ``fetch-error`` semantics are
# unchanged — those are real outages, not policy concerns.
# - ``disabled`` (very narrow use case — repos that genuinely have
# no issue tracker): skip resolution entirely, render a single
# "linked-issue resolution disabled by repo policy" placeholder
# section. The worker is told it has no traceability signal to
# check, period.
LINKED_ISSUE_POLICY_STRICT = "strict"
LINKED_ISSUE_POLICY_INFORMATIONAL_ON_NOT_FOUND = "informational-on-not-found"
LINKED_ISSUE_POLICY_DISABLED = "disabled"
KNOWN_LINKED_ISSUE_POLICIES: frozenset[str] = frozenset(
{
LINKED_ISSUE_POLICY_STRICT,
LINKED_ISSUE_POLICY_INFORMATIONAL_ON_NOT_FOUND,
LINKED_ISSUE_POLICY_DISABLED,
}
)
def normalize_linked_issue_policy(value: str | None) -> str:
"""Return a validated policy token. Unknown / empty inputs raise
``ValueError`` so the dispatcher's ``load_config`` surfaces the
problem at startup instead of silently defaulting (which would
mask an operator's typo'd env var until a review later went the
wrong way).
The empty / unset case maps to :data:`LINKED_ISSUE_POLICY_STRICT`
so a deployment that has never set the env var keeps the
historical behaviour exactly.
"""
if value is None or value == "":
return LINKED_ISSUE_POLICY_STRICT
token = str(value).strip()
if token in KNOWN_LINKED_ISSUE_POLICIES:
return token
valid = ", ".join(sorted(KNOWN_LINKED_ISSUE_POLICIES))
raise ValueError(f"unknown linked_issue_policy={value!r}; valid values: {valid}")
def _redact(text: str, end_marker: str) -> str:
"""Replace every literal occurrence of ``end_marker`` with
``<marker>_REDACTED`` so the marker cannot appear inside the
content body and prematurely close the fence.
"""
if not text:
return text
if end_marker not in text:
return text
return text.replace(end_marker, f"{end_marker}_REDACTED")
def _truncate_for_prompt(payload: str, max_chars: int) -> tuple[str, bool]:
"""Cap ``payload`` at ``max_chars`` characters at a UTF-8-safe
boundary. Returns ``(text, truncated_bool)``."""
if len(payload) <= max_chars:
return payload, False
# ``json.dumps`` always produces ASCII unless ensure_ascii=False is
# used; we use the default, so character == byte and a hard slice
# is safe (no risk of cutting a multi-byte character mid-sequence).
return payload[:max_chars] + "\n[... truncated ...]", True
def count_active_request_changes(reviews: list[dict[str, Any]] | None) -> int:
"""Count REQUEST_CHANGES reviews that are STILL active.
A review is "active" when it is in ``REQUEST_CHANGES`` state AND
has NOT been dismissed. Forgejo's
``GET /pulls/{n}/reviews`` returns dismissed reviews verbatim,
so a flat ``state == "REQUEST_CHANGES"`` count over-states the
Tier 1F threshold (the worker's old prompt and the dispatcher's
defensive enforcement both used the over-counted value, which
could escalate PRs that legitimately addressed every previous
blocking review).
Both the prompt-time count (in
:func:`_review_views.build_existing_reviews_section`) and the
dispatcher's Tier 1F override threshold (in
:func:`_review_pipeline._compute_review_action`) consume this
helper so the two sites can never drift.
"""
if not reviews:
return 0
return sum(
1
for review in reviews
if isinstance(review, dict)
and review.get("state") == "REQUEST_CHANGES"
and not bool(review.get("dismissed"))
)
@overload
def _api_get_paginated(
cfg: Any,
path: str,
*,
page_size: int = ...,
max_pages: int = ...,
) -> tuple[list[dict[str, Any]], bool]: ...
@overload
def _api_get_paginated(
cfg: Any,
path: str,
*,
page_size: int = ...,
max_pages: int = ...,
return_truncation: bool,
) -> tuple[list[dict[str, Any]], bool, bool]: ...
def _api_get_paginated(
cfg: Any,
path: str,
*,
page_size: int = _PAGE_SIZE,
max_pages: int = _MAX_PAGES,
return_truncation: bool = False,
) -> tuple[list[dict[str, Any]], bool] | tuple[list[dict[str, Any]], bool, bool]:
"""GET ``path`` with ``?limit=N&page=K`` pagination.
Returns ``(items, completed)``. ``completed=True`` means we
walked the entire result set (a short page or an empty page
terminated the loop normally). ``completed=False`` means the
list is partial — we hit a network error, a non-200 response,
a malformed body, or the ``max_pages`` defensive ceiling. The
caller propagates ``completed`` into the section's
``data_complete`` field so the dispatcher can fail-closed on
APPROVED verdicts.
When ``return_truncation`` is True, returns a 3-tuple
``(items, completed, truncated)``. ``truncated`` is True ONLY when
the ``max_pages`` ceiling was hit — i.e. the data is valid but
incomplete-by-design — and is distinct from a transient failure
(network / 5xx / malformed body), which yields
``completed=False, truncated=False``. The persistent comment cache
uses this to seed from a page-capped fetch (the data is good,
just clipped) while still rejecting genuinely-failed fetches.
Never raises — fetcher robustness is the dispatcher's job, the
worker falls back to ``"Pre-fetched X unavailable"`` sections
if a fetcher errors.
"""
out: list[dict[str, Any]] = []
def _result(
completed: bool, truncated: bool
) -> tuple[list[dict[str, Any]], bool] | tuple[list[dict[str, Any]], bool, bool]:
return (out, completed, truncated) if return_truncation else (out, completed)
sep = "&" if "?" in path else "?"
for page in range(1, max_pages + 1):
url = f"{path}{sep}limit={page_size}&page={page}"
try:
response = _claim_runtime.get(url, cfg)
except Exception as exc: # network / runtime — log + bail
_logger.warning(
"review-pipeline GET %s failed on page %s: %s", path, page, exc
)
return _result(False, False)
status = int(response.get("status") or 0)
if status != 200:
_logger.warning(
"review-pipeline GET %s returned status=%s; partial list size=%s",
path,
status,
len(out),
)
return _result(False, False)
body = response.get("body") or []
if not isinstance(body, list):
_logger.warning(
"review-pipeline GET %s returned non-list body (%s); stopping",
path,
type(body).__name__,
)
return _result(False, False)
out.extend(item for item in body if isinstance(item, dict))
if len(body) < page_size:
return _result(True, False)
_logger.warning(
"review-pipeline GET %s hit max_pages=%s; truncating result at %s "
"items (suspect server bug or extremely large PR)",
path,
max_pages,
len(out),
)
return _result(False, True)
def fetch_pr_details(cfg: Any, pr_number: int) -> dict[str, Any] | None:
"""GET the PR's full Forgejo object (title, body, head, base,
labels, milestone, mergeable, requested_reviewers, ...).
Returns the dict on success, ``None`` on any error / non-200. The
caller falls back to a "Pre-fetched PR metadata unavailable"
section in the worker's prompt and the worker can still proceed
with the raw ``item`` fields it already has.
"""
path = f"/repos/{cfg.owner}/{cfg.repo}/pulls/{int(pr_number)}"
try:
response = _claim_runtime.get(path, cfg)
except Exception as exc:
_logger.warning("PR detail fetch for #%s failed: %s", pr_number, exc)
return None
if int(response.get("status") or 0) != 200:
_logger.warning(
"PR detail fetch for #%s returned status=%s",
pr_number,
response.get("status"),
)
return None
body = response.get("body")
if not isinstance(body, dict):
return None
return body
def fetch_ci_status(cfg: Any, head_sha: str) -> dict[str, Any] | None:
"""GET the combined CI status for ``head_sha``.
Forgejo's ``/commits/{sha}/status`` returns a combined object with
a ``state`` summary plus a ``statuses`` array of individual checks.
"""
if not head_sha:
return None
path = f"/repos/{cfg.owner}/{cfg.repo}/commits/{head_sha}/status"
try:
response = _claim_runtime.get(path, cfg)
except Exception as exc:
_logger.warning("CI status fetch for %s failed: %s", head_sha[:12], exc)
return None
if int(response.get("status") or 0) != 200:
return None
body = response.get("body")
if not isinstance(body, dict):
return None
return body
def fetch_existing_reviews(
cfg: Any, pr_number: int
) -> tuple[list[dict[str, Any]], bool]:
"""List the PR's reviews (paginated) and attach each review's
inline comments inline under ``review["comments"]``.
Returns ``(reviews, completed)``. ``completed=False`` if the
review-list pagination was partial OR if any of the per-review
inline-comment paginations were partial. The dispatcher
aggregates this signal into ``data_complete`` so the worker
cannot APPROVE against a partial review history.
Each review object carries Forgejo fields ``id``, ``state``,
``submitted_at``, ``user``, ``body``, ``commit_id``, ``stale``,
``dismissed``. We add a ``comments`` array of ``{path, body,
new_position}`` so the worker can inspect the specific feedback
that was given without making any further API calls.
"""
base_path = f"/repos/{cfg.owner}/{cfg.repo}/pulls/{int(pr_number)}/reviews"
reviews, completed = _api_get_paginated(cfg, base_path)
overall = completed
for review in reviews:
review_id = review.get("id")
if not isinstance(review_id, int):
continue
comments_path = f"{base_path}/{review_id}/comments"
try:
review_comments, comments_completed = _api_get_paginated(cfg, comments_path)
review["comments"] = review_comments
if not comments_completed:
overall = False
except Exception as exc: # extremely defensive — paginator already swallows
_logger.warning(
"review-comments fetch failed (review_id=%s): %s",
review_id,
exc,
)
review["comments"] = []
overall = False
return reviews, overall
def fetch_pr_comments(cfg: Any, pr_number: int) -> tuple[list[dict[str, Any]], bool]:
"""List the PR's issue-style comments (paginated). Returns
``(comments, completed)`` so the caller can flag partial
pagination on the prompt."""
path = f"/repos/{cfg.owner}/{cfg.repo}/issues/{int(pr_number)}/comments"
return _api_get_paginated(cfg, path)
# ``see`` is intentionally NOT in the closing-keyword set.
# A bare ``see #123`` in a PR body is conventionally an informational
# cross-reference (the PR is *related to* #123, not closing it),
# while ``closes`` / ``fixes`` / ``resolves`` / ``refs`` carry the
# project-management semantics worth surfacing in the prompt. Adding
# ``see`` produced false positives the old worker prompt did not
# match and inflated the linked-issues section unnecessarily.
_LINK_PATTERNS = [
re.compile(r"(?im)\b(?:closes|fixes|resolves|refs?)\s*#(\d+)"),
]
def parse_linked_issue_numbers(body: str | None) -> list[int]:
"""Extract issue numbers referenced from a PR body via the standard
GitHub-style closing keywords.
Only the keywords that conventionally indicate a *closing* link
(closes, fixes, resolves, refs, ref) are matched. The regex is
case-insensitive and tolerant of optional whitespace between the
keyword and the ``#``. Duplicates are removed while preserving
first-occurrence order.
"""
if not body:
return []
numbers: list[int] = []
seen: set[int] = set()
for pattern in _LINK_PATTERNS:
for match in pattern.finditer(body):
try:
value = int(match.group(1))
except (TypeError, ValueError):
continue
if value <= 0 or value in seen:
continue
seen.add(value)
numbers.append(value)
return numbers
def fetch_linked_issues(
cfg: Any,
body: str | None,
*,
max_issues: int = 5,
policy: str | None = None,
) -> tuple[list[dict[str, Any]], bool]:
"""Resolve every "Closes #N" / "Fixes #N" / "Refs #N" reference in
``body`` to a Forgejo issue object.
Returns ``(issues, completed)``. The ``completed`` flag is ONLY
flipped to False on genuine partial-fetch conditions:
- the body referenced more than ``max_issues`` issues (the tail
is silently dropped to keep the prompt budget bounded);
- a per-issue fetch raised a runtime/network exception;
- a per-issue fetch returned 5xx (server-side transient);
- a per-issue fetch returned 200 but the body was malformed.
A 4xx response (404 most commonly, also 403 / 410) is NOT a
partial fetch — it is a deterministic answer that the issue is
not retrievable from this repo's tracker. The issue is emitted
as a synthetic placeholder dict with an
``_review_pipeline_link.unresolved=True`` annotation so the
section builder can render the broken link and the worker can
surface it as a review-quality concern. ``completed`` is left
alone in that case so APPROVED is not defensively downgraded
on what is really an implementer-side data-quality signal.
Likewise, a successfully-fetched issue in a non-trusted state
(closed-without-merge, locked, closed-by-unrelated-PR) keeps
its ``link.fully_addressed=False`` annotation so the worker
sees the staleness, but no longer poisons ``completed``: that
is a review concern the worker is expected to call out, not a
fetch-completeness concern.
Annotation: every emitted issue object gets a
``_review_pipeline_link`` field that the section builder uses
to render an explicit ``state=...`` / ``merged=...`` /
``unresolved=...`` line. We do not mutate the upstream Forgejo
schema; the prefix namespaces our annotation so any future
field rename in the upstream API can't collide.
"""
# Policy precedence: explicit kwarg > cfg.linked_issue_policy >
# default strict. Reading off cfg lets every caller (reviewer
# prefetch, implementer prefetch) inherit the per-repo setting
# without changing its call site; the explicit kwarg is for tests
# that want to pin a mode without constructing a full cfg.
effective_policy = normalize_linked_issue_policy(
policy if policy is not None else getattr(cfg, "linked_issue_policy", None),
)
if effective_policy == LINKED_ISSUE_POLICY_DISABLED:
# Skip resolution entirely. The section builder will render
# a single explanatory placeholder; the worker is told it
# has no traceability check to perform.
return [], True
references = parse_linked_issue_numbers(body)
if not references:
return [], True
overshoot = len(references) > max_issues
out: list[dict[str, Any]] = []
completed = not overshoot
for number in references[:max_issues]:
path = f"/repos/{cfg.owner}/{cfg.repo}/issues/{number}"
try:
response = _claim_runtime.get(path, cfg)
except Exception as exc:
_logger.warning(
"linked-issue #%s fetch failed: %s: %s",
number,
type(exc).__name__,
exc,
)
completed = False
out.append(
unresolved_link_placeholder(
number,
reason=UNRESOLVED_REASON_FETCH_ERROR,
detail=str(exc),
)
)
continue
status = int(response.get("status") or 0)
if status != 200:
if 400 <= status < 500:
# Deterministic non-resolution (404, 403, 410, etc.).
# Per-process dedup so repeating sentinel cycles do not
# spam the operator's INFO stream once per (PR, ref).
_log_unresolved_4xx_once(cfg, number, status)
placeholder = unresolved_link_placeholder(
number,
reason=UNRESOLVED_REASON_NOT_FOUND,
status=status,
)
# Stamp the active policy on the placeholder so the
# section builder can render a policy-aware note
# (e.g. "informational per repo policy") next to the
# link without having to thread the policy through
# its own argument list.
placeholder["_review_pipeline_link"]["policy"] = effective_policy
out.append(placeholder)
else:
# 5xx, redirects we did not follow, AND status==0
# (which falls through to here because 0 is neither in
# [400, 500) nor a normal HTTP code). All three are
# safer to treat as transient: the next dispatcher
# cycle gets to retry.
_logger.warning(
"linked-issue #%s fetch returned transient status=%s; "
"flagging section as partial",
number,
status,
)
completed = False
out.append(
unresolved_link_placeholder(
number,
reason=UNRESOLVED_REASON_FETCH_ERROR,
status=status,
)
)
continue
issue = response.get("body")
if not isinstance(issue, dict):
_logger.warning(
"linked-issue #%s returned 200 with non-dict body (%s); "
"flagging section as partial",
number,
type(issue).__name__,
)
completed = False
out.append(
unresolved_link_placeholder(
number,
reason=UNRESOLVED_REASON_FETCH_ERROR,
detail="non-dict body",
)
)
continue
annotated, fully_addressed = _annotate_linked_issue(issue)
# ``fully_addressed=False`` (closed-without-merge, locked,
# closed-by-unrelated-PR) is a *review concern*, not a
# partial-fetch concern. The annotation already rides on the
# issue object so the worker sees the staleness and can call
# it out in the review body. We keep the boolean here for
# structured DEBUG logging so an operator running with verbose
# logging can audit which staleness states the dispatcher
# observed without re-deriving from the prompt archive.
if not fully_addressed:
link_meta = annotated.get("_review_pipeline_link") or {}
_logger.debug(
"linked-issue #%s annotated as not-fully-addressed "
"(link.state=%s); worker sees staleness in section, no "
"defensive downgrade",
number,
link_meta.get("state"),
)
out.append(annotated)
return out, completed
def _log_unresolved_4xx_once(cfg: Any, number: int, status: int) -> None:
"""Emit one INFO log line per (owner, repo, issue_number, status)
quad for the dispatcher-process lifetime.
Without dedup, every polling cycle of every sentinel-fork PR
(whose ``Closes #N`` keeps pointing at an upstream-only issue
number) emits a fresh INFO line and the operator's log stream
fills with redundant noise.
"""
key = (str(cfg.owner), str(cfg.repo), int(number), int(status))
if key in _LOGGED_UNRESOLVED_4XX:
return
_LOGGED_UNRESOLVED_4XX.add(key)
_logger.info(
"linked-issue #%s on %s/%s returned status=%s; marking reference "
"as unresolved (worker will be told); completed flag preserved. "
"Future cycles for this (PR, ref, status) will not re-log.",
number,
cfg.owner,
cfg.repo,
status,
)
def unresolved_link_placeholder(
number: int,
*,
reason: str,
status: int | None = None,
detail: str | None = None,
) -> dict[str, Any]:
"""Synthesize a placeholder issue dict for a ``Closes #N``/``Fixes #N``
reference the dispatcher could not resolve.
The placeholder mirrors the shape of a real Forgejo issue payload
(``number``, ``title``, ``body``, ``state``) so downstream
serialisation and slim helpers don't have to special-case it, but
every content field is None/empty. The truth lives in the
``_review_pipeline_link`` annotation:
- ``unresolved=True`` is the discriminator the slim helper and
the section builder both key off.
- ``unresolved_reason`` is :data:`UNRESOLVED_REASON_NOT_FOUND`
for 4xx terminal answers (issue genuinely not retrievable from
this repo's tracker) or :data:`UNRESOLVED_REASON_FETCH_ERROR`
for transient failures (5xx / runtime exception / malformed
body / status==0).
- ``unresolved_status`` carries the HTTP status when available so
the worker can tell ``404 not-found`` from ``403 forbidden``.
Always emitted (None when no status is available).
- ``unresolved_detail`` carries an exception string or short
free-form note. Always emitted (None when none was supplied).
Long values are truncated to
:data:`_UNRESOLVED_DETAIL_MAX_CHARS` with an explicit
``...[truncated]`` suffix so an operator reading the JSON can
tell head from whole.
We populate ``state`` / ``fully_addressed`` / ``merged`` for shape
parity with annotated real issues, but the ``unresolved`` flag
overrides them in the worker's view.
"""
annotation: dict[str, Any] = {
"state": LINK_STATE_UNRESOLVED,
"fully_addressed": False,
"merged": False,
"is_pull_request": False,
"unresolved": True,
"unresolved_reason": reason,
"unresolved_status": int(status) if status is not None else None,
"unresolved_detail": _truncate_unresolved_detail(detail),
}
return {
"number": int(number),
"title": None,
"body": "",
# Top-level ``state`` mirrors the ``link.state`` annotation so
# the slim helper does not produce a placeholder with
# ``state=null`` next to ``link.state="unresolved"`` (a worker
# glancing at the JSON would see two ``state`` keys with
# different values and reasonably wonder which to trust). For
# real Forgejo issues the top-level value is the upstream
# schema value (kept for parity); for synthetic placeholders
# it is the dispatcher's own taxonomy.
"state": LINK_STATE_UNRESOLVED,
"labels": [],
"_review_pipeline_link": annotation,
}
def _truncate_unresolved_detail(detail: str | None) -> str | None:
"""Cap ``detail`` at :data:`_UNRESOLVED_DETAIL_MAX_CHARS` with an
explicit ``...[truncated]`` suffix when the cap fires.
Returns None for falsy input so the placeholder's
``unresolved_detail`` field stays consistently shaped (always
present, ``None`` when no detail was supplied).
The ``max(head_len, 0)`` clamp is defensive against future
tuning of the cap that drops it below the suffix length — the
module-load assertion above already catches this at import
time, but the runtime clamp guarantees we never slice with a
negative offset (which Python would interpret as a tail-relative
index, producing surprising output).
"""
if not detail:
return None
text = str(detail)
if len(text) <= _UNRESOLVED_DETAIL_MAX_CHARS:
return text
head_len = max(
_UNRESOLVED_DETAIL_MAX_CHARS - len(_UNRESOLVED_DETAIL_TRUNCATION_SUFFIX),
0,
)
return text[:head_len] + _UNRESOLVED_DETAIL_TRUNCATION_SUFFIX
def _annotate_linked_issue(
issue: dict[str, Any],
) -> tuple[dict[str, Any], bool]:
"""Return ``(annotated_issue, fully_addressed)``.
``fully_addressed`` is True when the issue is in a terminal
state we trust as "this PR's linked acceptance criterion really
did land":
- an open issue (the PR under review will close it on merge);
- a closed pull request that was *merged*.
Everything else (closed-without-merge, closed-by-unrelated-PR,
locked, etc.) flips it to False so the dispatcher's aggregate
``data_complete`` flag is False and APPROVED is downgraded to
COMMENT defensively. The worker sees the explicit annotation
in the section text and can call out a genuine staleness.
"""
state = str(issue.get("state") or "").lower()
is_pr = bool(issue.get("pull_request"))
pr_meta = (
issue.get("pull_request") if isinstance(issue.get("pull_request"), dict) else {}
)
merged = bool(pr_meta.get("merged")) if isinstance(pr_meta, dict) else False
if state == "open" or not state:
# Missing ``state`` is the common shape for stubs and for some
# legacy Forgejo deployments. Treat an unknown-but-fetched
# issue as "available" rather than flagging the whole section
# incomplete on a missing optional field — the worker already
# sees the raw payload and can disagree.
link_state = "open" if state == "open" else (state or "open")
fully_addressed = True
elif state == "closed" and is_pr and merged:
link_state = "closed-merged"
fully_addressed = True
elif state == "closed":
link_state = "closed-without-merge"
fully_addressed = False
else:
link_state = state
fully_addressed = False
annotated = dict(issue)
annotated["_review_pipeline_link"] = {
"state": link_state,
"fully_addressed": fully_addressed,
"merged": merged,
"is_pull_request": is_pr,
}
return annotated, fully_addressed
def fetch_pr_commits(cfg: Any, pr_number: int) -> tuple[list[dict[str, Any]], bool]:
"""Fetch the commits on this PR via Forgejo's
``/pulls/{n}/commits`` endpoint.
Returns ``(commits, completed)``. The endpoint paginates the
same way as the reviews / comments endpoints, so this is a thin
wrapper around :func:`_api_get_paginated`. The worker uses the
commit-list to enforce conventional-commit footer requirements
(``ISSUES CLOSED: #N``) and to spot squashed/rebased commits in
a re-review.
"""
path = f"/repos/{cfg.owner}/{cfg.repo}/pulls/{int(pr_number)}/commits"
return _api_get_paginated(cfg, path)
def fetch_ci_check_detail(cfg: Any, head_sha: str) -> tuple[list[dict[str, Any]], bool]:
"""Fetch every individual ``status`` for ``head_sha`` via
``/commits/{sha}/statuses`` (NOT the combined ``/status``).
The combined-status endpoint already returns a ``statuses``
array, but Forgejo paginates the individual-statuses endpoint
independently, and on a non-success commit the operator and the
worker both want the full per-check log URL list. The worker
matches the failing context against its CI-flag heuristics
(e.g. lint-only failures vs. infra failures).
Returns ``(statuses, completed)`` so the dispatcher can flag
partial pagination on the prompt.
"""
if not head_sha:
return [], True
path = f"/repos/{cfg.owner}/{cfg.repo}/commits/{head_sha}/statuses"
return _api_get_paginated(cfg, path)
__all__ = (
"KNOWN_UNRESOLVED_REASONS",
"LINK_STATES_CANONICAL",
"LINK_STATE_CLOSED_MERGED",
"LINK_STATE_CLOSED_WITHOUT_MERGE",
"LINK_STATE_OPEN",
"LINK_STATE_UNKNOWN",
"LINK_STATE_UNRESOLVED",
"UNRESOLVED_REASON_FETCH_ERROR",
"UNRESOLVED_REASON_NOT_FOUND",
"UnresolvedReason",
"count_active_request_changes",
"fetch_ci_check_detail",
"fetch_ci_status",
"fetch_existing_reviews",
"fetch_linked_issues",
"fetch_pr_comments",
"fetch_pr_commits",
"fetch_pr_details",
"parse_linked_issue_numbers",
"unresolved_link_placeholder",
)