feat: manage issue blockers in app (Closes #531)
This commit is contained in:
1 parent
29ceedfc01
commit
2c8a1225e0
8 files changed
+484
-1
No files matched your search
@@ -169,6 +169,10 @@ class IssueEditConflictError(ValueError):
|
||||
"""Raised when an issue changed after the editor loaded it."""
|
||||
|
||||
|
||||
class IssueDependencyInvalidError(ValueError):
|
||||
"""Raised when a requested blocker relationship is not valid."""
|
||||
|
||||
|
||||
def _auth() -> dict[str, str]:
|
||||
headers: dict[str, str] = {"Accept": "application/json"}
|
||||
if GITEA_TOKEN:
|
||||
@@ -1609,6 +1613,67 @@ async def issue_dependencies(repository: str, number: int, limit: int = 20) -> l
|
||||
return dependencies
|
||||
|
||||
|
||||
async def mutate_assigned_issue_dependency(
|
||||
repository: str,
|
||||
number: int,
|
||||
blocker_repository: str,
|
||||
blocker_number: int,
|
||||
remove: bool = False,
|
||||
) -> dict:
|
||||
"""Add or remove one prerequisite and return the canonical open dependency set."""
|
||||
if (repository, number) == (blocker_repository, blocker_number):
|
||||
raise IssueDependencyInvalidError("an issue cannot block itself")
|
||||
|
||||
login, source = await _current_login_and_target(
|
||||
f"repos/{repository}/issues/{number}"
|
||||
)
|
||||
if (
|
||||
source.get("state") != "open"
|
||||
or isinstance(source.get("pull_request"), dict)
|
||||
or not _login_in_users(login, source.get("assignees"))
|
||||
):
|
||||
raise IssueNotAvailableError("assigned issue not found")
|
||||
|
||||
if not remove:
|
||||
candidate = await fetch(f"repos/{blocker_repository}/issues/{blocker_number}")
|
||||
if (
|
||||
not isinstance(candidate, dict)
|
||||
or candidate.get("number") != blocker_number
|
||||
or candidate.get("state") != "open"
|
||||
or isinstance(candidate.get("pull_request"), dict)
|
||||
):
|
||||
raise IssueDependencyInvalidError("blocker must be an accessible open issue")
|
||||
current = await issue_dependencies(repository, number)
|
||||
if any(
|
||||
item["repository"] == blocker_repository and item["number"] == blocker_number
|
||||
for item in current
|
||||
):
|
||||
raise IssueDependencyInvalidError("that issue is already a blocker")
|
||||
|
||||
owner, repo = blocker_repository.split("/", 1)
|
||||
response = await _get_client().request(
|
||||
"DELETE" if remove else "POST",
|
||||
f"/api/v1/repos/{repository}/issues/{number}/dependencies",
|
||||
headers=_auth(),
|
||||
json={"owner": owner, "repo": repo, "index": blocker_number},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
confirmed = await issue_dependencies(repository, number)
|
||||
present = any(
|
||||
item["repository"] == blocker_repository and item["number"] == blocker_number
|
||||
for item in confirmed
|
||||
)
|
||||
if present == remove:
|
||||
raise ValueError("Gitea did not confirm the dependency change")
|
||||
return {
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"dependencies_available": True,
|
||||
"dependencies": confirmed,
|
||||
}
|
||||
|
||||
|
||||
async def update_assigned_issue(
|
||||
repository: str,
|
||||
number: int,
|
||||
|
||||
+56
@@ -502,6 +502,15 @@ class IssueHandoff(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class IssueBlockerUpdate(BaseModel):
|
||||
repository: str = Field(
|
||||
min_length=3,
|
||||
max_length=255,
|
||||
pattern=r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$",
|
||||
)
|
||||
number: PositiveInt
|
||||
|
||||
|
||||
class PullReviewComment(BaseModel):
|
||||
path: str = Field(min_length=1, max_length=1_000)
|
||||
body: str = Field(min_length=1, max_length=10_000)
|
||||
@@ -3213,6 +3222,53 @@ async def assigned_issue_detail(owner: str, repo: str, number: int = PathParam(g
|
||||
)
|
||||
|
||||
|
||||
async def _mutate_assigned_issue_blocker(
|
||||
update: IssueBlockerUpdate,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int,
|
||||
remove: bool,
|
||||
) -> JSONResponse:
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
gitea_proxy.mutate_assigned_issue_dependency(
|
||||
f"{owner}/{repo}", number, update.repository, update.number, remove
|
||||
),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except gitea_proxy.IssueNotAvailableError:
|
||||
raise HTTPException(status_code=404, detail="Assigned issue not found")
|
||||
except gitea_proxy.IssueDependencyInvalidError as exc:
|
||||
return JSONResponse({"error": str(exc)}, status_code=422)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "The blocker change could not be confirmed. Reload the issue before retrying."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(result, headers={"Cache-Control": "no-store"})
|
||||
|
||||
|
||||
@app.post("/api/v1/repos/{owner}/{repo}/issues/{number}/blockers")
|
||||
async def add_assigned_issue_blocker(
|
||||
update: IssueBlockerUpdate,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
) -> JSONResponse:
|
||||
return await _mutate_assigned_issue_blocker(update, owner, repo, number, False)
|
||||
|
||||
|
||||
@app.delete("/api/v1/repos/{owner}/{repo}/issues/{number}/blockers")
|
||||
async def remove_assigned_issue_blocker(
|
||||
update: IssueBlockerUpdate,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
) -> JSONResponse:
|
||||
return await _mutate_assigned_issue_blocker(update, owner, repo, number, True)
|
||||
|
||||
|
||||
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/content")
|
||||
async def update_assigned_issue_content(
|
||||
update: IssueContentUpdate,
|
||||
|
||||
Reference in new issue
Block a user