feat: react to mobile conversation comments (Closes #1380)
CI / lint (pull_request) Successful in 3m46s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 6m29s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-25 05:09:32 +00:00
1 parent fc7a83fbd7
commit b4f3c6d419
6 files changed
+708 -2

No files matched your search

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