418 lines
14 KiB
Python
418 lines
14 KiB
Python
import asyncio
|
|
import os
|
|
import shlex
|
|
from typing import Any
|
|
from urllib.parse import 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
|
|
_client: httpx.AsyncClient | None = None
|
|
|
|
|
|
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) -> httpx.AsyncClient:
|
|
"""Create the application-lifetime Gitea transport."""
|
|
global _client
|
|
_client = httpx.AsyncClient(base_url=GITEA_URL, timeout=10, **kwargs)
|
|
return _client
|
|
|
|
|
|
def _get_client() -> httpx.AsyncClient:
|
|
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()
|
|
|
|
|
|
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 repos() -> list[dict]:
|
|
return await fetch("user/repos?limit=50")
|
|
|
|
|
|
async def issues() -> list[dict]:
|
|
return await fetch(
|
|
"repos/issues/search?state=open&assigned=true&type=issues&limit=50"
|
|
)
|
|
|
|
|
|
def _safe_web_url(value: Any) -> str:
|
|
if not isinstance(value, str):
|
|
return ""
|
|
parsed = urlsplit(value)
|
|
return value if parsed.scheme in {"http", "https"} and parsed.netloc else ""
|
|
|
|
|
|
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_web_url(subject.get("html_url"))
|
|
latest_url = _safe_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 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()
|
|
|
|
|
|
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_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"))
|
|
subject_detail = await fetch(subject_path) if subject_path else {}
|
|
comment = await fetch(comment_path) if comment_path else {}
|
|
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_web_url(subject.get("html_url"))
|
|
latest_url = _safe_web_url(comment.get("html_url")) or _safe_web_url(
|
|
subject.get("latest_comment_html_url")
|
|
)
|
|
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,
|
|
},
|
|
}
|
|
|
|
|
|
async def pull_requests() -> list[dict]:
|
|
assigned, review_requested = await asyncio.gather(
|
|
fetch("repos/issues/search?state=open&assigned=true&type=pulls&limit=50"),
|
|
fetch(
|
|
"repos/issues/search?state=open&review_requested=true&type=pulls&limit=50"
|
|
),
|
|
)
|
|
merged: dict[int, dict] = {}
|
|
for reason, pulls in (
|
|
("assigned_to_me", assigned or []),
|
|
("review_requested", review_requested or []),
|
|
):
|
|
for pull in pulls:
|
|
identity = pull.get("id")
|
|
if identity not in merged:
|
|
merged[identity] = {**pull, "work_reasons": []}
|
|
merged[identity]["work_reasons"].append(reason)
|
|
return list(merged.values())
|
|
|
|
|
|
async def is_requested_review(repository: str, number: int) -> bool:
|
|
pulls = await fetch(
|
|
"repos/issues/search?state=open&review_requested=true&type=pulls&limit=50"
|
|
)
|
|
return any(
|
|
isinstance(pull, dict)
|
|
and pull.get("number") == number
|
|
and isinstance(pull.get("repository"), dict)
|
|
and pull["repository"].get("full_name") == repository
|
|
for pull in (pulls or [])
|
|
)
|
|
|
|
|
|
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",
|
|
"files": normalized_files,
|
|
"reviews": normalized_reviews,
|
|
}
|
|
|
|
|
|
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)
|
|
]
|