From 1536c2ea9902121675c5bbc1989511e8d0d3d72c Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 25 Sep 2026 10:00:06 -0700 Subject: [PATCH] feat(cursor-origin): Write pull request comments and reviews Add support for posting general and inline review comments, editing them, and collapsing them. Origin cannot hide a comment, so collapsing resolves its thread. Add support for listing a pull request's reviews and submitting a review with its comments in one request. Origin submits the review and comments atomically, so if any comment cannot be anchored to a line in the diff, nothing is posted. --- src/scm/providers/cursor_origin/provider.py | 179 ++++++++++++++++++ tests/unit/provider/test_cursor_origin.py | 189 ++++++++++++++++++++ 2 files changed, 368 insertions(+) diff --git a/src/scm/providers/cursor_origin/provider.py b/src/scm/providers/cursor_origin/provider.py index 8af75d1..b9a15a6 100644 --- a/src/scm/providers/cursor_origin/provider.py +++ b/src/scm/providers/cursor_origin/provider.py @@ -36,8 +36,10 @@ CommitAuthorParam, CommitComparison, CommitFile, + CoPilotChatExtension, CredentialsSet, DeleteCommitAction, + DiffLine, FileContent, FileContentType, FileStatus, @@ -54,10 +56,15 @@ PullRequestBranch, PullRequestCommit, PullRequestFile, + PullRequestReviewState, PullRequestState, Repository, RequestOptions, ResourceId, + Review, + ReviewComment, + ReviewCommentInput, + ReviewEvent, ReviewThread, ReviewThreadComment, TreeEntry, @@ -119,6 +126,20 @@ "copied": "copied", } +CURSOR_ORIGIN_REVIEW_EVENT_MAP: dict[ReviewEvent, str] = { + "approve": "approve", + "change_request": "request_changes", + "comment": "comment", +} +CURSOR_ORIGIN_REVIEW_STATE_MAP: dict[str, PullRequestReviewState] = { + "approve": "approved", + "request_changes": "changes_requested", + "comment": "commented", +} + + +type DiffSide = Literal["left", "right"] + class CursorOriginProvider: def __init__( @@ -248,6 +269,16 @@ def get_pull_request_comments( general = [map_comment(comment) for comment in comments if _is_general_discussion(comment)] return map_comments_page(response, comments, general) + def create_pull_request_comment( + self, + pull_request_id: str, + body: str, + extensions: list[CoPilotChatExtension] | None = None, + ) -> ActionResult[Comment]: + """Opens a general-discussion thread""" + response = self.post(f"/repos/{self.repository_path}/pulls/{pull_request_id}/comments", data={"body": body}) + return map_action(response, map_comment) + def get_branch( self, branch: BranchName, @@ -825,6 +856,106 @@ def get_pull_request_commits( response, lambda raw: [map_pull_request_commit(commit) for commit in raw["commits"]] ) + def create_review_comment( + self, + pull_request_id: str, + commit_id: SHA, + body: str, + path: str, + line: DiffLine, + start_line: DiffLine | None = None, + ) -> ActionResult[ReviewComment]: + response = self.post( + f"/repos/{self.repository_path}/pulls/{pull_request_id}/comments", + data={"body": body, "inline": inline_anchor(path, line, start_line)}, + ) + return map_action(response, map_review_comment) + + def update_review_comment(self, pull_request_id: str, comment_id: str, body: str) -> ActionResult[ReviewComment]: + response = self.patch(f"/repos/{self.repository_path}/pulls/comments/{comment_id}", data={"body": body}) + return map_action(response, map_review_comment) + + def create_review( + self, + pull_request_id: str, + commit_sha: SHA, + event: ReviewEvent, + comments: list[ReviewCommentInput], + body: str | None = None, + ) -> ActionResult[Review]: + """Submit a review and its comments in one request. + + Origin reviews the pull request's latest version, ignoring ``commit_sha``. If any + comment cannot be anchored, the entire request is rejected. + """ + data: dict[str, Any] = { + "verdict": CURSOR_ORIGIN_REVIEW_EVENT_MAP[event], + "comments": [self._review_comment(comment) for comment in comments], + } + if body is not None: + data["body"] = body + response = self.post(f"/repos/{self.repository_path}/pulls/{pull_request_id}/reviews", data=data) + return map_action(response, lambda raw: self._map_review(raw, pull_request_id)) + + def _review_comment(self, comment: ReviewCommentInput) -> dict[str, Any]: + """A comment with no line anchors the whole file.""" + line = comment.get("line") + if line is None: + return {"body": comment["body"], "file": {"path": comment["path"]}} + return {"body": comment["body"], "inline": inline_anchor(comment["path"], line, comment.get("start_line"))} + + def list_pull_request_reviews( + self, + pull_request_id: str, + pagination: PaginationParams | None = None, + request_options: RequestOptions | None = None, + ) -> PaginatedActionResult[list[Review]]: + """Submitted reviews, oldest first.""" + response = self.get( + f"/repos/{self.repository_path}/pulls/{pull_request_id}/reviews", + pagination=pagination, + request_options=request_options, + ) + return map_paginated_action( + response, + lambda raw: [self._map_review(review, pull_request_id) for review in raw["reviews"]], + ) + + def _map_review(self, raw: dict[str, Any], pull_request_id: str) -> Review: + """Origin has no per-review web link, so the pull request's stands in.""" + return Review( + id=raw["id"], + html_url=f"{self._web_base_url}/{self.repository_path}/pull/{pull_request_id}", + state="dismissed" if raw.get("dismissal") else CURSOR_ORIGIN_REVIEW_STATE_MAP[raw["verdict"]], + author=map_author(raw["author"]), + body=raw["body"] or None, + submitted_at=raw["submittedAt"], + commit_id=raw["pullRequestVersion"]["headSha"], + ) + + def collapse_pull_request_comment( + self, + pull_request_id: str, + thread_id: str, + comment_node_id: str, + reason: str = "OUTDATED", + ) -> None: + """Origin cannot minimize a comment, so we resolve instead.""" + self.patch(f"/repos/{self.repository_path}/pulls/threads/{thread_id}", data={"resolved": True}) + + def update_and_collapse_pull_request_comment( + self, + pull_request_id: str, + thread_id: str, + comment_id: str, + comment_node_id: str, + body: str, + reason: str = "OUTDATED", + ) -> ActionResult[ReviewComment]: + result = self.update_review_comment(pull_request_id, comment_id, body) + self.collapse_pull_request_comment(pull_request_id, thread_id, comment_node_id, reason) + return result + def get_pull_request_review_threads( self, pull_request_id: str, @@ -1011,6 +1142,54 @@ def _is_general_discussion(raw: dict[str, Any]) -> bool: return not raw["thread"]["path"] +def map_diff_line(side: DiffSide, line: int) -> DiffLine: + return DiffLine(head=line) if side == "right" else DiffLine(base=line) + + +def map_review_comment(raw: dict[str, Any]) -> ReviewComment: + thread = raw["thread"] + line = start_line = None + start = thread.get("startLine", 0) + if start: + end = thread["endLine"] + line = map_diff_line(thread["side"], end or start) + start_line = map_diff_line(thread["side"], start) if end else None + return ReviewComment( + id=raw["id"], + unique_id=raw["id"], + url=None, + file_path=thread.get("path") or None, + body=raw["body"], + author=map_author(raw["author"]), + created_at=raw["createdAt"], + diff_hunk=None, + line=line, + start_line=start_line, + review_id=None, + author_association=None, + commit_sha=thread["version"]["headSha"] if "version" in thread else None, + head=None, + thread_id=thread["id"], + ) + + +def _diff_anchor(line: DiffLine) -> tuple[DiffSide, int]: + if "head" in line: + return "right", line["head"] + return "left", line["base"] + + +def inline_anchor(path: str, line: DiffLine, start_line: DiffLine | None) -> dict[str, Any]: + side, end = _diff_anchor(line) + anchor: dict[str, Any] = {"path": path, "side": side, "startLine": end} + if start_line is not None: + start = start_line.get("head") if side == "right" else start_line.get("base") + if start is not None and start < end: + anchor["startLine"] = start + anchor["endLine"] = end + return anchor + + def map_review_thread_comment(raw: dict[str, Any]) -> ReviewThreadComment: return ReviewThreadComment( id=raw["id"], diff --git a/tests/unit/provider/test_cursor_origin.py b/tests/unit/provider/test_cursor_origin.py index 98789aa..d74a326 100644 --- a/tests/unit/provider/test_cursor_origin.py +++ b/tests/unit/provider/test_cursor_origin.py @@ -17,6 +17,7 @@ from scm.helpers import iter_all_pages from scm.providers.cursor_origin.provider import ( CursorOriginProvider, + inline_anchor, map_app_installation, map_author, map_check_run, @@ -1274,3 +1275,191 @@ def test_a_resolved_thread(self) -> None: ) assert thread["is_resolved"] is True + + +class TestCommentWrites: + def test_a_general_comment_opens_a_discussion( + self, provider: CursorOriginProvider, client: unittest.mock.MagicMock + ) -> None: + client.request.return_value = _response(_comment_raw("c1", "t1")) + + result = provider.create_pull_request_comment("7", "Looks good") + + assert client.request.call_args.kwargs["method"] == "POST" + assert client.request.call_args.kwargs["path"] == f"/repos/{REPO}/pulls/7/comments" + assert client.request.call_args.kwargs["data"] == {"body": "Looks good"} + assert result["data"]["id"] == "c1" + + def test_a_review_comment_anchors_a_line_range( + self, provider: CursorOriginProvider, client: unittest.mock.MagicMock + ) -> None: + client.request.return_value = _response( + _comment_raw("c1", "t1", path="src/app.py", side="right", startLine=3, endLine=5) + ) + + result = provider.create_review_comment( + "7", "head123", "Fix this", "src/app.py", line={"head": 5}, start_line={"base": 2, "head": 3} + ) + + assert client.request.call_args.kwargs["data"] == { + "body": "Fix this", + "inline": {"path": "src/app.py", "side": "right", "startLine": 3, "endLine": 5}, + } + assert result["data"]["file_path"] == "src/app.py" + assert result["data"]["line"] == {"head": 5} + assert result["data"]["start_line"] == {"head": 3} + assert result["data"]["thread_id"] == "t1" + assert result["data"]["commit_sha"] == "head123" + + def test_a_removed_line_anchors_the_left_side( + self, provider: CursorOriginProvider, client: unittest.mock.MagicMock + ) -> None: + client.request.return_value = _response(_comment_raw("c1", "t1", path="src/app.py", side="left", startLine=17)) + + result = provider.create_review_comment("7", "head123", "Why?", "src/app.py", line={"base": 17}) + + assert client.request.call_args.kwargs["data"]["inline"] == { + "path": "src/app.py", + "side": "left", + "startLine": 17, + } + assert result["data"]["line"] == {"base": 17} + assert result["data"]["start_line"] is None + + def test_a_comment_is_updated_by_its_id( + self, provider: CursorOriginProvider, client: unittest.mock.MagicMock + ) -> None: + client.request.return_value = _response({**_comment_raw("c1", "t1"), "thread": {"id": "t1"}}) + + result = provider.update_review_comment("7", "c1", "Edited") + + assert client.request.call_args.kwargs["method"] == "PATCH" + assert client.request.call_args.kwargs["path"] == f"/repos/{REPO}/pulls/comments/c1" + assert client.request.call_args.kwargs["data"] == {"body": "Edited"} + assert result["data"]["thread_id"] == "t1" + assert result["data"]["line"] is None + assert result["data"]["commit_sha"] is None + + def test_collapsing_resolves_the_thread( + self, provider: CursorOriginProvider, client: unittest.mock.MagicMock + ) -> None: + client.request.side_effect = [ + _response({**_comment_raw("c1", "t1"), "thread": {"id": "t1"}}), + _response({"id": "t1"}), + ] + + provider.update_and_collapse_pull_request_comment("7", "t1", "c1", "c1", "Outdated") + + update, resolve = client.request.call_args_list + assert update.kwargs["path"] == f"/repos/{REPO}/pulls/comments/c1" + assert resolve.kwargs["method"] == "PATCH" + assert resolve.kwargs["path"] == f"/repos/{REPO}/pulls/threads/t1" + assert resolve.kwargs["data"] == {"resolved": True} + + +class TestInlineAnchor: + def test_a_range_on_one_side(self) -> None: + assert inline_anchor("a.py", {"head": 5}, {"head": 3}) == { + "path": "a.py", + "side": "right", + "startLine": 3, + "endLine": 5, + } + + def test_the_start_is_read_on_the_end_side(self) -> None: + """A context line carries both numbers; the range uses the one on the end's side.""" + assert inline_anchor("a.py", {"base": 17}, {"base": 15, "head": 20}) == { + "path": "a.py", + "side": "left", + "startLine": 15, + "endLine": 17, + } + + def test_a_start_on_the_other_side_anchors_the_end_alone(self) -> None: + assert inline_anchor("a.py", {"head": 5}, {"base": 3}) == {"path": "a.py", "side": "right", "startLine": 5} + + def test_a_single_line(self) -> None: + assert inline_anchor("a.py", {"base": 9}, None) == {"path": "a.py", "side": "left", "startLine": 9} + + +def _review_raw(**overrides: Any) -> dict[str, Any]: + return { + "id": "rev_01example", + "author": USER, + "verdict": "approve", + "body": "Looks good", + "submittedAt": "2026-08-01T09:30:00Z", + "pullRequestVersion": {"number": "1", "headSha": "head123", "baseSha": "base123"}, + **overrides, + } + + +class TestReviews: + def test_submitted_reviews_are_listed( + self, provider: CursorOriginProvider, client: unittest.mock.MagicMock + ) -> None: + client.request.return_value = _response({"reviews": [_review_raw()], "nextPageToken": ""}) + + result = provider.list_pull_request_reviews("7") + + assert client.request.call_args.kwargs["path"] == f"/repos/{REPO}/pulls/7/reviews" + assert result["data"] == [ + { + "id": "rev_01example", + "html_url": f"https://cursor.com/codebase/{REPO}/pull/7", + "state": "approved", + "author": {"id": "user_01", "username": "jane"}, + "body": "Looks good", + "submitted_at": "2026-08-01T09:30:00Z", + "commit_id": "head123", + } + ] + + def test_a_dismissed_review(self, provider: CursorOriginProvider, client: unittest.mock.MagicMock) -> None: + client.request.return_value = _response( + {"reviews": [_review_raw(dismissal={"dismissedBy": USER})], "nextPageToken": ""} + ) + + assert provider.list_pull_request_reviews("7")["data"][0]["state"] == "dismissed" + + def test_a_review_carries_its_comments( + self, provider: CursorOriginProvider, client: unittest.mock.MagicMock + ) -> None: + client.request.return_value = _response(_review_raw(verdict="request_changes")) + + result = provider.create_review( + "7", + "head123", + "change_request", + [ + {"path": "src/app.py", "body": "Fix this", "line": {"head": 5}, "start_line": {"head": 3}}, + {"path": "src/other.py", "body": "And this", "line": {"base": 9}}, + {"path": "src/whole.py", "body": "This file"}, + ], + body="Some changes needed", + ) + + assert client.request.call_args.kwargs["method"] == "POST" + assert client.request.call_args.kwargs["path"] == f"/repos/{REPO}/pulls/7/reviews" + assert client.request.call_args.kwargs["data"] == { + "verdict": "request_changes", + "body": "Some changes needed", + "comments": [ + { + "body": "Fix this", + "inline": {"path": "src/app.py", "side": "right", "startLine": 3, "endLine": 5}, + }, + {"body": "And this", "inline": {"path": "src/other.py", "side": "left", "startLine": 9}}, + {"body": "This file", "file": {"path": "src/whole.py"}}, + ], + } + assert result["data"]["state"] == "changes_requested" + + def test_a_review_without_a_body(self, provider: CursorOriginProvider, client: unittest.mock.MagicMock) -> None: + client.request.return_value = _response(_review_raw(verdict="comment", body="")) + + result = provider.create_review("7", "head123", "comment", []) + + assert client.request.call_args.kwargs["data"] == {"verdict": "comment", "comments": []} + assert result["data"]["body"] is None + assert result["data"]["state"] == "commented"