feat: make Find Work production-schema-safe (#181)
This commit is contained in:
1 parent
b4f984e331
commit
b6c7ec83e4
7 files changed
+331
-71
No files matched your search
+78
-46
@@ -179,48 +179,70 @@ async def work_page(stream: str, page: int = 1, limit: int = 50) -> dict:
|
||||
}
|
||||
|
||||
|
||||
async def available_issue_page(page: int = 1, limit: int = 50) -> dict:
|
||||
"""Return one bounded page of open, unassigned issues visible to the user."""
|
||||
response = await _get_client().get(
|
||||
"/api/v1/repos/issues/search",
|
||||
headers=_auth(),
|
||||
params={"state": "open", "type": "issues", "limit": limit, "page": page},
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
if not isinstance(payload, list):
|
||||
raise ValueError("Gitea available issue search response was not a list")
|
||||
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_web_url(item.get("html_url")),
|
||||
}
|
||||
|
||||
items = []
|
||||
for item in payload:
|
||||
if (
|
||||
not isinstance(item, dict)
|
||||
or item.get("state") != "open"
|
||||
or "pull_request" in item
|
||||
or item.get("assignees") not in (None, [])
|
||||
|
||||
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()
|
||||
upstream_total: int | None = None
|
||||
for upstream_page in range(1, max_pages + 1):
|
||||
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")
|
||||
if upstream_total is None:
|
||||
try:
|
||||
upstream_total = max(0, int(response.headers["X-Total-Count"]))
|
||||
except (KeyError, TypeError, ValueError):
|
||||
upstream_total = None
|
||||
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)
|
||||
loaded = upstream_page * upstream_limit
|
||||
if not payload or len(payload) < upstream_limit or (
|
||||
upstream_total is not None and loaded >= upstream_total
|
||||
):
|
||||
continue
|
||||
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 {}
|
||||
items.append({
|
||||
"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_web_url(item.get("html_url")),
|
||||
})
|
||||
break
|
||||
|
||||
priority = {"p0", "priority-high", "critical"}
|
||||
items.sort(key=lambda item: (item["repository"], item["number"] or 0))
|
||||
@@ -228,11 +250,21 @@ async def available_issue_page(page: int = 1, limit: int = 50) -> dict:
|
||||
items.sort(key=lambda item: (
|
||||
0 if any(str(label).lower() in priority for label in item["labels"]) else 1
|
||||
))
|
||||
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}
|
||||
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:
|
||||
@@ -575,7 +607,7 @@ async def claim_available_issue(repository: str, number: int) -> dict:
|
||||
if (
|
||||
not isinstance(issue, dict)
|
||||
or issue.get("state") != "open"
|
||||
or "pull_request" in issue
|
||||
or issue.get("pull_request") is not None
|
||||
or assignees_value not in (None, [])
|
||||
):
|
||||
raise IssueNotAvailableError("Issue is no longer available")
|
||||
|
||||
+70
-11
@@ -30,23 +30,27 @@ from src.views import router as frontend_router
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI):
|
||||
global _live_snapshot_task
|
||||
global _live_snapshot_task, _available_issue_snapshot_task
|
||||
gitea_proxy.start_client()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
task = _live_snapshot_task
|
||||
if task is not None and not task.done():
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
live_task = _live_snapshot_task
|
||||
available_task = _available_issue_snapshot_task
|
||||
for task in (live_task, available_task):
|
||||
if task is not None and not task.done():
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
try:
|
||||
await gitea_proxy.stop_client()
|
||||
finally:
|
||||
if _live_snapshot_task is task:
|
||||
if _live_snapshot_task is live_task:
|
||||
_live_snapshot_task = None
|
||||
if _available_issue_snapshot_task is available_task:
|
||||
_available_issue_snapshot_task = None
|
||||
|
||||
|
||||
app = FastAPI(title="Stackchain Dashboard", lifespan=lifespan)
|
||||
@@ -66,6 +70,7 @@ BULK_NOTIFICATION_DEADLINE_SECONDS = 6.0
|
||||
LIVE_SNAPSHOT_FRESHNESS_SECONDS = 8.0
|
||||
LIVE_SNAPSHOT_RETRY_BASE_SECONDS = 5.0
|
||||
LIVE_SNAPSHOT_RETRY_MAX_SECONDS = 60.0
|
||||
AVAILABLE_ISSUE_SNAPSHOT_FRESHNESS_SECONDS = 15.0
|
||||
FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
|
||||
_live_snapshot_task: asyncio.Task | None = None
|
||||
_live_snapshot_value: dict | None = None
|
||||
@@ -85,6 +90,9 @@ _read_notification_ids: set[int] = set()
|
||||
_issue_creation_operations: dict[
|
||||
str, tuple[tuple[Any, ...], asyncio.Task, float]
|
||||
] = {}
|
||||
_available_issue_snapshot_task: asyncio.Task | None = None
|
||||
_available_issue_snapshot_value: list[dict] | None = None
|
||||
_available_issue_snapshot_created_at: float | None = None
|
||||
|
||||
|
||||
class ContextPayloadError(ValueError):
|
||||
@@ -405,11 +413,52 @@ async def paged_work(
|
||||
})
|
||||
|
||||
|
||||
async def _refresh_available_issue_snapshot() -> list[dict]:
|
||||
global _available_issue_snapshot_value, _available_issue_snapshot_created_at
|
||||
result = await gitea_proxy.available_issue_snapshot()
|
||||
_available_issue_snapshot_value = result
|
||||
_available_issue_snapshot_created_at = time.monotonic()
|
||||
return result
|
||||
|
||||
|
||||
async def _available_issue_snapshot() -> tuple[list[dict], bool]:
|
||||
global _available_issue_snapshot_task
|
||||
now = time.monotonic()
|
||||
if (
|
||||
_available_issue_snapshot_value is not None
|
||||
and _available_issue_snapshot_created_at is not None
|
||||
and now - _available_issue_snapshot_created_at
|
||||
< AVAILABLE_ISSUE_SNAPSHOT_FRESHNESS_SECONDS
|
||||
):
|
||||
return _available_issue_snapshot_value, False
|
||||
if _available_issue_snapshot_task is None or _available_issue_snapshot_task.done():
|
||||
_available_issue_snapshot_task = asyncio.create_task(
|
||||
_refresh_available_issue_snapshot()
|
||||
)
|
||||
try:
|
||||
return await asyncio.shield(_available_issue_snapshot_task), False
|
||||
except Exception:
|
||||
if _available_issue_snapshot_value is not None:
|
||||
return _available_issue_snapshot_value, True
|
||||
raise
|
||||
|
||||
|
||||
def _available_issue_page(items: list[dict], page: int, limit: int = 50) -> dict:
|
||||
start = (page - 1) * limit
|
||||
page_items = items[start:start + limit]
|
||||
return {
|
||||
"items": page_items,
|
||||
"page": page,
|
||||
"total": len(items),
|
||||
"has_more": start + len(page_items) < len(items),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/v1/available-issues")
|
||||
async def available_issues(page: int = Query(default=1, ge=1, le=100)) -> JSONResponse:
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
gitea_proxy.available_issue_page(page), timeout=WORK_PAGE_TIMEOUT_SECONDS
|
||||
items, stale = await asyncio.wait_for(
|
||||
_available_issue_snapshot(), timeout=WORK_PAGE_TIMEOUT_SECONDS
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
@@ -417,6 +466,9 @@ async def available_issues(page: int = Query(default=1, ge=1, le=100)) -> JSONRe
|
||||
status_code=503,
|
||||
headers={"Retry-After": str(math.ceil(WORK_PAGE_TIMEOUT_SECONDS))},
|
||||
)
|
||||
result = _available_issue_page(items, page)
|
||||
if stale:
|
||||
result["stale"] = True
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@@ -897,6 +949,13 @@ async def claim_available_issue(
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
if _available_issue_snapshot_value is not None:
|
||||
_available_issue_snapshot_value[:] = [
|
||||
item for item in _available_issue_snapshot_value
|
||||
if not (
|
||||
item.get("repository") == repository and item.get("number") == number
|
||||
)
|
||||
]
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user