feat: paginate mobile My Work queues (#169)
This commit is contained in:
1 parent
29a786cea3
commit
625e65e7e4
8 files changed
+588
-84
No files matched your search
+93
-42
@@ -14,6 +14,14 @@ REVIEW_DIFF_MAX_LINES = 400
|
||||
_client: httpx.AsyncClient | None = None
|
||||
|
||||
|
||||
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 StaleReviewError(ValueError):
|
||||
"""Raised before mutation when a pull request head changed during review."""
|
||||
|
||||
@@ -129,10 +137,55 @@ 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"
|
||||
WORK_SEARCHES = {
|
||||
"issue": ("assigned=true", "issues", None),
|
||||
"pull": ("assigned=true", "pulls", "assigned_to_me"),
|
||||
"review": ("review_requested=true", "pulls", "review_requested"),
|
||||
}
|
||||
|
||||
|
||||
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": "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,
|
||||
}
|
||||
|
||||
|
||||
def _page_metadata(result: dict) -> dict:
|
||||
return {
|
||||
"page": result["page"],
|
||||
"total": result["total"],
|
||||
"has_more": result["has_more"],
|
||||
}
|
||||
|
||||
|
||||
async def issues() -> WorkItems:
|
||||
result = await work_page("issue")
|
||||
return WorkItems(result["items"], {"issue": _page_metadata(result)})
|
||||
|
||||
|
||||
def _safe_web_url(value: Any) -> str:
|
||||
@@ -491,61 +544,59 @@ async def issue_detail(repository: str, number: int) -> dict:
|
||||
}
|
||||
|
||||
|
||||
async def _work_contains(stream: str, repository: str, number: int) -> bool:
|
||||
page = 1
|
||||
while page <= 100:
|
||||
result = await work_page(stream, page)
|
||||
if any(
|
||||
item.get("number") == number
|
||||
and isinstance(item.get("repository"), dict)
|
||||
and item["repository"].get("full_name") == repository
|
||||
for item in result["items"]
|
||||
):
|
||||
return True
|
||||
if not result["has_more"]:
|
||||
return False
|
||||
page += 1
|
||||
return False
|
||||
|
||||
|
||||
async def is_assigned_issue(repository: str, number: int) -> bool:
|
||||
assigned = await issues()
|
||||
return any(
|
||||
isinstance(issue, dict)
|
||||
and issue.get("number") == number
|
||||
and isinstance(issue.get("repository"), dict)
|
||||
and issue["repository"].get("full_name") == repository
|
||||
for issue in (assigned or [])
|
||||
)
|
||||
return await _work_contains("issue", repository, number)
|
||||
|
||||
|
||||
async def pull_requests() -> list[dict]:
|
||||
async def pull_requests() -> WorkItems:
|
||||
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"
|
||||
),
|
||||
work_page("pull"),
|
||||
work_page("review"),
|
||||
)
|
||||
merged: dict[int, dict] = {}
|
||||
for reason, pulls in (
|
||||
("assigned_to_me", assigned or []),
|
||||
("review_requested", review_requested or []),
|
||||
for result in (
|
||||
assigned,
|
||||
review_requested,
|
||||
):
|
||||
for pull in pulls:
|
||||
for pull in result["items"]:
|
||||
identity = pull.get("id")
|
||||
if identity not in merged:
|
||||
merged[identity] = {**pull, "work_reasons": []}
|
||||
merged[identity]["work_reasons"].append(reason)
|
||||
return list(merged.values())
|
||||
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()),
|
||||
{
|
||||
"pull": _page_metadata(assigned),
|
||||
"review": _page_metadata(review_requested),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
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 [])
|
||||
)
|
||||
return await _work_contains("review", repository, number)
|
||||
|
||||
|
||||
async def is_assigned_pull(repository: str, number: int) -> bool:
|
||||
pulls = await fetch(
|
||||
"repos/issues/search?state=open&assigned=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 [])
|
||||
)
|
||||
return await _work_contains("pull", repository, number)
|
||||
|
||||
|
||||
async def pull_completion_detail(repository: str, number: int) -> dict:
|
||||
|
||||
+65
-3
@@ -4,7 +4,7 @@ import time
|
||||
from collections.abc import Awaitable
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Path as PathParam, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
@@ -57,6 +57,7 @@ REVIEW_DETAIL_TIMEOUT_SECONDS = 5.0
|
||||
ISSUE_ACTION_TIMEOUT_SECONDS = 5.0
|
||||
NOTIFICATION_MUTATION_TIMEOUT_SECONDS = 5.0
|
||||
NOTIFICATION_PAGE_TIMEOUT_SECONDS = 5.0
|
||||
WORK_PAGE_TIMEOUT_SECONDS = 5.0
|
||||
NOTIFICATION_DETAIL_TIMEOUT_SECONDS = 5.0
|
||||
BULK_NOTIFICATION_CONCURRENCY = 5
|
||||
BULK_NOTIFICATION_DEADLINE_SECONDS = 6.0
|
||||
@@ -218,7 +219,44 @@ def _context_payload(user_data, repo_data, issues_data, prs_data) -> dict:
|
||||
if isinstance(p, dict)
|
||||
and all(field in p for field in ("id", "number", "title", "state", "html_url"))
|
||||
]
|
||||
return compute(user_model, repo_models, issue_models, pr_models).model_dump()
|
||||
payload = compute(user_model, repo_models, issue_models, pr_models).model_dump()
|
||||
pagination = {}
|
||||
pagination.update(getattr(issues_data, "pagination", {}))
|
||||
pagination.update(getattr(prs_data, "pagination", {}))
|
||||
if pagination:
|
||||
payload["work_pagination"] = pagination
|
||||
return payload
|
||||
|
||||
|
||||
def _normalize_work_items(stream: str, items: list[dict]) -> list[dict]:
|
||||
if stream == "issue":
|
||||
return [
|
||||
Issue(
|
||||
id=item["id"], number=item["number"], title=item["title"],
|
||||
state=item["state"],
|
||||
labels=[label.get("name", "") for label in (item.get("labels") or []) if isinstance(label, dict)],
|
||||
assignees=[assignee.get("login", "") for assignee in (item.get("assignees") or []) if isinstance(assignee, dict)],
|
||||
repository=item["repository"].get("full_name", "") if isinstance(item.get("repository"), dict) else "",
|
||||
updated_at=item.get("updated_at") or "", url=item["html_url"],
|
||||
).model_dump()
|
||||
for item in items
|
||||
if all(field in item for field in ("id", "number", "title", "state", "html_url"))
|
||||
]
|
||||
reason = "assigned_to_me" if stream == "pull" else "review_requested"
|
||||
return [
|
||||
PullRequest(
|
||||
id=item["id"], number=item["number"], title=item["title"],
|
||||
state=item["state"],
|
||||
user=item["user"].get("login", "") if isinstance(item.get("user"), dict) else "",
|
||||
labels=[label.get("name", "") for label in (item.get("labels") or []) if isinstance(label, dict)],
|
||||
assignees=[assignee.get("login", "") for assignee in (item.get("assignees") or []) if isinstance(assignee, dict)],
|
||||
work_reasons=[reason],
|
||||
repository=item["repository"].get("full_name", "") if isinstance(item.get("repository"), dict) else "",
|
||||
updated_at=item.get("updated_at") or "", url=item["html_url"],
|
||||
).model_dump()
|
||||
for item in items
|
||||
if all(field in item for field in ("id", "number", "title", "state", "html_url"))
|
||||
]
|
||||
|
||||
app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static")
|
||||
app.include_router(frontend_router)
|
||||
@@ -227,7 +265,7 @@ app.include_router(frontend_router)
|
||||
@app.middleware("http")
|
||||
async def prevent_live_api_caching(request, call_next):
|
||||
response = await call_next(request)
|
||||
if request.url.path in {"/api/v1/context", "/api/v1/events", "/api/v1/live"} or (
|
||||
if request.url.path in {"/api/v1/context", "/api/v1/events", "/api/v1/live"} or request.url.path.startswith("/api/v1/work/") or (
|
||||
request.url.path.startswith("/api/v1/repos/")
|
||||
and request.url.path.endswith("/review")
|
||||
) or request.url.path.startswith("/api/v1/notifications") or (
|
||||
@@ -334,6 +372,30 @@ async def context() -> JSONResponse:
|
||||
return JSONResponse(_context_payload(user_data, repo_data, issues_data, prs_data))
|
||||
|
||||
|
||||
@app.get("/api/v1/work/{stream}")
|
||||
async def paged_work(
|
||||
stream: Literal["issue", "pull", "review"],
|
||||
page: int = Query(ge=2, le=100),
|
||||
) -> JSONResponse:
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
gitea_proxy.work_page(stream, page), timeout=WORK_PAGE_TIMEOUT_SECONDS
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "Work page is temporarily unavailable"},
|
||||
status_code=503,
|
||||
headers={"Retry-After": str(math.ceil(WORK_PAGE_TIMEOUT_SECONDS))},
|
||||
)
|
||||
return JSONResponse({
|
||||
"stream": stream,
|
||||
"page": result["page"],
|
||||
"total": result["total"],
|
||||
"has_more": result["has_more"],
|
||||
"items": _normalize_work_items(stream, result["items"]),
|
||||
})
|
||||
|
||||
|
||||
async def _load_context_for_user(user_data: dict) -> dict:
|
||||
repo_data, issues_data, prs_data = await asyncio.gather(
|
||||
repos(), issues(), pull_requests()
|
||||
|
||||
Reference in new issue
Block a user