import asyncio import base64 import os import re import shlex from contextlib import asynccontextmanager from typing import Any from urllib.parse import quote, urljoin, urlsplit import httpx GITEA_URL = os.getenv("GITEA_URL", "http://127.0.0.1:3000").rstrip("/") GITEA_TOKEN = os.getenv("GITEA_TOKEN", "") REVIEW_DIFF_MAX_BYTES = 64 * 1024 REVIEW_DIFF_MAX_LINES = 400 ROLLBACK_MAX_FILES = 20 ROLLBACK_MAX_FILE_BYTES = 256 * 1024 ROLLBACK_MAX_TOTAL_BYTES = 1024 * 1024 AVAILABLE_ISSUE_PAGE_CONCURRENCY = 3 _client: "GiteaTransport | None" = None class GiteaOverloadedError(RuntimeError): """Raised when bounded transport admission expires before capacity is available.""" class CommentMutationForbiddenError(RuntimeError): """Raised when a comment is not owned by the operator or enclosing thread.""" class GiteaTransport: """Application-lifetime HTTP transport with single-flight concurrent reads.""" def __init__( self, *, max_concurrency: int = 8, admission_timeout: float = 0.25, **kwargs, ) -> None: max_concurrency = max(2, max_concurrency) admission_timeout = max(0.001, admission_timeout) self._http = httpx.AsyncClient(base_url=GITEA_URL, timeout=10, **kwargs) self.max_concurrency = max_concurrency self.admission_timeout = admission_timeout self._request_slots = asyncio.Semaphore(max(2, max_concurrency)) self._read_slots = asyncio.Semaphore(max(1, max_concurrency - 1)) self._reads: dict[tuple, asyncio.Task[httpx.Response]] = {} @property def is_closed(self) -> bool: return self._http.is_closed def _read_key(self, url: str, kwargs: dict) -> tuple: params = tuple(httpx.QueryParams(kwargs.get("params", {})).multi_items()) headers = tuple(sorted(httpx.Headers(kwargs.get("headers", {})).multi_items())) return url, params, headers async def _acquire(self, semaphore: asyncio.Semaphore) -> None: try: await asyncio.wait_for(semaphore.acquire(), timeout=self.admission_timeout) except TimeoutError as exc: raise GiteaOverloadedError("Gitea transport is saturated") from exc async def _perform_get(self, url: str, kwargs: dict) -> httpx.Response: await self._acquire(self._read_slots) try: await self._acquire(self._request_slots) try: return await self._http.get(url, **kwargs) finally: self._request_slots.release() finally: self._read_slots.release() async def get(self, url: str, **kwargs) -> httpx.Response: key = self._read_key(url, kwargs) task = self._reads.get(key) if task is None: task = asyncio.create_task(self._perform_get(url, kwargs)) self._reads[key] = task task.add_done_callback( lambda completed, request_key=key: ( self._reads.pop(request_key, None) if self._reads.get(request_key) is completed else None ) ) return await asyncio.shield(task) async def _mutate(self, method: str, url: str, kwargs: dict) -> httpx.Response: await self._acquire(self._request_slots) try: return await self._http.request(method, url, **kwargs) finally: self._request_slots.release() async def post(self, url: str, **kwargs) -> httpx.Response: return await self._mutate("POST", url, kwargs) async def patch(self, url: str, **kwargs) -> httpx.Response: return await self._mutate("PATCH", url, kwargs) async def put(self, url: str, **kwargs) -> httpx.Response: return await self._mutate("PUT", url, kwargs) async def delete(self, url: str, **kwargs) -> httpx.Response: return await self._mutate("DELETE", url, kwargs) @asynccontextmanager async def stream(self, method: str, url: str, **kwargs): await self._acquire(self._read_slots) try: await self._acquire(self._request_slots) try: async with self._http.stream(method, url, **kwargs) as response: yield response finally: self._request_slots.release() finally: self._read_slots.release() def __getattr__(self, name: str): return getattr(self._http, name) async def aclose(self) -> None: tasks = list(self._reads.values()) for task in tasks: if not task.done(): task.cancel() if tasks: await asyncio.gather(*tasks, return_exceptions=True) self._reads.clear() await self._http.aclose() class WorkItems(list[dict]): """A list-compatible first page carrying truthful per-stream totals.""" def __init__(self, items: list[dict], pagination: dict[str, dict]): super().__init__(items) self.pagination = pagination class RepositoryItems(list[dict]): """A list-compatible repository page carrying truthful pagination metadata.""" def __init__(self, items: list[dict], pagination: dict): super().__init__(items) self.pagination = pagination class WorkRouteUnavailableError(ValueError): """Raised when a shared route no longer belongs in the current user's queue.""" class StaleReviewError(ValueError): """Raised before mutation when a pull request head changed during review.""" class InvalidReviewCommentError(ValueError): """Raised before mutation when an inline comment cannot target this change.""" class StalePullError(ValueError): """Raised before merge when an assigned pull request head changed.""" class PullNotMergeableError(ValueError): """Raised before merge when current pull state or checks prohibit it.""" class IssueNotAvailableError(ValueError): """Raised before assignment when an issue is no longer open and unassigned.""" class IssueEditConflictError(ValueError): """Raised when an issue changed after the editor loaded it.""" class PullUpdateConflictError(ValueError): """Raised when Gitea cannot merge a pull request's base into its head.""" class PullCreateConflictError(ValueError): """Raised when the selected source branch changed before pull creation.""" class ReleaseRollbackConflictError(ValueError): """Raised when current target content no longer permits an exact rollback.""" class ReleaseRollbackUnsupportedError(ValueError): """Raised when a merge cannot be represented as one bounded text rollback.""" class SourceBranchChangedError(ValueError): """Raised before deletion when a merged source branch has advanced.""" class SourceBranchCleanupForbiddenError(ValueError): """Raised before deletion when a merged branch is not safe for this operator.""" class IssueDependencyInvalidError(ValueError): """Raised when a requested blocker relationship is not valid.""" def _auth() -> dict[str, str]: headers: dict[str, str] = {"Accept": "application/json"} if GITEA_TOKEN: headers["Authorization"] = f"token {GITEA_TOKEN}" return headers def start_client(**kwargs) -> GiteaTransport: """Create the application-lifetime Gitea transport.""" global _client kwargs.setdefault("max_concurrency", int(os.getenv("GITEA_MAX_CONCURRENCY", "8"))) kwargs.setdefault( "admission_timeout", float(os.getenv("GITEA_ADMISSION_TIMEOUT_SECONDS", "0.25")), ) _client = GiteaTransport(**kwargs) return _client def _get_client() -> GiteaTransport: if _client is None or _client.is_closed: return start_client() return _client async def stop_client() -> None: global _client if _client is not None and not _client.is_closed: await _client.aclose() async def fetch(path: str) -> Any: r = await _get_client().get(f"/api/v1/{path}", headers=_auth()) r.raise_for_status() return r.json() def issue_time_target(identity: str) -> tuple[str, str]: """Return the canonical repository and issue number encoded by an identity.""" match = re.fullmatch( r"(?:issue|pull):([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+):([1-9][0-9]*):", identity, ) if match is None: raise ValueError("time log target is invalid") return match.groups() async def log_issue_time(identity: str, seconds: int) -> None: """Log time to the canonical issue endpoint encoded by a dashboard identity.""" repository, number = issue_time_target(identity) if not isinstance(seconds, int) or isinstance(seconds, bool) or not 1 <= seconds <= 86400: raise ValueError("time log duration is invalid") response = await _get_client().post( f"/api/v1/repos/{repository}/issues/{number}/times", headers={**_auth(), "Content-Type": "application/json"}, json={"time": seconds}, ) response.raise_for_status() def time_log_failure_is_retryable(error: Exception) -> bool: """Return true only when failure proves no successful response was lost.""" return isinstance(error, (ValueError, GiteaOverloadedError, httpx.ConnectError, httpx.HTTPStatusError)) async def fetch_text(path: str, max_bytes: int) -> tuple[str, bool]: chunks: list[bytes] = [] size = 0 truncated = False async with _get_client().stream( "GET", f"/api/v1/{path}", headers={**_auth(), "Accept": "text/plain"} ) as response: response.raise_for_status() async for chunk in response.aiter_bytes(): remaining = max_bytes - size if len(chunk) > remaining: chunks.append(chunk[:remaining]) truncated = True break chunks.append(chunk) size += len(chunk) return b"".join(chunks).decode("utf-8", errors="replace"), truncated def _diff_previews(diff: str, stream_truncated: bool) -> dict[str, dict]: previews: dict[str, dict] = {} current: dict | None = None in_hunk = False remaining = REVIEW_DIFF_MAX_LINES for line in diff.splitlines(): if line.startswith("diff --git "): try: target = shlex.split(line)[3] filename = target[2:] if target.startswith("b/") else target except (IndexError, ValueError): current = None continue current = { "diff_lines": [], "diff_available": False, "diff_binary": False, "diff_truncated": stream_truncated, } previews[filename] = current in_hunk = False continue if current is None: continue if line.startswith("Binary files ") or line == "GIT binary patch": current["diff_binary"] = True in_hunk = False continue if line.startswith("@@"): in_hunk = True if in_hunk and not line.startswith("\\ No newline at end of file"): if remaining: current["diff_lines"].append(line) current["diff_available"] = True remaining -= 1 else: current["diff_truncated"] = True return previews async def current_user() -> dict: return await fetch("user") async def repo_page(page: int = 1, limit: int = 50) -> dict: """Load one bounded page of repositories available to the current user.""" response = await _get_client().get( "/api/v1/user/repos", headers=_auth(), params={"page": page, "limit": limit}, ) response.raise_for_status() payload = response.json() if not isinstance(payload, list): raise ValueError("Gitea repository response was not a list") items = [item for item in payload if isinstance(item, dict)] try: total = max(len(items), int(response.headers.get("X-Total-Count", len(items)))) except (TypeError, ValueError): total = len(items) return { "items": items, "page": page, "total": total, "has_more": page * limit < total, } async def repos() -> RepositoryItems: result = await repo_page() return RepositoryItems( result["items"], {key: result[key] for key in ("page", "total", "has_more")}, ) async def search_repositories(query: str, limit: int = 20) -> list[dict]: """Search repositories visible to the authenticated Gitea user.""" response = await _get_client().get( "/api/v1/repos/search", headers=_auth(), params={"q": query.strip(), "limit": limit, "page": 1}, ) response.raise_for_status() payload = response.json() items = payload.get("data") if isinstance(payload, dict) else None if not isinstance(items, list): raise ValueError("Gitea repository search response was not a list") return [item for item in items if isinstance(item, dict)] async def repository_access(repository: str) -> dict | None: """Return a repository only when the authenticated user can access it.""" response = await _get_client().get( f"/api/v1/repos/{repository}", headers=_auth() ) if response.status_code == 404: return None response.raise_for_status() payload = response.json() return payload if isinstance(payload, dict) else None async def repo_branches(repository: str) -> list[dict]: """Return the bounded branch choices visible in one repository.""" response = await _get_client().get( f"/api/v1/repos/{repository}/branches", headers=_auth(), params={"page": 1, "limit": 100}, ) response.raise_for_status() payload = response.json() if not isinstance(payload, list): raise ValueError("Gitea branch response was not a list") return [item for item in payload if isinstance(item, dict)] WORK_SEARCHES = { "issue": ("assigned=true", "issues", None), "filed": ("created=true", "issues", "created_by_me"), "pull": ("assigned=true", "pulls", "assigned_to_me"), "review": ("review_requested=true", "pulls", "review_requested"), "authored": ("created=true", "pulls", "authored_by_me"), } async def work_page(stream: str, page: int = 1, limit: int = 50) -> dict: """Load exactly one bounded My Work stream page with upstream totals.""" selector, item_type, reason = WORK_SEARCHES[stream] response = await _get_client().get( "/api/v1/repos/issues/search", headers=_auth(), params={ "state": "all" if stream == "filed" else "open", selector.split("=", 1)[0]: "true", "type": item_type, "limit": limit, "page": page, }, ) response.raise_for_status() payload = response.json() if not isinstance(payload, list): raise ValueError("Gitea work search response was not a list") items = [item for item in payload if isinstance(item, dict)] if reason: items = [{**item, "work_reasons": [reason]} for item in items] try: total = max(len(items), int(response.headers.get("X-Total-Count", len(items)))) except (TypeError, ValueError): total = len(items) return { "stream": stream, "items": items, "page": page, "total": total, "has_more": page * limit < total, } async def assigned_issue_snapshot( *, limit: int = 50, max_pages: int = 20, max_concurrency: int = 4, deadline_seconds: float = 5.0, ) -> dict: """Load a complete, bounded assigned-issue snapshot for deadline dispatch.""" async with asyncio.timeout(deadline_seconds): first = await work_page("issue", 1, limit) page_count = max(1, (first["total"] + limit - 1) // limit) if page_count > max_pages: raise ValueError("Assigned issue snapshot exceeds the scan limit") semaphore = asyncio.Semaphore(max(1, max_concurrency)) async def load(page: int) -> dict: async with semaphore: return await work_page("issue", page, limit) remaining = await asyncio.gather( *(load(page) for page in range(2, page_count + 1)) ) pages = [first, *remaining] if any(page.get("total") != first["total"] for page in pages[1:]): raise ValueError("Assigned issue pagination changed during the scan") items = [item for page in pages for item in page["items"]] issue_ids = [ item.get("id") for item in items if isinstance(item, dict) and isinstance(item.get("id"), int) and item["id"] > 0 ] if len(items) != first["total"] or len(set(issue_ids)) != first["total"]: raise ValueError("Assigned issue snapshot has an incomplete issue set") return {"items": items, "complete": True} def _normalize_global_search_item(item: Any, kind: str) -> dict | None: if not isinstance(item, dict): return None repository = item.get("repository") repository = repository if isinstance(repository, dict) else {} url = _safe_gitea_web_url(item.get("html_url")) if not ( isinstance(item.get("number"), int) and isinstance(item.get("title"), str) and isinstance(item.get("state"), str) and isinstance(repository.get("full_name"), str) and repository.get("full_name") and url ): return None return { "kind": kind, "repository": repository["full_name"], "number": item["number"], "title": item["title"], "state": item["state"], "url": url, } async def global_search( query: str, limit: int = 10, page: int = 1, kind: str = "all", state: str = "all", repository: str | None = None, continuation: dict[str, int | None] | None = None, ) -> dict: """Search accessible work with lossless, independently retryable streams.""" item_types = ("issues", "pulls") if kind == "all" else ( "issues" if kind == "issue" else "pulls", ) stream_limits = { item_type: ( (limit + 1) // 2 if item_type == "issues" else limit // 2 ) if kind == "all" else limit for item_type in item_types } stream_pages = { item_type: continuation.get(item_type) if continuation is not None else page for item_type in item_types } active_types = tuple( item_type for item_type in item_types if stream_pages[item_type] is not None and stream_limits[item_type] > 0 ) async def load(item_type: str) -> Any: params = { "q": query, "type": item_type, "state": state, "limit": stream_limits[item_type], "page": stream_pages[item_type], } if repository: owner, repo = repository.split("/", 1) params.update({"owner": owner, "repo": repo}) response = await _get_client().get( "/api/v1/repos/issues/search", headers=_auth(), params=params, ) response.raise_for_status() payload = response.json() if not isinstance(payload, list): raise ValueError("Gitea global search response was not a list") return payload outcomes = await asyncio.gather( *(load(item_type) for item_type in active_types), return_exceptions=True ) for outcome in outcomes: if isinstance(outcome, asyncio.CancelledError): raise outcome if outcomes and all(isinstance(outcome, BaseException) for outcome in outcomes): raise outcomes[0] streams: list[list[dict]] = [] seen: set[tuple[str, str, int]] = set() result_kinds = tuple( "issue" if item_type == "issues" else "pull" for item_type in active_types ) for outcome, result_kind in zip(outcomes, result_kinds, strict=True): normalized_stream = [] if isinstance(outcome, BaseException): streams.append(normalized_stream) continue for item in outcome: normalized = _normalize_global_search_item(item, result_kind) if normalized is not None: identity = (result_kind, normalized["repository"], normalized["number"]) if identity in seen: continue seen.add(identity) normalized_stream.append(normalized) streams.append(normalized_stream) results = [] for index in range(max((len(stream) for stream in streams), default=0)): for stream in streams: if index < len(stream): results.append(stream[index]) failed_streams = [ "issue" if item_type == "issues" else "pull" for item_type, outcome in zip(active_types, outcomes, strict=True) if isinstance(outcome, BaseException) ] next_continuation = {item_type: None for item_type in item_types} for item_type, outcome in zip(active_types, outcomes, strict=True): if isinstance(outcome, BaseException): next_continuation[item_type] = stream_pages[item_type] elif len(outcome) >= stream_limits[item_type]: next_continuation[item_type] = int(stream_pages[item_type]) + 1 has_more = any(value is not None for value in next_continuation.values()) return { "items": results[:limit], "partial": bool(failed_streams), "has_more": has_more, "next_page": page + 1, "continuation": next_continuation, "failed_streams": failed_streams, } async def work_preview(repository: str, kind: str, number: int) -> dict: """Load bounded, read-only context for a global search result.""" issue, user = await asyncio.gather( fetch(f"repos/{repository}/issues/{number}"), current_user() ) if not isinstance(issue, dict): raise ValueError("Gitea work preview response was not an object") labels_value = issue.get("labels") assignees_value = issue.get("assignees") labels = labels_value if isinstance(labels_value, list) else [] assignees = assignees_value if isinstance(assignees_value, list) else [] assignee_names = [ assignee["login"] for assignee in assignees if isinstance(assignee, dict) and isinstance(assignee.get("login"), str) ] author = issue.get("user") author = author if isinstance(author, dict) else {} login = user.get("login") if isinstance(user, dict) else "" state = issue.get("state") if isinstance(issue.get("state"), str) else "" actual_kind = "pull" if isinstance(issue.get("pull_request"), dict) else "issue" reviewable = False pull = None if actual_kind == "pull" and login: try: pull = await fetch(f"repos/{repository}/pulls/{number}") except Exception: pull = None if actual_kind == "pull" and state == "open" and login: requested_reviewers = pull.get("requested_reviewers") if isinstance(pull, dict) else [] if not isinstance(requested_reviewers, list): requested_reviewers = [] reviewable = any( isinstance(reviewer, dict) and reviewer.get("login") == login for reviewer in requested_reviewers ) pull_author = pull.get("user") if isinstance(pull, dict) and isinstance(pull.get("user"), dict) else {} pull_head = pull.get("head") if isinstance(pull, dict) and isinstance(pull.get("head"), dict) else {} head_sha = pull_head.get("sha") if isinstance(pull_head.get("sha"), str) else "" authored_pull_reopenable = bool( actual_kind == "pull" and state == "closed" and isinstance(pull, dict) and pull.get("state") == "closed" and pull.get("merged") is not True and login and pull_author.get("login", "").casefold() == login.casefold() and head_sha ) return { "kind": actual_kind, "repository": repository, "number": number, "title": issue.get("title", "") if isinstance(issue.get("title"), str) else "", "body": issue.get("body", "") if isinstance(issue.get("body"), str) else "", "state": state, "updated_at": issue.get("updated_at", "") if isinstance(issue.get("updated_at"), str) else "", "author": author.get("login", "") if isinstance(author.get("login"), str) else "", "labels": [ label["name"] for label in labels if isinstance(label, dict) and isinstance(label.get("name"), str) ], "assignees": assignee_names, "url": _safe_gitea_web_url(issue.get("html_url")), "claimable": actual_kind == "issue" and state == "open" and not assignee_names, "reopenable": actual_kind == "issue" and state == "closed", "assigned_to_me": bool(login and login in assignee_names), "reviewable": reviewable, "commentable": bool(login), **({ "authored_pull_reopenable": authored_pull_reopenable, "head_sha": head_sha, } if actual_kind == "pull" else {}), } def _normalize_available_issue(item: Any) -> dict | None: if ( not isinstance(item, dict) or item.get("state") != "open" or item.get("pull_request") is not None or item.get("assignees") not in (None, []) ): return None labels_value = item.get("labels") labels = labels_value if isinstance(labels_value, list) else [] repository_value = item.get("repository") repository = repository_value if isinstance(repository_value, dict) else {} return { "id": item.get("id"), "number": item.get("number"), "title": item.get("title", "") if isinstance(item.get("title"), str) else "", "body": item.get("body", "") if isinstance(item.get("body"), str) else "", "state": "open", "repository": repository.get("full_name", "") if isinstance(repository.get("full_name"), str) else "", "labels": [ label["name"] for label in labels if isinstance(label, dict) and isinstance(label.get("name"), str) ], "assignees": [], "updated_at": item.get("updated_at", "") if isinstance(item.get("updated_at"), str) else "", "url": _safe_gitea_web_url(item.get("html_url")), } async def available_issue_snapshot(max_pages: int = 10, upstream_limit: int = 50) -> list[dict]: """Load, filter, and globally rank a bounded snapshot of available issues.""" items: list[dict] = [] seen_ids: set[Any] = set() async def load_page(upstream_page: int) -> tuple[list[Any], int | None]: response = await _get_client().get( "/api/v1/repos/issues/search", headers=_auth(), params={ "state": "open", "type": "issues", "limit": upstream_limit, "page": upstream_page, }, ) response.raise_for_status() payload = response.json() if not isinstance(payload, list): raise ValueError("Gitea available issue search response was not a list") try: total = max(0, int(response.headers["X-Total-Count"])) except (KeyError, TypeError, ValueError): total = None return payload, total first_payload, upstream_total = await load_page(1) pages: list[list[Any]] = [first_payload] if upstream_total is not None: page_count = min(max_pages, max(1, (upstream_total + upstream_limit - 1) // upstream_limit)) semaphore = asyncio.Semaphore(AVAILABLE_ISSUE_PAGE_CONCURRENCY) async def load_bounded(page: int) -> list[Any]: async with semaphore: payload, _ = await load_page(page) return payload if page_count > 1: pages.extend(await asyncio.gather(*( load_bounded(page) for page in range(2, page_count + 1) ))) else: previous = first_payload for page in range(2, max_pages + 1): if not previous or len(previous) < upstream_limit: break previous, _ = await load_page(page) pages.append(previous) for payload in pages: for raw_item in payload: item = _normalize_available_issue(raw_item) if item is not None and item["id"] not in seen_ids: seen_ids.add(item["id"]) items.append(item) priority = {"p0", "priority-high", "critical"} items.sort(key=lambda item: (item["repository"], item["number"] or 0)) items.sort(key=lambda item: item["updated_at"], reverse=True) items.sort(key=lambda item: ( 0 if any(str(label).lower() in priority for label in item["labels"]) else 1 )) return items async def available_issue_page(page: int = 1, limit: int = 50) -> dict: """Return one logical page from a bounded, globally ranked available-work scan.""" items = await available_issue_snapshot() total = len(items) start = (page - 1) * limit page_items = items[start:start + limit] return { "items": page_items, "page": page, "total": total, "has_more": start + len(page_items) < total, } def _page_metadata(result: dict) -> dict: return { "page": result["page"], "total": result["total"], "has_more": result["has_more"], } async def issues() -> WorkItems: assigned, filed = await asyncio.gather(work_page("issue"), work_page("filed")) merged: dict[int, dict] = {} for result in (assigned, filed): for issue in result["items"]: identity = issue.get("id") if identity not in merged: merged[identity] = {**issue, "work_reasons": []} for reason in issue.get("work_reasons", []): if reason not in merged[identity]["work_reasons"]: merged[identity]["work_reasons"].append(reason) return WorkItems( list(merged.values()), {"issue": _page_metadata(assigned), "filed": _page_metadata(filed)}, ) def _safe_gitea_web_url(value: Any) -> str: if not isinstance(value, str) or not value.strip(): return "" resolved = urljoin(f"{GITEA_URL}/", value.strip()) parsed = urlsplit(resolved) configured = urlsplit(GITEA_URL) base_path = configured.path.rstrip("/") if ( parsed.scheme not in {"http", "https"} or parsed.scheme != configured.scheme or parsed.netloc != configured.netloc or (base_path and parsed.path != base_path and not parsed.path.startswith(f"{base_path}/")) ): return "" return resolved def _normalize_commit_checks(status: Any) -> list[dict]: entries = status.get("statuses") if isinstance(status, dict) else None if not isinstance(entries, list): return [] rank = {"error": 0, "failure": 0, "pending": 1, "warning": 2, "success": 3} checks = [] for index, entry in enumerate(entries): if not isinstance(entry, dict): continue name = entry.get("context") if not isinstance(name, str) or not name.strip(): continue state = entry.get("status", entry.get("state", "unknown")) state = state.lower() if isinstance(state, str) else "unknown" if state not in rank: state = "unknown" description = entry.get("description") url = _safe_gitea_web_url(entry.get("target_url")) check = { "name": name.strip()[:120], "state": state, "description": description.strip()[:240] if isinstance(description, str) else "", "url": url, "_index": index, } configured_path = urlsplit(GITEA_URL).path.rstrip("/") action_match = re.fullmatch( re.escape(configured_path) + r"/[^/]+/[^/]+/actions/runs/(\d+)/jobs/(\d+)", urlsplit(url).path, ) if action_match: check["recovery"] = { "run_id": int(action_match.group(1)), "job_index": int(action_match.group(2)), } checks.append(check) checks.sort(key=lambda check: (rank.get(check["state"], 2), check["_index"])) return [{key: value for key, value in check.items() if key != "_index"} for check in checks[:20]] def _normalize_notifications(threads: Any) -> list[dict]: if not isinstance(threads, list): raise ValueError("Gitea notification response was not a list") normalized = [] for thread in threads: if not isinstance(thread, dict): continue repository = thread.get("repository") subject = thread.get("subject") repository = repository if isinstance(repository, dict) else {} subject = subject if isinstance(subject, dict) else {} subject_url = _safe_gitea_web_url(subject.get("html_url")) latest_url = _safe_gitea_web_url(subject.get("latest_comment_html_url")) number_text = ( urlsplit(subject_url).path.rstrip("/").rsplit("/", 1)[-1] if subject_url else "" ) normalized.append( { "id": thread.get("id"), "unread": thread.get("unread") is True, "updated_at": ( thread.get("updated_at") if isinstance(thread.get("updated_at"), str) else "" ), "repository": ( repository.get("full_name") if isinstance(repository.get("full_name"), str) else "" ), "number": int(number_text) if number_text.isdigit() else None, "title": ( subject.get("title") if isinstance(subject.get("title"), str) and subject.get("title") else "Untitled update" ), "subject_type": ( subject.get("type") if isinstance(subject.get("type"), str) and subject.get("type") else "Update" ), "state": ( subject.get("state") if isinstance(subject.get("state"), str) else "" ), "url": latest_url or subject_url, "subject_url": subject_url, } ) return normalized async def notifications() -> dict: return await notification_page(1) async def unread_notification_snapshot( *, limit: int = 50, max_pages: int = 20, max_concurrency: int = 4, deadline_seconds: float = 5.0, ) -> dict: """Load one complete, bounded snapshot of unread notification threads.""" async with asyncio.timeout(deadline_seconds): first = await notification_page(1, limit) page_count = max(1, (first["total"] + limit - 1) // limit) if page_count > max_pages: raise ValueError("Unread notification snapshot exceeds the scan limit") semaphore = asyncio.Semaphore(max(1, max_concurrency)) async def load(page: int) -> dict: async with semaphore: return await notification_page(page, limit) remaining = await asyncio.gather( *(load(page) for page in range(2, page_count + 1)) ) pages = [first, *remaining] if any(page["total"] != first["total"] for page in pages[1:]): raise ValueError("Unread notification pagination changed during the scan") items = [item for page in pages for item in page["items"]] thread_ids = [ item.get("id") for item in items if isinstance(item, dict) and isinstance(item.get("id"), int) and item["id"] > 0 ] if len(items) != first["total"] or len(set(thread_ids)) != first["total"]: raise ValueError("Unread notification snapshot has an incomplete thread set") return {"items": items, "total": first["total"], "complete": True} async def notification_page(page: int, limit: int = 50) -> dict: response = await _get_client().get( f"/api/v1/notifications?status-types=unread&limit={limit}&page={page}", headers=_auth(), ) response.raise_for_status() items = _normalize_notifications(response.json()) try: total = max(len(items), int(response.headers.get("X-Total-Count", len(items)))) except (TypeError, ValueError): total = len(items) return { "items": items, "page": page, "total": total, "has_more": page * limit < total, } async def mark_notification_read(thread_id: int) -> None: response = await _get_client().patch( f"/api/v1/notifications/threads/{thread_id}?to-status=read", headers=_auth(), ) response.raise_for_status() async def mark_notification_unread(thread_id: int) -> None: response = await _get_client().patch( f"/api/v1/notifications/threads/{thread_id}?to-status=unread", headers=_auth(), ) response.raise_for_status() async def acknowledge_notification(thread_id: int) -> dict: thread = await fetch(f"notifications/threads/{thread_id}") if not isinstance(thread, dict): raise ValueError("Gitea notification thread response was not an object") repository = thread.get("repository") subject = thread.get("subject") if not isinstance(repository, dict) or not isinstance(subject, dict): raise ValueError("Notification does not identify a conversation") repository_name = repository.get("full_name") subject_path = _gitea_api_path(subject.get("url")) comment_path = _gitea_api_path(subject.get("latest_comment_url")) subject_match = re.fullmatch( r"repos/([^/]+/[^/]+)/(issues|pulls)/(\d+)", subject_path ) comment_match = re.fullmatch( r"repos/([^/]+/[^/]+)/issues/comments/(\d+)", comment_path ) if ( not subject_match or not comment_match or subject_match.group(1) != repository_name or comment_match.group(1) != repository_name or subject.get("type") not in {"Issue", "Pull"} ): raise ValueError("Notification has no supported latest comment") user = await current_user() login = user.get("login") if isinstance(user, dict) else None if not isinstance(login, str) or not login: raise ValueError("Authenticated Gitea user is unavailable") reaction_path = f"/api/v1/{comment_path}/reactions" response = await _get_client().get(reaction_path, headers=_auth()) response.raise_for_status() reactions = response.json() existing = any( isinstance(reaction, dict) and reaction.get("content") == "+1" and isinstance(reaction.get("user"), dict) and reaction["user"].get("login") == login for reaction in (reactions if isinstance(reactions, list) else []) ) if not existing: response = await _get_client().post( reaction_path, headers=_auth(), json={"content": "+1"} ) response.raise_for_status() await mark_notification_read(thread_id) return { "id": thread_id, "reaction": "existing" if existing else "created", "status": "read", } def _gitea_api_path(value: Any) -> str: if not isinstance(value, str): return "" parsed = urlsplit(value) configured = urlsplit(GITEA_URL) prefix = "/api/v1/" if ( parsed.scheme not in {"http", "https"} or parsed.netloc != configured.netloc or not parsed.path.startswith(prefix) ): return "" return parsed.path[len(prefix):] + (("?" + parsed.query) if parsed.query else "") async def _notification_subscription(repository: str, number: str) -> dict: try: value = await fetch(f"repos/{repository}/issues/{number}/subscriptions/check") except Exception: return {} return value if isinstance(value, dict) else {} async def issue_subscription(repository: str, number: int) -> dict: """Return the current operator's authoritative issue subscription state.""" value = await fetch(f"repos/{repository}/issues/{number}/subscriptions/check") if not isinstance(value, dict): raise ValueError("Gitea subscription response was not an object") return { "watching": value.get("subscribed") is True and value.get("ignored") is not True } async def set_issue_subscription(repository: str, number: int, watching: bool) -> dict: """Set and then confirm the current operator's issue subscription state.""" user = await fetch("user") login = user.get("login") if isinstance(user, dict) else None if not isinstance(login, str) or not login: raise ValueError("Gitea did not identify the current operator") path = ( f"/api/v1/repos/{repository}/issues/{number}/subscriptions/" f"{quote(login, safe='')}" ) client = _get_client() response = ( await client.put(path, headers=_auth()) if watching else await client.delete(path, headers=_auth()) ) response.raise_for_status() confirmed = await issue_subscription(repository, number) if confirmed["watching"] is not watching: raise ValueError("Gitea did not confirm the requested subscription state") return confirmed async def notification_detail(thread_id: int) -> dict: thread = await fetch(f"notifications/threads/{thread_id}") if not isinstance(thread, dict): raise ValueError("Gitea notification thread response was not an object") repository_value = thread.get("repository") repository = repository_value if isinstance(repository_value, dict) else {} subject_value = thread.get("subject") subject = subject_value if isinstance(subject_value, dict) else {} subject_path = _gitea_api_path(subject.get("url")) comment_path = _gitea_api_path(subject.get("latest_comment_url")) conversation_match = re.fullmatch( r"repos/([^/]+/[^/]+)/(issues|pulls)/(\d+)", subject_path ) comment_match = re.fullmatch( r"repos/([^/]+/[^/]+)/issues/comments/(\d+)", comment_path ) repository_name = repository.get("full_name") supported_conversation = ( conversation_match and conversation_match.group(1) == repository_name and subject.get("type") in {"Issue", "Pull"} ) if supported_conversation: assert conversation_match is not None subject_detail, comment, subscription = await asyncio.gather( fetch(subject_path), fetch(comment_path) if comment_path else asyncio.sleep(0, result={}), _notification_subscription(conversation_match.group(1), conversation_match.group(3)), ) else: subject_detail, comment, subscription = await asyncio.gather( fetch(subject_path) if subject_path else asyncio.sleep(0, result={}), fetch(comment_path) if comment_path else asyncio.sleep(0, result={}), asyncio.sleep(0, result={}), ) subject_detail = subject_detail if isinstance(subject_detail, dict) else {} comment = comment if isinstance(comment, dict) else {} user_value = comment.get("user") user = user_value if isinstance(user_value, dict) else {} subject_url = _safe_gitea_web_url(subject.get("html_url")) latest_url = _safe_gitea_web_url(comment.get("html_url")) or _safe_gitea_web_url( subject.get("latest_comment_html_url") ) assignee_values = subject_detail.get("assignees") assignees = [ value.get("login") for value in (assignee_values if isinstance(assignee_values, list) else []) if isinstance(value, dict) and isinstance(value.get("login"), str) ] issue = { "number": subject_detail.get("number"), "assignees": assignees, "claimable": ( subject.get("type") == "Issue" and subject_detail.get("state", subject.get("state")) == "open" and not assignees ), } if supported_conversation and subject.get("type") == "Issue" else None return { "id": thread_id, "repository": repository.get("full_name", "") if isinstance(repository.get("full_name"), str) else "", "title": subject.get("title", "") if isinstance(subject.get("title"), str) else "", "subject_type": subject.get("type", "Update") if isinstance(subject.get("type"), str) else "Update", "state": subject.get("state", "") if isinstance(subject.get("state"), str) else "", "url": latest_url or subject_url, "subject_body": subject_detail.get("body", "") if isinstance(subject_detail.get("body"), str) else "", "latest_comment": { "author": user.get("login", "") if isinstance(user.get("login"), str) else "", "body": comment.get("body", "") if isinstance(comment.get("body"), str) else "", "created_at": comment.get("created_at", "") if isinstance(comment.get("created_at"), str) else "", "url": latest_url, }, "issue": issue, "acknowledge_supported": bool( supported_conversation and comment_match and comment_match.group(1) == repository_name ), "mute_supported": bool( supported_conversation and isinstance(subscription, dict) and subscription.get("subscribed") is True and subscription.get("ignored") is not True ), "conversation_available": bool(supported_conversation), } async def notification_conversation_page( thread_id: int, page: int | None, limit: int = 20 ) -> dict: thread = await fetch(f"notifications/threads/{thread_id}") if not isinstance(thread, dict): raise ValueError("Gitea notification thread response was not an object") repository = thread.get("repository") subject = thread.get("subject") if not isinstance(repository, dict) or not isinstance(subject, dict): raise ValueError("Notification does not identify a conversation") subject_path = _gitea_api_path(subject.get("url")) match = re.fullmatch(r"repos/([^/]+/[^/]+)/(issues|pulls)/(\d+)", subject_path) if ( not match or match.group(1) != repository.get("full_name") or subject.get("type") not in {"Issue", "Pull"} ): raise ValueError("Notification subject is not a supported conversation") return await issue_conversation_page( match.group(1), int(match.group(3)), page=page, limit=limit ) async def notification_conversation_target(thread_id: int) -> tuple[str, int]: thread = await fetch(f"notifications/threads/{thread_id}") if not isinstance(thread, dict): raise ValueError("Gitea notification thread response was not an object") repository = thread.get("repository") subject_value = thread.get("subject") subject = subject_value if isinstance(subject_value, dict) else {} repository_name = repository.get("full_name") if isinstance(repository, dict) else None subject_path = _gitea_api_path(subject.get("url")) match = re.fullmatch(r"repos/([^/]+/[^/]+)/(issues|pulls)/(\d+)", subject_path) if ( not match or match.group(1) != repository_name or subject.get("type") not in {"Issue", "Pull"} ): raise ValueError("Notification subject is not a supported conversation") return match.group(1), int(match.group(3)) async def mute_notification(thread_id: int) -> dict: """Unsubscribe the current operator from the trusted notification conversation.""" repository, number = await notification_conversation_target(thread_id) user = await fetch("user") login = user.get("login") if isinstance(user, dict) else None if not isinstance(login, str) or not login: raise ValueError("Gitea did not identify the current operator") response = await _get_client().delete( f"/api/v1/repos/{repository}/issues/{number}/subscriptions/{quote(login, safe='')}", headers=_auth(), ) response.raise_for_status() return {"id": thread_id, "repository": repository, "number": number, "muted": True} async def reply_to_notification(thread_id: int, body: str) -> dict: thread = await fetch(f"notifications/threads/{thread_id}") if not isinstance(thread, dict): raise ValueError("Gitea notification thread response was not an object") repository = thread.get("repository") subject = thread.get("subject") if not isinstance(repository, dict) or not isinstance(subject, dict): raise ValueError("Notification does not identify a conversation") repository_name = repository.get("full_name") subject_path = _gitea_api_path(subject.get("url")) match = re.fullmatch( r"repos/([^/]+/[^/]+)/(issues|pulls)/(\d+)", subject_path ) if ( not match or match.group(1) != repository_name or subject.get("type") not in {"Issue", "Pull"} ): raise ValueError("Notification subject is not a supported conversation") response = await _get_client().post( f"/api/v1/repos/{match.group(1)}/issues/{match.group(3)}/comments", headers=_auth(), json={"body": body}, ) response.raise_for_status() comment = response.json() if not isinstance(comment, dict): raise ValueError("Gitea comment response was not an object") return _normalize_issue_comment(comment) async def upload_notification_attachment( thread_id: int, filename: str, content_type: str, content: bytes ) -> dict: """Upload to the exact issue or pull identified by a trusted notification.""" thread = await fetch(f"notifications/threads/{thread_id}") if not isinstance(thread, dict): raise ValueError("Gitea notification thread response was not an object") repository = thread.get("repository") subject = thread.get("subject") if not isinstance(repository, dict) or not isinstance(subject, dict): raise ValueError("Notification does not identify a conversation") subject_path = _gitea_api_path(subject.get("url")) match = re.fullmatch(r"repos/([^/]+/[^/]+)/(issues|pulls)/(\d+)", subject_path) if ( not match or match.group(1) != repository.get("full_name") or subject.get("type") not in {"Issue", "Pull"} ): raise ValueError("Notification subject is not a supported conversation") # Gitea stores pull-request assets on its shared issue asset endpoint. response = await _get_client().post( f"/api/v1/repos/{match.group(1)}/issues/{match.group(3)}/assets", headers=_auth(), params={"name": filename}, files={"attachment": (filename, content, content_type)}, ) response.raise_for_status() attachment = response.json() if not isinstance(attachment, dict): raise ValueError("Gitea attachment response was not an object") name = attachment.get("name") url = _safe_gitea_web_url(attachment.get("browser_download_url")) size = attachment.get("size") if not isinstance(name, str) or not name or not url or not isinstance(size, int): raise ValueError("Gitea did not confirm the attachment") return {"name": name, "url": url, "size": size} async def close_issue(repository: str, number: int) -> dict: response = await _get_client().patch( f"/api/v1/repos/{repository}/issues/{number}", headers=_auth(), json={"state": "closed"}, ) response.raise_for_status() issue = response.json() if not isinstance(issue, dict) or issue.get("state") != "closed": raise ValueError("Gitea did not confirm issue closure") result = { "number": issue.get("number"), "state": "closed", "closed_at": issue.get("closed_at", "") if isinstance(issue.get("closed_at"), str) else "", } if isinstance(issue.get("updated_at"), str): result["updated_at"] = issue["updated_at"] return result def _normalize_issue_comment(comment: dict) -> dict: user_value = comment.get("user") user: dict = user_value if isinstance(user_value, dict) else {} return { "id": comment.get("id"), "author": user.get("login", "") if isinstance(user.get("login"), str) else "", "body": comment.get("body", "") if isinstance(comment.get("body"), str) else "", "created_at": comment.get("created_at", "") if isinstance(comment.get("created_at"), str) else "", "url": _safe_gitea_web_url(comment.get("html_url")), } async def comment_on_issue(repository: str, number: int, body: str) -> dict: response = await _get_client().post( f"/api/v1/repos/{repository}/issues/{number}/comments", headers=_auth(), json={"body": body}, ) response.raise_for_status() comment = response.json() if not isinstance(comment, dict): raise ValueError("Gitea comment response was not an object") return _normalize_issue_comment(comment) async def _owned_comment(repository: str, number: int, comment_id: int) -> dict: user, comment = await asyncio.gather( current_user(), fetch(f"repos/{repository}/issues/comments/{comment_id}"), ) login = user.get("login") if isinstance(user, dict) else None author = comment.get("user") if isinstance(comment, dict) else None expected_issue_path = f"repos/{repository}/issues/{number}" if ( not isinstance(login, str) or not login or not isinstance(comment, dict) or not isinstance(author, dict) or author.get("login") != login or _gitea_api_path(comment.get("issue_url")) != expected_issue_path ): raise CommentMutationForbiddenError("Comment is not editable in this conversation") return comment async def edit_owned_comment( repository: str, number: int, comment_id: int, body: str ) -> dict: await _owned_comment(repository, number, comment_id) response = await _get_client().patch( f"/api/v1/repos/{repository}/issues/comments/{comment_id}", headers=_auth(), json={"body": body}, ) response.raise_for_status() comment = response.json() if not isinstance(comment, dict): raise ValueError("Gitea comment response was not an object") return _normalize_issue_comment(comment) async def delete_owned_comment(repository: str, number: int, comment_id: int) -> dict: await _owned_comment(repository, number, comment_id) response = await _get_client().delete( f"/api/v1/repos/{repository}/issues/comments/{comment_id}", headers=_auth() ) response.raise_for_status() return {"id": comment_id, "deleted": True} async def upload_assigned_issue_attachment( repository: str, number: int, filename: str, content_type: str, content: bytes, ) -> dict: if not await is_assigned_issue(repository, number): raise IssueNotAvailableError("Assigned issue not found") response = await _get_client().post( f"/api/v1/repos/{repository}/issues/{number}/assets", headers=_auth(), params={"name": filename}, files={"attachment": (filename, content, content_type)}, ) response.raise_for_status() attachment = response.json() if not isinstance(attachment, dict): raise ValueError("Gitea attachment response was not an object") name = attachment.get("name") url = _safe_gitea_web_url(attachment.get("browser_download_url")) size = attachment.get("size") if not isinstance(name, str) or not name or not url or not isinstance(size, int): raise ValueError("Gitea did not confirm the attachment") return {"name": name, "url": url, "size": size} async def upload_preview_attachment( repository: str, number: int, filename: str, content_type: str, content: bytes, ) -> dict: """Upload evidence after the caller verifies an exact Search preview target.""" response = await _get_client().post( f"/api/v1/repos/{repository}/issues/{number}/assets", headers=_auth(), params={"name": filename}, files={"attachment": (filename, content, content_type)}, ) response.raise_for_status() attachment = response.json() if not isinstance(attachment, dict): raise ValueError("Gitea attachment response was not an object") name = attachment.get("name") url = _safe_gitea_web_url(attachment.get("browser_download_url")) size = attachment.get("size") if not isinstance(name, str) or not name or not url or not isinstance(size, int): raise ValueError("Gitea did not confirm the attachment") return {"name": name, "url": url, "size": size} async def upload_assigned_pull_attachment( repository: str, number: int, filename: str, content_type: str, content: bytes, ) -> dict: if not await is_assigned_pull(repository, number): raise IssueNotAvailableError("Assigned pull request not found") response = await _get_client().post( f"/api/v1/repos/{repository}/issues/{number}/assets", headers=_auth(), params={"name": filename}, files={"attachment": (filename, content, content_type)}, ) response.raise_for_status() attachment = response.json() if not isinstance(attachment, dict): raise ValueError("Gitea attachment response was not an object") name = attachment.get("name") url = _safe_gitea_web_url(attachment.get("browser_download_url")) size = attachment.get("size") if not isinstance(name, str) or not name or not url or not isinstance(size, int): raise ValueError("Gitea did not confirm the attachment") return {"name": name, "url": url, "size": size} async def repo_labels(repository: str) -> list[dict]: response = await _get_client().get( f"/api/v1/repos/{repository}/labels", headers=_auth(), params={"limit": 50, "page": 1}, ) response.raise_for_status() payload = response.json() if not isinstance(payload, list): raise ValueError("Gitea labels response was not a list") return [ { "id": item["id"], "name": item["name"], "color": item.get("color", "") if isinstance(item.get("color"), str) else "", "description": item.get("description", "") if isinstance(item.get("description"), str) else "", } for item in payload if isinstance(item, dict) and isinstance(item.get("id"), int) and isinstance(item.get("name"), str) ] async def repo_issue_templates(repository: str) -> list[dict]: """Return a small allowlisted view of repository issue templates.""" response = await _get_client().get( f"/api/v1/repos/{repository}/issue_templates", headers=_auth() ) response.raise_for_status() payload = response.json() if payload is None: return [] if not isinstance(payload, list): raise ValueError("Gitea issue templates response was not a list") templates = [] for item in payload: if len(templates) >= 20: break if not isinstance(item, dict): continue name = item.get("name") if not isinstance(name, str) or not name.strip(): continue name = name.strip()[:80] identifier = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")[:80] labels = [] for label in item.get("labels", []) if isinstance(item.get("labels"), list) else []: if not isinstance(label, str): continue clean = label.strip()[:80] if clean and clean not in labels: labels.append(clean) if len(labels) >= 20: break scalar = lambda key, limit: ( item.get(key, "")[:limit] if isinstance(item.get(key), str) else "" ) templates.append({ "id": identifier or f"template-{len(templates) + 1}", "name": name, "about": scalar("about", 240).strip(), "title": scalar("title", 255), "body": scalar("content", 9000).strip(), "labels": labels, }) return templates async def repo_milestones(repository: str) -> list[dict]: response = await _get_client().get( f"/api/v1/repos/{repository}/milestones", headers=_auth(), params={"state": "open", "limit": 50, "page": 1}, ) response.raise_for_status() payload = response.json() if not isinstance(payload, list): raise ValueError("Gitea milestones response was not a list") return [ {"id": item["id"], "title": item["title"]} for item in payload if isinstance(item, dict) and isinstance(item.get("id"), int) and isinstance(item.get("title"), str) and item.get("state") == "open" ] async def create_pull( repository: str, *, head: str, base: str, title: str, body: str, draft: bool, expected_head_sha: str, ) -> dict: """Create one pull only after revalidating its source and duplicate identity.""" branch_response = await _get_client().get( f"/api/v1/repos/{repository}/branches/{quote(head, safe='')}", headers=_auth(), ) if branch_response.status_code == 404: raise PullCreateConflictError("Source branch is no longer available") branch_response.raise_for_status() branch = branch_response.json() commit = branch.get("commit", {}) if isinstance(branch, dict) else {} if commit.get("id") != expected_head_sha: raise PullCreateConflictError("Source branch changed before pull creation") existing_response = await _get_client().get( f"/api/v1/repos/{repository}/pulls", headers=_auth(), params={"state": "open", "page": 1, "limit": 50}, ) existing_response.raise_for_status() existing_items = existing_response.json() if not isinstance(existing_items, list): raise ValueError("Gitea pull response was not a list") existing = next(( item for item in existing_items if isinstance(item, dict) and isinstance(item.get("head"), dict) and isinstance(item.get("base"), dict) and item["head"].get("ref") == head and item["base"].get("ref") == base and item.get("state") == "open" ), None) pull = existing if pull is None: response = await _get_client().post( f"/api/v1/repos/{repository}/pulls", headers=_auth(), json={ "head": head, "base": base, "title": title, "body": body, "draft": draft, }, ) response.raise_for_status() pull = response.json() if not isinstance(pull, dict) or not isinstance(pull.get("number"), int): raise ValueError("Gitea did not confirm pull creation") pull_head = pull.get("head") if isinstance(pull.get("head"), dict) else {} pull_base = pull.get("base") if isinstance(pull.get("base"), dict) else {} pull_user = pull.get("user") if isinstance(pull.get("user"), dict) else {} if ( pull.get("state") != "open" or pull_head.get("ref") != head or pull_head.get("sha") != expected_head_sha or pull_base.get("ref") != base or not isinstance(pull_user.get("login"), str) or (existing is None and ( pull.get("title") != title or pull.get("body", "") != body or bool(pull.get("draft")) is not draft )) ): raise ValueError("Gitea did not confirm the requested pull") return { "number": pull["number"], "repository": repository, "title": pull.get("title", ""), "body": pull.get("body", ""), "state": "open", "draft": bool(pull.get("draft")), "head": {"ref": head, "sha": expected_head_sha}, "base": {"ref": base}, "author": pull_user["login"], "url": _safe_gitea_web_url(pull.get("html_url")), "existing": existing is not None, } async def create_issue( repository: str, title: str, body: str, assignee: str | None, label_ids: list[int] | None = None, milestone_id: int | None = None, due_date: str | None = None, ) -> dict: payload: dict = {"title": title, "body": body} if assignee is not None: payload["assignee"] = assignee if label_ids: payload["labels"] = label_ids if milestone_id is not None: payload["milestone"] = milestone_id if due_date is not None: payload["due_date"] = due_date response = await _get_client().post( f"/api/v1/repos/{repository}/issues", headers=_auth(), json=payload, ) response.raise_for_status() issue = response.json() if not isinstance(issue, dict) or not isinstance(issue.get("number"), int): raise ValueError("Gitea did not confirm issue creation") assignees_value = issue.get("assignees") assignees = assignees_value if isinstance(assignees_value, list) else [] confirmed_assignees = [ item["login"] for item in assignees if isinstance(item, dict) and isinstance(item.get("login"), str) ] expected_assignees = [assignee] if assignee is not None else [] if confirmed_assignees != expected_assignees: raise ValueError( "Gitea did not confirm self-assignment or exact issue assignment" ) labels_value = issue.get("labels") labels = labels_value if isinstance(labels_value, list) else [] milestone_value = issue.get("milestone") milestone = ( {"id": milestone_value["id"], "title": milestone_value["title"]} if isinstance(milestone_value, dict) and isinstance(milestone_value.get("id"), int) and isinstance(milestone_value.get("title"), str) else None ) confirmed_due_date = ( issue.get("due_date") if isinstance(issue.get("due_date"), str) else None ) if ( (milestone_id is not None and (milestone or {}).get("id") != milestone_id) or (due_date is not None and confirmed_due_date != due_date) ): raise ValueError("Gitea did not confirm issue release plan") return { "id": issue.get("id"), "number": issue["number"], "title": issue.get("title", "") if isinstance(issue.get("title"), str) else "", "state": issue.get("state", "") if isinstance(issue.get("state"), str) else "", "repository": repository, "labels": [ item["name"] for item in labels if isinstance(item, dict) and isinstance(item.get("name"), str) ], "assignees": confirmed_assignees, "milestone": milestone, "due_date": confirmed_due_date, "updated_at": issue.get("updated_at", "") if isinstance(issue.get("updated_at"), str) else "", "url": _safe_gitea_web_url(issue.get("html_url")), } async def claim_available_issue(repository: str, number: int) -> dict: issue = await fetch(f"repos/{repository}/issues/{number}") user = await current_user() assignees_value = issue.get("assignees") if isinstance(issue, dict) else None if ( not isinstance(issue, dict) or issue.get("state") != "open" or issue.get("pull_request") is not None or assignees_value not in (None, []) ): raise IssueNotAvailableError("Issue is no longer available") login = user.get("login") if isinstance(user, dict) else None if not isinstance(login, str) or not login: raise ValueError("Gitea current user did not include a login") response = await _get_client().patch( f"/api/v1/repos/{repository}/issues/{number}", headers=_auth(), json={"assignee": login}, ) response.raise_for_status() confirmed = response.json() if not isinstance(confirmed, dict) or confirmed.get("number") != number: raise ValueError("Gitea did not confirm issue assignment") confirmed_assignees_value = confirmed.get("assignees") confirmed_assignees = ( confirmed_assignees_value if isinstance(confirmed_assignees_value, list) else [] ) logins = [ assignee["login"] for assignee in confirmed_assignees if isinstance(assignee, dict) and isinstance(assignee.get("login"), str) ] if login not in logins: raise ValueError("Gitea did not confirm issue assignment") labels_value = confirmed.get("labels") labels = labels_value if isinstance(labels_value, list) else [] return { "id": confirmed.get("id"), "number": number, "title": confirmed.get("title", "") if isinstance(confirmed.get("title"), str) else "", "state": "open", "repository": repository, "labels": [ label["name"] for label in labels if isinstance(label, dict) and isinstance(label.get("name"), str) ], "assignees": logins, "updated_at": confirmed.get("updated_at", "") if isinstance(confirmed.get("updated_at"), str) else "", "url": _safe_gitea_web_url(confirmed.get("html_url")), } async def reopen_issue(repository: str, number: int) -> dict: """Reopen a closed issue, assign it to the current user, and confirm both.""" issue, user = await asyncio.gather( fetch(f"repos/{repository}/issues/{number}"), current_user() ) login = user.get("login") if isinstance(user, dict) else None if ( not isinstance(issue, dict) or issue.get("number") != number or issue.get("pull_request") is not None or not isinstance(login, str) or not login ): raise IssueNotAvailableError("Issue cannot be resumed") if issue.get("state") == "open" and _login_in_users(login, issue.get("assignees")): confirmed = issue elif issue.get("state") == "closed": response = await _get_client().patch( f"/api/v1/repos/{repository}/issues/{number}", headers=_auth(), json={"state": "open", "assignee": login}, ) response.raise_for_status() confirmed = response.json() else: raise IssueNotAvailableError("Issue is no longer available to resume") assignees_value = confirmed.get("assignees") if isinstance(confirmed, dict) else None assignees = assignees_value if isinstance(assignees_value, list) else [] logins = [ assignee["login"] for assignee in assignees if isinstance(assignee, dict) and isinstance(assignee.get("login"), str) ] if ( not isinstance(confirmed, dict) or confirmed.get("number") != number or confirmed.get("state") != "open" or login not in logins ): raise ValueError("Gitea did not confirm issue reopening and assignment") labels_value = confirmed.get("labels") labels = labels_value if isinstance(labels_value, list) else [] return { "id": confirmed.get("id"), "number": number, "title": confirmed.get("title", "") if isinstance(confirmed.get("title"), str) else "", "state": "open", "repository": repository, "labels": [ label["name"] for label in labels if isinstance(label, dict) and isinstance(label.get("name"), str) ], "assignees": logins, "updated_at": confirmed.get("updated_at", "") if isinstance(confirmed.get("updated_at"), str) else "", "url": _safe_gitea_web_url(confirmed.get("html_url")), } async def release_assigned_issue(repository: str, number: int) -> dict: login, issue = await _current_login_and_target( f"repos/{repository}/issues/{number}" ) assignees_value = issue.get("assignees") if ( issue.get("state") != "open" or issue.get("pull_request") is not None or not _login_in_users(login, assignees_value) ): raise IssueNotAvailableError("Issue is not assigned to the current user") assignees = assignees_value if isinstance(assignees_value, list) else [] remaining = [ assignee["login"] for assignee in assignees if isinstance(assignee, dict) and isinstance(assignee.get("login"), str) and assignee["login"] != login ] response = await _get_client().patch( f"/api/v1/repos/{repository}/issues/{number}", headers=_auth(), json={"assignees": remaining}, ) response.raise_for_status() confirmed = response.json() confirmed_value = confirmed.get("assignees") if isinstance(confirmed, dict) else None confirmed_assignees = [ assignee["login"] for assignee in confirmed_value if isinstance(assignee, dict) and isinstance(assignee.get("login"), str) ] if isinstance(confirmed_value, list) else [] if ( not isinstance(confirmed, dict) or confirmed.get("number") != number or login in confirmed_assignees or set(confirmed_assignees) != set(remaining) ): raise ValueError("Gitea did not confirm issue release") return { "number": number, "repository": repository, "state": confirmed.get("state", "open"), "assignees": confirmed_assignees, "available": not confirmed_assignees, } async def issue_handoff_candidates(repository: str) -> list[dict]: user, response = await asyncio.gather( current_user(), _get_client().get( f"/api/v1/repos/{repository}/assignees", headers=_auth() ), ) response.raise_for_status() payload = response.json() if not isinstance(payload, list): raise ValueError("Gitea assignees response was not a list") current_login = user.get("login") if isinstance(user, dict) else None candidates = [] for item in payload: if not isinstance(item, dict): continue login = item.get("login") if not isinstance(login, str) or not login or login == current_login: continue full_name = item.get("full_name") candidates.append({ "login": login, "name": full_name if isinstance(full_name, str) and full_name else login, }) return candidates async def mention_candidates( repository: str, query: str, *, limit: int = 8 ) -> list[dict]: response = await _get_client().get( f"/api/v1/repos/{repository}/assignees", headers=_auth() ) response.raise_for_status() payload = response.json() if not isinstance(payload, list): raise ValueError("Gitea assignees response was not a list") needle = query.casefold() matches = [] for item in payload: if not isinstance(item, dict): continue login = item.get("login") if ( not isinstance(login, str) or not re.fullmatch(r"[A-Za-z0-9_.-]+", login) ): continue full_name = item.get("full_name") name = full_name if isinstance(full_name, str) and full_name else login login_match = needle in login.casefold() name_match = needle in name.casefold() if login_match or name_match: matches.append((0 if login.casefold().startswith(needle) else 1, login.casefold(), { "login": login, "name": name, })) matches.sort(key=lambda item: (item[0], item[1])) return [item[2] for item in matches[:limit]] async def handoff_assigned_issue( repository: str, number: int, recipient: str ) -> dict: login, issue = await _current_login_and_target( f"repos/{repository}/issues/{number}" ) if ( issue.get("state") != "open" or issue.get("pull_request") is not None or not _login_in_users(login, issue.get("assignees")) ): raise IssueNotAvailableError("Issue is not assigned to the current user") eligible = { item["login"] for item in await issue_handoff_candidates(repository) } if recipient not in eligible: raise IssueNotAvailableError("Handoff recipient is not eligible") assignees_value = issue.get("assignees") assignees = assignees_value if isinstance(assignees_value, list) else [] desired = [ item["login"] for item in assignees if isinstance(item, dict) and isinstance(item.get("login"), str) and item["login"] != login ] if recipient not in desired: desired.append(recipient) response = await _get_client().patch( f"/api/v1/repos/{repository}/issues/{number}", headers=_auth(), json={"assignees": desired}, ) response.raise_for_status() confirmed = response.json() confirmed_value = confirmed.get("assignees") if isinstance(confirmed, dict) else None confirmed_assignees = [ item["login"] for item in confirmed_value if isinstance(item, dict) and isinstance(item.get("login"), str) ] if isinstance(confirmed_value, list) else [] if ( not isinstance(confirmed, dict) or confirmed.get("number") != number or login in confirmed_assignees or recipient not in confirmed_assignees or set(confirmed_assignees) != set(desired) ): raise ValueError("Gitea did not confirm issue handoff") return { "repository": repository, "number": number, "state": confirmed.get("state", "open"), "assignees": confirmed_assignees, "recipient": recipient, } async def reassign_authored_issue( repository: str, number: int, recipient: str, expected_assignees: list[str] ) -> dict: login, issue = await _current_login_and_target( f"repos/{repository}/issues/{number}" ) author = issue.get("user") author_login = author.get("login") if isinstance(author, dict) else None assignees_value = issue.get("assignees") current_assignees = [ item["login"] for item in assignees_value if isinstance(item, dict) and isinstance(item.get("login"), str) ] if isinstance(assignees_value, list) else [] if ( issue.get("state") != "open" or issue.get("pull_request") is not None or not isinstance(author_login, str) or author_login.casefold() != login.casefold() or not current_assignees or current_assignees != expected_assignees ): raise IssueNotAvailableError("Authored issue delegate changed") eligible = { item["login"] for item in await issue_handoff_candidates(repository) } if recipient not in eligible or recipient in current_assignees: raise IssueNotAvailableError("Reassignment recipient is not eligible") response = await _get_client().patch( f"/api/v1/repos/{repository}/issues/{number}", headers=_auth(), json={"assignees": [recipient]}, ) response.raise_for_status() confirmed = response.json() confirmed_value = confirmed.get("assignees") if isinstance(confirmed, dict) else None confirmed_assignees = [ item["login"] for item in confirmed_value if isinstance(item, dict) and isinstance(item.get("login"), str) ] if isinstance(confirmed_value, list) else [] if ( not isinstance(confirmed, dict) or confirmed.get("number") != number or confirmed_assignees != [recipient] ): raise ValueError("Gitea did not confirm issue reassignment") return { "repository": repository, "number": number, "state": confirmed.get("state", "open"), "assignees": confirmed_assignees, "recipient": recipient, "previous_assignees": expected_assignees, } async def pull_handoff_candidates(repository: str) -> list[dict]: candidates = await issue_handoff_candidates(repository) return [ item for item in candidates if re.fullmatch(r"[A-Za-z0-9_.-]+", item["login"]) ][:25] async def pull_review_candidates(repository: str, number: int) -> list[dict]: pull, candidates = await asyncio.gather( fetch(f"repos/{repository}/pulls/{number}"), pull_handoff_candidates(repository), ) if not isinstance(pull, dict): raise ValueError("Gitea pull request response was not an object") excluded = { item["login"].casefold() for item in (pull.get("requested_reviewers") or []) if isinstance(item, dict) and isinstance(item.get("login"), str) } author = pull.get("user") if isinstance(pull.get("user"), dict) else {} if isinstance(author.get("login"), str): excluded.add(author["login"].casefold()) return [ item for item in candidates if item["login"].casefold() not in excluded ][:25] def _login_can_manage_pull(login: str, pull: dict) -> bool: author = pull.get("user") if isinstance(pull.get("user"), dict) else {} return ( author.get("login", "").casefold() == login.casefold() or _login_in_users(login, pull.get("assignees")) ) async def _transition_authored_pull( repository: str, number: int, expected_head_sha: str, *, from_state: str, to_state: str, ) -> dict: login, pull = await _current_login_and_target( f"repos/{repository}/pulls/{number}" ) author = pull.get("user") if isinstance(pull.get("user"), dict) else {} head = pull.get("head") if isinstance(pull.get("head"), dict) else {} if ( pull.get("state") != from_state or pull.get("merged") is True or author.get("login", "").casefold() != login.casefold() or head.get("sha") != expected_head_sha ): raise IssueNotAvailableError("Pull request state changed") response = await _get_client().patch( f"/api/v1/repos/{repository}/pulls/{number}", headers=_auth(), json={"state": to_state}, ) response.raise_for_status() confirmed_response = await _get_client().get( f"/api/v1/repos/{repository}/pulls/{number}", headers=_auth() ) confirmed_response.raise_for_status() confirmed = confirmed_response.json() confirmed_head = confirmed.get("head") if isinstance(confirmed, dict) and isinstance(confirmed.get("head"), dict) else {} if ( not isinstance(confirmed, dict) or confirmed.get("number") != number or confirmed.get("state") != to_state or confirmed.get("merged") is True or confirmed_head.get("sha") != expected_head_sha ): raise ValueError("Gitea did not confirm the pull request state") return { "repository": repository, "number": number, "title": confirmed.get("title", ""), "head_sha": expected_head_sha, "state": to_state, "merged": False, } async def close_authored_pull( repository: str, number: int, expected_head_sha: str ) -> dict: return await _transition_authored_pull( repository, number, expected_head_sha, from_state="open", to_state="closed" ) async def reopen_authored_pull( repository: str, number: int, expected_head_sha: str ) -> dict: return await _transition_authored_pull( repository, number, expected_head_sha, from_state="closed", to_state="open" ) async def authored_pull_feedback_file( repository: str, number: int, path: str, expected_head_sha: str ) -> dict: """Load one bounded UTF-8 file from an authored same-repository pull head.""" if ( not path or path.startswith("/") or "\\" in path or any(part in {"", ".", ".."} for part in path.split("/")) ): raise IssueNotAvailableError("File path is not eligible") login, pull = await _current_login_and_target( f"repos/{repository}/pulls/{number}" ) author = pull.get("user") if isinstance(pull.get("user"), dict) else {} head = pull.get("head") if isinstance(pull.get("head"), dict) else {} base = pull.get("base") if isinstance(pull.get("base"), dict) else {} head_repo = head.get("repo") if isinstance(head.get("repo"), dict) else {} base_repo = base.get("repo") if isinstance(base.get("repo"), dict) else {} if ( pull.get("state") != "open" or pull.get("merged") is True or author.get("login", "").casefold() != login.casefold() or head.get("sha") != expected_head_sha or head_repo.get("full_name") != repository or base_repo.get("full_name") != repository ): raise IssueNotAvailableError("Pull request state changed") response = await _get_client().get( f"/api/v1/repos/{repository}/contents/{quote(path, safe='/')}", headers=_auth(), params={"ref": expected_head_sha}, ) response.raise_for_status() payload = response.json() if not isinstance(payload, dict): raise IssueNotAvailableError("File is not editable text") encoded = payload.get("content") if ( payload.get("type") != "file" or payload.get("encoding") != "base64" or not isinstance(encoded, str) or not isinstance(payload.get("sha"), str) ): raise IssueNotAvailableError("File is not editable text") try: raw = base64.b64decode(encoded, validate=True) content = raw.decode("utf-8") except (ValueError, UnicodeDecodeError): raise IssueNotAvailableError("File is not editable text") from None if len(raw) > 128 * 1024 or "\x00" in content: raise IssueNotAvailableError("File is not editable text") return { "repository": repository, "number": number, "path": path, "head_sha": expected_head_sha, "blob_sha": payload["sha"], "content": content, } async def commit_authored_pull_feedback_fix( repository: str, number: int, path: str, content: str, message: str, expected_head_sha: str, expected_blob_sha: str, ) -> dict: """Commit and verify one race-guarded text-file fix on an authored pull.""" encoded = content.encode("utf-8") message = message.strip() if len(encoded) > 128 * 1024 or "\x00" in content or not message or len(message) > 120: raise IssueNotAvailableError("Feedback fix is outside the editable bounds") login, pull = await _current_login_and_target( f"repos/{repository}/pulls/{number}" ) author = pull.get("user") if isinstance(pull.get("user"), dict) else {} head = pull.get("head") if isinstance(pull.get("head"), dict) else {} base = pull.get("base") if isinstance(pull.get("base"), dict) else {} head_repo = head.get("repo") if isinstance(head.get("repo"), dict) else {} base_repo = base.get("repo") if isinstance(base.get("repo"), dict) else {} branch = head.get("ref") if ( pull.get("state") != "open" or pull.get("merged") is True or author.get("login", "").casefold() != login.casefold() or head.get("sha") != expected_head_sha or head_repo.get("full_name") != repository or base_repo.get("full_name") != repository or not isinstance(branch, str) or not branch ): raise IssueNotAvailableError("Pull request state changed") current = await authored_pull_feedback_file( repository, number, path, expected_head_sha ) if current["blob_sha"] != expected_blob_sha or current["content"] == content: raise IssueNotAvailableError("File changed or has no new content") response = await _get_client().put( f"/api/v1/repos/{repository}/contents/{quote(path, safe='/')}", headers=_auth(), json={ "branch": branch, "sha": expected_blob_sha, "message": message, "content": base64.b64encode(encoded).decode(), }, ) if response.status_code in {409, 422}: raise IssueNotAvailableError("Pull request or file changed") response.raise_for_status() confirmed_pull = await fetch(f"repos/{repository}/pulls/{number}") confirmed_head = confirmed_pull.get("head") if isinstance(confirmed_pull, dict) and isinstance(confirmed_pull.get("head"), dict) else {} new_head_sha = confirmed_head.get("sha") if not isinstance(new_head_sha, str) or not new_head_sha or new_head_sha == expected_head_sha: raise ValueError("Gitea did not confirm a new pull request head") confirmed = await authored_pull_feedback_file( repository, number, path, new_head_sha ) if confirmed["content"] != content: raise ValueError("Gitea did not confirm the committed file content") return { "repository": repository, "number": number, "path": path, "previous_head_sha": expected_head_sha, "head_sha": new_head_sha, "blob_sha": confirmed["blob_sha"], "message": message, } async def update_authored_pull_branch( repository: str, number: int, expected_head_sha: str ) -> dict: login, pull = await _current_login_and_target( f"repos/{repository}/pulls/{number}" ) author = pull.get("user") if isinstance(pull.get("user"), dict) else {} head = pull.get("head") if isinstance(pull.get("head"), dict) else {} if ( pull.get("state") != "open" or pull.get("merged") is True or pull.get("draft") is True or author.get("login", "").casefold() != login.casefold() or head.get("sha") != expected_head_sha ): raise IssueNotAvailableError("Pull request state changed") response = await _get_client().post( f"/api/v1/repos/{repository}/pulls/{number}/update", headers=_auth(), params={"style": "merge"}, ) if response.status_code in {409, 422}: raise PullUpdateConflictError("Pull request has merge conflicts") response.raise_for_status() confirmed_response = await _get_client().get( f"/api/v1/repos/{repository}/pulls/{number}", headers=_auth() ) confirmed_response.raise_for_status() confirmed = confirmed_response.json() confirmed_head = confirmed.get("head") if isinstance(confirmed, dict) and isinstance(confirmed.get("head"), dict) else {} new_head_sha = confirmed_head.get("sha") if ( not isinstance(confirmed, dict) or confirmed.get("number") != number or confirmed.get("state") != "open" or confirmed.get("merged") is True or not isinstance(new_head_sha, str) or not new_head_sha or new_head_sha == expected_head_sha ): raise ValueError("Gitea did not confirm a new pull request head") return { "repository": repository, "number": number, "title": confirmed.get("title", ""), "previous_head_sha": expected_head_sha, "head_sha": new_head_sha, "state": "open", "draft": confirmed.get("draft") is True, } async def request_assigned_pull_review( repository: str, number: int, reviewer: str, expected_head_sha: str ) -> dict: login, pull = await _current_login_and_target( f"repos/{repository}/pulls/{number}" ) head = pull.get("head") if isinstance(pull.get("head"), dict) else {} if ( pull.get("state") != "open" or pull.get("merged") is True or not _login_can_manage_pull(login, pull) or head.get("sha") != expected_head_sha ): raise IssueNotAvailableError("Pull request is no longer eligible for review request") eligible = { item["login"] for item in await pull_review_candidates(repository, number) } if reviewer not in eligible: raise IssueNotAvailableError("Reviewer is not eligible") response = await _get_client().post( f"/api/v1/repos/{repository}/pulls/{number}/requested_reviewers", headers=_auth(), json={"reviewers": [reviewer]}, ) response.raise_for_status() confirmed_response = await _get_client().get( f"/api/v1/repos/{repository}/pulls/{number}", headers=_auth() ) confirmed_response.raise_for_status() confirmed = confirmed_response.json() requested = [ item["login"] for item in (confirmed.get("requested_reviewers") or []) if isinstance(item, dict) and isinstance(item.get("login"), str) ] if isinstance(confirmed, dict) else [] if reviewer not in requested: raise ValueError("Gitea did not confirm the requested reviewer") return { "repository": repository, "number": number, "head_sha": expected_head_sha, "requested_reviewers": requested, "reviewer": reviewer, } async def publish_authored_assigned_pull( repository: str, number: int, expected_head_sha: str ) -> dict: login, pull = await _current_login_and_target( f"repos/{repository}/pulls/{number}" ) author = pull.get("user") if isinstance(pull.get("user"), dict) else {} head = pull.get("head") if isinstance(pull.get("head"), dict) else {} title = pull.get("title") if isinstance(pull.get("title"), str) else "" ready_title = re.sub( r"^(?:WIP\s*:|\[WIP\]|Draft\s*:|\[Draft\])\s*", "", title, count=1, flags=re.IGNORECASE, ).strip() if ( pull.get("state") != "open" or pull.get("merged") is True or pull.get("draft") is not True or author.get("login", "").casefold() != login.casefold() or head.get("sha") != expected_head_sha or not ready_title or ready_title == title ): raise IssueNotAvailableError("Draft pull request is no longer publishable") response = await _get_client().patch( f"/api/v1/repos/{repository}/pulls/{number}", headers=_auth(), json={"title": ready_title}, ) response.raise_for_status() confirmed_response = await _get_client().get( f"/api/v1/repos/{repository}/pulls/{number}", headers=_auth() ) confirmed_response.raise_for_status() confirmed = confirmed_response.json() confirmed_head = ( confirmed.get("head") if isinstance(confirmed, dict) and isinstance(confirmed.get("head"), dict) else {} ) if ( not isinstance(confirmed, dict) or confirmed.get("number") != number or confirmed.get("state") != "open" or confirmed.get("draft") is not False or confirmed.get("title") != ready_title or confirmed_head.get("sha") != expected_head_sha ): raise ValueError("Gitea did not confirm the pull request is ready") return { "repository": repository, "number": number, "title": ready_title, "head_sha": expected_head_sha, "state": "open", "draft": False, } async def cancel_assigned_pull_review( repository: str, number: int, reviewer: str, expected_head_sha: str ) -> dict: login, pull = await _current_login_and_target( f"repos/{repository}/pulls/{number}" ) head = pull.get("head") if isinstance(pull.get("head"), dict) else {} requested = [ item["login"] for item in (pull.get("requested_reviewers") or []) if isinstance(item, dict) and isinstance(item.get("login"), str) ] if ( pull.get("state") != "open" or pull.get("merged") is True or not _login_can_manage_pull(login, pull) or head.get("sha") != expected_head_sha or reviewer not in requested ): raise IssueNotAvailableError("Pending review request changed") response = await _get_client().request( "DELETE", f"/api/v1/repos/{repository}/pulls/{number}/requested_reviewers", headers=_auth(), json={"reviewers": [reviewer]}, ) response.raise_for_status() confirmed_response = await _get_client().get( f"/api/v1/repos/{repository}/pulls/{number}", headers=_auth() ) confirmed_response.raise_for_status() confirmed = confirmed_response.json() remaining = [ item["login"] for item in (confirmed.get("requested_reviewers") or []) if isinstance(item, dict) and isinstance(item.get("login"), str) ] if isinstance(confirmed, dict) else [] confirmed_head = confirmed.get("head") if isinstance(confirmed, dict) and isinstance(confirmed.get("head"), dict) else {} if reviewer in remaining or confirmed_head.get("sha") != expected_head_sha: raise ValueError("Gitea did not confirm reviewer cancellation") return { "repository": repository, "number": number, "head_sha": expected_head_sha, "requested_reviewers": remaining, "reviewer": reviewer, } async def _change_assigned_pull_owners( repository: str, number: int, recipient: str | None ) -> dict: login, pull = await _current_login_and_target( f"repos/{repository}/pulls/{number}" ) assignees_value = pull.get("assignees") assignees = assignees_value if isinstance(assignees_value, list) else [] login_folded = login.casefold() assigned_to_login = any( isinstance(item, dict) and isinstance(item.get("login"), str) and item["login"].casefold() == login_folded for item in assignees ) if ( pull.get("state") != "open" or pull.get("merged") is True or not assigned_to_login ): raise IssueNotAvailableError("Pull request is not assigned to the current user") if recipient is not None: eligible = { item["login"] for item in await pull_handoff_candidates(repository) } if recipient not in eligible: raise IssueNotAvailableError("Handoff recipient is not eligible") desired = [ item["login"] for item in assignees if isinstance(item, dict) and isinstance(item.get("login"), str) and item["login"].casefold() != login_folded ] if recipient is not None and recipient not in desired: desired.append(recipient) response = await _get_client().patch( f"/api/v1/repos/{repository}/issues/{number}", headers=_auth(), json={"assignees": desired}, ) response.raise_for_status() confirmed = response.json() confirmed_value = confirmed.get("assignees") if isinstance(confirmed, dict) else None confirmed_assignees = [ item["login"] for item in confirmed_value if isinstance(item, dict) and isinstance(item.get("login"), str) ] if isinstance(confirmed_value, list) else [] if ( not isinstance(confirmed, dict) or confirmed.get("number") != number or login_folded in {item.casefold() for item in confirmed_assignees} or set(confirmed_assignees) != set(desired) or (recipient is not None and recipient not in confirmed_assignees) ): raise ValueError("Gitea did not confirm pull request ownership change") result = { "repository": repository, "number": number, "state": confirmed.get("state", "open"), "assignees": confirmed_assignees, } if recipient is not None: result["recipient"] = recipient return result async def handoff_assigned_pull( repository: str, number: int, recipient: str ) -> dict: return await _change_assigned_pull_owners(repository, number, recipient) async def release_assigned_pull(repository: str, number: int) -> dict: return await _change_assigned_pull_owners(repository, number, None) async def update_issue_labels(repository: str, number: int, label_ids: list[int]) -> dict: response = await _get_client().patch( f"/api/v1/repos/{repository}/issues/{number}", headers=_auth(), json={"labels": label_ids}, ) response.raise_for_status() issue = response.json() if not isinstance(issue, dict) or issue.get("number") != number: raise ValueError("Gitea did not confirm the label update") labels_value = issue.get("labels") labels = labels_value if isinstance(labels_value, list) else [] confirmed_ids = { item["id"] for item in labels if isinstance(item, dict) and isinstance(item.get("id"), int) } if confirmed_ids != set(label_ids): raise ValueError("Gitea did not confirm the requested label set") return { "number": number, "labels": [ item["name"] for item in labels if isinstance(item, dict) and isinstance(item.get("name"), str) ], } async def update_assigned_issue_due_date( repository: str, number: int, due_date: str | None ) -> dict: path = f"repos/{repository}/issues/{number}" login, issue = await _current_login_and_target(path) if ( issue.get("state") != "open" or isinstance(issue.get("pull_request"), dict) or not _login_in_users(login, issue.get("assignees")) ): raise IssueNotAvailableError("assigned issue not found") payload = {"due_date": due_date} if due_date else {"unset_due_date": True} response = await _get_client().patch( f"/api/v1/{path}", headers=_auth(), json=payload ) response.raise_for_status() confirmed = response.json() confirmed_due_date = confirmed.get("due_date") if isinstance(confirmed, dict) else None if ( not isinstance(confirmed, dict) or confirmed.get("number") != number or confirmed_due_date != due_date ): raise ValueError("Gitea did not confirm the issue due date update") return { "repository": repository, "number": number, "state": confirmed.get("state", "open"), "due_date": confirmed_due_date, } async def update_assigned_issue_milestone( repository: str, number: int, milestone_id: int | None ) -> dict: path = f"repos/{repository}/issues/{number}" login, issue = await _current_login_and_target(path) if ( issue.get("state") != "open" or isinstance(issue.get("pull_request"), dict) or not _login_in_users(login, issue.get("assignees")) ): raise IssueNotAvailableError("assigned issue not found") selected = None if milestone_id is not None: selected = next( (item for item in await repo_milestones(repository) if item["id"] == milestone_id), None, ) if selected is None: raise ValueError("Unknown open repository milestone") response = await _get_client().patch( f"/api/v1/{path}", headers=_auth(), json={"milestone": milestone_id or 0} ) response.raise_for_status() confirmed = response.json() milestone_value = confirmed.get("milestone") if isinstance(confirmed, dict) else None normalized = ( {"id": milestone_value["id"], "title": milestone_value["title"]} if isinstance(milestone_value, dict) and isinstance(milestone_value.get("id"), int) and isinstance(milestone_value.get("title"), str) else None ) if ( not isinstance(confirmed, dict) or confirmed.get("number") != number or normalized != selected ): raise ValueError("Gitea did not confirm the issue milestone update") return { "repository": repository, "number": number, "state": confirmed.get("state", "open"), "milestone": normalized, } async def update_assigned_issue_release_plan( repository: str, number: int, milestone_id: int, due_date: str | None ) -> dict: path = f"repos/{repository}/issues/{number}" login, issue = await _current_login_and_target(path) if ( issue.get("state") != "open" or isinstance(issue.get("pull_request"), dict) or not _login_in_users(login, issue.get("assignees")) ): raise IssueNotAvailableError("assigned issue not found") selected = next( (item for item in await repo_milestones(repository) if item["id"] == milestone_id), None, ) if selected is None: raise ValueError("Unknown open repository milestone") payload: dict = {"milestone": milestone_id} if due_date is not None: payload["due_date"] = due_date response = await _get_client().patch( f"/api/v1/{path}", headers=_auth(), json=payload ) response.raise_for_status() confirmed = response.json() milestone_value = confirmed.get("milestone") if isinstance(confirmed, dict) else None normalized = ( {"id": milestone_value["id"], "title": milestone_value["title"]} if isinstance(milestone_value, dict) and isinstance(milestone_value.get("id"), int) and isinstance(milestone_value.get("title"), str) else None ) expected_due_date = due_date if due_date is not None else issue.get("due_date") if ( not isinstance(confirmed, dict) or confirmed.get("number") != number or normalized != selected or confirmed.get("due_date") != expected_due_date ): raise ValueError("Gitea did not confirm the issue release plan") return { "repository": repository, "number": number, "state": confirmed.get("state", "open"), "milestone": normalized, "due_date": confirmed.get("due_date"), } async def issue_conversation_page( repository: str, number: int, page: int | None = None, limit: int = 20, ) -> dict: """Return one bounded comment page, opening on the newest page by default.""" bounded_limit = min(50, max(1, limit)) requested_page = max(1, page or 1) path = f"/api/v1/repos/{repository}/issues/{number}/comments" async def load(selected_page: int) -> tuple[list, int]: response = await _get_client().get( path, headers=_auth(), params={"limit": bounded_limit, "page": selected_page}, ) response.raise_for_status() payload = response.json() if not isinstance(payload, list): raise ValueError("Gitea issue comments response was not a list") comments = [item for item in payload if isinstance(item, dict)] try: total = max(len(comments), int(response.headers.get("X-Total-Count", len(comments)))) except (TypeError, ValueError): total = len(comments) return comments, total comments, total = await load(requested_page) newest_page = max(1, (total + bounded_limit - 1) // bounded_limit) if len(comments) >= total: selected_page = newest_page if page is None else min(requested_page, newest_page) start = (selected_page - 1) * bounded_limit comments = comments[start:start + bounded_limit] else: selected_page = requested_page if page is None and newest_page != requested_page: selected_page = newest_page comments, confirmed_total = await load(selected_page) total = max(total, confirmed_total) return { "comments": [_normalize_issue_comment(item) for item in comments], "page": selected_page, "older_page": selected_page - 1 if selected_page > 1 else None, "total": total, } async def issue_detail(repository: str, number: int) -> dict: base = f"repos/{repository}/issues/{number}" async def load_dependencies() -> tuple[bool, list[dict]]: try: return True, await issue_dependencies(repository, number) except Exception: return False, [] issue, conversation, dependency_result = await asyncio.gather( fetch(base), issue_conversation_page(repository, number), load_dependencies(), ) dependencies_available, dependencies = dependency_result if not isinstance(issue, dict): raise ValueError("Gitea issue response was not an object") labels_value = issue.get("labels") labels: list = labels_value if isinstance(labels_value, list) else [] assignees_value = issue.get("assignees") assignees: list = assignees_value if isinstance(assignees_value, list) else [] normalized_comments = conversation["comments"] return { "repository": repository, "number": number, "title": issue.get("title", "") if isinstance(issue.get("title"), str) else "", "state": issue.get("state", "") if isinstance(issue.get("state"), str) else "", "body": issue.get("body", "") if isinstance(issue.get("body"), str) else "", "updated_at": issue.get("updated_at", "") if isinstance(issue.get("updated_at"), str) else "", "due_date": issue.get("due_date") if isinstance(issue.get("due_date"), str) else None, "milestone": ( {"id": issue["milestone"]["id"], "title": issue["milestone"]["title"]} if isinstance(issue.get("milestone"), dict) and isinstance(issue["milestone"].get("id"), int) and isinstance(issue["milestone"].get("title"), str) else None ), "url": _safe_gitea_web_url(issue.get("html_url")), "labels": [ label["name"] for label in labels if isinstance(label, dict) and isinstance(label.get("name"), str) ], "assignees": [ assignee["login"] for assignee in assignees if isinstance(assignee, dict) and isinstance(assignee.get("login"), str) ], "dependencies_available": dependencies_available, "dependencies": dependencies, "comments": normalized_comments, "conversation": conversation, } async def issue_dependencies(repository: str, number: int, limit: int = 20) -> list[dict]: """Return bounded open prerequisites for an issue.""" response = await _get_client().get( f"/api/v1/repos/{repository}/issues/{number}/dependencies", headers=_auth(), params={"limit": limit}, ) response.raise_for_status() value = response.json() items = value if isinstance(value, list) else [] dependencies = [] for item in items[:limit]: if not isinstance(item, dict) or item.get("state") != "open": continue repo_value = item.get("repository") repo = repo_value if isinstance(repo_value, dict) else {} dependency_repository = repo.get("full_name") dependency_number = item.get("number") if not isinstance(dependency_repository, str) or not isinstance(dependency_number, int): continue dependencies.append({ "repository": dependency_repository, "number": dependency_number, "title": item.get("title", "") if isinstance(item.get("title"), str) else "", "state": "open", "url": _safe_gitea_web_url(item.get("html_url")), }) return dependencies async def mutate_assigned_issue_dependency( repository: str, number: int, blocker_repository: str, blocker_number: int, remove: bool = False, ) -> dict: """Add or remove one prerequisite and return the canonical open dependency set.""" if (repository, number) == (blocker_repository, blocker_number): raise IssueDependencyInvalidError("an issue cannot block itself") login, source = await _current_login_and_target( f"repos/{repository}/issues/{number}" ) if ( source.get("state") != "open" or isinstance(source.get("pull_request"), dict) or not _login_in_users(login, source.get("assignees")) ): raise IssueNotAvailableError("assigned issue not found") current = await issue_dependencies(repository, number) currently_present = any( item["repository"] == blocker_repository and item["number"] == blocker_number for item in current ) if currently_present != remove: return { "repository": repository, "number": number, "dependencies_available": True, "dependencies": current, } if not remove: candidate = await fetch(f"repos/{blocker_repository}/issues/{blocker_number}") if ( not isinstance(candidate, dict) or candidate.get("number") != blocker_number or candidate.get("state") != "open" or isinstance(candidate.get("pull_request"), dict) ): raise IssueDependencyInvalidError("blocker must be an accessible open issue") owner, repo = blocker_repository.split("/", 1) response = await _get_client().request( "DELETE" if remove else "POST", f"/api/v1/repos/{repository}/issues/{number}/dependencies", headers=_auth(), json={"owner": owner, "repo": repo, "index": blocker_number}, ) response.raise_for_status() confirmed = await issue_dependencies(repository, number) present = any( item["repository"] == blocker_repository and item["number"] == blocker_number for item in confirmed ) if present == remove: raise ValueError("Gitea did not confirm the dependency change") return { "repository": repository, "number": number, "dependencies_available": True, "dependencies": confirmed, } async def update_assigned_issue( repository: str, number: int, title: str, body: str, expected_updated_at: str, ) -> dict: return await _update_issue_content( repository, number, title, body, expected_updated_at, require_author=False ) async def update_authored_issue( repository: str, number: int, title: str, body: str, expected_updated_at: str, ) -> dict: return await _update_issue_content( repository, number, title, body, expected_updated_at, require_author=True ) async def _update_issue_content( repository: str, number: int, title: str, body: str, expected_updated_at: str, *, require_author: bool, ) -> dict: path = f"repos/{repository}/issues/{number}" login, issue = await _current_login_and_target(path) author = issue.get("user") if isinstance(issue.get("user"), dict) else {} authorized = ( author.get("login") == login if require_author else _login_in_users(login, issue.get("assignees")) ) if ( issue.get("state") != "open" or isinstance(issue.get("pull_request"), dict) or not authorized ): raise IssueNotAvailableError("issue not found") if issue.get("updated_at") != expected_updated_at: if issue.get("title") == title and issue.get("body", "") == body: return { "repository": repository, "number": number, "title": title, "body": body, "state": issue.get("state", "open"), "updated_at": issue.get("updated_at", ""), "url": _safe_gitea_web_url(issue.get("html_url")), } raise IssueEditConflictError("issue changed upstream") response = await _get_client().patch( f"/api/v1/{path}", headers=_auth(), json={"title": title, "body": body}, ) response.raise_for_status() confirmed = response.json() if ( not isinstance(confirmed, dict) or confirmed.get("number") != number or confirmed.get("title") != title or confirmed.get("body", "") != body ): raise ValueError("Gitea did not confirm the issue content update") return { "repository": repository, "number": number, "title": title, "body": body, "state": confirmed.get("state", "open"), "updated_at": confirmed.get("updated_at", ""), "url": _safe_gitea_web_url(confirmed.get("html_url")), } async def _current_login_and_target(path: str) -> tuple[str, dict]: user, target = await asyncio.gather(current_user(), fetch(path)) login = user.get("login") if isinstance(user, dict) else None if not isinstance(login, str) or not login or not isinstance(target, dict): return "", {} return login, target def _login_in_users(login: str, value: object) -> bool: return isinstance(value, list) and any( isinstance(user, dict) and user.get("login") == login for user in value ) async def resolve_work_route( kind: str, repository: str | None, number: int | None, notification_id: int | None, ) -> dict: if kind == "update": if notification_id is None: raise WorkRouteUnavailableError("Notification identity is missing") thread = await fetch(f"notifications/threads/{notification_id}") if not isinstance(thread, dict) or thread.get("unread") is not True: raise WorkRouteUnavailableError("Notification is no longer unread") detail = await notification_detail(notification_id) return { "kind": "update", "notification_id": notification_id, "has_update": True, "repository": detail.get("repository", ""), "title": detail.get("title", ""), "url": detail.get("url", ""), } if repository is None or number is None or kind not in {"issue", "filed", "pull", "review"}: raise WorkRouteUnavailableError("Work identity is missing") target_path = "issues" if kind in {"issue", "filed"} else "pulls" login, target = await _current_login_and_target( f"repos/{repository}/{target_path}/{number}" ) assigned = _login_in_users(login, target.get("assignees")) requested = _login_in_users(login, target.get("requested_reviewers")) author = target.get("user") if isinstance(target.get("user"), dict) else {} authored = author.get("login") == login state = target.get("state") eligible = ( (state == "open" or (kind == "filed" and state == "closed")) and ( (kind == "issue" and not isinstance(target.get("pull_request"), dict) and assigned) or (kind == "filed" and not isinstance(target.get("pull_request"), dict) and authored) or (kind == "pull" and (assigned or authored)) or (kind == "review" and requested) ) ) if not eligible: raise WorkRouteUnavailableError("Work item is no longer in My Work") return { "kind": kind, "repository": repository, "number": number, "title": target.get("title", "") if isinstance(target.get("title"), str) else "", "state": state, "url": _safe_gitea_web_url(target.get("html_url")), **({"is_review": True, "work_reasons": ["review_requested"]} if kind == "review" else {}), **({ "is_filed": True, "is_assigned": assigned, "work_reasons": ["created_by_me"], } if kind == "filed" else {}), **({ "is_assigned": assigned, "work_reasons": [ reason for reason, present in ( ("assigned_to_me", assigned), ("authored_by_me", authored) ) if present ], } if kind == "pull" else {}), } async def is_assigned_issue(repository: str, number: int) -> bool: login, issue = await _current_login_and_target( f"repos/{repository}/issues/{number}" ) return ( issue.get("state") == "open" and not isinstance(issue.get("pull_request"), dict) and _login_in_users(login, issue.get("assignees")) ) async def is_authored_issue(repository: str, number: int) -> bool: login, issue = await _current_login_and_target( f"repos/{repository}/issues/{number}" ) author = issue.get("user") if isinstance(issue.get("user"), dict) else {} return ( issue.get("state") in {"open", "closed"} and not isinstance(issue.get("pull_request"), dict) and author.get("login") == login ) async def is_open_authored_issue(repository: str, number: int) -> bool: login, issue = await _current_login_and_target( f"repos/{repository}/issues/{number}" ) author = issue.get("user") if isinstance(issue.get("user"), dict) else {} return ( issue.get("state") == "open" and not isinstance(issue.get("pull_request"), dict) and author.get("login") == login ) async def pull_requests() -> WorkItems: streams = ("pull", "review", "authored") outcomes = await asyncio.gather( *(work_page(stream) for stream in streams), return_exceptions=True, ) for outcome in outcomes: if isinstance(outcome, asyncio.CancelledError): raise outcome if all(isinstance(outcome, BaseException) for outcome in outcomes): raise outcomes[0] merged: dict[int, dict] = {} pagination = {} for stream, outcome in zip(streams, outcomes, strict=True): if isinstance(outcome, BaseException): pagination[stream] = { "page": 1, "total": 0, "has_more": False, "unavailable": True, } continue pagination[stream] = _page_metadata(outcome) for pull in outcome["items"]: identity = pull.get("id") if identity not in merged: merged[identity] = {**pull, "work_reasons": []} for reason in pull.get("work_reasons", []): if reason not in merged[identity]["work_reasons"]: merged[identity]["work_reasons"].append(reason) return WorkItems(list(merged.values()), pagination) async def is_requested_review(repository: str, number: int) -> bool: login, pull = await _current_login_and_target( f"repos/{repository}/pulls/{number}" ) return ( pull.get("state") == "open" and _login_in_users(login, pull.get("requested_reviewers")) ) async def can_recover_merged_release( repository: str, number: int, commit_sha: str ) -> bool: """Confirm the current operator participated in the exact merged pull.""" login, pull = await _current_login_and_target( f"repos/{repository}/pulls/{number}" ) author = pull.get("user") if isinstance(pull.get("user"), dict) else {} participant = ( author.get("login", "").casefold() == login.casefold() or _login_in_users(login, pull.get("assignees")) ) return ( participant and pull.get("state") == "closed" and pull.get("merged") is True and pull.get("merge_commit_sha") == commit_sha ) async def prepare_release_rollback( repository: str, number: int, commit_sha: str ) -> dict: """Create one bounded reverse commit and draft pull for an exact merged pull.""" login, pull = await _current_login_and_target( f"repos/{repository}/pulls/{number}" ) author = pull.get("user") if isinstance(pull.get("user"), dict) else {} if not ( pull.get("state") == "closed" and pull.get("merged") is True and pull.get("merge_commit_sha") == commit_sha and ( author.get("login", "").casefold() == login.casefold() or _login_in_users(login, pull.get("assignees")) ) ): raise IssueNotAvailableError("merged pull request not found") access = await repository_access(repository) permissions = access.get("permissions", {}) if isinstance(access, dict) else {} default_branch = access.get("default_branch") if isinstance(access, dict) else None if permissions.get("push") is not True or not isinstance(default_branch, str): raise IssueNotAvailableError("writable repository not found") commit_response = await _get_client().get( f"/api/v1/repos/{repository}/git/commits/{commit_sha}", headers=_auth() ) commit_response.raise_for_status() merge_commit = commit_response.json() parents = merge_commit.get("parents") if isinstance(merge_commit, dict) else None files = merge_commit.get("files") if isinstance(merge_commit, dict) else None if ( merge_commit.get("sha") != commit_sha or not isinstance(parents, list) or len(parents) < 2 or not isinstance(parents[0], dict) or not isinstance(parents[0].get("sha"), str) or not isinstance(files, list) or not 1 <= len(files) <= ROLLBACK_MAX_FILES ): raise ReleaseRollbackUnsupportedError("merge is not a bounded merge commit") parent_sha = parents[0]["sha"] branch = f"{login}/rollback-{number}-{commit_sha[:12]}" branch_path = quote(branch, safe="") branch_response = await _get_client().get( f"/api/v1/repos/{repository}/branches/{branch_path}", headers=_auth() ) if branch_response.status_code == 404: async def content(path: str, ref: str) -> tuple[bytes, str] | None: response = await _get_client().get( f"/api/v1/repos/{repository}/contents/{quote(path, safe='/')}", headers=_auth(), params={"ref": ref}, ) if response.status_code == 404: return None response.raise_for_status() value = response.json() if ( not isinstance(value, dict) or value.get("type") != "file" or value.get("encoding") != "base64" or not isinstance(value.get("content"), str) or not isinstance(value.get("sha"), str) ): raise ReleaseRollbackUnsupportedError("rollback contains a non-file entry") try: raw = base64.b64decode(value["content"], validate=True) raw.decode("utf-8") except (ValueError, UnicodeDecodeError) as exc: raise ReleaseRollbackUnsupportedError( "rollback contains binary or invalid content" ) from exc if len(raw) > ROLLBACK_MAX_FILE_BYTES: raise ReleaseRollbackUnsupportedError("rollback file is too large") return raw, value["sha"] operations = [] total_bytes = 0 for changed in files: filename = changed.get("filename") if isinstance(changed, dict) else None status = changed.get("status") if isinstance(changed, dict) else None if ( not isinstance(filename, str) or not filename or filename.startswith("/") or ".." in filename.split("/") or status not in {"added", "modified", "removed"} ): raise ReleaseRollbackUnsupportedError("rollback contains an unsupported change") merged = await content(filename, commit_sha) parent = await content(filename, parent_sha) current = await content(filename, default_branch) if ( (current is None) != (merged is None) or ( current is not None and merged is not None and current[0] != merged[0] ) ): raise ReleaseRollbackConflictError( f"{filename} changed after the failed release" ) if status == "added" and merged is not None and parent is None: operations.append({ "operation": "delete", "path": filename, "sha": current[1], }) elif status == "removed" and merged is None and parent is not None: operations.append({ "operation": "create", "path": filename, "content": base64.b64encode(parent[0]).decode(), }) total_bytes += len(parent[0]) elif status == "modified" and merged is not None and parent is not None: operations.append({ "operation": "update", "path": filename, "sha": current[1], "content": base64.b64encode(parent[0]).decode(), }) total_bytes += len(parent[0]) else: raise ReleaseRollbackUnsupportedError("change metadata did not match content") if total_bytes > ROLLBACK_MAX_TOTAL_BYTES: raise ReleaseRollbackUnsupportedError("rollback content is too large") mutation = await _get_client().post( f"/api/v1/repos/{repository}/contents", headers=_auth(), json={ "branch": default_branch, "new_branch": branch, "message": f"Revert {commit_sha} from pull #{number}", "files": operations, }, ) if mutation.status_code in {409, 422}: reconciled = await _get_client().get( f"/api/v1/repos/{repository}/branches/{branch_path}", headers=_auth() ) reconciled.raise_for_status() branch_payload = reconciled.json() branch_commit = ( branch_payload.get("commit") if isinstance(branch_payload, dict) else None ) rollback_sha = ( branch_commit.get("id") if isinstance(branch_commit, dict) else None ) if not isinstance(rollback_sha, str): raise ValueError("Gitea did not confirm the concurrent rollback branch") else: mutation.raise_for_status() payload = mutation.json() created_commit = payload.get("commit") if isinstance(payload, dict) else None rollback_sha = created_commit.get("sha") if isinstance(created_commit, dict) else None if not isinstance(rollback_sha, str): raise ValueError("Gitea did not confirm rollback commit creation") else: branch_response.raise_for_status() branch_payload = branch_response.json() branch_commit = branch_payload.get("commit") if isinstance(branch_payload, dict) else None rollback_sha = branch_commit.get("id") if isinstance(branch_commit, dict) else None if not isinstance(rollback_sha, str): raise ValueError("Gitea did not confirm the rollback branch") title = f"Rollback #{number}: {pull.get('title', 'failed release')}" body = ( f"Rollback of #{number} at `{commit_sha}` after failed release checks.\n\n" "This draft reverses only files that still matched the failed merge." ) result = await create_pull( repository, head=branch, base=default_branch, title=title, body=body, draft=True, expected_head_sha=rollback_sha, ) return { **result, "rollback_of": commit_sha, "files_changed": len(files), } async def pull_workspace_capabilities(repository: str, number: int) -> dict[str, bool]: login, pull = await _current_login_and_target( f"repos/{repository}/pulls/{number}" ) author = pull.get("user") if isinstance(pull.get("user"), dict) else {} open_pull = pull.get("state") == "open" and pull.get("merged") is not True return { "authored": open_pull and author.get("login", "").casefold() == login.casefold(), "assigned": open_pull and _login_in_users(login, pull.get("assignees")), } async def pull_workspace_snapshot(repository: str, number: int) -> dict: """Resolve one canonical pull and the access granted by that exact snapshot.""" login, pull = await _current_login_and_target( f"repos/{repository}/pulls/{number}" ) author = pull.get("user") if isinstance(pull.get("user"), dict) else {} open_pull = pull.get("state") == "open" and pull.get("merged") is not True return { "pull": pull, "capabilities": { "authored": open_pull and author.get("login", "").casefold() == login.casefold(), "assigned": open_pull and _login_in_users(login, pull.get("assignees")), }, } async def is_assigned_pull(repository: str, number: int) -> bool: login, pull = await _current_login_and_target( f"repos/{repository}/pulls/{number}" ) return ( pull.get("state") == "open" and _login_in_users(login, pull.get("assignees")) ) async def update_authored_assigned_pull( repository: str, number: int, title: str, body: str, expected_head_sha: str, ) -> dict: path = f"repos/{repository}/pulls/{number}" login, pull = await _current_login_and_target(path) author = pull.get("user") if isinstance(pull.get("user"), dict) else {} head = pull.get("head") if isinstance(pull.get("head"), dict) else {} if ( pull.get("state") != "open" or pull.get("merged") is True or author.get("login", "").casefold() != login.casefold() ): raise IssueNotAvailableError("pull request not found") if head.get("sha") != expected_head_sha: raise IssueEditConflictError("pull request head changed upstream") response = await _get_client().patch( f"/api/v1/{path}", headers=_auth(), json={"title": title, "body": body}, ) response.raise_for_status() confirmed = response.json() confirmed_head = confirmed.get("head") if isinstance(confirmed.get("head"), dict) else {} if ( confirmed.get("number") != number or confirmed.get("title") != title or confirmed.get("body", "") != body or confirmed_head.get("sha") != expected_head_sha ): raise ValueError("Gitea did not confirm the pull request update") return { "repository": repository, "number": number, "title": title, "body": body, "head_sha": expected_head_sha, "state": confirmed.get("state", "open"), "url": _safe_gitea_web_url(confirmed.get("html_url")), } async def pull_completion_detail( repository: str, number: int, pull: dict | None = None ) -> dict: base = f"repos/{repository}/pulls/{number}" if pull is None: pull = await fetch(base) if not isinstance(pull, dict): raise ValueError("Gitea pull request response was not an object") conversation = await issue_conversation_page(repository, number) head = pull.get("head") if isinstance(pull.get("head"), dict) else {} sha = head.get("sha") if isinstance(head.get("sha"), str) else "" user = pull.get("user") if isinstance(pull.get("user"), dict) else {} return { "repository": repository, "number": number, "title": pull.get("title") if isinstance(pull.get("title"), str) else "", "body": pull.get("body") if isinstance(pull.get("body"), str) else "", "url": _safe_gitea_web_url(pull.get("html_url")), "author": user.get("login") if isinstance(user.get("login"), str) else "", "head_sha": sha, "state": pull.get("state") if isinstance(pull.get("state"), str) else "", "conversation": conversation, } def _normalize_review_comments(comments: object) -> list[dict]: normalized = [] for comment in (comments if isinstance(comments, list) else [])[:100]: if not isinstance(comment, dict): continue path = comment.get("path") body = comment.get("body") if not isinstance(path, str) or not path.strip() or not isinstance(body, str) or not body.strip(): continue item = {"path": path.strip()[:300], "body": body.strip()[:500]} comment_id = comment.get("id") if isinstance(comment_id, int) and comment_id > 0: item["id"] = comment_id position = comment.get("new_position") or comment.get("old_position") if isinstance(position, int) and position > 0: item["line"] = position normalized.append(item) if len(normalized) == 20: break return normalized def _latest_feedback_review_ids(reviews: object) -> dict[str, int]: latest: dict[str, tuple[int, str]] = {} for review in (reviews if isinstance(reviews, list) else [])[:100]: if not isinstance(review, dict): continue user = review.get("user") if isinstance(review.get("user"), dict) else {} login = user.get("login") review_id = review.get("id") state = review.get("state") if ( not isinstance(login, str) or not login or not isinstance(review_id, int) or review_id <= 0 or not isinstance(state, str) ): continue key = login.casefold() if key not in latest or review_id >= latest[key][0]: latest[key] = (review_id, state) feedback = [ (key, review_id) for key, (review_id, state) in latest.items() if state in {"REQUEST_CHANGES", "COMMENT"} ] return dict(feedback[:10]) def _normalize_reviewer_statuses(pull: dict, reviews: object, head_sha: str) -> list[dict]: """Return one bounded, current decision per reviewer without exposing raw review data.""" latest: dict[str, dict] = {} for review in (reviews if isinstance(reviews, list) else [])[:100]: if not isinstance(review, dict): continue user = review.get("user") if isinstance(review.get("user"), dict) else {} login = user.get("login") state = review.get("state") commit_id = review.get("commit_id") if ( not isinstance(login, str) or not login or state not in {"APPROVED", "REQUEST_CHANGES", "COMMENT"} or not isinstance(commit_id, str) or not commit_id ): continue previous = latest.get(login.casefold()) review_id = review.get("id") if isinstance(review.get("id"), int) else 0 if previous is None or review_id >= previous["id"]: body = review.get("body") latest[login.casefold()] = { "id": review_id, "login": login, "state": state, "commit_id": commit_id, "summary": body.strip()[:500] if isinstance(body, str) and body.strip() else "", } statuses: dict[str, dict] = {} state_names = { "APPROVED": "approved", "REQUEST_CHANGES": "changes_requested", "COMMENT": "commented", } for key, review in latest.items(): current = review["commit_id"] == head_sha status = state_names[review["state"]] if current else "outdated" statuses[key] = { "review_id": review["id"], "login": review["login"], "status": status, "head_sha": review["commit_id"], "blocking": status in {"changes_requested", "outdated"}, } if review["summary"]: statuses[key]["summary"] = review["summary"] requested = pull.get("requested_reviewers") if isinstance(pull, dict) else [] for reviewer in (requested if isinstance(requested, list) else [])[:25]: login = reviewer.get("login") if isinstance(reviewer, dict) else None if isinstance(login, str) and login: statuses[login.casefold()] = { "login": login, "status": "waiting", "head_sha": head_sha, "blocking": True, } return sorted(statuses.values(), key=lambda item: item["login"].casefold()) async def pull_completion_review( repository: str, number: int, pull: dict | None = None ) -> dict: base = f"repos/{repository}/pulls/{number}" if pull is None: pull = await fetch(base) if not isinstance(pull, dict): raise ValueError("Gitea pull request response was not an object") head_value = pull.get("head") head: dict = head_value if isinstance(head_value, dict) else {} sha_value = head.get("sha") sha = sha_value if isinstance(sha_value, str) else "" files, status, diff_result, reviews = await asyncio.gather( fetch(f"{base}/files"), fetch(f"repos/{repository}/commits/{sha}/status"), fetch_text( f"repos/{repository}/pulls/{number}.diff", REVIEW_DIFF_MAX_BYTES ), fetch(f"{base}/reviews?limit=100"), ) diff, diff_truncated = diff_result previews = _diff_previews(diff, diff_truncated) reviewers = _normalize_reviewer_statuses(pull, reviews, sha) return { "repository": repository, "number": number, "head_sha": sha, "state": pull.get("state") if isinstance(pull.get("state"), str) else "", "draft": pull.get("draft") is True, "mergeable": pull.get("mergeable") is True, "merged": pull.get("merged") is True, "ci_state": status.get("state", "unknown") if isinstance(status, dict) else "unknown", "checks": _normalize_commit_checks(status), "reviewers": reviewers, "files": [ { "filename": item.get("filename", ""), "status": item.get("status") or "changed", "additions": item.get("additions") or 0, "deletions": item.get("deletions") or 0, **previews.get( item["filename"], { "diff_lines": [], "diff_available": False, "diff_binary": False, "diff_truncated": diff_truncated, }, ), } for item in (files if isinstance(files, list) else [])[:100] if isinstance(item, dict) and isinstance(item.get("filename"), str) ], } async def pull_review_feedback( repository: str, number: int, review_id: int, expected_head_sha: str ) -> dict: """Load optional inline feedback only for a review on the confirmed pull head.""" base = f"repos/{repository}/pulls/{number}" pull, reviews = await asyncio.gather( fetch(base), fetch(f"{base}/reviews?limit=100"), ) head = pull.get("head") if isinstance(pull, dict) else None head_sha = head.get("sha") if isinstance(head, dict) else None if head_sha != expected_head_sha: raise StaleReviewError("Pull request head changed") review_items = reviews if isinstance(reviews, list) else [] latest_ids = set(_latest_feedback_review_ids(review_items).values()) review = next( ( item for item in review_items if isinstance(item, dict) and item.get("id") == review_id ), None, ) if review is None or review_id not in latest_ids: raise StaleReviewError("Review feedback is no longer current") comments = await fetch(f"{base}/reviews/{review_id}/comments") reviewed_head = review.get("commit_id") return { "review_id": review_id, "head_sha": head_sha, "reviewed_head_sha": reviewed_head if isinstance(reviewed_head, str) else "", "comments": _normalize_review_comments(comments), } async def release_receipt_status(repository: str, commit_sha: str) -> dict: """Return CI and release evidence for one exact merge commit.""" status, releases = await asyncio.gather( fetch(f"repos/{repository}/commits/{commit_sha}/status"), fetch(f"repos/{repository}/releases?limit=20"), ) normalized_checks = _normalize_commit_checks(status) checks = [ { "name": check["name"], "state": check["state"], "url": check["url"], **( {"description": check["description"]} if check.get("description") else {} ), **( {"recovery": check["recovery"]} if isinstance(check.get("recovery"), dict) else {} ), } for check in normalized_checks ] matching = next( ( release for release in (releases if isinstance(releases, list) else []) if isinstance(release, dict) and release.get("target_commitish") == commit_sha ), None, ) normalized_release = None if matching is not None: assets = matching.get("assets") normalized_release = { "tag": matching.get("tag_name") if isinstance(matching.get("tag_name"), str) else "", "url": _safe_gitea_web_url(matching.get("html_url")), "assets": [ { "name": asset.get("name") if isinstance(asset.get("name"), str) else "", "url": _safe_gitea_web_url(asset.get("browser_download_url")), } for asset in (assets if isinstance(assets, list) else [])[:20] if isinstance(asset, dict) ], } return { "commit_sha": commit_sha, "ci_state": status.get("state", "unknown") if isinstance(status, dict) else "unknown", "checks": checks, "release": normalized_release, } async def pull_check_status(repository: str, number: int) -> dict: """Load only mutable pull and CI state, without immutable review data.""" base = f"repos/{repository}/pulls/{number}" pull = await fetch(base) if not isinstance(pull, dict): raise ValueError("Gitea pull request response was not an object") head_value = pull.get("head") head: dict = head_value if isinstance(head_value, dict) else {} sha_value = head.get("sha") sha = sha_value if isinstance(sha_value, str) else "" status, reviews = await asyncio.gather( fetch(f"repos/{repository}/commits/{sha}/status"), fetch(f"{base}/reviews?limit=100"), ) return { "repository": repository, "number": number, "head_sha": sha, "state": pull.get("state") if isinstance(pull.get("state"), str) else "", "draft": pull.get("draft") is True, "mergeable": pull.get("mergeable") is True, "merged": pull.get("merged") is True, "ci_state": status.get("state", "unknown") if isinstance(status, dict) else "unknown", "checks": _normalize_commit_checks(status), "reviewers": _normalize_reviewer_statuses(pull, reviews, sha), } def _bounded_action_log_excerpt(value: str, max_bytes: int = 24 * 1024) -> str: redacted = re.sub( r"(?im)^(authorization\s*:)\s*[^\r\n]*$", r"\1 [redacted]", value, ) redacted = re.sub( r"(?im)\b(gitea_token|github_token|access_token)=\S+", r"\1=[redacted]", redacted, ) encoded = redacted.encode("utf-8") if len(encoded) <= max_bytes: return redacted return encoded[-max_bytes:].decode("utf-8", errors="ignore") async def release_action_failure_excerpt( repository: str, commit_sha: str, run_id: int, job_index: int, ) -> dict: """Return a bounded failed-job excerpt only when it still belongs to a merge commit.""" status = await fetch(f"repos/{repository}/commits/{commit_sha}/status") matching = next( ( check for check in _normalize_commit_checks(status) if check.get("state") in {"failure", "error"} and check.get("recovery") == {"run_id": run_id, "job_index": job_index} ), None, ) if matching is None: raise ValueError("Failed Gitea Actions job is unavailable") response = await _get_client().get( f"{GITEA_URL}/{quote(repository, safe='/')}/actions/runs/{run_id}/jobs/{job_index}/logs", headers=_auth(), ) response.raise_for_status() return { "commit_sha": commit_sha, "run_id": run_id, "job_index": job_index, "name": matching["name"], "excerpt": _bounded_action_log_excerpt(response.text), } async def action_failure_excerpt( repository: str, number: int, expected_head_sha: str, run_id: int, job_index: int, ) -> dict: pull = await fetch(f"repos/{repository}/pulls/{number}") head = pull.get("head") if isinstance(pull, dict) else None sha = head.get("sha") if isinstance(head, dict) else None if sha != expected_head_sha: raise StalePullError("Pull request head changed") status = await fetch(f"repos/{repository}/commits/{sha}/status") matching = next( ( check for check in _normalize_commit_checks(status) if check.get("state") in {"failure", "error"} and check.get("recovery") == {"run_id": run_id, "job_index": job_index} ), None, ) if matching is None: raise ValueError("Failed Gitea Actions job is unavailable") response = await _get_client().get( f"{GITEA_URL}/{quote(repository, safe='/')}/actions/runs/{run_id}/jobs/{job_index}/logs", headers=_auth(), ) response.raise_for_status() return { "head_sha": sha, "run_id": run_id, "job_index": job_index, "name": matching["name"], "excerpt": _bounded_action_log_excerpt(response.text), } async def retry_release_action_job( repository: str, commit_sha: str, run_id: int, job_index: int, ) -> dict: """Re-run a failed Actions job only while it belongs to the tracked merge commit.""" status = await fetch(f"repos/{repository}/commits/{commit_sha}/status") matching = next( ( check for check in _normalize_commit_checks(status) if check.get("state") in {"failure", "error"} and check.get("recovery") == {"run_id": run_id, "job_index": job_index} ), None, ) if matching is None: raise ValueError("Failed Gitea Actions job is unavailable") job_url = ( f"{GITEA_URL}/{quote(repository, safe='/')}" f"/actions/runs/{run_id}/jobs/{job_index}" ) page = await _get_client().get(job_url, headers=_auth()) page.raise_for_status() csrf_match = re.search(r"\bcsrfToken:\s*'([^']+)'", page.text) if not csrf_match: raise ValueError("Gitea did not provide a retry authorization token") csrf_token = csrf_match.group(1) response = await _get_client().post( f"{job_url}/rerun", headers={**_auth(), "X-Csrf-Token": csrf_token}, data={"_csrf": csrf_token}, ) if response.is_error: response.raise_for_status() return { "commit_sha": commit_sha, "run_id": run_id, "job_index": job_index, "status": "queued", } async def retry_action_job( repository: str, number: int, expected_head_sha: str, run_id: int, job_index: int, ) -> dict: pull = await fetch(f"repos/{repository}/pulls/{number}") head = pull.get("head") if isinstance(pull, dict) else None sha = head.get("sha") if isinstance(head, dict) else None if sha != expected_head_sha: raise StalePullError("Pull request head changed") status = await fetch(f"repos/{repository}/commits/{sha}/status") matching = next( ( check for check in _normalize_commit_checks(status) if check.get("state") in {"failure", "error"} and check.get("recovery") == {"run_id": run_id, "job_index": job_index} ), None, ) if matching is None: raise ValueError("Failed Gitea Actions job is unavailable") job_url = ( f"{GITEA_URL}/{quote(repository, safe='/')}" f"/actions/runs/{run_id}/jobs/{job_index}" ) page = await _get_client().get(job_url, headers=_auth()) page.raise_for_status() csrf_match = re.search(r"\bcsrfToken:\s*'([^']+)'", page.text) if not csrf_match: raise ValueError("Gitea did not provide a retry authorization token") csrf_token = csrf_match.group(1) response = await _get_client().post( f"{job_url}/rerun", headers={**_auth(), "X-Csrf-Token": csrf_token}, data={"_csrf": csrf_token}, ) if response.is_error: response.raise_for_status() return { "head_sha": sha, "run_id": run_id, "job_index": job_index, "status": "queued", } async def is_pull_merged_at_head( repository: str, number: int, expected_head_sha: str ) -> bool: pull = await fetch(f"repos/{repository}/pulls/{number}") if not isinstance(pull, dict): return False head_value = pull.get("head") head = head_value if isinstance(head_value, dict) else {} return ( pull.get("merged") is True and pull.get("state") == "closed" and head.get("sha") == expected_head_sha ) async def merge_assigned_pull( repository: str, number: int, expected_head_sha: str ) -> dict: base = f"repos/{repository}/pulls/{number}" pull = await fetch(base) if not isinstance(pull, dict): raise PullNotMergeableError("Pull request state is unavailable") head_value = pull.get("head") head: dict = head_value if isinstance(head_value, dict) else {} current_sha = head.get("sha") if current_sha != expected_head_sha: raise StalePullError("Pull request changed before merge") status = await fetch(f"repos/{repository}/commits/{current_sha}/status") ci_state = status.get("state") if isinstance(status, dict) else "unknown" if ( pull.get("state") != "open" or pull.get("draft") is True or pull.get("mergeable") is not True or pull.get("merged") is True or ci_state != "success" ): raise PullNotMergeableError("Pull request is not currently safe to merge") reviews = await fetch(f"{base}/reviews?limit=100") if any( reviewer["blocking"] for reviewer in _normalize_reviewer_statuses(pull, reviews, current_sha) ): raise PullNotMergeableError("Pull request review is not currently resolved") response = await _get_client().post( f"/api/v1/{base}/merge", headers=_auth(), json={"Do": "merge", "head_commit_id": current_sha}, ) response.raise_for_status() payload = response.json() merge_commit_sha = payload.get("sha") if isinstance(payload, dict) else None if not isinstance(merge_commit_sha, str) or not merge_commit_sha: raise ValueError("Gitea merge response did not include the merge commit") head_repo = head.get("repo") if isinstance(head.get("repo"), dict) else {} source_branch = head.get("ref") source_repository = head_repo.get("full_name") return { "number": number, "merged": True, "state": "closed", "merge_commit_sha": merge_commit_sha, **( { "source_branch": source_branch, "source_head_sha": current_sha, "source_repository": source_repository, } if isinstance(source_branch, str) and source_branch and isinstance(source_repository, str) and source_repository else {} ), } async def delete_merged_source_branch( repository: str, number: int, source_branch: str, expected_head_sha: str, ) -> dict: """Delete an author's merged same-repository branch only at its exact head.""" pull, operator, repo = await asyncio.gather( fetch(f"repos/{repository}/pulls/{number}"), fetch("user"), fetch(f"repos/{repository}"), ) if not all(isinstance(value, dict) for value in (pull, operator, repo)): raise SourceBranchCleanupForbiddenError("Branch cleanup identity is unavailable") author = pull.get("user") if isinstance(pull.get("user"), dict) else {} head = pull.get("head") if isinstance(pull.get("head"), dict) else {} head_repo = head.get("repo") if isinstance(head.get("repo"), dict) else {} if ( pull.get("merged") is not True or pull.get("state") != "closed" or author.get("login") != operator.get("login") or head.get("ref") != source_branch or head.get("sha") != expected_head_sha or head_repo.get("full_name") != repository or repo.get("default_branch") == source_branch ): raise SourceBranchCleanupForbiddenError("Source branch is not eligible for cleanup") branch_path = f"/api/v1/repos/{repository}/branches/{quote(source_branch, safe='')}" branch_response = await _get_client().get(branch_path, headers=_auth()) if branch_response.status_code == 404: return { "number": number, "deleted": True, "source_branch": source_branch, "source_head_sha": expected_head_sha, } branch_response.raise_for_status() branch = branch_response.json() commit = branch.get("commit") if isinstance(branch, dict) and isinstance(branch.get("commit"), dict) else {} if not isinstance(branch, dict) or branch.get("protected") is True: raise SourceBranchCleanupForbiddenError("Protected source branches cannot be deleted") if commit.get("id") != expected_head_sha: raise SourceBranchChangedError("Source branch advanced after merge") try: deleted = await _get_client().delete(branch_path, headers=_auth()) deleted.raise_for_status() except Exception: confirmation = await _get_client().get(branch_path, headers=_auth()) if confirmation.status_code == 404: return { "number": number, "deleted": True, "source_branch": source_branch, "source_head_sha": expected_head_sha, } raise confirmation = await _get_client().get(branch_path, headers=_auth()) if confirmation.status_code != 404: confirmation.raise_for_status() raise RuntimeError("Source branch deletion could not be confirmed") return { "number": number, "deleted": True, "source_branch": source_branch, "source_head_sha": expected_head_sha, } async def pull_review_detail(repository: str, number: int) -> dict: base = f"repos/{repository}/pulls/{number}" pull = await fetch(base) if not isinstance(pull, dict): raise ValueError("Gitea pull request response was not an object") head_value = pull.get("head") head: dict = head_value if isinstance(head_value, dict) else {} sha_value = head.get("sha") sha = sha_value if isinstance(sha_value, str) else "" files, status, reviews, diff_result = await asyncio.gather( fetch(f"{base}/files"), fetch(f"repos/{repository}/commits/{sha}/status"), fetch(f"{base}/reviews"), fetch_text( f"repos/{repository}/pulls/{number}.diff", REVIEW_DIFF_MAX_BYTES ), ) diff, diff_truncated = diff_result previews = _diff_previews(diff, diff_truncated) user_value = pull.get("user") user: dict = user_value if isinstance(user_value, dict) else {} normalized_files = [ { "filename": file["filename"], "status": file.get("status") or "changed", "additions": file.get("additions") or 0, "deletions": file.get("deletions") or 0, **previews.get( file["filename"], { "diff_lines": [], "diff_available": False, "diff_binary": False, "diff_truncated": diff_truncated, }, ), } for file in (files if isinstance(files, list) else [])[:100] if isinstance(file, dict) and isinstance(file.get("filename"), str) ] normalized_reviews = [ { "user": { "login": ( review["user"].get("login", "") if isinstance(review.get("user"), dict) else "" ) }, "state": review.get("state") or "COMMENT", "body": review.get("body") or "", } for review in (reviews if isinstance(reviews, list) else [])[:50] if isinstance(review, dict) ] return { "repository": repository, "number": number, "title": pull.get("title", ""), "body": pull.get("body") or "", "url": pull.get("html_url", ""), "author": user.get("login", ""), "head_sha": sha, "ci_state": status.get("state", "unknown") if isinstance(status, dict) else "unknown", "checks": _normalize_commit_checks(status), "files": normalized_files, "reviews": normalized_reviews, } async def submit_pull_review( repository: str, number: int, expected_head_sha: str, decision: str, body: str, comments: list[dict] | None = None, ) -> dict: base = f"repos/{repository}/pulls/{number}" pull = await fetch(base) head = pull.get("head") if isinstance(pull, dict) else None current_sha = head.get("sha") if isinstance(head, dict) else None if current_sha != expected_head_sha: raise StaleReviewError("Pull request changed while it was being reviewed") if comments: files = await fetch(f"{base}/files") changed_paths = { item.get("filename") for item in (files if isinstance(files, list) else []) if isinstance(item, dict) and isinstance(item.get("filename"), str) } if any(comment.get("path") not in changed_paths for comment in comments): raise InvalidReviewCommentError("Inline comment path is not in this pull request") response = await _get_client().post( f"/api/v1/{base}/reviews", headers=_auth(), json={ "body": body, "event": { "comment": "COMMENT", "approve": "APPROVE", "request_changes": "REQUEST_CHANGES", }[decision], "commit_id": current_sha, **({"comments": comments} if comments else {}), }, ) response.raise_for_status() review = response.json() if not isinstance(review, dict): raise ValueError("Gitea review response was not an object") return { "id": review.get("id"), "state": review.get("state") or "COMMENT", "url": _safe_gitea_web_url(review.get("html_url")), } async def activity_events(user: dict | None = None) -> list[dict]: if user is None: user = await current_user() events = await fetch(f"users/{user['login']}/activities/feeds?limit=20") if events is None: events = [] elif not isinstance(events, list): raise ValueError("Gitea activity feed response was not a list") return [ { "type": ( event.get("op_type") if isinstance(event.get("op_type"), str) and event.get("op_type") else "activity" ), "actor": ( event.get("act_user") if isinstance(event.get("act_user"), dict) else {} ), "repo": ( event.get("repo") if isinstance(event.get("repo"), dict) else {} ), "created_at": ( event.get("created") if isinstance(event.get("created"), str) else "" ), } for event in (events or []) if isinstance(event, dict) ]