feat: react to mobile conversation comments (Closes #1380)
This commit is contained in:
1 parent
fc7a83fbd7
commit
b4f3c6d419
6 files changed
+708
-2
No files matched your search
@@ -1381,6 +1381,86 @@ def _normalize_issue_comment(comment: dict) -> dict:
|
||||
}
|
||||
|
||||
|
||||
COMMENT_REACTIONS = ("+1", "-1", "laugh", "hooray", "confused", "heart", "rocket", "eyes")
|
||||
|
||||
|
||||
async def _conversation_comment(repository: str, number: int, comment_id: int) -> dict:
|
||||
comment = await fetch(f"repos/{repository}/issues/comments/{comment_id}")
|
||||
expected_issue_path = f"repos/{repository}/issues/{number}"
|
||||
issue_url = comment.get("issue_url") if isinstance(comment, dict) else None
|
||||
safe_issue_url = _safe_gitea_web_url(issue_url)
|
||||
web_path = urlsplit(safe_issue_url).path.rstrip("/") if safe_issue_url else ""
|
||||
if (
|
||||
not isinstance(comment, dict)
|
||||
or (
|
||||
_gitea_api_path(issue_url) != expected_issue_path
|
||||
and not web_path.endswith(f"/{repository}/issues/{number}")
|
||||
)
|
||||
):
|
||||
raise CommentMutationForbiddenError("Comment is not part of this conversation")
|
||||
return comment
|
||||
|
||||
|
||||
async def comment_reactions(repository: str, number: int, comment_id: int) -> dict:
|
||||
user = await current_user()
|
||||
login = user.get("login") if isinstance(user, dict) else None
|
||||
if not isinstance(login, str) or not login:
|
||||
raise ValueError("Authenticated Gitea user is unavailable")
|
||||
await _conversation_comment(repository, number, comment_id)
|
||||
path = f"/api/v1/repos/{repository}/issues/comments/{comment_id}/reactions"
|
||||
response = await _get_client().get(path, headers=_auth())
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
counts: dict[str, int] = {}
|
||||
selected: set[str] = set()
|
||||
for reaction in payload if isinstance(payload, list) else []:
|
||||
content = reaction.get("content") if isinstance(reaction, dict) else None
|
||||
author = reaction.get("user") if isinstance(reaction, dict) else None
|
||||
author_login = author.get("login") if isinstance(author, dict) else None
|
||||
if content not in COMMENT_REACTIONS or not isinstance(author_login, str) or not author_login:
|
||||
continue
|
||||
counts[content] = counts.get(content, 0) + 1
|
||||
if author_login == login:
|
||||
selected.add(content)
|
||||
return {
|
||||
"comment_id": comment_id,
|
||||
"reactions": [
|
||||
{"content": content, "count": counts[content], "selected": content in selected}
|
||||
for content in COMMENT_REACTIONS
|
||||
if counts.get(content)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def set_comment_reaction(
|
||||
repository: str, number: int, comment_id: int, content: str, active: bool
|
||||
) -> dict:
|
||||
if content not in COMMENT_REACTIONS:
|
||||
raise ValueError("Unsupported comment reaction")
|
||||
before = await comment_reactions(repository, number, comment_id)
|
||||
selected = any(
|
||||
item["content"] == content and item["selected"]
|
||||
for item in before["reactions"]
|
||||
)
|
||||
path = f"/api/v1/repos/{repository}/issues/comments/{comment_id}/reactions"
|
||||
if selected != active:
|
||||
client = _get_client()
|
||||
response = (
|
||||
await client.post(path, headers=_auth(), json={"content": content})
|
||||
if active
|
||||
else await client.delete(path, headers=_auth(), json={"content": content})
|
||||
)
|
||||
response.raise_for_status()
|
||||
confirmed = await comment_reactions(repository, number, comment_id)
|
||||
confirmed_selected = any(
|
||||
item["content"] == content and item["selected"]
|
||||
for item in confirmed["reactions"]
|
||||
)
|
||||
if confirmed_selected != active:
|
||||
raise ValueError("Gitea did not confirm the requested reaction state")
|
||||
return confirmed
|
||||
|
||||
|
||||
async def comment_on_issue(repository: str, number: int, body: str) -> dict:
|
||||
response = await _get_client().post(
|
||||
f"/api/v1/repos/{repository}/issues/{number}/comments",
|
||||
|
||||
+142
@@ -987,6 +987,10 @@ class IssueComment(BaseModel):
|
||||
return value
|
||||
|
||||
|
||||
class CommentReactionState(BaseModel):
|
||||
active: bool
|
||||
|
||||
|
||||
def _validate_attachment_metadata(filename: str, content_type: str) -> None:
|
||||
expected = {
|
||||
"image/png": {"png"},
|
||||
@@ -6441,6 +6445,144 @@ async def delete_notification_comment(
|
||||
)
|
||||
|
||||
|
||||
async def _read_conversation_reactions(
|
||||
repository: str, number: int, comment_id: int
|
||||
) -> JSONResponse:
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
gitea_proxy.comment_reactions(repository, number, comment_id),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except gitea_proxy.CommentMutationForbiddenError as exc:
|
||||
raise HTTPException(status_code=403, detail="Comment is not part of this conversation") from exc
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "Reactions could not be loaded. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
async def _set_conversation_reaction(
|
||||
repository: str,
|
||||
number: int,
|
||||
comment_id: int,
|
||||
content: str,
|
||||
active: bool,
|
||||
) -> JSONResponse:
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
gitea_proxy.set_comment_reaction(
|
||||
repository, number, comment_id, content, active
|
||||
),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except gitea_proxy.CommentMutationForbiddenError as exc:
|
||||
raise HTTPException(status_code=403, detail="Comment is not part of this conversation") from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "The reaction could not be confirmed. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/comments/{comment_id}/reactions")
|
||||
async def assigned_issue_comment_reactions(
|
||||
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 _read_conversation_reactions(repository, number, comment_id)
|
||||
|
||||
|
||||
@app.put("/api/v1/repos/{owner}/{repo}/issues/{number}/comments/{comment_id}/reactions/{content}")
|
||||
async def set_assigned_issue_comment_reaction(
|
||||
state: CommentReactionState,
|
||||
owner: str,
|
||||
repo: str,
|
||||
content: 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 _set_conversation_reaction(
|
||||
repository, number, comment_id, content, state.active
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/comments/{comment_id}/reactions")
|
||||
async def assigned_pull_comment_reactions(
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
comment_id: int = PathParam(gt=0),
|
||||
):
|
||||
repository = f"{owner}/{repo}"
|
||||
if not _has_pull_workspace_access(
|
||||
await _pull_workspace_capabilities(repository, number)
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="Pull request not found")
|
||||
return await _read_conversation_reactions(repository, number, comment_id)
|
||||
|
||||
|
||||
@app.put("/api/v1/repos/{owner}/{repo}/pulls/{number}/comments/{comment_id}/reactions/{content}")
|
||||
async def set_assigned_pull_comment_reaction(
|
||||
state: CommentReactionState,
|
||||
owner: str,
|
||||
repo: str,
|
||||
content: str,
|
||||
number: int = PathParam(gt=0),
|
||||
comment_id: int = PathParam(gt=0),
|
||||
):
|
||||
repository = f"{owner}/{repo}"
|
||||
if not _has_pull_workspace_access(
|
||||
await _pull_workspace_capabilities(repository, number)
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="Pull request not found")
|
||||
return await _set_conversation_reaction(
|
||||
repository, number, comment_id, content, state.active
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/notifications/{thread_id}/comments/{comment_id}/reactions")
|
||||
async def notification_comment_reactions(
|
||||
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 _read_conversation_reactions(repository, number, comment_id)
|
||||
|
||||
|
||||
@app.put("/api/v1/notifications/{thread_id}/comments/{comment_id}/reactions/{content}")
|
||||
async def set_notification_comment_reaction(
|
||||
state: CommentReactionState,
|
||||
content: str,
|
||||
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 _set_conversation_reaction(
|
||||
repository, number, comment_id, content, state.active
|
||||
)
|
||||
|
||||
|
||||
@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