0bc734c020
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>
982 lines
40 KiB
Python
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",
|
|
)
|