stackchain-dashboard/src/gitea_proxy.py
timmy f7073e1393
All checks were successful
CI / lint (pull_request) Successful in 11s
CI / build-frontend (pull_request) Successful in 5s
feat: triage unread updates in My Work (#151)
2026-08-07 00:35:34 +00:00

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