feat: discover and claim mobile work (#175)
This commit is contained in:
1 parent
f9e11fca6a
commit
1f997cc504
8 files changed
+585
-6
No files matched your search
@@ -38,6 +38,10 @@ 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."""
|
||||
|
||||
|
||||
def _auth() -> dict[str, str]:
|
||||
headers: dict[str, str] = {"Accept": "application/json"}
|
||||
if GITEA_TOKEN:
|
||||
@@ -175,6 +179,62 @@ 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")
|
||||
|
||||
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, [])
|
||||
):
|
||||
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")),
|
||||
})
|
||||
|
||||
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
|
||||
))
|
||||
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}
|
||||
|
||||
|
||||
def _page_metadata(result: dict) -> dict:
|
||||
return {
|
||||
"page": result["page"],
|
||||
@@ -508,6 +568,60 @@ async def create_issue(
|
||||
}
|
||||
|
||||
|
||||
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 "pull_request" in issue
|
||||
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_web_url(confirmed.get("html_url")),
|
||||
}
|
||||
|
||||
|
||||
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}",
|
||||
|
||||
+40
-1
@@ -269,7 +269,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 request.url.path.startswith("/api/v1/work/") or (
|
||||
if request.url.path in {"/api/v1/context", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues"} 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 (
|
||||
@@ -400,6 +400,21 @@ async def paged_work(
|
||||
})
|
||||
|
||||
|
||||
@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
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "Available issues are temporarily unavailable. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": str(math.ceil(WORK_PAGE_TIMEOUT_SECONDS))},
|
||||
)
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
async def _load_context_for_user(user_data: dict) -> dict:
|
||||
repo_data, issues_data, prs_data = await asyncio.gather(
|
||||
repos(), issues(), pull_requests()
|
||||
@@ -856,6 +871,30 @@ async def reply_to_notification(
|
||||
return JSONResponse(result, status_code=201)
|
||||
|
||||
|
||||
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/claim")
|
||||
async def claim_available_issue(
|
||||
owner: str, repo: str, number: int = PathParam(gt=0)
|
||||
) -> JSONResponse:
|
||||
repository = f"{owner}/{repo}"
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
gitea_proxy.claim_available_issue(repository, number),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except gitea_proxy.IssueNotAvailableError:
|
||||
return JSONResponse(
|
||||
{"error": "This issue was already claimed or is no longer open. Refresh Find Work."},
|
||||
status_code=409,
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "The issue could not be assigned. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/detail")
|
||||
async def assigned_issue_detail(owner: str, repo: str, number: int = PathParam(gt=0)):
|
||||
repository = f"{owner}/{repo}"
|
||||
|
||||
Reference in new issue
Block a user