feat: request teammate pull reviews (Closes #1330)
CI / lint (pull_request) Successful in 3m36s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 5m35s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-24 02:11:24 +00:00
1 parent 505a40d077
commit 1f950f93dd
8 files changed
+373 -3

No files matched your search

+66
View File
@@ -1962,6 +1962,72 @@ async def pull_handoff_candidates(repository: str) -> list[dict]:
][:25]
async def pull_review_candidates(repository: str, number: int) -> list[dict]:
pull, candidates = await asyncio.gather(
fetch(f"repos/{repository}/pulls/{number}"),
pull_handoff_candidates(repository),
)
if not isinstance(pull, dict):
raise ValueError("Gitea pull request response was not an object")
excluded = {
item["login"].casefold()
for item in (pull.get("requested_reviewers") or [])
if isinstance(item, dict) and isinstance(item.get("login"), str)
}
author = pull.get("user") if isinstance(pull.get("user"), dict) else {}
if isinstance(author.get("login"), str):
excluded.add(author["login"].casefold())
return [
item for item in candidates
if item["login"].casefold() not in excluded
][:25]
async def request_assigned_pull_review(
repository: str, number: int, reviewer: str, expected_head_sha: str
) -> dict:
login, pull = await _current_login_and_target(
f"repos/{repository}/pulls/{number}"
)
head = pull.get("head") if isinstance(pull.get("head"), dict) else {}
if (
pull.get("state") != "open"
or pull.get("merged") is True
or not _login_in_users(login, pull.get("assignees"))
or head.get("sha") != expected_head_sha
):
raise IssueNotAvailableError("Pull request is no longer eligible for review request")
eligible = {
item["login"] for item in await pull_review_candidates(repository, number)
}
if reviewer not in eligible:
raise IssueNotAvailableError("Reviewer is not eligible")
response = await _get_client().post(
f"/api/v1/repos/{repository}/pulls/{number}/requested_reviewers",
headers=_auth(),
json={"reviewers": [reviewer]},
)
response.raise_for_status()
confirmed_response = await _get_client().get(
f"/api/v1/repos/{repository}/pulls/{number}", headers=_auth()
)
confirmed_response.raise_for_status()
confirmed = confirmed_response.json()
requested = [
item["login"] for item in (confirmed.get("requested_reviewers") or [])
if isinstance(item, dict) and isinstance(item.get("login"), str)
] if isinstance(confirmed, dict) else []
if reviewer not in requested:
raise ValueError("Gitea did not confirm the requested reviewer")
return {
"repository": repository,
"number": number,
"head_sha": expected_head_sha,
"requested_reviewers": requested,
"reviewer": reviewer,
}
async def _change_assigned_pull_owners(
repository: str, number: int, recipient: str | None
) -> dict:
+60
View File
@@ -1108,6 +1108,15 @@ class IssueHandoff(BaseModel):
)
class PullReviewRequest(BaseModel):
reviewer: str = Field(
min_length=1, max_length=255, pattern=r"^[A-Za-z0-9_.-]+$"
)
expected_head_sha: str = Field(
min_length=7, max_length=64, pattern=r"^[A-Fa-f0-9]+$"
)
class IssueReassignment(IssueHandoff):
expected_assignees: list[str] = Field(min_length=1, max_length=10)
@@ -6662,6 +6671,57 @@ async def handoff_assigned_pull(
return JSONResponse(result)
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/review-candidates")
async def pull_review_candidates(
owner: str, repo: str, number: int = PathParam(gt=0)
) -> JSONResponse:
repository = f"{owner}/{repo}"
try:
if not await gitea_proxy.is_assigned_pull(repository, number):
raise HTTPException(status_code=404, detail="Assigned pull request not found")
result = await asyncio.wait_for(
gitea_proxy.pull_review_candidates(repository, number),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except HTTPException:
raise
except Exception:
return JSONResponse(
{"error": "Reviewers could not be loaded. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result, headers={"Cache-Control": "no-store"})
@app.post("/api/v1/repos/{owner}/{repo}/pulls/{number}/request-review")
async def request_assigned_pull_review(
request: PullReviewRequest,
owner: str,
repo: str,
number: int = PathParam(gt=0),
) -> JSONResponse:
try:
result = await asyncio.wait_for(
gitea_proxy.request_assigned_pull_review(
f"{owner}/{repo}", number, request.reviewer, request.expected_head_sha
),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except gitea_proxy.IssueNotAvailableError:
return JSONResponse(
{"error": "The pull request or reviewer changed. Reload before requesting review."},
status_code=409,
)
except Exception:
return JSONResponse(
{"error": "The review request could not be confirmed. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result)
@app.patch("/api/v1/repos/{owner}/{repo}/pulls/{number}/release")
async def release_assigned_pull(
owner: str, repo: str, number: int = PathParam(gt=0)