stackchain-dashboard/src/gitea_proxy.py
timmy 502fcb30f5
All checks were successful
CI / lint (pull_request) Successful in 3m3s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m18s
CI / release-candidate (pull_request) Has been skipped
feat: commit review feedback fixes from mobile (Closes #1376)
2026-08-25 03:08:53 +00:00

4294 lines
160 KiB
Python

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)
]