feat: manage issue blockers in app (Closes #531)
CI / lint (pull_request) Successful in 1m14s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-11 02:02:23 +00:00
1 parent 29ceedfc01
commit 2c8a1225e0
8 files changed
+484 -1

No files matched your search

+65
View File
@@ -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
View File
@@ -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,