Skip to content

Commit 657bfa1

Browse files
alexsohn1126claude
andauthored
feat(reactions): Add GET/POST/DELETE PR review comment reactions for GitHub (#111)
## Summary Implements GET/POST/DELETE reactions on **pull request review comments** (inline code-review comments) for GitHub, per [CW-1590](https://linear.app/getsentry/issue/CW-1590/scm-platform-update-to-getpostdelete-pr-review-comment-reactions). Unlike PR *conversation* comment reactions (which alias the issue-comment endpoint), review comment reactions use GitHub's dedicated endpoint `/repos/{owner}/{repo}/pulls/comments/{comment_id}/reactions`, so these are real implementations. ## Changes | Layer | File | |---|---| | Protocols (+ `ALL_PROTOCOLS`) | `src/scm/types.py` | | Actions | `src/scm/actions.py` | | GitHub provider | `src/scm/providers/github/provider.py` | | Test fixtures (`BaseTestProvider`) | `src/scm/test_fixtures.py` | | CLI | `bin/github-client` | | Tests | `test_actions.py`, `test_github.py`, `test_rpc_integration.py` | New actions: `get_review_comment_reactions`, `create_review_comment_reaction`, `delete_review_comment_reaction`. New CLI commands (`bin/github-client`): - `get-review-comment-reactions <pr_id> <comment_id> [--cursor <n>] [--per-page <n>]` - `create-review-comment-reaction <pr_id> <comment_id> <reaction>` - `delete-review-comment-reaction <pr_id> <comment_id> <reaction_id>` The facade and RPC layers auto-wire from `ALL_PROTOCOLS` + per-provider capability detection, so no manual wiring was needed there. ## Notes - **GitLab** is intentionally not implemented yet — capability detection is per-provider, so GitLab simply won't expose these methods until added. - These are the first reaction commands exposed in `bin/github-client`. - Signatures include `pull_request_id` for consistency with `update_review_comment` / `create_review_comment_reply`, even though GitHub's endpoint only needs `comment_id`. ## Testing - `uv run pytest` — 800 passed - `uv run mypy .` — clean - `uv run ruff check .` — clean 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 327e0de commit 657bfa1

8 files changed

Lines changed: 236 additions & 0 deletions

File tree

bin/github-client

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ Commands:
3030
create-review-comment-line <pr_id> <commit_id> <body> <path> <side> <line>
3131
create-review-comment-multiline <pr_id> <commit_id> <body> <path> <side> <start_side> <start_line> <end_line>
3232
update-review-comment <pr_id> <comment_id> <body>
33+
get-review-comment-reactions <pr_id> <comment_id> [--cursor <n>] [--per-page <n>]
34+
create-review-comment-reaction <pr_id> <comment_id> <reaction>
35+
delete-review-comment-reaction <pr_id> <comment_id> <reaction_id>
3336
collapse-pull-request-comment <pr_id> <thread_id> <comment_node_id> [--reason OUTDATED]
3437
update-and-collapse-pull-request-comment <pr_id> <thread_id> <comment_id> <comment_node_id> <body>
3538
[--reason OUTDATED]
@@ -95,8 +98,10 @@ from scm.types import (
9598
CreatePullRequestCommentProtocol,
9699
CreateReviewCommentLineProtocol,
97100
CreateReviewCommentMultilineProtocol,
101+
CreateReviewCommentReactionProtocol,
98102
DeleteBranchProtocol,
99103
DeleteCommitAction,
104+
DeleteReviewCommentReactionProtocol,
100105
DownloadArchiveProtocol,
101106
DownloadWorkflowJobLogProtocol,
102107
GetAppInstallationProtocol,
@@ -129,6 +134,7 @@ from scm.types import (
129134
GetRepositoryProtocol,
130135
GetRepositoryTopicsProtocol,
131136
GetRepositoryUserPermissionProtocol,
137+
GetReviewCommentReactionsProtocol,
132138
GetReviewCommentsProtocol,
133139
ListCheckRunsForRefProtocol,
134140
ListCheckRunsInCheckSuiteProtocol,
@@ -157,6 +163,7 @@ BUILD_CONCLUSION_CHOICES = [
157163
"action_required",
158164
"unknown",
159165
]
166+
REACTION_CHOICES = ["+1", "-1", "laugh", "confused", "heart", "hooray", "rocket", "eyes"]
160167

161168

162169
def build_check_run_output(args: argparse.Namespace) -> CheckRunOutput | None:
@@ -300,6 +307,22 @@ def main() -> None:
300307
p.add_argument("comment_id")
301308
p.add_argument("body")
302309

310+
p = sub.add_parser("get-review-comment-reactions")
311+
p.add_argument("pr_id")
312+
p.add_argument("comment_id")
313+
p.add_argument("--cursor", type=int, default=None)
314+
p.add_argument("--per-page", type=int, default=None)
315+
316+
p = sub.add_parser("create-review-comment-reaction")
317+
p.add_argument("pr_id")
318+
p.add_argument("comment_id")
319+
p.add_argument("reaction", choices=REACTION_CHOICES)
320+
321+
p = sub.add_parser("delete-review-comment-reaction")
322+
p.add_argument("pr_id")
323+
p.add_argument("comment_id")
324+
p.add_argument("reaction_id")
325+
303326
p = sub.add_parser("collapse-pull-request-comment")
304327
p.add_argument("pr_id")
305328
p.add_argument("thread_id", help="review thread id (e.g. PRRT_...)")
@@ -551,6 +574,23 @@ def main() -> None:
551574
assert isinstance(scm, UpdateReviewCommentProtocol)
552575
dump(scm.update_review_comment(args.pr_id, args.comment_id, args.body))
553576

577+
elif args.command == "get-review-comment-reactions":
578+
assert isinstance(scm, GetReviewCommentReactionsProtocol)
579+
pagination = {}
580+
if args.cursor is not None:
581+
pagination["cursor"] = args.cursor
582+
if args.per_page is not None:
583+
pagination["per_page"] = args.per_page
584+
dump(scm.get_review_comment_reactions(args.pr_id, args.comment_id, pagination=pagination or None))
585+
586+
elif args.command == "create-review-comment-reaction":
587+
assert isinstance(scm, CreateReviewCommentReactionProtocol)
588+
dump(scm.create_review_comment_reaction(args.pr_id, args.comment_id, args.reaction))
589+
590+
elif args.command == "delete-review-comment-reaction":
591+
assert isinstance(scm, DeleteReviewCommentReactionProtocol)
592+
scm.delete_review_comment_reaction(args.pr_id, args.comment_id, args.reaction_id)
593+
554594
elif args.command == "collapse-pull-request-comment":
555595
assert isinstance(scm, CollapsePullRequestCommentProtocol)
556596
scm.collapse_pull_request_comment(args.pr_id, args.thread_id, args.comment_node_id, args.reason)

src/scm/actions.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
CreateReviewCommentFileProtocol,
4444
CreateReviewCommentLineProtocol,
4545
CreateReviewCommentMultilineProtocol,
46+
CreateReviewCommentReactionProtocol,
4647
CreateReviewCommentReplyProtocol,
4748
CreateReviewProtocol,
4849
DeleteBranchProtocol,
@@ -53,6 +54,7 @@
5354
DeletePullRequestCommentProtocol,
5455
DeletePullRequestCommentReactionProtocol,
5556
DeletePullRequestReactionProtocol,
57+
DeleteReviewCommentReactionProtocol,
5658
DownloadArchiveProtocol,
5759
DownloadWorkflowJobLogProtocol,
5860
FileContent,
@@ -94,6 +96,7 @@
9496
GetRepositoryProtocol,
9597
GetRepositoryTopicsProtocol,
9698
GetRepositoryUserPermissionProtocol,
99+
GetReviewCommentReactionsProtocol,
97100
GetReviewCommentsProtocol,
98101
GetTreeProtocol,
99102
GitBlob,
@@ -353,6 +356,37 @@ def delete_pull_request_comment_reaction(
353356
return scm.delete_pull_request_comment_reaction(pull_request_id, comment_id, reaction_id)
354357

355358

359+
def get_review_comment_reactions(
360+
scm: GetReviewCommentReactionsProtocol,
361+
pull_request_id: str,
362+
comment_id: str,
363+
pagination: PaginationParams | None = None,
364+
request_options: RequestOptions | None = None,
365+
) -> PaginatedActionResult[list[ReactionResult]]:
366+
"""Get reactions on a pull request review comment."""
367+
return scm.get_review_comment_reactions(pull_request_id, comment_id, pagination, request_options)
368+
369+
370+
def create_review_comment_reaction(
371+
scm: CreateReviewCommentReactionProtocol,
372+
pull_request_id: str,
373+
comment_id: str,
374+
reaction: Reaction,
375+
) -> ActionResult[ReactionResult]:
376+
"""Create a reaction on a pull request review comment."""
377+
return scm.create_review_comment_reaction(pull_request_id, comment_id, reaction)
378+
379+
380+
def delete_review_comment_reaction(
381+
scm: DeleteReviewCommentReactionProtocol,
382+
pull_request_id: str,
383+
comment_id: str,
384+
reaction_id: str,
385+
) -> None:
386+
"""Delete a reaction on a pull request review comment."""
387+
return scm.delete_review_comment_reaction(pull_request_id, comment_id, reaction_id)
388+
389+
356390
def get_issue_reactions(
357391
scm: GetIssueReactionsProtocol,
358392
issue_id: str,

src/scm/providers/github/provider.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -783,6 +783,32 @@ def create_pull_request_comment_reaction(
783783
def delete_pull_request_comment_reaction(self, pull_request_id: str, comment_id: str, reaction_id: str) -> None:
784784
return self.delete_issue_comment_reaction(pull_request_id, comment_id, reaction_id)
785785

786+
def get_review_comment_reactions(
787+
self,
788+
pull_request_id: str,
789+
comment_id: str,
790+
pagination: PaginationParams | None = None,
791+
request_options: RequestOptions | None = None,
792+
) -> PaginatedActionResult[list[ReactionResult]]:
793+
response = self.get(
794+
f"/repos/{self.repository['name']}/pulls/comments/{comment_id}/reactions",
795+
pagination=pagination,
796+
request_options=request_options,
797+
)
798+
return map_paginated_action(pagination, response, lambda r: [map_reaction(c) for c in r])
799+
800+
def create_review_comment_reaction(
801+
self, pull_request_id: str, comment_id: str, reaction: Reaction
802+
) -> ActionResult[ReactionResult]:
803+
response = self.post(
804+
f"/repos/{self.repository['name']}/pulls/comments/{comment_id}/reactions",
805+
data={"content": reaction},
806+
)
807+
return map_action(response, map_reaction)
808+
809+
def delete_review_comment_reaction(self, pull_request_id: str, comment_id: str, reaction_id: str) -> None:
810+
self.delete(f"/repos/{self.repository['name']}/pulls/comments/{comment_id}/reactions/{reaction_id}")
811+
786812
def get_issue_reactions(
787813
self,
788814
issue_id: str,

src/scm/test_fixtures.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -945,6 +945,38 @@ def create_pull_request_comment_reaction(
945945
def delete_pull_request_comment_reaction(self, pull_request_id: str, comment_id: str, reaction_id: str) -> None:
946946
return None
947947

948+
# Review comment reactions
949+
950+
def get_review_comment_reactions(
951+
self,
952+
pull_request_id: str,
953+
comment_id: str,
954+
pagination: PaginationParams | None = None,
955+
request_options: RequestOptions | None = None,
956+
) -> PaginatedActionResult[list[ReactionResult]]:
957+
return PaginatedActionResult(
958+
data=[
959+
ReactionResult(id="3", content="rocket", author={"id": "1", "username": "testuser"}),
960+
ReactionResult(id="4", content="hooray", author={"id": "2", "username": "otheruser"}),
961+
],
962+
type="github",
963+
raw={"headers": None, "data": None},
964+
meta=_DEFAULT_PAGINATED_META,
965+
)
966+
967+
def create_review_comment_reaction(
968+
self, pull_request_id: str, comment_id: str, reaction: Reaction
969+
) -> ActionResult[ReactionResult]:
970+
return ActionResult(
971+
data=ReactionResult(id="1", content=reaction, author=None),
972+
type="github",
973+
raw={"headers": None, "data": None},
974+
meta={},
975+
)
976+
977+
def delete_review_comment_reaction(self, pull_request_id: str, comment_id: str, reaction_id: str) -> None:
978+
return None
979+
948980
# Issue reactions
949981

950982
def get_issue_reactions(

src/scm/types.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -861,6 +861,32 @@ class DeletePullRequestCommentReactionProtocol(Protocol):
861861
def delete_pull_request_comment_reaction(self, pull_request_id: str, comment_id: str, reaction_id: str) -> None: ...
862862

863863

864+
# Review Comment Reaction Protocols
865+
866+
867+
@runtime_checkable
868+
class GetReviewCommentReactionsProtocol(Protocol):
869+
def get_review_comment_reactions(
870+
self,
871+
pull_request_id: str,
872+
comment_id: str,
873+
pagination: PaginationParams | None = None,
874+
request_options: RequestOptions | None = None,
875+
) -> PaginatedActionResult[list[ReactionResult]]: ...
876+
877+
878+
@runtime_checkable
879+
class CreateReviewCommentReactionProtocol(Protocol):
880+
def create_review_comment_reaction(
881+
self, pull_request_id: str, comment_id: str, reaction: Reaction
882+
) -> ActionResult[ReactionResult]: ...
883+
884+
885+
@runtime_checkable
886+
class DeleteReviewCommentReactionProtocol(Protocol):
887+
def delete_review_comment_reaction(self, pull_request_id: str, comment_id: str, reaction_id: str) -> None: ...
888+
889+
864890
# Issue Reaction Protocols
865891

866892

@@ -1519,6 +1545,7 @@ def update_and_collapse_pull_request_comment(
15191545
CreateReviewCommentFileProtocol,
15201546
CreateReviewCommentLineProtocol,
15211547
CreateReviewCommentMultilineProtocol,
1548+
CreateReviewCommentReactionProtocol,
15221549
CreateReviewCommentReplyProtocol,
15231550
CreateReviewProtocol,
15241551
DeleteBranchProtocol,
@@ -1528,6 +1555,7 @@ def update_and_collapse_pull_request_comment(
15281555
DeletePullRequestCommentProtocol,
15291556
DeletePullRequestCommentReactionProtocol,
15301557
DeletePullRequestReactionProtocol,
1558+
DeleteReviewCommentReactionProtocol,
15311559
DownloadArchiveProtocol,
15321560
GetAppInstallationProtocol,
15331561
GetAuthenticatedActorProtocol,
@@ -1557,6 +1585,7 @@ def update_and_collapse_pull_request_comment(
15571585
GetPullRequestProtocol,
15581586
GetPullRequestReactionsProtocol,
15591587
GetPullRequestReviewThreadsProtocol,
1588+
GetReviewCommentReactionsProtocol,
15601589
GetReviewCommentsProtocol,
15611590
GetPullRequestsProtocol,
15621591
GetPullRequestTemplateProtocol,

tests/unit/provider/test_github.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -612,6 +612,16 @@ def expected_workflow_job(raw: dict[str, Any]) -> dict[str, Any]:
612612
"expected_data": [expected_reaction(REACTION_RAW)],
613613
"next_cursor": "2",
614614
},
615+
{
616+
"name": "get_review_comment_reactions",
617+
"kwargs": {"pull_request_id": "42", "comment_id": "99"},
618+
"path": "/repos/test-org/test-repo/pulls/comments/99/reactions",
619+
"params": None,
620+
"pagination": None,
621+
"raw": [REACTION_RAW],
622+
"expected_data": [expected_reaction(REACTION_RAW)],
623+
"next_cursor": "2",
624+
},
615625
{
616626
"name": "get_commits",
617627
"kwargs": {"ref": "main", "pagination": {"cursor": "3", "per_page": 10}},
@@ -960,6 +970,15 @@ def expected_workflow_job(raw: dict[str, Any]) -> dict[str, Any]:
960970
"raw": REACTION_RAW,
961971
"expected_data": expected_reaction(REACTION_RAW),
962972
},
973+
{
974+
"name": "create_review_comment_reaction",
975+
"operation": "post",
976+
"kwargs": {"pull_request_id": "42", "comment_id": "99", "reaction": "heart"},
977+
"path": "/repos/test-org/test-repo/pulls/comments/99/reactions",
978+
"data": {"content": "heart"},
979+
"raw": REACTION_RAW,
980+
"expected_data": expected_reaction(REACTION_RAW),
981+
},
963982
{
964983
"name": "get_branch",
965984
"operation": "get",
@@ -1319,6 +1338,12 @@ def expected_workflow_job(raw: dict[str, Any]) -> dict[str, Any]:
13191338
"kwargs": {"issue_id": "42", "reaction_id": "5"},
13201339
"path": "/repos/test-org/test-repo/issues/42/reactions/5",
13211340
},
1341+
{
1342+
"name": "delete_review_comment_reaction",
1343+
"operation": "delete",
1344+
"kwargs": {"pull_request_id": "42", "comment_id": "99", "reaction_id": "5"},
1345+
"path": "/repos/test-org/test-repo/pulls/comments/99/reactions/5",
1346+
},
13221347
{
13231348
"name": "request_review",
13241349
"operation": "post",

tests/unit/test_actions.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
create_review,
2626
create_review_comment_file,
2727
create_review_comment_line,
28+
create_review_comment_reaction,
2829
create_review_comment_reply,
2930
delete_branch,
3031
delete_issue_comment,
@@ -33,6 +34,7 @@
3334
delete_pull_request_comment,
3435
delete_pull_request_comment_reaction,
3536
delete_pull_request_reaction,
37+
delete_review_comment_reaction,
3638
download_archive,
3739
get_authenticated_actor,
3840
get_branch,
@@ -68,6 +70,7 @@
6870
get_repository_assignees,
6971
get_repository_labels,
7072
get_repository_topics,
73+
get_review_comment_reactions,
7174
get_review_comments,
7275
get_thread_id_from_review_comment_unique_id,
7376
get_tree,
@@ -138,6 +141,16 @@ def fetch_repository(oid, rid) -> Repository:
138141
delete_pull_request_comment_reaction,
139142
{"pull_request_id": "1", "comment_id": "1", "reaction_id": "123"},
140143
),
144+
# Review comment reactions
145+
(get_review_comment_reactions, {"pull_request_id": "1", "comment_id": "1"}),
146+
(
147+
create_review_comment_reaction,
148+
{"pull_request_id": "1", "comment_id": "1", "reaction": "eyes"},
149+
),
150+
(
151+
delete_review_comment_reaction,
152+
{"pull_request_id": "1", "comment_id": "1", "reaction_id": "123"},
153+
),
141154
# Issue reactions
142155
(get_issue_reactions, {"issue_id": "1"}),
143156
(create_issue_reaction, {"issue_id": "1", "reaction": "eyes"}),
@@ -749,6 +762,21 @@ def _check_download_archive(result: Any) -> None:
749762
{"pull_request_id": "1", "comment_id": "1", "reaction_id": "123"},
750763
_check_none,
751764
),
765+
(
766+
get_review_comment_reactions,
767+
{"pull_request_id": "1", "comment_id": "1"},
768+
_check_pr_comment_reactions,
769+
),
770+
(
771+
create_review_comment_reaction,
772+
{"pull_request_id": "1", "comment_id": "1", "reaction": "eyes"},
773+
_check_created_reaction,
774+
),
775+
(
776+
delete_review_comment_reaction,
777+
{"pull_request_id": "1", "comment_id": "1", "reaction_id": "123"},
778+
_check_none,
779+
),
752780
(get_issue_reactions, {"issue_id": "1"}, _check_issue_reactions),
753781
(
754782
create_issue_reaction,

0 commit comments

Comments
 (0)