"""Provider boundary owned with get_project_issue_context. Read-only adapter that maps a Gitea issue/pull-request onto the neutral ``IssueContextOutput`` schema declared in ``tools/issue_context.py``. Nothing here changes the shared runtime, the registry, or the tool schema — this module only fills in ``build_provider`` so ``runtime.py``'s ``PROVIDER_FACTORIES["get_project_issue_context"]`` resolves to a real, read-only Gitea client instead of :class:`UnconfiguredIssueProvider`. Design notes (documented here so a reviewer does not have to guess): * Credentials (``GITEA_TOKEN``) and the base URL are read INSIDE ``build_provider``/``_resolve_target`` only — never at import time — because ``build_provider`` is only ever invoked by ``ProjectProviderResolver.resolve`` (see ``runtime.py``), which ``server.py::dispatch`` calls strictly AFTER ``policy.decide`` returns ``True``. Reading env vars at module scope would read them before the policy check ever runs. * The provider is bound to exactly one project/repository at construction time (``identity.project`` -> ``PROJECT_CONTEXT_REPO_MAP``). The ``project_id`` argument received per call is only used to echo it back in the response and as a defensive equality check — it is never used to pick which repository to query. This means a caller can never redirect the provider to an arbitrary repository by tampering with ``project_id``. * ``revision`` pins to the issue's own ``updated_at`` timestamp. Gitea issues (unlike commits/PRs) have no natural commit SHA of their own, so the most meaningful, verifiable "version marker" Gitea offers for an issue is its last-modified time: given the same URL and the same ``revision`` string, a reviewer can confirm whether the issue has changed since this was fetched. * ``related`` items are derived from BARE ``#`` cross-references found in the issue body — never from a ``#`` that is only the label text of an explicit Markdown link (``[other-repo PR #4](http://.../ other-repo/pulls/4)``). A real issue body regularly links to a pull request or an issue in a DIFFERENT repository this way; re-guessing that as "issue #4 in THIS repo" would silently point at the wrong resource entirely, which is worse than omitting it — the author already gave the correct URL right there, so extraction strips whole ``[label](url)`` spans, then any remaining bare URLs, before scanning for mentions. Titles for the mentions that DO survive are NOT fetched with an extra API call (kept deliberately simple and bounded) — each related item's title is a generic, honest placeholder ("Referenced item #"), and its ``url`` always resolves to a real, openable page in this same repository. This is a known, documented simplification, not a bug. * ``acceptance_criteria`` looks for the issue's own "Acceptance Criteria" Markdown heading (``#`` through ``######``, case-insensitive) and only collects checklist items (``- [ ]``/``- [x]``) from inside that section, up to the next heading of equal-or-shallower depth. This matters because a real work-item body commonly has a SEPARATE "Definition of Done" section that also uses checklist syntax — without scoping to the matching heading, those unrelated items would be silently folded into "acceptance criteria" and misrepresent the issue. When no such heading exists at all (ad-hoc issues with no fixed template), this falls back to scanning the whole body, so those issues still get a best-effort result instead of an always-empty list. * ``truncated``/``returned``/``remaining``/``next_cursor`` describe pagination over the ``related`` list only. ``description`` has its own independent, generous hard safety cap (so the response stays bounded even for a pathological issue body) and is never silently cut without a visible notice appended. * The regexes that derive ``acceptance_criteria``/``related`` never scan more than ``_MAX_SCAN_CHARS`` of the raw body, and always run on a copy with ``http(s)://`` links stripped first — otherwise a `#42` inside a URL fragment (e.g. a doc anchor) would be mis-detected as a cross-reference to issue #42, and a pathologically large body would cost unbounded regex work on every call regardless of the display cap on ``description``. """ from __future__ import annotations import json import os import re from dataclasses import dataclass from datetime import datetime, timezone from typing import Any, Protocol import requests from ..foundation import IdentityContext, ProviderError # ---- tunables (documented, not hardcoded secrets) ------------------------- _REQUEST_TIMEOUT_SECONDS = 10 _STANDARD_RELATED_PAGE_SIZE = 20 _FULL_RELATED_PAGE_SIZE = 100 _SUMMARY_DESCRIPTION_CHARS = 280 _MAX_DESCRIPTION_CHARS = 20_000 _MAX_SCAN_CHARS = 200_000 # hard cap on regex work, independent of the display cap above _TRUNCATION_NOTICE = "\n\n[description truncated: exceeds the display size limit]" _ISSUE_KEY_PATTERN = re.compile(r"^[1-9][0-9]*$") _CHECKLIST_PATTERN = re.compile(r"^[-*]\s+\[[ xX]\]\s+(.+)$", re.MULTILINE) _MENTION_PATTERN = re.compile(r"(?` that is only the link's label text (often a cross-repo or # pull-request reference) is never re-guessed as a same-repo issue mention. _MARKDOWN_LINK_PATTERN = re.compile(r"\[[^\]]*\]\([^)]*\)") # ATX heading line, e.g. "# Acceptance Criteria" / "## Acceptance Criteria". _HEADING_PATTERN = re.compile(r"^(#{1,6})[ \t]+(.+?)\s*$", re.MULTILINE) _ACCEPTANCE_HEADING_NAMES = ("acceptance criteria",) def _extract_heading_section(text: str, heading_names: tuple[str, ...]) -> str | None: """Return the body of the first ATX heading whose title case-insensitively matches one of ``heading_names``, up to the next heading of equal or shallower depth (or the end of ``text``). Returns ``None`` when no such heading exists, so the caller can fall back to the whole body.""" wanted = {name.strip().lower() for name in heading_names} headings = list(_HEADING_PATTERN.finditer(text)) for index, match in enumerate(headings): if match.group(2).strip().lower() not in wanted: continue level = len(match.group(1)) end = len(text) for later in headings[index + 1 :]: if len(later.group(1)) <= level: end = later.start() break return text[match.end() : end] return None class IssueProvider(Protocol): def get_issue_context(self, **arguments: Any) -> dict[str, Any]: ... class UnconfiguredIssueProvider: def get_issue_context(self, **arguments: Any) -> dict[str, Any]: raise ProviderError( "UNAVAILABLE", "The issue provider is not configured for this environment.", retryable=False, ) @dataclass(frozen=True) class _GiteaRepoTarget: base_url: str owner: str repo: str token: str project_id: str def _load_repo_map() -> dict[str, str]: raw = os.environ.get("PROJECT_CONTEXT_REPO_MAP", "").strip() if not raw: return {} try: parsed = json.loads(raw) except json.JSONDecodeError as exc: raise ProviderError( "UNAVAILABLE", "PROJECT_CONTEXT_REPO_MAP is not valid JSON.", retryable=False, ) from exc if not isinstance(parsed, dict) or not all( isinstance(k, str) and isinstance(v, str) for k, v in parsed.items() ): raise ProviderError( "UNAVAILABLE", "PROJECT_CONTEXT_REPO_MAP must be a JSON object of project_id -> 'owner/repo'.", retryable=False, ) return parsed def _resolve_target(identity: IdentityContext) -> _GiteaRepoTarget: """Read-only credential/target resolution. Only ever called AFTER the runtime's policy has already granted ALLOW for this identity/tool.""" base_url = os.environ.get("GITEA_BASE_URL", "").strip().rstrip("/") token = os.environ.get("GITEA_TOKEN", "").strip() if not base_url or not token: raise ProviderError( "UNAVAILABLE", "GITEA_BASE_URL/GITEA_TOKEN are not configured for this environment.", retryable=False, ) repo_map = _load_repo_map() slug = repo_map.get(identity.project, "") if not slug or "/" not in slug: raise ProviderError( "UNAVAILABLE", "This project is not mapped to an approved Gitea repository.", retryable=False, ) owner, _, repo = slug.partition("/") if not owner or not repo: raise ProviderError( "UNAVAILABLE", "This project's Gitea repository mapping is malformed.", retryable=False, ) return _GiteaRepoTarget( base_url=base_url, owner=owner, repo=repo, token=token, project_id=identity.project, ) def build_provider(identity: IdentityContext) -> IssueProvider: """Replace only this factory when wiring the approved read-only issue adapter.""" return GiteaIssueProvider(_resolve_target(identity)) class GiteaIssueProvider: """Read-only adapter mapping one Gitea issue/PR onto the neutral schema.""" def __init__(self, target: _GiteaRepoTarget) -> None: self._target = target def get_issue_context( self, *, project_id: str, issue_key: str, detail: str, cursor: str | None, **_: Any, ) -> dict[str, Any]: if project_id != self._target.project_id: # Defense in depth: the runtime's policy already guarantees this # can never happen (DENIED would have fired first), but the # provider never trusts caller-supplied routing regardless. raise ProviderError( "INTERNAL", "Resolved provider does not match the requested project.", retryable=False, ) if not _ISSUE_KEY_PATTERN.match(issue_key): raise ProviderError( "INVALID_INPUT", "issue_key must be a positive work item number.", retryable=False, ) offset = self._decode_cursor(cursor) payload = self._fetch_issue(issue_key) title = str(payload.get("title") or "") raw_state = str(payload.get("state") or "") status = raw_state if raw_state in {"open", "closed"} else "unknown" body = str(payload.get("body") or "") description = self._build_description(body, detail) # Bounded regardless of the actual body size: caps worst-case regex # cost, independently of `description`'s own display-only cap. scan_text = body[:_MAX_SCAN_CHARS] acceptance_section = _extract_heading_section(scan_text, _ACCEPTANCE_HEADING_NAMES) acceptance_criteria = tuple( _CHECKLIST_PATTERN.findall(acceptance_section if acceptance_section is not None else scan_text) ) related_all = self._extract_related(scan_text, issue_key) related_page, returned, remaining, truncated, next_cursor = self._paginate_related( related_all, detail, offset, ) html_url = str( payload.get("html_url") or f"{self._target.base_url}/{self._target.owner}/{self._target.repo}/issues/{issue_key}" ) updated_at = str(payload.get("updated_at") or "") retrieved_at = datetime.now(timezone.utc).isoformat() return { "project_id": project_id, "issue_key": issue_key, "title": title, "status": status, "description": description, "acceptance_criteria": acceptance_criteria, "related": related_page, "source": { "system": "gitea", "url": html_url, "revision": f"issue-updated:{updated_at or retrieved_at}", "retrieved_at": retrieved_at, }, "truncated": truncated, "returned": returned, "remaining": remaining, "next_cursor": next_cursor, } # ---- internals --------------------------------------------------- def _decode_cursor(self, cursor: str | None) -> int: if cursor is None: return 0 try: offset = int(cursor) except ValueError as exc: raise ProviderError("INVALID_INPUT", "cursor is not valid.", retryable=False) from exc if offset < 0: raise ProviderError("INVALID_INPUT", "cursor is not valid.", retryable=False) return offset def _build_description(self, body: str, detail: str) -> str: text = body.strip() if detail == "summary": return text.split("\n\n", 1)[0][:_SUMMARY_DESCRIPTION_CHARS] if len(text) > _MAX_DESCRIPTION_CHARS: return text[:_MAX_DESCRIPTION_CHARS] + _TRUNCATION_NOTICE return text def _extract_related(self, body: str, issue_key: str) -> tuple[dict[str, str], ...]: # Strip whole `[label](url)` spans FIRST (as one unit) so a `#` # that only appears as a Markdown link's label — often a cross-repo or # pull-request reference with its own, possibly different, URL right # there — is never re-guessed as "issue # in this repo". text_without_links = _MARKDOWN_LINK_PATTERN.sub(" ", body) # Then strip any remaining bare URLs so a doc-anchor link like # ".../guide#42" is never mistaken for a cross-reference to issue #42. text_without_urls = _URL_PATTERN.sub(" ", text_without_links) numbers = sorted({int(n) for n in _MENTION_PATTERN.findall(text_without_urls) if n != issue_key}) return tuple( { "item_id": str(number), "relation": "mentioned", "title": f"Referenced item #{number}", "url": f"{self._target.base_url}/{self._target.owner}/{self._target.repo}/issues/{number}", } for number in numbers ) def _paginate_related( self, related_all: tuple[dict[str, str], ...], detail: str, offset: int, ) -> tuple[tuple[dict[str, str], ...], int, int, bool, str | None]: if detail == "summary": # Summary mode intentionally omits related items outright; it is # not a size-limit truncation, so callers who need them must # call again with detail="standard"/"full". remaining = len(related_all) return (), 0, remaining, remaining > 0, None page_size = _FULL_RELATED_PAGE_SIZE if detail == "full" else _STANDARD_RELATED_PAGE_SIZE page = related_all[offset : offset + page_size] remaining = max(0, len(related_all) - (offset + page_size)) truncated = remaining > 0 next_cursor = str(offset + page_size) if truncated else None return page, len(page), remaining, truncated, next_cursor def _fetch_issue(self, issue_key: str) -> dict[str, Any]: url = ( f"{self._target.base_url}/api/v1/repos/{self._target.owner}/" f"{self._target.repo}/issues/{issue_key}" ) headers = {"Authorization": f"token {self._target.token}"} try: response = requests.get(url, headers=headers, timeout=_REQUEST_TIMEOUT_SECONDS) except requests.exceptions.Timeout as exc: raise ProviderError( "UPSTREAM_TIMEOUT", "The Gitea request timed out.", retryable=True, ) from exc except requests.exceptions.RequestException as exc: # Never surface str(exc) — it can embed the request URL/host and, # in some transport errors, request headers. raise ProviderError( "UPSTREAM_ERROR", "The Gitea request failed.", retryable=True, ) from exc if response.status_code == 404: raise ProviderError( "NOT_FOUND", "The work item was not found or is not accessible.", retryable=False, ) if response.status_code == 429: raise ProviderError("RATE_LIMITED", "Gitea rate-limited this request.", retryable=True) if response.status_code in (401, 403): raise ProviderError( "UPSTREAM_ERROR", "The read-only Gitea credential could not access the repository.", retryable=False, ) if response.status_code >= 500: raise ProviderError("UPSTREAM_ERROR", "Gitea returned a server error.", retryable=True) if response.status_code != 200: raise ProviderError( "UPSTREAM_ERROR", "Gitea returned an unexpected response.", retryable=False, ) try: data = response.json() except ValueError as exc: raise ProviderError( "UPSTREAM_ERROR", "Gitea returned a response that could not be parsed.", retryable=False, ) from exc if not isinstance(data, dict): raise ProviderError( "UPSTREAM_ERROR", "Gitea returned an unexpected response shape.", retryable=False, ) return data