feat: hand off assigned issues from mobile (#281)
This commit is contained in:
1 parent
1fdf6f417d
commit
04bb6839ae
8 files changed
+471
-3
No files matched your search
@@ -1038,6 +1038,90 @@ async def release_assigned_issue(repository: str, number: int) -> dict:
|
||||
}
|
||||
|
||||
|
||||
async def issue_handoff_candidates(repository: str) -> list[dict]:
|
||||
user, response = await asyncio.gather(
|
||||
current_user(),
|
||||
_get_client().get(
|
||||
f"/api/v1/repos/{repository}/assignees", headers=_auth()
|
||||
),
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
if not isinstance(payload, list):
|
||||
raise ValueError("Gitea assignees response was not a list")
|
||||
current_login = user.get("login") if isinstance(user, dict) else None
|
||||
candidates = []
|
||||
for item in payload:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
login = item.get("login")
|
||||
if not isinstance(login, str) or not login or login == current_login:
|
||||
continue
|
||||
full_name = item.get("full_name")
|
||||
candidates.append({
|
||||
"login": login,
|
||||
"name": full_name if isinstance(full_name, str) and full_name else login,
|
||||
})
|
||||
return candidates
|
||||
|
||||
|
||||
async def handoff_assigned_issue(
|
||||
repository: str, number: int, recipient: str
|
||||
) -> dict:
|
||||
login, issue = await _current_login_and_target(
|
||||
f"repos/{repository}/issues/{number}"
|
||||
)
|
||||
if (
|
||||
issue.get("state") != "open"
|
||||
or issue.get("pull_request") is not None
|
||||
or not _login_in_users(login, issue.get("assignees"))
|
||||
):
|
||||
raise IssueNotAvailableError("Issue is not assigned to the current user")
|
||||
|
||||
eligible = {
|
||||
item["login"] for item in await issue_handoff_candidates(repository)
|
||||
}
|
||||
if recipient not in eligible:
|
||||
raise IssueNotAvailableError("Handoff recipient is not eligible")
|
||||
assignees_value = issue.get("assignees")
|
||||
assignees = assignees_value if isinstance(assignees_value, list) else []
|
||||
desired = [
|
||||
item["login"] for item in assignees
|
||||
if isinstance(item, dict)
|
||||
and isinstance(item.get("login"), str)
|
||||
and item["login"] != login
|
||||
]
|
||||
if recipient not in desired:
|
||||
desired.append(recipient)
|
||||
response = await _get_client().patch(
|
||||
f"/api/v1/repos/{repository}/issues/{number}",
|
||||
headers=_auth(),
|
||||
json={"assignees": desired},
|
||||
)
|
||||
response.raise_for_status()
|
||||
confirmed = response.json()
|
||||
confirmed_value = confirmed.get("assignees") if isinstance(confirmed, dict) else None
|
||||
confirmed_assignees = [
|
||||
item["login"] for item in confirmed_value
|
||||
if isinstance(item, dict) and isinstance(item.get("login"), str)
|
||||
] if isinstance(confirmed_value, list) else []
|
||||
if (
|
||||
not isinstance(confirmed, dict)
|
||||
or confirmed.get("number") != number
|
||||
or login in confirmed_assignees
|
||||
or recipient not in confirmed_assignees
|
||||
or set(confirmed_assignees) != set(desired)
|
||||
):
|
||||
raise ValueError("Gitea did not confirm issue handoff")
|
||||
return {
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"state": confirmed.get("state", "open"),
|
||||
"assignees": confirmed_assignees,
|
||||
"recipient": recipient,
|
||||
}
|
||||
|
||||
|
||||
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}",
|
||||
|
||||
+61
@@ -239,6 +239,12 @@ class IssueMilestoneUpdate(BaseModel):
|
||||
milestone_id: PositiveInt | None = None
|
||||
|
||||
|
||||
class IssueHandoff(BaseModel):
|
||||
recipient: str = Field(
|
||||
min_length=1, max_length=255, pattern=r"^[A-Za-z0-9_.-]+$"
|
||||
)
|
||||
|
||||
|
||||
class PullReviewComment(BaseModel):
|
||||
path: str = Field(min_length=1, max_length=1_000)
|
||||
body: str = Field(min_length=1, max_length=10_000)
|
||||
@@ -1478,6 +1484,61 @@ async def release_assigned_issue(
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/handoff-candidates")
|
||||
async def issue_handoff_candidates(
|
||||
owner: str, repo: str, number: int = PathParam(gt=0)
|
||||
) -> JSONResponse:
|
||||
repository = f"{owner}/{repo}"
|
||||
|
||||
async def load_candidates():
|
||||
if not await gitea_proxy.is_assigned_issue(repository, number):
|
||||
raise HTTPException(status_code=404, detail="Assigned issue not found")
|
||||
return await gitea_proxy.issue_handoff_candidates(repository)
|
||||
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
load_candidates(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "Teammates could not be loaded. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/handoff")
|
||||
async def handoff_assigned_issue(
|
||||
handoff: IssueHandoff,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
) -> JSONResponse:
|
||||
repository = f"{owner}/{repo}"
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
gitea_proxy.handoff_assigned_issue(
|
||||
repository, number, handoff.recipient
|
||||
),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except gitea_proxy.IssueNotAvailableError:
|
||||
return JSONResponse(
|
||||
{"error": "The issue or recipient changed. Reload before handing off."},
|
||||
status_code=409,
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "The handoff could not be confirmed. It remains in My Work; 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