feat: edit assigned issue content from mobile (#189)
CI / lint (pull_request) Successful in 15s
CI / build-frontend (pull_request) Successful in 4s

This commit is contained in:
timmy committed 2026-08-07 11:07:56 +00:00
1 parent c0eabbf865
commit 937df5d20b
7 files changed
+479 -1

No files matched your search

+48
View File
@@ -42,6 +42,10 @@ class IssueNotAvailableError(ValueError):
"""Raised before assignment when an issue is no longer open and unassigned."""
class IssueEditConflictError(ValueError):
"""Raised when an issue changed after the editor loaded it."""
def _auth() -> dict[str, str]:
headers: dict[str, str] = {"Accept": "application/json"}
if GITEA_TOKEN:
@@ -751,6 +755,7 @@ async def issue_detail(repository: str, number: int) -> dict:
"title": issue.get("title", "") if isinstance(issue.get("title"), str) else "",
"state": issue.get("state", "") if isinstance(issue.get("state"), str) else "",
"body": issue.get("body", "") if isinstance(issue.get("body"), str) else "",
"updated_at": issue.get("updated_at", "") if isinstance(issue.get("updated_at"), str) else "",
"url": _safe_web_url(issue.get("html_url")),
"labels": [
label["name"] for label in labels
@@ -764,6 +769,49 @@ async def issue_detail(repository: str, number: int) -> dict:
}
async def update_assigned_issue(
repository: str,
number: int,
title: str,
body: str,
expected_updated_at: str,
) -> dict:
path = f"repos/{repository}/issues/{number}"
login, issue = await _current_login_and_target(path)
if (
issue.get("state") != "open"
or isinstance(issue.get("pull_request"), dict)
or not _login_in_users(login, issue.get("assignees"))
):
raise IssueNotAvailableError("assigned issue not found")
if issue.get("updated_at") != expected_updated_at:
raise IssueEditConflictError("issue changed upstream")
response = await _get_client().patch(
f"/api/v1/{path}",
headers=_auth(),
json={"title": title, "body": body},
)
response.raise_for_status()
confirmed = response.json()
if (
not isinstance(confirmed, dict)
or confirmed.get("number") != number
or confirmed.get("title") != title
or confirmed.get("body", "") != body
):
raise ValueError("Gitea did not confirm the issue content update")
return {
"repository": repository,
"number": number,
"title": title,
"body": body,
"state": confirmed.get("state", "open"),
"updated_at": confirmed.get("updated_at", ""),
"url": _safe_web_url(confirmed.get("html_url")),
}
async def _current_login_and_target(path: str) -> tuple[str, dict]:
user, target = await asyncio.gather(current_user(), fetch(path))
login = user.get("login") if isinstance(user, dict) else None
+56
View File
@@ -155,6 +155,25 @@ class IssueCreation(BaseModel):
return value.strip()
class IssueContentUpdate(BaseModel):
title: str = Field(min_length=1, max_length=255)
body: str = Field(default="", max_length=10_000)
expected_updated_at: str = Field(min_length=1, max_length=64)
@field_validator("title")
@classmethod
def strip_title(cls, value: str) -> str:
value = value.strip()
if not value:
raise ValueError("title must not be blank")
return value
@field_validator("body")
@classmethod
def strip_body(cls, value: str) -> str:
return value.strip()
class IssueLabelUpdate(BaseModel):
label_ids: list[PositiveInt] = Field(max_length=20)
@@ -1070,6 +1089,43 @@ async def assigned_issue_detail(owner: str, repo: str, number: int = PathParam(g
)
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/content")
async def update_assigned_issue_content(
update: IssueContentUpdate,
owner: str,
repo: str,
number: int = PathParam(gt=0),
):
repository = f"{owner}/{repo}"
try:
result = await asyncio.wait_for(
gitea_proxy.update_assigned_issue(
repository,
number,
update.title,
update.body,
update.expected_updated_at,
),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except gitea_proxy.IssueEditConflictError:
return JSONResponse(
{"error": "This issue changed in Gitea. Your draft is safe; reload the latest issue before saving."},
status_code=409,
)
except gitea_proxy.IssueNotAvailableError:
raise HTTPException(status_code=404, detail="Assigned issue not found")
except HTTPException:
raise
except Exception:
return JSONResponse(
{"error": "The issue could not be updated. Your draft is safe; please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result)
@app.get("/api/v1/repos/{owner}/{repo}/labels")
async def repository_labels(owner: str, repo: str):
repository = f"{owner}/{repo}"