feat: plan mobile work by milestone (#203)
This commit is contained in:
1 parent
09b2898d1c
commit
2be29b684a
9 files changed
+514
-10
No files matched your search
@@ -657,6 +657,26 @@ async def repo_labels(repository: str) -> list[dict]:
|
||||
]
|
||||
|
||||
|
||||
async def repo_milestones(repository: str) -> list[dict]:
|
||||
response = await _get_client().get(
|
||||
f"/api/v1/repos/{repository}/milestones",
|
||||
headers=_auth(),
|
||||
params={"state": "open", "limit": 50, "page": 1},
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
if not isinstance(payload, list):
|
||||
raise ValueError("Gitea milestones response was not a list")
|
||||
return [
|
||||
{"id": item["id"], "title": item["title"]}
|
||||
for item in payload
|
||||
if isinstance(item, dict)
|
||||
and isinstance(item.get("id"), int)
|
||||
and isinstance(item.get("title"), str)
|
||||
and item.get("state") == "open"
|
||||
]
|
||||
|
||||
|
||||
async def create_issue(
|
||||
repository: str,
|
||||
title: str,
|
||||
@@ -871,6 +891,53 @@ async def update_assigned_issue_due_date(
|
||||
}
|
||||
|
||||
|
||||
async def update_assigned_issue_milestone(
|
||||
repository: str, number: int, milestone_id: int | None
|
||||
) -> dict:
|
||||
path = f"repos/{repository}/issues/{number}"
|
||||
login, issue = await _current_login_and_target(path)
|
||||
if (
|
||||
issue.get("state") != "open"
|
||||
or isinstance(issue.get("pull_request"), dict)
|
||||
or not _login_in_users(login, issue.get("assignees"))
|
||||
):
|
||||
raise IssueNotAvailableError("assigned issue not found")
|
||||
|
||||
selected = None
|
||||
if milestone_id is not None:
|
||||
selected = next(
|
||||
(item for item in await repo_milestones(repository) if item["id"] == milestone_id),
|
||||
None,
|
||||
)
|
||||
if selected is None:
|
||||
raise ValueError("Unknown open repository milestone")
|
||||
response = await _get_client().patch(
|
||||
f"/api/v1/{path}", headers=_auth(), json={"milestone": milestone_id or 0}
|
||||
)
|
||||
response.raise_for_status()
|
||||
confirmed = response.json()
|
||||
milestone_value = confirmed.get("milestone") if isinstance(confirmed, dict) else None
|
||||
normalized = (
|
||||
{"id": milestone_value["id"], "title": milestone_value["title"]}
|
||||
if isinstance(milestone_value, dict)
|
||||
and isinstance(milestone_value.get("id"), int)
|
||||
and isinstance(milestone_value.get("title"), str)
|
||||
else None
|
||||
)
|
||||
if (
|
||||
not isinstance(confirmed, dict)
|
||||
or confirmed.get("number") != number
|
||||
or normalized != selected
|
||||
):
|
||||
raise ValueError("Gitea did not confirm the issue milestone update")
|
||||
return {
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"state": confirmed.get("state", "open"),
|
||||
"milestone": normalized,
|
||||
}
|
||||
|
||||
|
||||
async def issue_detail(repository: str, number: int) -> dict:
|
||||
base = f"repos/{repository}/issues/{number}"
|
||||
issue, comments = await asyncio.gather(
|
||||
@@ -896,6 +963,13 @@ async def issue_detail(repository: str, number: int) -> dict:
|
||||
"body": issue.get("body", "") if isinstance(issue.get("body"), str) else "",
|
||||
"updated_at": issue.get("updated_at", "") if isinstance(issue.get("updated_at"), str) else "",
|
||||
"due_date": issue.get("due_date") if isinstance(issue.get("due_date"), str) else None,
|
||||
"milestone": (
|
||||
{"id": issue["milestone"]["id"], "title": issue["milestone"]["title"]}
|
||||
if isinstance(issue.get("milestone"), dict)
|
||||
and isinstance(issue["milestone"].get("id"), int)
|
||||
and isinstance(issue["milestone"].get("title"), str)
|
||||
else None
|
||||
),
|
||||
"url": _safe_web_url(issue.get("html_url")),
|
||||
"labels": [
|
||||
label["name"] for label in labels
|
||||
|
||||
+76
-2
@@ -26,7 +26,7 @@ from src.gitea_proxy import (
|
||||
repos,
|
||||
)
|
||||
from src.idempotency import IdempotencyLedger
|
||||
from src.models import Issue, PullRequest, Repo, User
|
||||
from src.models import Issue, Milestone, PullRequest, Repo, User
|
||||
from src.suggestion_engine import compute
|
||||
from src.views import router as frontend_router
|
||||
|
||||
@@ -195,6 +195,10 @@ class IssueDueDateUpdate(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class IssueMilestoneUpdate(BaseModel):
|
||||
milestone_id: PositiveInt | None = None
|
||||
|
||||
|
||||
class PullReviewComment(BaseModel):
|
||||
path: str = Field(min_length=1, max_length=1_000)
|
||||
body: str = Field(min_length=1, max_length=10_000)
|
||||
@@ -318,6 +322,9 @@ def _context_payload(user_data, repo_data, issues_data, prs_data) -> dict:
|
||||
repository=i["repository"].get("full_name", "") if isinstance(i.get("repository"), dict) else "",
|
||||
updated_at=i.get("updated_at") or "",
|
||||
due_date=i.get("due_date") if isinstance(i.get("due_date"), str) else None,
|
||||
milestone=Milestone(**i["milestone"]) if isinstance(i.get("milestone"), dict)
|
||||
and isinstance(i["milestone"].get("id"), int)
|
||||
and isinstance(i["milestone"].get("title"), str) else None,
|
||||
url=i["html_url"],
|
||||
)
|
||||
for i in (issues_data or [])[:50]
|
||||
@@ -358,6 +365,10 @@ def _normalize_work_items(stream: str, items: list[dict]) -> list[dict]:
|
||||
repository=item["repository"].get("full_name", "") if isinstance(item.get("repository"), dict) else "",
|
||||
updated_at=item.get("updated_at") or "",
|
||||
due_date=item.get("due_date") if isinstance(item.get("due_date"), str) else None,
|
||||
milestone=Milestone(**item["milestone"])
|
||||
if isinstance(item.get("milestone"), dict)
|
||||
and isinstance(item["milestone"].get("id"), int)
|
||||
and isinstance(item["milestone"].get("title"), str) else None,
|
||||
url=item["html_url"],
|
||||
).model_dump()
|
||||
for item in items
|
||||
@@ -397,7 +408,7 @@ async def prevent_live_api_caching(request, call_next):
|
||||
and request.url.path.endswith("/issues")
|
||||
) or (
|
||||
request.url.path.startswith("/api/v1/repos/")
|
||||
and request.url.path.endswith("/labels")
|
||||
and request.url.path.endswith(("/labels", "/milestones"))
|
||||
):
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
@@ -1229,6 +1240,69 @@ async def update_assigned_issue_due_date(
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/milestone")
|
||||
async def update_assigned_issue_milestone(
|
||||
update: IssueMilestoneUpdate,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
):
|
||||
repository = f"{owner}/{repo}"
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
gitea_proxy.update_assigned_issue_milestone(
|
||||
repository, number, update.milestone_id
|
||||
),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except gitea_proxy.IssueNotAvailableError:
|
||||
raise HTTPException(status_code=404, detail="Assigned issue not found")
|
||||
except ValueError as exc:
|
||||
if str(exc) == "Unknown open repository milestone":
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
return JSONResponse(
|
||||
{"error": "The milestone could not be updated. Your selection is safe; please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "The milestone could not be updated. Your selection is safe; please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.get("/api/v1/repos/{owner}/{repo}/milestones")
|
||||
async def repository_milestones(owner: str, repo: str):
|
||||
repository = f"{owner}/{repo}"
|
||||
|
||||
async def load_milestones():
|
||||
available = await gitea_proxy.repos()
|
||||
accessible = {
|
||||
item.get("full_name")
|
||||
for item in (available if isinstance(available, list) else [])
|
||||
if isinstance(item, dict)
|
||||
}
|
||||
if repository not in accessible:
|
||||
raise HTTPException(status_code=404, detail="Repository not found")
|
||||
return await gitea_proxy.repo_milestones(repository)
|
||||
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
load_milestones(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "Milestones could not be loaded. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/repos/{owner}/{repo}/labels")
|
||||
async def repository_labels(owner: str, repo: str):
|
||||
repository = f"{owner}/{repo}"
|
||||
|
||||
@@ -17,6 +17,11 @@ class Repo(BaseModel):
|
||||
updated_at: str = ""
|
||||
|
||||
|
||||
class Milestone(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
|
||||
|
||||
class Issue(BaseModel):
|
||||
id: int
|
||||
number: int
|
||||
@@ -27,6 +32,7 @@ class Issue(BaseModel):
|
||||
repository: str = ""
|
||||
updated_at: str = ""
|
||||
due_date: str | None = None
|
||||
milestone: Milestone | None = None
|
||||
url: str
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user