Add a Phase 3 read-only console that resolves issue↔PR linkage with evidence (closes keyword, branch marker, body mention), surfaces the latest canonical handoff for a focused thread, and deep-links to Gitea only under the admin reveal opt-in. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
772 lines
27 KiB
Python
772 lines
27 KiB
Python
"""Gitea issue↔PR linkage model for the console (#645, Phase 3).
|
|
|
|
Operators lose context between an issue and the PR that closes it: which PR
|
|
carries which issue, whether two PRs claim the same issue, and what the latest
|
|
canonical handoff on that thread said. The evidence exists in Gitea, but only
|
|
as free text scattered across PR titles, bodies, and branch names.
|
|
|
|
This module resolves that linkage into one read-only model:
|
|
|
|
* :func:`resolve_linkage` is a pure function from raw Gitea issue/PR payloads to
|
|
a :class:`LinkageIndex`. It records *how* each edge was found (a ``Closes #N``
|
|
keyword, the canonical ``feat/issue-N-…`` branch marker, or a bare ``#N``
|
|
body reference) and never collapses several candidates into one silent guess.
|
|
* :func:`load_linkage_snapshot` scopes that index to a registry project and
|
|
optionally attaches the latest Canonical Thread Handoff (CTH) summary for one
|
|
focused issue or PR.
|
|
|
|
Design rules, matching the rest of the console:
|
|
|
|
- **Read-only.** Gitea is read through the shared authenticated helpers. No
|
|
endpoint here mutates anything, and no write action is registered.
|
|
- **Qualified absence.** Linkage is a claim about a *loaded* window of Gitea.
|
|
When pagination did not complete, when credentials were unavailable, or when
|
|
only open items were fetched, the snapshot says so and every "no linked PR"
|
|
is marked non-authoritative. An empty edge list from a partial read is not
|
|
evidence that no link exists.
|
|
- **Handoff is loaded, never assumed.** CTH comments are thread-scoped, so they
|
|
are fetched only for an explicitly focused issue or PR. Every other row
|
|
reports ``not_loaded`` rather than rendering as "no handoff".
|
|
- **Redaction at the boundary.** Titles, labels, handoff fields, and error
|
|
reasons are free text from Gitea and cross :mod:`webui.console_redaction`
|
|
before they leave this module.
|
|
- **Deep links are opt-in.** A link to the Gitea web UI is emitted only under
|
|
the ``GITEA_MCP_REVEAL_ENDPOINTS`` admin opt-in, exactly as the MCP tools
|
|
gate their own URL exposure.
|
|
|
|
Non-goals (from the issue): no issue/PR editor, no browser review or merge, no
|
|
reimplementation of Gitea search.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
from dataclasses import dataclass
|
|
from typing import Any, Callable, Iterable, Sequence
|
|
|
|
from gitea_auth import api_fetch_page, get_auth_header, gitea_url, repo_api_url
|
|
|
|
from webui import console_redaction
|
|
from webui.project_registry import ProjectRecord, load_registry
|
|
from webui.queue_loader import (
|
|
PaginationMeta,
|
|
_fetch_issues,
|
|
_fetch_prs,
|
|
_host_from_url,
|
|
)
|
|
|
|
#: Version of the serialized linkage contract. Bump on any breaking change.
|
|
LINKAGE_SCHEMA_VERSION = 1
|
|
|
|
# --- Linkage evidence -------------------------------------------------------
|
|
# Ordered strongest to weakest. The strength ordering is what makes an
|
|
# ambiguous PR detectable: two candidates at the same strength are a genuine
|
|
# ambiguity, while a weaker candidate alongside a stronger one is not.
|
|
EVIDENCE_CLOSES = "closes_keyword"
|
|
EVIDENCE_BRANCH = "branch_marker"
|
|
EVIDENCE_REFERENCE = "body_reference"
|
|
|
|
EVIDENCE_ORDER: tuple[str, ...] = (
|
|
EVIDENCE_CLOSES,
|
|
EVIDENCE_BRANCH,
|
|
EVIDENCE_REFERENCE,
|
|
)
|
|
_EVIDENCE_RANK = {name: rank for rank, name in enumerate(EVIDENCE_ORDER)}
|
|
|
|
EVIDENCE_DESCRIPTIONS: dict[str, str] = {
|
|
EVIDENCE_CLOSES: (
|
|
"the PR title or body declares 'closes/fixes/resolves #N' — Gitea itself "
|
|
"acts on this keyword, so it is the strongest available evidence"
|
|
),
|
|
EVIDENCE_BRANCH: (
|
|
"the PR head branch carries the canonical issue marker "
|
|
"'(fix|feat|docs|chore)/issue-N-…' minted by the issue lock"
|
|
),
|
|
EVIDENCE_REFERENCE: (
|
|
"the PR body mentions '#N' without a closing keyword; a mention is not "
|
|
"a claim that the PR closes that issue"
|
|
),
|
|
}
|
|
|
|
_CLOSES_RE = re.compile(r"(?:closes|fixes|resolves)\s+#(\d+)", re.IGNORECASE)
|
|
_REFERENCE_RE = re.compile(r"#(\d+)")
|
|
_BRANCH_MARKER_RE = re.compile(
|
|
r"^(?:fix|feat|docs|chore)/issue-(\d+)(?:[-/]|$)", re.IGNORECASE
|
|
)
|
|
|
|
# Handoff-source states. ``not_loaded`` is deliberately distinct from "none
|
|
# found": a row whose comments were never fetched proves nothing about whether
|
|
# a handoff exists on that thread.
|
|
HANDOFF_NOT_LOADED = "not_loaded"
|
|
HANDOFF_LOADED = "loaded"
|
|
HANDOFF_UNAVAILABLE = "unavailable"
|
|
|
|
# Which item states were fetched. Linkage claims are scoped to this window.
|
|
STATE_OPEN = "open"
|
|
STATE_ALL = "all"
|
|
_SUPPORTED_STATES = (STATE_OPEN, STATE_ALL)
|
|
|
|
|
|
def _redact(value: Any) -> Any:
|
|
"""Redact one free-text field, failing closed to the placeholder."""
|
|
if value is None:
|
|
return None
|
|
return console_redaction.redact_text(str(value))
|
|
|
|
|
|
def deep_links_enabled(env: dict[str, str] | None = None) -> bool:
|
|
"""Whether Gitea web-UI deep links may be emitted (admin/debug opt-in)."""
|
|
source = env if env is not None else os.environ
|
|
return (source.get("GITEA_MCP_REVEAL_ENDPOINTS") or "").strip().lower() in {
|
|
"1",
|
|
"true",
|
|
"yes",
|
|
"on",
|
|
}
|
|
|
|
|
|
def _deep_link(host: str, org: str, repo: str, kind: str, number: int) -> str | None:
|
|
"""Build a Gitea web link for one item, or None when reveal is not enabled."""
|
|
if not deep_links_enabled() or not (host and org and repo):
|
|
return None
|
|
segment = "pulls" if kind == "pr" else "issues"
|
|
try:
|
|
return gitea_url(host, f"/{org}/{repo}/{segment}/{int(number)}")
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
# --- Pure linkage resolution -------------------------------------------------
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class IssueLink:
|
|
"""One resolved edge from a PR to an issue, with the evidence that found it."""
|
|
|
|
issue_number: int
|
|
evidence: tuple[str, ...]
|
|
|
|
@property
|
|
def strength(self) -> int:
|
|
"""Rank of the strongest evidence backing this edge (lower is stronger)."""
|
|
return min(
|
|
(_EVIDENCE_RANK.get(name, len(EVIDENCE_ORDER)) for name in self.evidence),
|
|
default=len(EVIDENCE_ORDER),
|
|
)
|
|
|
|
@property
|
|
def closes(self) -> bool:
|
|
"""True only when the PR *declares* it closes the issue."""
|
|
return EVIDENCE_CLOSES in self.evidence
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return {
|
|
"issue_number": self.issue_number,
|
|
"evidence": list(self.evidence),
|
|
"closes": self.closes,
|
|
}
|
|
|
|
|
|
def _sorted_links(links: Iterable[IssueLink]) -> tuple[IssueLink, ...]:
|
|
return tuple(sorted(links, key=lambda link: (link.strength, link.issue_number)))
|
|
|
|
|
|
def resolve_pr_links(pr: dict[str, Any]) -> tuple[IssueLink, ...]:
|
|
"""Resolve every issue a PR points at, strongest evidence first.
|
|
|
|
Every candidate is kept. Collapsing to a single "linked issue" is what makes
|
|
a mislinked or double-claimed PR invisible, so the caller decides what to do
|
|
with several candidates rather than being handed one guess.
|
|
"""
|
|
found: dict[int, set[str]] = {}
|
|
|
|
def _add(number: Any, evidence: str) -> None:
|
|
try:
|
|
issue_number = int(number)
|
|
except (TypeError, ValueError):
|
|
return
|
|
if issue_number <= 0:
|
|
return
|
|
found.setdefault(issue_number, set()).add(evidence)
|
|
|
|
title = str(pr.get("title") or "")
|
|
body = str(pr.get("body") or "")
|
|
for text in (title, body):
|
|
for match in _CLOSES_RE.finditer(text):
|
|
_add(match.group(1), EVIDENCE_CLOSES)
|
|
|
|
head_ref = str((pr.get("head") or {}).get("ref") or "")
|
|
branch_match = _BRANCH_MARKER_RE.match(head_ref.strip())
|
|
if branch_match:
|
|
_add(branch_match.group(1), EVIDENCE_BRANCH)
|
|
|
|
# The ``#N`` inside "Closes #N" is the *same* textual occurrence as the
|
|
# closing keyword, not a second, independent mention. Blanking the closing
|
|
# phrases first keeps "mention" meaning what the legend says it means: a
|
|
# reference the PR made without claiming to close anything.
|
|
for match in _REFERENCE_RE.finditer(_CLOSES_RE.sub(" ", body)):
|
|
_add(match.group(1), EVIDENCE_REFERENCE)
|
|
|
|
# A PR's own number appearing in its body is self-reference, not linkage.
|
|
try:
|
|
found.pop(int(pr.get("number")), None)
|
|
except (TypeError, ValueError):
|
|
pass
|
|
|
|
return _sorted_links(
|
|
IssueLink(
|
|
issue_number=number,
|
|
evidence=tuple(name for name in EVIDENCE_ORDER if name in evidence),
|
|
)
|
|
for number, evidence in found.items()
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class LinkageIndex:
|
|
"""Resolved linkage over one loaded window of issues and PRs."""
|
|
|
|
pr_links: dict[int, tuple[IssueLink, ...]]
|
|
issue_prs: dict[int, tuple[int, ...]]
|
|
|
|
def primary_issue(self, pr_number: int) -> IssueLink | None:
|
|
"""The strongest edge for a PR, or None when it points at no issue."""
|
|
links = self.pr_links.get(int(pr_number)) or ()
|
|
return links[0] if links else None
|
|
|
|
def ambiguous(self, pr_number: int) -> bool:
|
|
"""True when two or more issues tie at the PR's strongest evidence."""
|
|
links = self.pr_links.get(int(pr_number)) or ()
|
|
if len(links) < 2:
|
|
return False
|
|
best = links[0].strength
|
|
return sum(1 for link in links if link.strength == best) > 1
|
|
|
|
def contested_issues(self) -> tuple[int, ...]:
|
|
"""Issues claimed by more than one PR in the loaded window."""
|
|
return tuple(
|
|
number for number, prs in sorted(self.issue_prs.items()) if len(prs) > 1
|
|
)
|
|
|
|
|
|
def resolve_linkage(prs: Sequence[dict[str, Any]]) -> LinkageIndex:
|
|
"""Build the bidirectional linkage index for a loaded window of PRs.
|
|
|
|
Only *closing* and *branch-marker* edges populate the issue→PR direction: a
|
|
bare ``#N`` mention is a reference, and treating it as "this PR is the work
|
|
for issue N" would invent contested issues out of ordinary cross-links. The
|
|
weaker edge stays visible on the PR→issue side, where it is labelled.
|
|
"""
|
|
pr_links: dict[int, tuple[IssueLink, ...]] = {}
|
|
issue_prs: dict[int, list[int]] = {}
|
|
for pr in prs or []:
|
|
try:
|
|
pr_number = int(pr["number"])
|
|
except (KeyError, TypeError, ValueError):
|
|
continue
|
|
links = resolve_pr_links(pr)
|
|
pr_links[pr_number] = links
|
|
for link in links:
|
|
if link.evidence == (EVIDENCE_REFERENCE,):
|
|
continue
|
|
bucket = issue_prs.setdefault(link.issue_number, [])
|
|
if pr_number not in bucket:
|
|
bucket.append(pr_number)
|
|
return LinkageIndex(
|
|
pr_links=pr_links,
|
|
issue_prs={number: tuple(sorted(items)) for number, items in issue_prs.items()},
|
|
)
|
|
|
|
|
|
# --- Canonical handoff summary ----------------------------------------------
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class HandoffSummary:
|
|
"""The latest CTH comment on one thread, redacted for display."""
|
|
|
|
comment_id: int | None
|
|
created_at: str | None
|
|
author: str | None
|
|
cth_type: str
|
|
cth_type_known: bool
|
|
status: str | None
|
|
next_owner: str | None
|
|
current_blocker: str | None
|
|
decision: str | None
|
|
next_action: str | None
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return {
|
|
"comment_id": self.comment_id,
|
|
"created_at": self.created_at,
|
|
"author": self.author,
|
|
"cth_type": self.cth_type,
|
|
"cth_type_known": self.cth_type_known,
|
|
"status": self.status,
|
|
"next_owner": self.next_owner,
|
|
"current_blocker": self.current_blocker,
|
|
"decision": self.decision,
|
|
"next_action": self.next_action,
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class HandoffStatus:
|
|
"""Why a thread's handoff summary is present, absent, or unknown."""
|
|
|
|
state: str
|
|
reason: str | None = None
|
|
target: str | None = None
|
|
|
|
@property
|
|
def loaded(self) -> bool:
|
|
return self.state == HANDOFF_LOADED
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return {"state": self.state, "reason": self.reason, "target": self.target}
|
|
|
|
|
|
def summarize_handoff(comments: Sequence[dict[str, Any]]) -> HandoffSummary | None:
|
|
"""Summarize the newest CTH comment in *comments*, or None when there is none.
|
|
|
|
Every field is redacted before it is returned: a handoff body is operator
|
|
free text that regularly quotes commands, and it is rendered verbatim on the
|
|
page this feeds.
|
|
"""
|
|
from canonical_thread_handoff import find_latest_cth, is_known_cth_type
|
|
|
|
try:
|
|
latest = find_latest_cth(list(comments or []))
|
|
except Exception:
|
|
return None
|
|
if not latest:
|
|
return None
|
|
fields = latest.get("fields") or {}
|
|
cth_type = str(latest.get("cth_type") or "").strip()
|
|
known = is_known_cth_type(cth_type)
|
|
try:
|
|
comment_id: int | None = int(latest.get("comment_id"))
|
|
except (TypeError, ValueError):
|
|
comment_id = None
|
|
return HandoffSummary(
|
|
comment_id=comment_id,
|
|
created_at=_redact(latest.get("created_at")),
|
|
author=_redact(latest.get("author")),
|
|
# An unrecognised heading is reported as such rather than republished:
|
|
# the heading is free text, and CTH_TYPES is the only authority for what
|
|
# a handoff type may be.
|
|
cth_type=cth_type if known else "unrecognized",
|
|
cth_type_known=known,
|
|
status=_redact(fields.get("status")),
|
|
next_owner=_redact(fields.get("next owner")),
|
|
current_blocker=_redact(fields.get("current blocker")),
|
|
decision=_redact(fields.get("decision")),
|
|
next_action=_redact(fields.get("next action")),
|
|
)
|
|
|
|
|
|
CommentSource = Callable[[str, int], list[dict[str, Any]]]
|
|
|
|
|
|
def build_comment_source(host: str, org: str, repo: str) -> CommentSource | None:
|
|
"""Build an authenticated ``(kind, number) -> comments`` fetcher, or None.
|
|
|
|
Returns None when the console is running in offline test mode or when no
|
|
credential is available for *host*, so the caller reports the handoff source
|
|
as unavailable instead of as an empty thread.
|
|
"""
|
|
if _offline_test_mode() or not (host and org and repo):
|
|
return None
|
|
auth = get_auth_header(host)
|
|
if not auth:
|
|
return None
|
|
|
|
def _fetch(kind: str, number: int) -> list[dict[str, Any]]:
|
|
segment = "pulls" if kind == "pr" else "issues"
|
|
url = f"{repo_api_url(host, org, repo)}/{segment}/{int(number)}/comments"
|
|
comments: list[dict[str, Any]] = []
|
|
page = 1
|
|
while page <= 20:
|
|
raw, meta = api_fetch_page(url, auth, page=page, limit=50)
|
|
comments.extend(raw)
|
|
if bool(meta["is_final_page"]):
|
|
break
|
|
page += 1
|
|
return comments
|
|
|
|
return _fetch
|
|
|
|
|
|
# --- Snapshot ----------------------------------------------------------------
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class LinkageNode:
|
|
"""One issue or PR row with its resolved links and display metadata."""
|
|
|
|
kind: str
|
|
number: int
|
|
title: str
|
|
state: str
|
|
labels: tuple[str, ...] = ()
|
|
links: tuple[IssueLink, ...] = ()
|
|
linked_prs: tuple[int, ...] = ()
|
|
ambiguous: bool = False
|
|
contested: bool = False
|
|
deep_link: str | None = None
|
|
handoff: HandoffSummary | None = None
|
|
handoff_status: HandoffStatus = HandoffStatus(HANDOFF_NOT_LOADED)
|
|
links_authoritative: bool = True
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return {
|
|
"kind": self.kind,
|
|
"number": self.number,
|
|
"title": self.title,
|
|
"state": self.state,
|
|
"labels": list(self.labels),
|
|
"links": [link.to_dict() for link in self.links],
|
|
"linked_prs": list(self.linked_prs),
|
|
"ambiguous": self.ambiguous,
|
|
"contested": self.contested,
|
|
"deep_link": self.deep_link,
|
|
"links_authoritative": self.links_authoritative,
|
|
"handoff": self.handoff.to_dict() if self.handoff else None,
|
|
"handoff_status": self.handoff_status.to_dict(),
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class LinkageSnapshot:
|
|
"""One answered linkage query over a scoped window of a Gitea repo."""
|
|
|
|
ok: bool
|
|
project_id: str
|
|
repo_label: str
|
|
host: str
|
|
state_scope: str
|
|
issues: tuple[LinkageNode, ...] = ()
|
|
prs: tuple[LinkageNode, ...] = ()
|
|
contested_issues: tuple[int, ...] = ()
|
|
focus: tuple[str, int] | None = None
|
|
inventory_complete: bool = False
|
|
deep_links_enabled: bool = False
|
|
handoff_status: HandoffStatus = HandoffStatus(HANDOFF_NOT_LOADED)
|
|
fetch_error: str | None = None
|
|
|
|
@property
|
|
def orphan_prs(self) -> tuple[LinkageNode, ...]:
|
|
"""PRs in the loaded window that point at no issue at all."""
|
|
return tuple(node for node in self.prs if not node.links)
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return {
|
|
"ok": self.ok,
|
|
"schema_version": LINKAGE_SCHEMA_VERSION,
|
|
"project_id": self.project_id,
|
|
"repo": self.repo_label,
|
|
"state_scope": self.state_scope,
|
|
"inventory_complete": self.inventory_complete,
|
|
"deep_links_enabled": self.deep_links_enabled,
|
|
"focus": (
|
|
None
|
|
if self.focus is None
|
|
else {"kind": self.focus[0], "number": self.focus[1]}
|
|
),
|
|
"handoff_source": self.handoff_status.to_dict(),
|
|
"fetch_error": self.fetch_error,
|
|
"contested_issues": list(self.contested_issues),
|
|
"issues": [node.to_dict() for node in self.issues],
|
|
"prs": [node.to_dict() for node in self.prs],
|
|
"evidence_kinds": [
|
|
{"name": name, "description": EVIDENCE_DESCRIPTIONS[name]}
|
|
for name in EVIDENCE_ORDER
|
|
],
|
|
}
|
|
|
|
|
|
def snapshot_to_dict(snapshot: LinkageSnapshot) -> dict[str, Any]:
|
|
"""JSON-serializable export for ``/api/v1/gitea/linkage``."""
|
|
return snapshot.to_dict()
|
|
|
|
|
|
def _offline_test_mode() -> bool:
|
|
return (os.environ.get("WEBUI_TEST_OFFLINE") or "").strip().lower() in {
|
|
"1",
|
|
"true",
|
|
"yes",
|
|
}
|
|
|
|
|
|
def _labels_of(item: dict[str, Any]) -> tuple[str, ...]:
|
|
return tuple(
|
|
str(_redact(label.get("name")))
|
|
for label in (item.get("labels") or [])
|
|
if label.get("name")
|
|
)
|
|
|
|
|
|
def _failed_snapshot(
|
|
*,
|
|
project_id: str,
|
|
repo_label: str,
|
|
host: str,
|
|
state_scope: str,
|
|
reason: str,
|
|
) -> LinkageSnapshot:
|
|
"""A read that could not be answered. Never an empty-and-healthy snapshot."""
|
|
return LinkageSnapshot(
|
|
ok=False,
|
|
project_id=project_id,
|
|
repo_label=repo_label,
|
|
host=host,
|
|
state_scope=state_scope,
|
|
inventory_complete=False,
|
|
deep_links_enabled=deep_links_enabled(),
|
|
handoff_status=HandoffStatus(
|
|
HANDOFF_UNAVAILABLE, reason="linkage inventory could not be loaded"
|
|
),
|
|
fetch_error=str(_redact(reason)),
|
|
)
|
|
|
|
|
|
def _resolve_project(project_id: str | None) -> ProjectRecord | None:
|
|
registry = load_registry()
|
|
if project_id:
|
|
for entry in registry.projects:
|
|
if entry.id == project_id:
|
|
return entry
|
|
return None
|
|
return registry.projects[0] if registry.projects else None
|
|
|
|
|
|
def _normalize_state(state: str | None) -> str:
|
|
text = (state or STATE_OPEN).strip().lower()
|
|
return text if text in _SUPPORTED_STATES else STATE_OPEN
|
|
|
|
|
|
def load_linkage_snapshot(
|
|
project_id: str | None = None,
|
|
*,
|
|
state: str | None = None,
|
|
issue: int | None = None,
|
|
pr: int | None = None,
|
|
fetch_prs: Callable[..., tuple[list[dict], PaginationMeta]] | None = None,
|
|
fetch_issues: Callable[..., tuple[list[dict], PaginationMeta]] | None = None,
|
|
comment_source: CommentSource | None = None,
|
|
) -> LinkageSnapshot:
|
|
"""Load issue↔PR linkage for a registry project.
|
|
|
|
``issue``/``pr`` focus one thread: the focused row is the only one whose
|
|
Canonical Thread Handoff comments are fetched, because handoff comments are
|
|
thread-scoped and loading them for a whole queue would be one request per
|
|
row. Every unfocused row reports its handoff as ``not_loaded``.
|
|
"""
|
|
state_scope = _normalize_state(state)
|
|
try:
|
|
project = _resolve_project(project_id)
|
|
except Exception as exc: # registry invalid — fail closed with the reason
|
|
return _failed_snapshot(
|
|
project_id=project_id or "",
|
|
repo_label="",
|
|
host="",
|
|
state_scope=state_scope,
|
|
reason=f"project registry unavailable: {exc}",
|
|
)
|
|
|
|
if project is None:
|
|
return _failed_snapshot(
|
|
project_id=project_id or "",
|
|
repo_label="",
|
|
host="",
|
|
state_scope=state_scope,
|
|
reason=(
|
|
f"project {project_id!r} not found in registry"
|
|
if project_id
|
|
else "no projects registered"
|
|
),
|
|
)
|
|
|
|
host = _host_from_url(project.remote_host)
|
|
repo_label = f"{project.gitea_owner}/{project.repo_name}"
|
|
offline_test = _offline_test_mode()
|
|
|
|
def _empty_fetch(*_args, **_kwargs):
|
|
return [], PaginationMeta(
|
|
page=1,
|
|
per_page=50,
|
|
returned_count=0,
|
|
has_more=False,
|
|
is_final_page=True,
|
|
# An offline stub loaded nothing; claiming a complete inventory here
|
|
# would let the page assert that no issue has a linked PR.
|
|
inventory_complete=False,
|
|
pages_fetched=0,
|
|
)
|
|
|
|
pr_fetch = fetch_prs or (_empty_fetch if offline_test else _fetch_prs)
|
|
issue_fetch = fetch_issues or (_empty_fetch if offline_test else _fetch_issues)
|
|
using_live_fetch = not offline_test and (fetch_prs is None or fetch_issues is None)
|
|
auth = get_auth_header(host) if using_live_fetch else "test-auth"
|
|
if using_live_fetch and not auth:
|
|
return _failed_snapshot(
|
|
project_id=project.id,
|
|
repo_label=repo_label,
|
|
host=host,
|
|
state_scope=state_scope,
|
|
reason=(
|
|
f"Gitea credentials unavailable for {host}; linkage cannot be "
|
|
"loaded (fail closed — not rendering an empty linkage table)"
|
|
),
|
|
)
|
|
|
|
try:
|
|
raw_prs, pr_pagination = pr_fetch(
|
|
host, project.gitea_owner, project.repo_name, auth, state=state_scope
|
|
)
|
|
raw_issues, issue_pagination = issue_fetch(
|
|
host, project.gitea_owner, project.repo_name, auth, state=state_scope
|
|
)
|
|
except Exception as exc: # noqa: BLE001 — operator-visible fetch failure
|
|
return _failed_snapshot(
|
|
project_id=project.id,
|
|
repo_label=repo_label,
|
|
host=host,
|
|
state_scope=state_scope,
|
|
reason=f"Gitea fetch failed: {exc}",
|
|
)
|
|
|
|
inventory_complete = bool(
|
|
getattr(pr_pagination, "inventory_complete", False)
|
|
and getattr(issue_pagination, "inventory_complete", False)
|
|
)
|
|
|
|
index = resolve_linkage(raw_prs)
|
|
contested = index.contested_issues()
|
|
|
|
focus: tuple[str, int] | None = None
|
|
if pr is not None:
|
|
focus = ("pr", int(pr))
|
|
elif issue is not None:
|
|
focus = ("issue", int(issue))
|
|
|
|
unfocused_reason = (
|
|
"canonical handoff comments are thread-scoped; focus one issue or PR "
|
|
"to load its latest handoff"
|
|
)
|
|
handoff_status = HandoffStatus(HANDOFF_NOT_LOADED, reason=unfocused_reason)
|
|
focus_handoff: HandoffSummary | None = None
|
|
if focus is not None:
|
|
source = comment_source
|
|
if source is None and not offline_test:
|
|
source = build_comment_source(host, project.gitea_owner, project.repo_name)
|
|
target = f"{focus[0]}#{focus[1]}"
|
|
if source is None:
|
|
handoff_status = HandoffStatus(
|
|
HANDOFF_UNAVAILABLE,
|
|
reason="no authenticated comment source available for this read",
|
|
target=target,
|
|
)
|
|
else:
|
|
try:
|
|
focus_handoff = summarize_handoff(source(focus[0], focus[1]) or [])
|
|
handoff_status = HandoffStatus(HANDOFF_LOADED, target=target)
|
|
except Exception as exc: # fail soft: degrade this source only
|
|
handoff_status = HandoffStatus(
|
|
HANDOFF_UNAVAILABLE,
|
|
reason=str(_redact(f"handoff fetch failed: {exc}")),
|
|
target=target,
|
|
)
|
|
|
|
def _node_handoff(
|
|
kind: str, number: int
|
|
) -> tuple[HandoffSummary | None, HandoffStatus]:
|
|
"""Attach the handoff only to the focused row; qualify every other row."""
|
|
if focus == (kind, number):
|
|
return (focus_handoff, handoff_status)
|
|
return (
|
|
None,
|
|
HandoffStatus(
|
|
HANDOFF_NOT_LOADED,
|
|
reason=unfocused_reason if focus is None else "not the focused thread",
|
|
),
|
|
)
|
|
|
|
def _number_of(raw: dict[str, Any]) -> int | None:
|
|
try:
|
|
return int(raw["number"])
|
|
except (KeyError, TypeError, ValueError):
|
|
return None
|
|
|
|
def _sort_key(raw: dict[str, Any]) -> int:
|
|
number = _number_of(raw)
|
|
return -1 if number is None else number
|
|
|
|
issue_nodes: list[LinkageNode] = []
|
|
for raw in sorted(raw_issues or [], key=_sort_key, reverse=True):
|
|
number = _number_of(raw)
|
|
if number is None:
|
|
continue
|
|
node_handoff, node_status = _node_handoff("issue", number)
|
|
linked_prs = index.issue_prs.get(number, ())
|
|
issue_nodes.append(
|
|
LinkageNode(
|
|
kind="issue",
|
|
number=number,
|
|
title=str(_redact(raw.get("title")) or ""),
|
|
state=str(raw.get("state") or ""),
|
|
labels=_labels_of(raw),
|
|
linked_prs=linked_prs,
|
|
contested=len(linked_prs) > 1,
|
|
deep_link=_deep_link(
|
|
host, project.gitea_owner, project.repo_name, "issue", number
|
|
),
|
|
handoff=node_handoff,
|
|
handoff_status=node_status,
|
|
links_authoritative=inventory_complete,
|
|
)
|
|
)
|
|
|
|
pr_nodes: list[LinkageNode] = []
|
|
for raw in sorted(raw_prs or [], key=_sort_key, reverse=True):
|
|
number = _number_of(raw)
|
|
if number is None:
|
|
continue
|
|
node_handoff, node_status = _node_handoff("pr", number)
|
|
links = index.pr_links.get(number, ())
|
|
pr_nodes.append(
|
|
LinkageNode(
|
|
kind="pr",
|
|
number=number,
|
|
title=str(_redact(raw.get("title")) or ""),
|
|
state=str(raw.get("state") or ""),
|
|
labels=_labels_of(raw),
|
|
links=links,
|
|
ambiguous=index.ambiguous(number),
|
|
contested=any(link.issue_number in contested for link in links),
|
|
deep_link=_deep_link(
|
|
host, project.gitea_owner, project.repo_name, "pr", number
|
|
),
|
|
handoff=node_handoff,
|
|
handoff_status=node_status,
|
|
links_authoritative=inventory_complete,
|
|
)
|
|
)
|
|
|
|
return LinkageSnapshot(
|
|
ok=True,
|
|
project_id=project.id,
|
|
repo_label=repo_label,
|
|
host=host,
|
|
state_scope=state_scope,
|
|
issues=tuple(issue_nodes),
|
|
prs=tuple(pr_nodes),
|
|
contested_issues=contested,
|
|
focus=focus,
|
|
inventory_complete=inventory_complete,
|
|
deep_links_enabled=deep_links_enabled(),
|
|
handoff_status=handoff_status,
|
|
)
|