feat: edit and delete owned comments (Closes #575)
This commit is contained in:
1 parent
43f5833f7f
commit
8e357a097b
14 files changed
+634
-2
No files matched your search
@@ -17,6 +17,7 @@ COMMONJS_EXPORT_LINE = re.compile(
|
||||
)
|
||||
WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
|
||||
FEATURE_SOURCES = {
|
||||
"comment-actions": ("static/conversation.js", "static/comment-actions.js"),
|
||||
"issue-capture": ("static/create-issue-sheet.js",),
|
||||
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"),
|
||||
"push-notifications": ("static/push-notifications.js",),
|
||||
|
||||
@@ -20,6 +20,10 @@ class GiteaOverloadedError(RuntimeError):
|
||||
"""Raised when bounded transport admission expires before capacity is available."""
|
||||
|
||||
|
||||
class CommentMutationForbiddenError(RuntimeError):
|
||||
"""Raised when a comment is not owned by the operator or enclosing thread."""
|
||||
|
||||
|
||||
class GiteaTransport:
|
||||
"""Application-lifetime HTTP transport with single-flight concurrent reads."""
|
||||
|
||||
@@ -963,6 +967,25 @@ async def notification_conversation_page(
|
||||
)
|
||||
|
||||
|
||||
async def notification_conversation_target(thread_id: int) -> tuple[str, int]:
|
||||
thread = await fetch(f"notifications/threads/{thread_id}")
|
||||
if not isinstance(thread, dict):
|
||||
raise ValueError("Gitea notification thread response was not an object")
|
||||
repository = thread.get("repository")
|
||||
subject_value = thread.get("subject")
|
||||
subject = subject_value if isinstance(subject_value, dict) else {}
|
||||
repository_name = repository.get("full_name") if isinstance(repository, dict) else None
|
||||
subject_path = _gitea_api_path(subject.get("url"))
|
||||
match = re.fullmatch(r"repos/([^/]+/[^/]+)/(issues|pulls)/(\d+)", subject_path)
|
||||
if (
|
||||
not match
|
||||
or match.group(1) != repository_name
|
||||
or subject.get("type") not in {"Issue", "Pull"}
|
||||
):
|
||||
raise ValueError("Notification subject is not a supported conversation")
|
||||
return match.group(1), int(match.group(3))
|
||||
|
||||
|
||||
async def reply_to_notification(thread_id: int, body: str) -> dict:
|
||||
thread = await fetch(f"notifications/threads/{thread_id}")
|
||||
if not isinstance(thread, dict):
|
||||
@@ -1077,6 +1100,51 @@ async def comment_on_issue(repository: str, number: int, body: str) -> dict:
|
||||
return _normalize_issue_comment(comment)
|
||||
|
||||
|
||||
async def _owned_comment(repository: str, number: int, comment_id: int) -> dict:
|
||||
user, comment = await asyncio.gather(
|
||||
current_user(),
|
||||
fetch(f"repos/{repository}/issues/comments/{comment_id}"),
|
||||
)
|
||||
login = user.get("login") if isinstance(user, dict) else None
|
||||
author = comment.get("user") if isinstance(comment, dict) else None
|
||||
expected_issue_path = f"repos/{repository}/issues/{number}"
|
||||
if (
|
||||
not isinstance(login, str)
|
||||
or not login
|
||||
or not isinstance(comment, dict)
|
||||
or not isinstance(author, dict)
|
||||
or author.get("login") != login
|
||||
or _gitea_api_path(comment.get("issue_url")) != expected_issue_path
|
||||
):
|
||||
raise CommentMutationForbiddenError("Comment is not editable in this conversation")
|
||||
return comment
|
||||
|
||||
|
||||
async def edit_owned_comment(
|
||||
repository: str, number: int, comment_id: int, body: str
|
||||
) -> dict:
|
||||
await _owned_comment(repository, number, comment_id)
|
||||
response = await _get_client().patch(
|
||||
f"/api/v1/repos/{repository}/issues/comments/{comment_id}",
|
||||
headers=_auth(),
|
||||
json={"body": body},
|
||||
)
|
||||
response.raise_for_status()
|
||||
comment = response.json()
|
||||
if not isinstance(comment, dict):
|
||||
raise ValueError("Gitea comment response was not an object")
|
||||
return _normalize_issue_comment(comment)
|
||||
|
||||
|
||||
async def delete_owned_comment(repository: str, number: int, comment_id: int) -> dict:
|
||||
await _owned_comment(repository, number, comment_id)
|
||||
response = await _get_client().delete(
|
||||
f"/api/v1/repos/{repository}/issues/comments/{comment_id}", headers=_auth()
|
||||
)
|
||||
response.raise_for_status()
|
||||
return {"id": comment_id, "deleted": True}
|
||||
|
||||
|
||||
async def upload_assigned_issue_attachment(
|
||||
repository: str,
|
||||
number: int,
|
||||
|
||||
+125
@@ -3918,6 +3918,131 @@ async def comment_on_assigned_issue(
|
||||
return JSONResponse(result, status_code=201)
|
||||
|
||||
|
||||
async def _edit_conversation_comment(
|
||||
repository: str, number: int, comment_id: int, body: str
|
||||
) -> JSONResponse:
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
gitea_proxy.edit_owned_comment(repository, number, comment_id, body),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except gitea_proxy.CommentMutationForbiddenError as exc:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="You can only change your own comments"
|
||||
) from exc
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "The comment could not be updated. Your edit is safe; please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
async def _delete_conversation_comment(
|
||||
repository: str, number: int, comment_id: int
|
||||
) -> JSONResponse:
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
gitea_proxy.delete_owned_comment(repository, number, comment_id),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except gitea_proxy.CommentMutationForbiddenError as exc:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="You can only change your own comments"
|
||||
) from exc
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "The comment deletion could not be confirmed. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/comments/{comment_id}")
|
||||
async def edit_assigned_issue_comment(
|
||||
comment: IssueComment,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
comment_id: int = PathParam(gt=0),
|
||||
):
|
||||
repository = f"{owner}/{repo}"
|
||||
if not await gitea_proxy.is_assigned_issue(repository, number):
|
||||
raise HTTPException(status_code=404, detail="Assigned issue not found")
|
||||
return await _edit_conversation_comment(repository, number, comment_id, comment.body)
|
||||
|
||||
|
||||
@app.delete("/api/v1/repos/{owner}/{repo}/issues/{number}/comments/{comment_id}")
|
||||
async def delete_assigned_issue_comment(
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
comment_id: int = PathParam(gt=0),
|
||||
):
|
||||
repository = f"{owner}/{repo}"
|
||||
if not await gitea_proxy.is_assigned_issue(repository, number):
|
||||
raise HTTPException(status_code=404, detail="Assigned issue not found")
|
||||
return await _delete_conversation_comment(repository, number, comment_id)
|
||||
|
||||
|
||||
@app.patch("/api/v1/repos/{owner}/{repo}/pulls/{number}/comments/{comment_id}")
|
||||
async def edit_assigned_pull_comment(
|
||||
comment: IssueComment,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
comment_id: int = PathParam(gt=0),
|
||||
):
|
||||
repository = f"{owner}/{repo}"
|
||||
if not await gitea_proxy.is_assigned_pull(repository, number):
|
||||
raise HTTPException(status_code=404, detail="Assigned pull request not found")
|
||||
return await _edit_conversation_comment(repository, number, comment_id, comment.body)
|
||||
|
||||
|
||||
@app.delete("/api/v1/repos/{owner}/{repo}/pulls/{number}/comments/{comment_id}")
|
||||
async def delete_assigned_pull_comment(
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
comment_id: int = PathParam(gt=0),
|
||||
):
|
||||
repository = f"{owner}/{repo}"
|
||||
if not await gitea_proxy.is_assigned_pull(repository, number):
|
||||
raise HTTPException(status_code=404, detail="Assigned pull request not found")
|
||||
return await _delete_conversation_comment(repository, number, comment_id)
|
||||
|
||||
|
||||
@app.patch("/api/v1/notifications/{thread_id}/comments/{comment_id}")
|
||||
async def edit_notification_comment(
|
||||
comment: IssueComment,
|
||||
thread_id: int = PathParam(gt=0),
|
||||
comment_id: int = PathParam(gt=0),
|
||||
):
|
||||
try:
|
||||
repository, number = await gitea_proxy.notification_conversation_target(thread_id)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=404, detail="Notification conversation not found") from exc
|
||||
return await _edit_conversation_comment(repository, number, comment_id, comment.body)
|
||||
|
||||
|
||||
@app.delete("/api/v1/notifications/{thread_id}/comments/{comment_id}")
|
||||
async def delete_notification_comment(
|
||||
thread_id: int = PathParam(gt=0),
|
||||
comment_id: int = PathParam(gt=0),
|
||||
):
|
||||
try:
|
||||
repository, number = await gitea_proxy.notification_conversation_target(thread_id)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=404, detail="Notification conversation not found") from exc
|
||||
return await _delete_conversation_comment(repository, number, comment_id)
|
||||
|
||||
|
||||
@app.post("/api/v1/repos/{owner}/{repo}/issues/{number}/attachments", status_code=201)
|
||||
async def attach_to_assigned_issue(
|
||||
request: Request,
|
||||
|
||||
Reference in new issue
Block a user