From fb293ea096e063bbaf5dede6b27f555f2fa5a064 Mon Sep 17 00:00:00 2001 From: Colton Allen Date: Thu, 2 Apr 2026 11:53:02 -0500 Subject: [PATCH 1/7] Add multiline review comment action --- src/sentry/scm/actions.py | 17 +++++++++++++++ src/sentry/scm/private/providers/github.py | 25 +++++++++++++++++++++- src/sentry/scm/types.py | 3 +-- 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/src/sentry/scm/actions.py b/src/sentry/scm/actions.py index c4d6c0041a79bc..c4f96489f75439 100644 --- a/src/sentry/scm/actions.py +++ b/src/sentry/scm/actions.py @@ -42,6 +42,7 @@ CreatePullRequestProtocol, CreatePullRequestReactionProtocol, CreateReviewCommentFileProtocol, + CreateReviewCommentMultilineProtocol, CreateReviewCommentReplyProtocol, CreateReviewProtocol, DeleteIssueCommentProtocol, @@ -552,6 +553,22 @@ def create_review( return scm.create_review(pull_request_id, commit_sha, event, comments, body=body) +def create_review_comment_multiline( + scm: CreateReviewCommentMultilineProtocol, + pull_request_id: str, + commit_id: SHA, + body: str, + path: str, + side: ReviewSide, + start_line: int, + end_line: int, +) -> ActionResult[ReviewComment]: + """Leave a review comment on a line span.""" + return scm.create_review_comment_multiline( + pull_request_id, commit_id, body, path, side, start_line, end_line + ) + + def create_check_run( scm: CreateCheckRunProtocol, name: str, diff --git a/src/sentry/scm/private/providers/github.py b/src/sentry/scm/private/providers/github.py index 9d0f12372ce26e..346a0077e15204 100644 --- a/src/sentry/scm/private/providers/github.py +++ b/src/sentry/scm/private/providers/github.py @@ -812,8 +812,31 @@ def create_review_comment_file( ) return map_action(response, map_review_comment) + def create_review_comment_multiline( + self, + pull_request_id: str, + commit_id: SHA, + body: str, + path: str, + side: ReviewSide, + start_line: int, + end_line: int, + ) -> ActionResult[ReviewComment]: + """Leave a review comment on a line span.""" + response = self.client.post( + f"/repos/{self.repository['name']}/pulls/{pull_request_id}/comments", + data={ + "body": body, + "commit_id": commit_id, + "path": path, + "line": end_line, + "side": side, + "start_line": start_line, + }, + ) + return map_action(response, map_review_comment) + # create_review_comment_line: not supported - # create_review_comment_multiline: not supported def create_review_comment_reply( self, diff --git a/src/sentry/scm/types.py b/src/sentry/scm/types.py index 3cf9c60df6640d..a5d59a39051405 100644 --- a/src/sentry/scm/types.py +++ b/src/sentry/scm/types.py @@ -1043,10 +1043,9 @@ def create_review_comment_multiline( commit_id: SHA, body: str, path: str, + side: ReviewSide, start_line: int, - start_side: ReviewSide, end_line: int, - end_side: ReviewSide, ) -> ActionResult[ReviewComment]: ... From f295d928e772dcf9710cdada37542d93616c8228 Mon Sep 17 00:00:00 2001 From: Colton Allen Date: Thu, 2 Apr 2026 12:21:50 -0500 Subject: [PATCH 2/7] Add multiline review comment action --- tests/sentry/scm/test_fixtures.py | 23 ++++++++++++++++ tests/sentry/scm/unit/test_github_provider.py | 24 +++++++++++++++++ tests/sentry/scm/unit/test_scm_actions.py | 27 +++++++++++++++++++ 3 files changed, 74 insertions(+) diff --git a/tests/sentry/scm/test_fixtures.py b/tests/sentry/scm/test_fixtures.py index 4054aec3ce9b0a..9df9da6ab38a48 100644 --- a/tests/sentry/scm/test_fixtures.py +++ b/tests/sentry/scm/test_fixtures.py @@ -1097,6 +1097,29 @@ def create_review_comment_file( meta={}, ) + def create_review_comment_multiline( + self, + pull_request_id: str, + commit_id: str, + body: str, + path: str, + side: ReviewSide, + start_line: int, + end_line: int, + ) -> ActionResult[ReviewComment]: + raw = make_github_review_comment(body=body, path=path) + return ActionResult( + data=ReviewComment( + id=str(raw["id"]), + html_url=raw["html_url"], + path=raw["path"], + body=raw["body"], + ), + type="github", + raw={"headers": None, "data": raw}, + meta={}, + ) + def create_review_comment_reply( self, pull_request_id: str, diff --git a/tests/sentry/scm/unit/test_github_provider.py b/tests/sentry/scm/unit/test_github_provider.py index 2f18c974c8e34e..47de2f3acfe62d 100644 --- a/tests/sentry/scm/unit/test_github_provider.py +++ b/tests/sentry/scm/unit/test_github_provider.py @@ -631,6 +631,30 @@ def expected_check_run(raw: dict[str, Any]) -> dict[str, Any]: "raw": REVIEW_COMMENT_RAW, "expected_data": expected_review_comment(REVIEW_COMMENT_RAW), }, + { + "name": "create_review_comment_multiline", + "operation": "post", + "kwargs": { + "pull_request_id": "42", + "commit_id": "abc123", + "body": "Looks good", + "path": "src/main.py", + "side": "RIGHT", + "start_line": 1, + "end_line": 5, + }, + "path": "/repos/test-org/test-repo/pulls/42/comments", + "data": { + "body": "Looks good", + "commit_id": "abc123", + "path": "src/main.py", + "line": 5, + "side": "RIGHT", + "start_line": 1, + }, + "raw": REVIEW_COMMENT_RAW, + "expected_data": expected_review_comment(REVIEW_COMMENT_RAW), + }, { "name": "create_review_comment_reply", "operation": "post", diff --git a/tests/sentry/scm/unit/test_scm_actions.py b/tests/sentry/scm/unit/test_scm_actions.py index 3998a3dfaa843b..c91d94d4910b4b 100644 --- a/tests/sentry/scm/unit/test_scm_actions.py +++ b/tests/sentry/scm/unit/test_scm_actions.py @@ -22,6 +22,7 @@ create_pull_request_reaction, create_review, create_review_comment_file, + create_review_comment_multiline, create_review_comment_reply, delete_issue_comment, delete_issue_comment_reaction, @@ -159,6 +160,18 @@ def fetch_repository(oid, rid) -> Repository: "side": "RIGHT", }, ), + ( + create_review_comment_multiline, + { + "pull_request_id": "1", + "commit_id": "abc", + "body": "comment", + "path": "f.py", + "side": "RIGHT", + "start_line": 1, + "end_line": 5, + }, + ), ( create_review_comment_reply, { @@ -619,6 +632,19 @@ def _check_update_check_run(result: Any) -> None: }, _check_review_comment, ), + ( + create_review_comment_multiline, + { + "pull_request_id": "1", + "commit_id": "abc", + "body": "comment", + "path": "f.py", + "side": "RIGHT", + "start_line": 1, + "end_line": 5, + }, + _check_review_comment, + ), ( create_review_comment_reply, { @@ -812,6 +838,7 @@ def test_get_capabilities() -> None: "CreatePullRequestProtocol", "CreatePullRequestReactionProtocol", "CreateReviewCommentFileProtocol", + "CreateReviewCommentMultilineProtocol", "CreateReviewCommentReplyProtocol", "CreateReviewProtocol", "DeleteIssueCommentProtocol", From 3a9ed4ce1d184da7c5358db7357b5e125422aa79 Mon Sep 17 00:00:00 2001 From: Colton Allen Date: Thu, 2 Apr 2026 13:25:37 -0500 Subject: [PATCH 3/7] Add multiline review comment type --- src/sentry/scm/actions.py | 3 +- src/sentry/scm/private/providers/github.py | 24 ++++++- src/sentry/scm/private/webhooks/github.py | 68 +++---------------- src/sentry/scm/types.py | 19 +++++- tests/sentry/scm/test_fixtures.py | 48 ++++++++++++- tests/sentry/scm/unit/test_github_provider.py | 25 ++++++- tests/sentry/scm/unit/test_scm_actions.py | 16 ++++- 7 files changed, 135 insertions(+), 68 deletions(-) diff --git a/src/sentry/scm/actions.py b/src/sentry/scm/actions.py index c4f96489f75439..cb62794d8c99f8 100644 --- a/src/sentry/scm/actions.py +++ b/src/sentry/scm/actions.py @@ -78,6 +78,7 @@ GitTree, InputTreeEntry, MinimizeCommentProtocol, + MultilineReviewComment, PaginatedActionResult, PaginationParams, Provider, @@ -562,7 +563,7 @@ def create_review_comment_multiline( side: ReviewSide, start_line: int, end_line: int, -) -> ActionResult[ReviewComment]: +) -> ActionResult[MultilineReviewComment]: """Leave a review comment on a line span.""" return scm.create_review_comment_multiline( pull_request_id, commit_id, body, path, side, start_line, end_line diff --git a/src/sentry/scm/private/providers/github.py b/src/sentry/scm/private/providers/github.py index 346a0077e15204..03cb75007efb58 100644 --- a/src/sentry/scm/private/providers/github.py +++ b/src/sentry/scm/private/providers/github.py @@ -35,6 +35,7 @@ GitRef, GitTree, InputTreeEntry, + MultilineReviewComment, PaginatedActionResult, PaginatedResponseMeta, PaginationParams, @@ -821,7 +822,7 @@ def create_review_comment_multiline( side: ReviewSide, start_line: int, end_line: int, - ) -> ActionResult[ReviewComment]: + ) -> ActionResult[MultilineReviewComment]: """Leave a review comment on a line span.""" response = self.client.post( f"/repos/{self.repository['name']}/pulls/{pull_request_id}/comments", @@ -834,7 +835,7 @@ def create_review_comment_multiline( "start_line": start_line, }, ) - return map_action(response, map_review_comment) + return map_action(response, map_multiline_review_comment) # create_review_comment_line: not supported @@ -1086,6 +1087,25 @@ def map_review_comment(raw: dict[str, Any]) -> ReviewComment: ) +def map_multiline_review_comment(raw: dict[str, Any]) -> MultilineReviewComment: + return MultilineReviewComment( + id=str(raw["id"]), + node_id=raw.get("node_id"), + html_url=raw["html_url"], + path=raw["path"], + body=raw["body"], + author=map_author(raw.get("user")), + created_at=raw.get("created_at"), + diff_hunk=raw.get("diff_hunk"), + pull_request_review_id=str(raw["pull_request_review_id"]) + if raw.get("pull_request_review_id") + else None, + author_association=raw.get("author_association"), + original_commit_id=raw.get("original_commit_id"), + commit_id=raw.get("commit_id"), + ) + + def map_review(raw: dict[str, Any]) -> Review: return Review( id=str(raw["id"]), diff --git a/src/sentry/scm/private/webhooks/github.py b/src/sentry/scm/private/webhooks/github.py index 0addcdc6609133..a8574298dfbbdb 100644 --- a/src/sentry/scm/private/webhooks/github.py +++ b/src/sentry/scm/private/webhooks/github.py @@ -1,7 +1,11 @@ -from typing import Optional - import msgspec +from sentry.scm.private.github.types import ( + GitHubCheckRun, + GitHubIssue, + GitHubIssueComment, + GitHubPullRequest, +) from sentry.scm.types import ( CheckRunAction, CheckRunEvent, @@ -23,73 +27,21 @@ # * "push" -class GitHubUser(msgspec.Struct): - id: int - login: str # Username - type: str | None = None - - class GitHubCheckRunEvent(msgspec.Struct, gc=False): action: CheckRunAction - check_run: "GitHubCheckRun" - - -class GitHubCheckRun(msgspec.Struct, gc=False): - external_id: str - html_url: str + check_run: GitHubCheckRun class GitHubIssueCommentEvent(msgspec.Struct, gc=False): action: CommentAction - comment: "GitHubIssueComment" - issue: "GitHubIssue" - - -class GitHubIssueComment(msgspec.Struct, gc=False): - id: int - user: GitHubUser | None - body: str | None = None - - -class GitHubIssueCommentPullRequest(msgspec.Struct, gc=False): - pass - - -class GitHubIssue(msgspec.Struct, gc=False): - number: int - pull_request: GitHubIssueCommentPullRequest | None = None + comment: GitHubIssueComment + issue: GitHubIssue class GitHubPullRequestEvent(msgspec.Struct, gc=False): action: PullRequestAction number: int - pull_request: "GitHubPullRequest" - - -class GitHubPullRequest(msgspec.Struct, gc=False): - body: str | None - head: "GitHubPullRequestHead" - base: "GitHubPullRequestBase" - merge_commit_sha: str | None - title: str - user: GitHubUser - merged: bool | None = None - - -class GitHubPullRequestBase(msgspec.Struct, gc=False): - ref: str - repo: "GitHubPullRequestRepo" - sha: str - - -class GitHubPullRequestHead(msgspec.Struct, gc=False): - ref: str - repo: Optional["GitHubPullRequestRepo"] - sha: str - - -class GitHubPullRequestRepo(msgspec.Struct, gc=False): - private: bool + pull_request: GitHubPullRequest check_run_decoder = msgspec.json.Decoder(GitHubCheckRunEvent) diff --git a/src/sentry/scm/types.py b/src/sentry/scm/types.py index a5d59a39051405..327718fcef91c4 100644 --- a/src/sentry/scm/types.py +++ b/src/sentry/scm/types.py @@ -373,6 +373,23 @@ class ReviewComment(TypedDict): body: str +class MultilineReviewComment(TypedDict): + """Provider-agnostic representation of a multiline review comment.""" + + id: ResourceId + node_id: str | None + html_url: str | None + path: str + body: str + author: Author | None + created_at: str | None + diff_hunk: str | None + pull_request_review_id: ResourceId | None + author_association: str | None + original_commit_id: str | None + commit_id: str | None + + class Review(TypedDict): """Provider-agnostic representation of a pull request review.""" @@ -1046,7 +1063,7 @@ def create_review_comment_multiline( side: ReviewSide, start_line: int, end_line: int, - ) -> ActionResult[ReviewComment]: ... + ) -> ActionResult[MultilineReviewComment]: ... @runtime_checkable diff --git a/tests/sentry/scm/test_fixtures.py b/tests/sentry/scm/test_fixtures.py index 9df9da6ab38a48..6a4edbb493bbb1 100644 --- a/tests/sentry/scm/test_fixtures.py +++ b/tests/sentry/scm/test_fixtures.py @@ -21,6 +21,7 @@ GitRef, GitTree, InputTreeEntry, + MultilineReviewComment, PaginatedActionResult, PaginatedResponseMeta, PaginationParams, @@ -313,6 +314,37 @@ def make_github_review_comment( } +def make_github_multiline_review_comment( + comment_id: int = 100, + node_id: str = "PRRC_abc123", + html_url: str = "https://github.com/test-org/test-repo/pull/1#discussion_r100", + path: str = "src/main.py", + body: str = "Looks good", + user: dict[str, Any] | None = None, + created_at: str = "2025-01-01T00:00:00Z", + diff_hunk: str = "@@ -1,5 +1,5 @@", + pull_request_review_id: int | None = 500, + author_association: str = "MEMBER", + original_commit_id: str = "orig123", + commit_id: str = "abc123", +) -> dict[str, Any]: + """Factory for GitHub multiline review comment API responses.""" + return { + "id": comment_id, + "node_id": node_id, + "html_url": html_url, + "path": path, + "body": body, + "user": user, + "created_at": created_at, + "diff_hunk": diff_hunk, + "pull_request_review_id": pull_request_review_id, + "author_association": author_association, + "original_commit_id": original_commit_id, + "commit_id": commit_id, + } + + def make_github_review( review_id: int = 200, html_url: str = "https://github.com/test-org/test-repo/pull/1#pullrequestreview-200", @@ -1106,14 +1138,24 @@ def create_review_comment_multiline( side: ReviewSide, start_line: int, end_line: int, - ) -> ActionResult[ReviewComment]: - raw = make_github_review_comment(body=body, path=path) + ) -> ActionResult[MultilineReviewComment]: + raw = make_github_multiline_review_comment(body=body, path=path) return ActionResult( - data=ReviewComment( + data=MultilineReviewComment( id=str(raw["id"]), + node_id=raw.get("node_id"), html_url=raw["html_url"], path=raw["path"], body=raw["body"], + author=None, + created_at=raw.get("created_at"), + diff_hunk=raw.get("diff_hunk"), + pull_request_review_id=str(raw["pull_request_review_id"]) + if raw.get("pull_request_review_id") + else None, + author_association=raw.get("author_association"), + original_commit_id=raw.get("original_commit_id"), + commit_id=raw.get("commit_id"), ), type="github", raw={"headers": None, "data": raw}, diff --git a/tests/sentry/scm/unit/test_github_provider.py b/tests/sentry/scm/unit/test_github_provider.py index 47de2f3acfe62d..5ae2261f5c3902 100644 --- a/tests/sentry/scm/unit/test_github_provider.py +++ b/tests/sentry/scm/unit/test_github_provider.py @@ -23,6 +23,7 @@ make_github_git_commit_object, make_github_git_ref, make_github_git_tree, + make_github_multiline_review_comment, make_github_pull_request, make_github_pull_request_commit, make_github_pull_request_file, @@ -305,6 +306,25 @@ def expected_review_comment(raw: dict[str, Any]) -> dict[str, Any]: } +def expected_multiline_review_comment(raw: dict[str, Any]) -> dict[str, Any]: + return { + "id": str(raw["id"]), + "node_id": raw.get("node_id"), + "html_url": raw["html_url"], + "path": raw["path"], + "body": raw["body"], + "author": None, + "created_at": raw.get("created_at"), + "diff_hunk": raw.get("diff_hunk"), + "pull_request_review_id": str(raw["pull_request_review_id"]) + if raw.get("pull_request_review_id") + else None, + "author_association": raw.get("author_association"), + "original_commit_id": raw.get("original_commit_id"), + "commit_id": raw.get("commit_id"), + } + + def expected_review(raw: dict[str, Any]) -> dict[str, Any]: return {"id": str(raw["id"]), "html_url": raw["html_url"]} @@ -333,6 +353,7 @@ def expected_check_run(raw: dict[str, Any]) -> dict[str, Any]: PULL_REQUEST_FILE_RAW = make_github_pull_request_file(previous_filename="src/old.py") PULL_REQUEST_COMMIT_RAW = make_github_pull_request_commit() REVIEW_COMMENT_RAW = make_github_review_comment() +MULTILINE_REVIEW_COMMENT_RAW = make_github_multiline_review_comment() REVIEW_RAW = make_github_review() CHECK_RUN_RAW = make_github_check_run() @@ -652,8 +673,8 @@ def expected_check_run(raw: dict[str, Any]) -> dict[str, Any]: "side": "RIGHT", "start_line": 1, }, - "raw": REVIEW_COMMENT_RAW, - "expected_data": expected_review_comment(REVIEW_COMMENT_RAW), + "raw": MULTILINE_REVIEW_COMMENT_RAW, + "expected_data": expected_multiline_review_comment(MULTILINE_REVIEW_COMMENT_RAW), }, { "name": "create_review_comment_reply", diff --git a/tests/sentry/scm/unit/test_scm_actions.py b/tests/sentry/scm/unit/test_scm_actions.py index c91d94d4910b4b..ae14aa43acaf17 100644 --- a/tests/sentry/scm/unit/test_scm_actions.py +++ b/tests/sentry/scm/unit/test_scm_actions.py @@ -471,6 +471,20 @@ def _check_review_comment(result: Any) -> None: assert result["type"] == "github" +def _check_multiline_review_comment(result: Any) -> None: + rc = result["data"] + assert rc["id"] == "100" + assert rc["body"] == "comment" + assert rc["node_id"] == "PRRC_abc123" + assert rc["created_at"] == "2025-01-01T00:00:00Z" + assert rc["diff_hunk"] == "@@ -1,5 +1,5 @@" + assert rc["pull_request_review_id"] == "500" + assert rc["author_association"] == "MEMBER" + assert rc["original_commit_id"] == "orig123" + assert rc["commit_id"] == "abc123" + assert result["type"] == "github" + + def _check_review(result: Any) -> None: r = result["data"] assert r["id"] == "200" @@ -643,7 +657,7 @@ def _check_update_check_run(result: Any) -> None: "start_line": 1, "end_line": 5, }, - _check_review_comment, + _check_multiline_review_comment, ), ( create_review_comment_reply, From 9a1e3cf013ce05954673741f3075c95985fd1e62 Mon Sep 17 00:00:00 2001 From: Colton Allen Date: Thu, 2 Apr 2026 15:54:18 -0500 Subject: [PATCH 4/7] Extend ReviewComment type --- src/sentry/scm/actions.py | 33 ++++----- src/sentry/scm/private/github/types.py | 71 ++++++++++++++++++ src/sentry/scm/private/providers/github.py | 59 ++++++++------- src/sentry/scm/types.py | 23 ++---- tests/sentry/scm/test_fixtures.py | 74 +++++++------------ tests/sentry/scm/unit/test_github_provider.py | 41 ++++------ tests/sentry/scm/unit/test_scm_actions.py | 18 ++--- 7 files changed, 175 insertions(+), 144 deletions(-) create mode 100644 src/sentry/scm/private/github/types.py diff --git a/src/sentry/scm/actions.py b/src/sentry/scm/actions.py index cb62794d8c99f8..97e620b8898045 100644 --- a/src/sentry/scm/actions.py +++ b/src/sentry/scm/actions.py @@ -78,7 +78,6 @@ GitTree, InputTreeEntry, MinimizeCommentProtocol, - MultilineReviewComment, PaginatedActionResult, PaginationParams, Provider, @@ -533,6 +532,22 @@ def create_review_comment_file( return scm.create_review_comment_file(pull_request_id, commit_id, body, path, side) +def create_review_comment_multiline( + scm: CreateReviewCommentMultilineProtocol, + pull_request_id: str, + commit_id: SHA, + body: str, + path: str, + side: ReviewSide, + start_line: int, + end_line: int, +) -> ActionResult[ReviewComment]: + """Leave a review comment on a line span.""" + return scm.create_review_comment_multiline( + pull_request_id, commit_id, body, path, side, start_line, end_line + ) + + def create_review_comment_reply( scm: CreateReviewCommentReplyProtocol, pull_request_id: str, @@ -554,22 +569,6 @@ def create_review( return scm.create_review(pull_request_id, commit_sha, event, comments, body=body) -def create_review_comment_multiline( - scm: CreateReviewCommentMultilineProtocol, - pull_request_id: str, - commit_id: SHA, - body: str, - path: str, - side: ReviewSide, - start_line: int, - end_line: int, -) -> ActionResult[MultilineReviewComment]: - """Leave a review comment on a line span.""" - return scm.create_review_comment_multiline( - pull_request_id, commit_id, body, path, side, start_line, end_line - ) - - def create_check_run( scm: CreateCheckRunProtocol, name: str, diff --git a/src/sentry/scm/private/github/types.py b/src/sentry/scm/private/github/types.py new file mode 100644 index 00000000000000..634c1424865153 --- /dev/null +++ b/src/sentry/scm/private/github/types.py @@ -0,0 +1,71 @@ +import datetime + +import msgspec + + +class GitHubUser(msgspec.Struct, gc=False): + id: int + login: str + type: str | None = None + + +class GitHubCheckRun(msgspec.Struct, gc=False): + external_id: str + html_url: str + + +class GitHubIssueComment(msgspec.Struct, gc=False): + id: int + user: GitHubUser | None + body: str | None = None + + +class GitHubIssueCommentPullRequest(msgspec.Struct, gc=False): + pass + + +class GitHubIssue(msgspec.Struct, gc=False): + number: int + pull_request: GitHubIssueCommentPullRequest | None = None + + +class GitHubPullRequest(msgspec.Struct, gc=False): + body: str | None + head: "GitHubPullRequestHead" + base: "GitHubPullRequestBase" + merge_commit_sha: str | None + title: str + user: GitHubUser + merged: bool | None = None + + +class GitHubPullRequestBase(msgspec.Struct, gc=False): + ref: str + repo: "GitHubPullRequestRepo" + sha: str + + +class GitHubPullRequestRepo(msgspec.Struct, gc=False): + private: bool + + +class GitHubPullRequestHead(msgspec.Struct, gc=False): + ref: str + repo: GitHubPullRequestRepo | None + sha: str + + +class GitHubPullRequestReviewComment(msgspec.Struct, gc=False): + id: int + node_id: str + pull_request_review_id: int + author_association: str + body: str + commit_id: str + diff_hunk: str + html_url: str + original_commit_id: str + path: str + user: GitHubUser | None + created_at: datetime.datetime + updated_at: datetime.datetime diff --git a/src/sentry/scm/private/providers/github.py b/src/sentry/scm/private/providers/github.py index 03cb75007efb58..19b2ee53d40317 100644 --- a/src/sentry/scm/private/providers/github.py +++ b/src/sentry/scm/private/providers/github.py @@ -4,10 +4,12 @@ from email.utils import format_datetime, parsedate_to_datetime from typing import Any, cast +import msgspec import requests from sentry.integrations.github.client import GitHubApiClient from sentry.scm.errors import SCMProviderException +from sentry.scm.private.github.types import GitHubPullRequestReviewComment from sentry.scm.private.rate_limit import ( DynamicRateLimiter, RateLimitProvider, @@ -35,7 +37,6 @@ GitRef, GitTree, InputTreeEntry, - MultilineReviewComment, PaginatedActionResult, PaginatedResponseMeta, PaginationParams, @@ -811,7 +812,7 @@ def create_review_comment_file( "subject_type": "file", }, ) - return map_action(response, map_review_comment) + return deserialize_action(response, deserialize_pull_request_review_comment) def create_review_comment_multiline( self, @@ -822,7 +823,7 @@ def create_review_comment_multiline( side: ReviewSide, start_line: int, end_line: int, - ) -> ActionResult[MultilineReviewComment]: + ) -> ActionResult[ReviewComment]: """Leave a review comment on a line span.""" response = self.client.post( f"/repos/{self.repository['name']}/pulls/{pull_request_id}/comments", @@ -835,9 +836,7 @@ def create_review_comment_multiline( "start_line": start_line, }, ) - return map_action(response, map_multiline_review_comment) - - # create_review_comment_line: not supported + return deserialize_action(response, deserialize_pull_request_review_comment) def create_review_comment_reply( self, @@ -853,7 +852,7 @@ def create_review_comment_reply( "in_reply_to": int(comment_id), }, ) - return map_action(response, map_review_comment) + return deserialize_action(response, deserialize_pull_request_review_comment) def create_review( self, @@ -1087,25 +1086,6 @@ def map_review_comment(raw: dict[str, Any]) -> ReviewComment: ) -def map_multiline_review_comment(raw: dict[str, Any]) -> MultilineReviewComment: - return MultilineReviewComment( - id=str(raw["id"]), - node_id=raw.get("node_id"), - html_url=raw["html_url"], - path=raw["path"], - body=raw["body"], - author=map_author(raw.get("user")), - created_at=raw.get("created_at"), - diff_hunk=raw.get("diff_hunk"), - pull_request_review_id=str(raw["pull_request_review_id"]) - if raw.get("pull_request_review_id") - else None, - author_association=raw.get("author_association"), - original_commit_id=raw.get("original_commit_id"), - commit_id=raw.get("commit_id"), - ) - - def map_review(raw: dict[str, Any]) -> Review: return Review( id=str(raw["id"]), @@ -1189,3 +1169,30 @@ def map_paginated_action[T]( "raw": {"data": raw, "headers": dict(response.headers)}, "meta": meta, } + + +def deserialize_action[T](response: requests.Response, fn: Callable[[bytes], T]) -> ActionResult[T]: + return { + "data": fn(response.content), + "type": "github", + "raw": {"data": response.json(), "headers": dict(response.headers)}, + "meta": _extract_response_meta(response), + } + + +def deserialize_pull_request_review_comment(content: bytes) -> ReviewComment: + comment = msgspec.json.decode(content, type=GitHubPullRequestReviewComment) + return { + "author_association": comment.author_association, + "author": {"id": str(comment.user.id), "username": comment.user.login}, + "body": comment.body, + "commit_sha": comment.original_commit_id, + "created_at": comment.created_at.isoformat(), + "diff_hunk": comment.diff_hunk, + "file_path": comment.path, + "head": comment.commit_id, + "id": str(comment.id), + "review_id": str(comment.pull_request_review_id), + "unique_id": comment.node_id, + "url": comment.html_url, + } diff --git a/src/sentry/scm/types.py b/src/sentry/scm/types.py index 327718fcef91c4..172c0ed0125b23 100644 --- a/src/sentry/scm/types.py +++ b/src/sentry/scm/types.py @@ -368,26 +368,17 @@ class ReviewComment(TypedDict): """Provider-agnostic representation of a review comment.""" id: ResourceId - html_url: str | None - path: str - body: str - - -class MultilineReviewComment(TypedDict): - """Provider-agnostic representation of a multiline review comment.""" - - id: ResourceId - node_id: str | None - html_url: str | None - path: str + unique_id: str | None + url: str | None + file_path: str body: str author: Author | None created_at: str | None diff_hunk: str | None - pull_request_review_id: ResourceId | None + review_id: ResourceId | None author_association: str | None - original_commit_id: str | None - commit_id: str | None + commit_sha: str | None + head: str | None class Review(TypedDict): @@ -1063,7 +1054,7 @@ def create_review_comment_multiline( side: ReviewSide, start_line: int, end_line: int, - ) -> ActionResult[MultilineReviewComment]: ... + ) -> ActionResult[ReviewComment]: ... @runtime_checkable diff --git a/tests/sentry/scm/test_fixtures.py b/tests/sentry/scm/test_fixtures.py index 6a4edbb493bbb1..0554665a0c8f17 100644 --- a/tests/sentry/scm/test_fixtures.py +++ b/tests/sentry/scm/test_fixtures.py @@ -21,7 +21,6 @@ GitRef, GitTree, InputTreeEntry, - MultilineReviewComment, PaginatedActionResult, PaginatedResponseMeta, PaginationParams, @@ -300,21 +299,6 @@ def make_github_pull_request_commit( def make_github_review_comment( - comment_id: int = 100, - html_url: str = "https://github.com/test-org/test-repo/pull/1#discussion_r100", - path: str = "src/main.py", - body: str = "Looks good", -) -> dict[str, Any]: - """Factory for GitHub review comment API responses.""" - return { - "id": comment_id, - "html_url": html_url, - "path": path, - "body": body, - } - - -def make_github_multiline_review_comment( comment_id: int = 100, node_id: str = "PRRC_abc123", html_url: str = "https://github.com/test-org/test-repo/pull/1#discussion_r100", @@ -322,13 +306,14 @@ def make_github_multiline_review_comment( body: str = "Looks good", user: dict[str, Any] | None = None, created_at: str = "2025-01-01T00:00:00Z", + updated_at: str = "2025-01-01T00:00:00Z", diff_hunk: str = "@@ -1,5 +1,5 @@", - pull_request_review_id: int | None = 500, + pull_request_review_id: int = 500, author_association: str = "MEMBER", original_commit_id: str = "orig123", commit_id: str = "abc123", ) -> dict[str, Any]: - """Factory for GitHub multiline review comment API responses.""" + """Factory for GitHub review comment API responses.""" return { "id": comment_id, "node_id": node_id, @@ -337,6 +322,7 @@ def make_github_multiline_review_comment( "body": body, "user": user, "created_at": created_at, + "updated_at": updated_at, "diff_hunk": diff_hunk, "pull_request_review_id": pull_request_review_id, "author_association": author_association, @@ -500,6 +486,23 @@ def make_github_graphql_pr_comments_response( _DEFAULT_PAGINATED_META: PaginatedResponseMeta = PaginatedResponseMeta(next_cursor=None) +def _make_review_comment_data(raw: dict[str, Any]) -> ReviewComment: + return ReviewComment( + id=str(raw["id"]), + unique_id=raw.get("node_id"), + url=raw["html_url"], + file_path=raw["path"], + body=raw["body"], + author=None, + created_at=raw.get("created_at"), + diff_hunk=raw.get("diff_hunk"), + review_id=str(raw["pull_request_review_id"]) if raw.get("pull_request_review_id") else None, + author_association=raw.get("author_association"), + commit_sha=raw.get("original_commit_id"), + head=raw.get("commit_id"), + ) + + class BaseTestProvider(Provider): organization_id: int repository: Repository @@ -1118,12 +1121,7 @@ def create_review_comment_file( ) -> ActionResult[ReviewComment]: raw = make_github_review_comment(body=body, path=path) return ActionResult( - data=ReviewComment( - id=str(raw["id"]), - html_url=raw["html_url"], - path=raw["path"], - body=raw["body"], - ), + data=_make_review_comment_data(raw), type="github", raw={"headers": None, "data": raw}, meta={}, @@ -1138,25 +1136,10 @@ def create_review_comment_multiline( side: ReviewSide, start_line: int, end_line: int, - ) -> ActionResult[MultilineReviewComment]: - raw = make_github_multiline_review_comment(body=body, path=path) + ) -> ActionResult[ReviewComment]: + raw = make_github_review_comment(body=body, path=path) return ActionResult( - data=MultilineReviewComment( - id=str(raw["id"]), - node_id=raw.get("node_id"), - html_url=raw["html_url"], - path=raw["path"], - body=raw["body"], - author=None, - created_at=raw.get("created_at"), - diff_hunk=raw.get("diff_hunk"), - pull_request_review_id=str(raw["pull_request_review_id"]) - if raw.get("pull_request_review_id") - else None, - author_association=raw.get("author_association"), - original_commit_id=raw.get("original_commit_id"), - commit_id=raw.get("commit_id"), - ), + data=_make_review_comment_data(raw), type="github", raw={"headers": None, "data": raw}, meta={}, @@ -1170,12 +1153,7 @@ def create_review_comment_reply( ) -> ActionResult[ReviewComment]: raw = make_github_review_comment(body=body) return ActionResult( - data=ReviewComment( - id=str(raw["id"]), - html_url=raw["html_url"], - path=raw["path"], - body=raw["body"], - ), + data=_make_review_comment_data(raw), type="github", raw={"headers": None, "data": raw}, meta={}, diff --git a/tests/sentry/scm/unit/test_github_provider.py b/tests/sentry/scm/unit/test_github_provider.py index 5ae2261f5c3902..a69404f4a04b82 100644 --- a/tests/sentry/scm/unit/test_github_provider.py +++ b/tests/sentry/scm/unit/test_github_provider.py @@ -12,6 +12,7 @@ GitHubProviderApiClient, ) from sentry.scm.types import Referrer, Repository +from sentry.utils import json from tests.sentry.scm.test_fixtures import ( make_github_branch, make_github_check_run, @@ -23,7 +24,6 @@ make_github_git_commit_object, make_github_git_ref, make_github_git_tree, - make_github_multiline_review_comment, make_github_pull_request, make_github_pull_request_commit, make_github_pull_request_file, @@ -54,6 +54,7 @@ def __init__( url: str = "", ) -> None: self._payload = payload + self.content = json.dumps(payload).encode() self.headers = headers or {} self.status_code = status_code self.text = text if text is not None else "" @@ -300,28 +301,19 @@ def expected_pull_request_commit(raw: dict[str, Any]) -> dict[str, Any]: def expected_review_comment(raw: dict[str, Any]) -> dict[str, Any]: return { "id": str(raw["id"]), - "html_url": raw["html_url"], - "path": raw["path"], - "body": raw["body"], - } - - -def expected_multiline_review_comment(raw: dict[str, Any]) -> dict[str, Any]: - return { - "id": str(raw["id"]), - "node_id": raw.get("node_id"), - "html_url": raw["html_url"], - "path": raw["path"], + "unique_id": raw["node_id"], + "url": raw["html_url"], + "file_path": raw["path"], "body": raw["body"], - "author": None, - "created_at": raw.get("created_at"), - "diff_hunk": raw.get("diff_hunk"), - "pull_request_review_id": str(raw["pull_request_review_id"]) - if raw.get("pull_request_review_id") + "author": {"id": str(raw["user"]["id"]), "username": raw["user"]["login"]} + if raw.get("user") else None, - "author_association": raw.get("author_association"), - "original_commit_id": raw.get("original_commit_id"), - "commit_id": raw.get("commit_id"), + "created_at": "2025-01-01T00:00:00+00:00", + "diff_hunk": raw["diff_hunk"], + "review_id": str(raw["pull_request_review_id"]), + "author_association": raw["author_association"], + "commit_sha": raw["original_commit_id"], + "head": raw["commit_id"], } @@ -352,8 +344,7 @@ def expected_check_run(raw: dict[str, Any]) -> dict[str, Any]: GIT_COMMIT_OBJECT_RAW = make_github_git_commit_object() PULL_REQUEST_FILE_RAW = make_github_pull_request_file(previous_filename="src/old.py") PULL_REQUEST_COMMIT_RAW = make_github_pull_request_commit() -REVIEW_COMMENT_RAW = make_github_review_comment() -MULTILINE_REVIEW_COMMENT_RAW = make_github_multiline_review_comment() +REVIEW_COMMENT_RAW = make_github_review_comment(user={"id": 42, "login": "testuser"}) REVIEW_RAW = make_github_review() CHECK_RUN_RAW = make_github_check_run() @@ -673,8 +664,8 @@ def expected_check_run(raw: dict[str, Any]) -> dict[str, Any]: "side": "RIGHT", "start_line": 1, }, - "raw": MULTILINE_REVIEW_COMMENT_RAW, - "expected_data": expected_multiline_review_comment(MULTILINE_REVIEW_COMMENT_RAW), + "raw": REVIEW_COMMENT_RAW, + "expected_data": expected_review_comment(REVIEW_COMMENT_RAW), }, { "name": "create_review_comment_reply", diff --git a/tests/sentry/scm/unit/test_scm_actions.py b/tests/sentry/scm/unit/test_scm_actions.py index ae14aa43acaf17..36a8310f485450 100644 --- a/tests/sentry/scm/unit/test_scm_actions.py +++ b/tests/sentry/scm/unit/test_scm_actions.py @@ -467,21 +467,15 @@ def _check_created_reaction(result: Any) -> None: def _check_review_comment(result: Any) -> None: rc = result["data"] assert rc["id"] == "100" + assert rc["unique_id"] == "PRRC_abc123" + assert rc["url"] == "https://github.com/test-org/test-repo/pull/1#discussion_r100" assert rc["body"] == "comment" - assert result["type"] == "github" - - -def _check_multiline_review_comment(result: Any) -> None: - rc = result["data"] - assert rc["id"] == "100" - assert rc["body"] == "comment" - assert rc["node_id"] == "PRRC_abc123" assert rc["created_at"] == "2025-01-01T00:00:00Z" assert rc["diff_hunk"] == "@@ -1,5 +1,5 @@" - assert rc["pull_request_review_id"] == "500" + assert rc["review_id"] == "500" assert rc["author_association"] == "MEMBER" - assert rc["original_commit_id"] == "orig123" - assert rc["commit_id"] == "abc123" + assert rc["commit_sha"] == "orig123" + assert rc["head"] == "abc123" assert result["type"] == "github" @@ -657,7 +651,7 @@ def _check_update_check_run(result: Any) -> None: "start_line": 1, "end_line": 5, }, - _check_multiline_review_comment, + _check_review_comment, ), ( create_review_comment_reply, From 57955495956693e7db23b91ce90a3d24cb1a7557 Mon Sep 17 00:00:00 2001 From: Colton Allen Date: Tue, 7 Apr 2026 11:03:59 -0500 Subject: [PATCH 5/7] Fix tests --- src/sentry/scm/private/providers/gitlab.py | 15 ++++++++++-- tests/sentry/scm/unit/test_gitlab_provider.py | 24 +++++++++++++++---- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/src/sentry/scm/private/providers/gitlab.py b/src/sentry/scm/private/providers/gitlab.py index 4049057177ea39..94cef558d9c488 100644 --- a/src/sentry/scm/private/providers/gitlab.py +++ b/src/sentry/scm/private/providers/gitlab.py @@ -732,11 +732,22 @@ def map_tree_entry(raw: dict[str, Any]) -> TreeEntry: def map_review_comment(discussion_id: str) -> Callable[[dict[str, Any]], ReviewComment]: def _map_review_comment(raw: dict[str, Any]) -> ReviewComment: + author_raw = raw.get("author") return ReviewComment( id=f"{discussion_id}:{raw['id']}", - html_url=None, - path=raw["position"]["new_path"], + unique_id=f"{discussion_id}:{raw['id']}", + url=None, + file_path=raw["position"]["new_path"], body=raw["body"], + author=Author(id=str(author_raw["id"]), username=author_raw["username"]) + if author_raw + else None, + created_at=raw.get("created_at"), + diff_hunk=None, + review_id=None, + author_association=None, + commit_sha=None, + head=None, ) return _map_review_comment diff --git a/tests/sentry/scm/unit/test_gitlab_provider.py b/tests/sentry/scm/unit/test_gitlab_provider.py index b1ad8fe72f2280..24574fd366c386 100644 --- a/tests/sentry/scm/unit/test_gitlab_provider.py +++ b/tests/sentry/scm/unit/test_gitlab_provider.py @@ -11072,9 +11072,17 @@ class ForwardToClientTest(NamedTuple): provider_return_value={ "data": { "id": "c4604a0d82de5427ec0cdc8780c8f810ea9bec86:3149948866", - "html_url": None, - "path": "BLAH.md", + "unique_id": "c4604a0d82de5427ec0cdc8780c8f810ea9bec86:3149948866", + "url": None, + "file_path": "BLAH.md", "body": "A review comment, on a file, made by the API on 2026-03-11 11:06:19.945026.", + "author": {"id": "150871", "username": "jacquev6"}, + "created_at": "2026-03-11T11:06:21.007Z", + "diff_hunk": None, + "review_id": None, + "author_association": None, + "commit_sha": None, + "head": None, }, "type": "gitlab", "raw": { @@ -11195,9 +11203,17 @@ class ForwardToClientTest(NamedTuple): provider_return_value={ "data": { "id": "c4604a0d82de5427ec0cdc8780c8f810ea9bec86:3149949479", - "html_url": None, - "path": "BLAH.md", + "unique_id": "c4604a0d82de5427ec0cdc8780c8f810ea9bec86:3149949479", + "url": None, + "file_path": "BLAH.md", "body": "A reply to the previous comment, made by the API on 2026-03-11 11:06:21.487947.", + "author": {"id": "150871", "username": "jacquev6"}, + "created_at": "2026-03-11T11:06:31.033Z", + "diff_hunk": None, + "review_id": None, + "author_association": None, + "commit_sha": None, + "head": None, }, "type": "gitlab", "raw": { From 427a5653052e56e800feb93034fa4451000ecc72 Mon Sep 17 00:00:00 2001 From: Colton Allen Date: Tue, 7 Apr 2026 12:07:30 -0500 Subject: [PATCH 6/7] Fix typing --- src/sentry/scm/private/providers/github.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/src/sentry/scm/private/providers/github.py b/src/sentry/scm/private/providers/github.py index 19b2ee53d40317..9abc37a1eca52f 100644 --- a/src/sentry/scm/private/providers/github.py +++ b/src/sentry/scm/private/providers/github.py @@ -1077,15 +1077,6 @@ def map_git_commit_object(raw: dict[str, Any]) -> GitCommitObject: ) -def map_review_comment(raw: dict[str, Any]) -> ReviewComment: - return ReviewComment( - id=str(raw["id"]), - html_url=raw["html_url"], - path=raw["path"], - body=raw["body"], - ) - - def map_review(raw: dict[str, Any]) -> Review: return Review( id=str(raw["id"]), @@ -1184,7 +1175,9 @@ def deserialize_pull_request_review_comment(content: bytes) -> ReviewComment: comment = msgspec.json.decode(content, type=GitHubPullRequestReviewComment) return { "author_association": comment.author_association, - "author": {"id": str(comment.user.id), "username": comment.user.login}, + "author": Author(id=str(comment.user.id), username=comment.user.login) + if comment.user + else None, "body": comment.body, "commit_sha": comment.original_commit_id, "created_at": comment.created_at.isoformat(), From bdd507b0234edc3610d39fd8b9863d1ec5fc9550 Mon Sep 17 00:00:00 2001 From: Colton Allen Date: Wed, 8 Apr 2026 09:34:44 -0500 Subject: [PATCH 7/7] Expose multiline review comment over RPC --- src/sentry/scm/private/rpc.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/sentry/scm/private/rpc.py b/src/sentry/scm/private/rpc.py index 86ed393806cd5c..d43cfde3ace4dd 100644 --- a/src/sentry/scm/private/rpc.py +++ b/src/sentry/scm/private/rpc.py @@ -20,6 +20,7 @@ create_pull_request_reaction, create_review, create_review_comment_file, + create_review_comment_multiline, create_review_comment_reply, delete_issue_comment, delete_issue_comment_reaction, @@ -150,6 +151,7 @@ def get_extra_fields(self) -> dict[str, Any]: "create_pull_request_reaction_v1": create_pull_request_reaction, "create_pull_request_v1": create_pull_request, "create_review_comment_file_v1": create_review_comment_file, + "create_review_comment_multiline_v1": create_review_comment_multiline, "create_review_comment_reply_v1": create_review_comment_reply, "create_review_v1": create_review, "delete_issue_comment_reaction_v1": delete_issue_comment_reaction,