feat: reassign delegated filings from mobile Filed (Closes #895)
CI / lint (pull_request) Successful in 1m49s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Failing after 58s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-15 13:37:10 +00:00
1 parent 498d7e772a
commit 3c5c58fc38
8 files changed
+374 -12

No files matched your search

+57
View File
@@ -1843,6 +1843,63 @@ async def handoff_assigned_issue(
}
async def reassign_authored_issue(
repository: str, number: int, recipient: str, expected_assignees: list[str]
) -> dict:
login, issue = await _current_login_and_target(
f"repos/{repository}/issues/{number}"
)
author = issue.get("user")
author_login = author.get("login") if isinstance(author, dict) else None
assignees_value = issue.get("assignees")
current_assignees = [
item["login"] for item in assignees_value
if isinstance(item, dict) and isinstance(item.get("login"), str)
] if isinstance(assignees_value, list) else []
if (
issue.get("state") != "open"
or issue.get("pull_request") is not None
or not isinstance(author_login, str)
or author_login.casefold() != login.casefold()
or not current_assignees
or current_assignees != expected_assignees
):
raise IssueNotAvailableError("Authored issue delegate changed")
eligible = {
item["login"] for item in await issue_handoff_candidates(repository)
}
if recipient not in eligible or recipient in current_assignees:
raise IssueNotAvailableError("Reassignment recipient is not eligible")
response = await _get_client().patch(
f"/api/v1/repos/{repository}/issues/{number}",
headers=_auth(),
json={"assignees": [recipient]},
)
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 confirmed_assignees != [recipient]
):
raise ValueError("Gitea did not confirm issue reassignment")
return {
"repository": repository,
"number": number,
"state": confirmed.get("state", "open"),
"assignees": confirmed_assignees,
"recipient": recipient,
"previous_assignees": expected_assignees,
}
async def pull_handoff_candidates(repository: str) -> list[dict]:
candidates = await issue_handoff_candidates(repository)
return [
+58 -2
View File
@@ -881,6 +881,22 @@ class IssueHandoff(BaseModel):
)
class IssueReassignment(IssueHandoff):
expected_assignees: list[str] = Field(min_length=1, max_length=10)
@field_validator("expected_assignees")
@classmethod
def validate_expected_assignees(cls, values: list[str]) -> list[str]:
if any(
not isinstance(value, str)
or not re.fullmatch(r"[A-Za-z0-9_.-]+", value)
or len(value) > 255
for value in values
) or len(set(values)) != len(values):
raise ValueError("expected assignees must be unique valid logins")
return values
class IssueBlockerUpdate(BaseModel):
repository: str = Field(
min_length=3,
@@ -4369,12 +4385,20 @@ async def release_assigned_issue(
@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)
owner: str,
repo: str,
number: int = PathParam(gt=0),
access: Literal["assigned", "filed"] = Query(default="assigned"),
) -> JSONResponse:
repository = f"{owner}/{repo}"
async def load_candidates():
if not await gitea_proxy.is_assigned_issue(repository, number):
authorized = await (
gitea_proxy.is_authored_issue(repository, number)
if access == "filed"
else gitea_proxy.is_assigned_issue(repository, number)
)
if not authorized:
raise HTTPException(status_code=404, detail="Assigned issue not found")
return await gitea_proxy.issue_handoff_candidates(repository)
@@ -4444,6 +4468,38 @@ async def handoff_assigned_issue(
return JSONResponse(result)
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/reassign")
async def reassign_authored_issue(
reassignment: IssueReassignment,
owner: str,
repo: str,
number: int = PathParam(gt=0),
) -> JSONResponse:
repository = f"{owner}/{repo}"
try:
result = await asyncio.wait_for(
gitea_proxy.reassign_authored_issue(
repository,
number,
reassignment.recipient,
reassignment.expected_assignees,
),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except gitea_proxy.IssueNotAvailableError:
return JSONResponse(
{"error": "The delegate changed. Reload before reassigning."},
status_code=409,
)
except Exception:
return JSONResponse(
{"error": "The reassignment could not be confirmed. The current delegate is unchanged; 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,