diff --git a/src/scm/providers/cursor_origin/provider.py b/src/scm/providers/cursor_origin/provider.py index 3ba24ea..2fb32d5 100644 --- a/src/scm/providers/cursor_origin/provider.py +++ b/src/scm/providers/cursor_origin/provider.py @@ -7,6 +7,10 @@ PathIsDirectory, PathIsNotDirectory, ResourceBadRequest, + ResourceConflict, + ResourceNotFound, + SCMCodedError, + StaleBranchHead, UnexpectedResponseFormat, error_class_for_status, ) @@ -18,12 +22,18 @@ ArchiveLink, Author, BranchName, + ChmodCommitAction, + Commit, + CommitAuthor, + CommitAuthorParam, CredentialsSet, + DeleteCommitAction, FileContent, FileContentType, GitRef, GitRepository, GitTree, + MoveCommitAction, PaginatedActionResult, PaginationParams, ProviderName, @@ -33,10 +43,13 @@ Repository, RequestOptions, TreeEntry, + WriteCommitAction, ) PROVIDER_TYPE: ProviderName = "cursor_origin" CURSOR_ORIGIN_WEB_BASE_URL = "https://cursor.com/codebase" +# Origin has no no-reply address for an app. +CURSOR_ORIGIN_APP_COMMIT_EMAIL = "noreply@sentry.io" PAGE_TOKEN_PARAM = "pageToken" PAGE_SIZE_PARAM = "pageSize" # The cursor `iter_all_pages` sends for the first page. The first page takes no token. @@ -169,6 +182,16 @@ def get_branch( ) return map_action(response, map_git_ref) + def create_branch(self, branch: BranchName, sha: SHA) -> ActionResult[GitRef]: + response = self.post( + f"/repos/{self.repository_path}/git/refs", + data={"ref": f"refs/heads/{branch}", "sha": sha}, + ) + return map_action(response, map_git_ref) + + def delete_branch(self, branch: BranchName) -> None: + self.delete(f"/repos/{self.repository_path}/git/refs/heads/{branch}") + def get_file_content( self, path: str, @@ -210,6 +233,119 @@ def get_directory_contents( "meta": {"next_cursor": None}, } + def create_commit( + self, + branch: BranchName, + parent_sha: SHA, + message: str, + actions: list[ChmodCommitAction | DeleteCommitAction | MoveCommitAction | WriteCommitAction], + force: bool = False, + create_branch: bool = False, + author: CommitAuthorParam | None = None, + *, + expected_head_sha: SHA | None = None, + ) -> ActionResult[Commit]: + """Commit ``actions`` onto ``branch``. See :func:`scm.actions.create_commit`. + + Origin only commits at the branch head, so ``parent_sha`` must match it. New branches are + created at ``parent_sha`` first. + """ + if force: + raise ResourceBadRequest(detail="Origin cannot force a branch to a new commit.") + if expected_head_sha is not None: + if create_branch: + raise ResourceBadRequest( + detail="'expected_head_sha' cannot be combined with 'create_branch': the branch has no head yet.", + ) + if expected_head_sha != parent_sha: + raise ResourceBadRequest(detail="Origin commits onto the branch head, so it must be 'parent_sha'.") + author = author or self._app_author() + + files = [change for action in actions for change in self._file_changes(action, parent_sha)] + if not create_branch: + return self._commit_files(branch, parent_sha, message, author, files, expected_head_sha) + + try: + self.get_branch(branch) + except ResourceNotFound: + pass + else: + raise ResourceConflict(detail=f"Branch '{branch}' already exists.") + self.create_branch(branch, parent_sha) + try: + return self._commit_files(branch, parent_sha, message, author, files, expected_head_sha) + except SCMCodedError: + # A timeout can hide a commit that landed, and a second writer can share the branch. + if self.get_branch(branch)["data"]["sha"] == parent_sha: + self.delete_branch(branch) + raise + + def _commit_files( + self, + branch: BranchName, + parent_sha: SHA, + message: str, + author: CommitAuthorParam, + files: list[dict[str, Any]], + expected_head_sha: SHA | None, + ) -> ActionResult[Commit]: + try: + response = self.post( + f"/repos/{self.repository_path}/git/commits:createFromFiles", + data={ + "targetBranch": branch, + "expectedHeadSha": parent_sha, + "message": message, + "author": {"name": author["name"], "email": author["email"]}, + "files": files, + }, + ) + except ResourceBadRequest as e: + if expected_head_sha is None: + raise + current_head = self.get_branch(branch)["data"]["sha"] + if current_head == expected_head_sha: + raise + raise StaleBranchHead( + detail=f"Branch '{branch}' is at {current_head}, expected {expected_head_sha}.", + ) from e + + commit_author = CommitAuthor(name=author["name"], email=author["email"], date=None) + return map_action( + response, + lambda raw: Commit(id=raw["sha"], message=message, author=commit_author, additions=None, deletions=None), + ) + + def _app_author(self) -> CommitAuthorParam: + app = self.get("/app", credentials_set="application").json() + return CommitAuthorParam(name=app["displayName"], email=CURSOR_ORIGIN_APP_COMMIT_EMAIL) + + def _file_changes( + self, + action: ChmodCommitAction | DeleteCommitAction | MoveCommitAction | WriteCommitAction, + parent_sha: SHA, + ) -> list[dict[str, Any]]: + """Origin takes whole files, so a move or a mode change rewrites the file.""" + if isinstance(action, WriteCommitAction): + return [{"path": action.filename, "content": action.content, "encoding": action.encoding}] + if isinstance(action, DeleteCommitAction): + return [{"path": action.filename, "delete": True}] + if isinstance(action, MoveCommitAction): + existing = self.get_file_content(action.old_filename, ref=parent_sha)["data"] + return [ + {"path": action.old_filename, "delete": True}, + {"path": action.new_filename, "content": existing["content"], "encoding": existing["encoding"]}, + ] + existing = self.get_file_content(action.filename, ref=parent_sha)["data"] + return [ + { + "path": action.filename, + "content": existing["content"], + "encoding": existing["encoding"], + "mode": "executable" if action.executable else "file", + } + ] + def get_tree( self, tree_sha: SHA, diff --git a/tests/unit/provider/test_cursor_origin.py b/tests/unit/provider/test_cursor_origin.py index d48f834..0aadb37 100644 --- a/tests/unit/provider/test_cursor_origin.py +++ b/tests/unit/provider/test_cursor_origin.py @@ -7,7 +7,10 @@ PathIsDirectory, PathIsNotDirectory, ResourceBadRequest, + ResourceConflict, + ResourceGatewayTimeout, ResourceNotFound, + StaleBranchHead, UnexpectedResponseFormat, ) from scm.providers.cursor_origin.provider import ( @@ -19,7 +22,12 @@ map_repository, ) from scm.types import ( + ChmodCommitAction, + CommitAuthorParam, + DeleteCommitAction, + MoveCommitAction, Repository, + WriteCommitAction, ) REPO = "acme/rocket" @@ -451,3 +459,220 @@ def test_an_app(self) -> None: def test_a_service_account(self) -> None: assert map_author({"serviceAccount": {"id": "sa_01"}}) == {"id": "sa_01", "username": ""} + + +AUTHOR = CommitAuthorParam(name="Jane Doe", email="jane@example.com") +CREATED = {"sha": "new123", "treeSha": "tree456", "previousHeadSha": "parent123"} + + +class TestCreateBranch: + def test_a_branch_is_created_as_a_ref( + self, provider: CursorOriginProvider, client: unittest.mock.MagicMock + ) -> None: + client.request.return_value = _response( + {"ref": "refs/heads/fix", "object": {"sha": "abc123", "type": "commit"}} + ) + + result = provider.create_branch("fix", "abc123") + + assert client.request.call_args.kwargs["method"] == "POST" + assert client.request.call_args.kwargs["path"] == f"/repos/{REPO}/git/refs" + assert client.request.call_args.kwargs["data"] == {"ref": "refs/heads/fix", "sha": "abc123"} + assert result["data"] == {"ref": "fix", "sha": "abc123"} + + +class TestDeleteBranch: + def test_a_branch_is_deleted_by_its_ref( + self, provider: CursorOriginProvider, client: unittest.mock.MagicMock + ) -> None: + client.request.return_value = _response({}, status_code=204) + + provider.delete_branch("fix") + + assert client.request.call_args.kwargs["method"] == "DELETE" + assert client.request.call_args.kwargs["path"] == f"/repos/{REPO}/git/refs/heads/fix" + + +class TestCreateCommit: + def test_files_are_committed_onto_the_parent( + self, provider: CursorOriginProvider, client: unittest.mock.MagicMock + ) -> None: + client.request.return_value = _response(CREATED) + + result = provider.create_commit( + "fix", + "parent123", + "Fix the thing", + [ + WriteCommitAction(action="update", filename="a.py", content="print(1)", encoding="utf-8"), + DeleteCommitAction(filename="b.py"), + ], + author=AUTHOR, + ) + + assert client.request.call_args.kwargs["path"] == f"/repos/{REPO}/git/commits:createFromFiles" + assert client.request.call_args.kwargs["data"] == { + "targetBranch": "fix", + "expectedHeadSha": "parent123", + "message": "Fix the thing", + "author": AUTHOR, + "files": [ + {"path": "a.py", "content": "print(1)", "encoding": "utf-8"}, + {"path": "b.py", "delete": True}, + ], + } + assert result["data"] == { + "id": "new123", + "message": "Fix the thing", + "author": {**AUTHOR, "date": None}, + "additions": None, + "deletions": None, + } + + def test_a_new_branch_is_created_at_the_parent_first( + self, provider: CursorOriginProvider, client: unittest.mock.MagicMock + ) -> None: + client.request.side_effect = [ + _response({"message": "not found"}, status_code=404), + _response({"ref": "refs/heads/fix", "object": {"sha": "parent123", "type": "commit"}}), + _response(CREATED), + ] + + provider.create_commit( + "fix", "parent123", "m", [DeleteCommitAction(filename="b.py")], create_branch=True, author=AUTHOR + ) + + lookup, create, commit = client.request.call_args_list + assert lookup.kwargs["path"] == f"/repos/{REPO}/git/ref/heads/fix" + assert create.kwargs["data"] == {"ref": "refs/heads/fix", "sha": "parent123"} + assert commit.kwargs["data"]["expectedHeadSha"] == "parent123" + + def test_a_branch_that_already_exists_is_a_conflict( + self, provider: CursorOriginProvider, client: unittest.mock.MagicMock + ) -> None: + """Origin would accept a branch already at the parent; GitHub refuses any existing one.""" + client.request.return_value = _response({"ref": "refs/heads/fix", "object": {"sha": "parent123"}}) + + with pytest.raises(ResourceConflict): + provider.create_commit( + "fix", "parent123", "m", [DeleteCommitAction(filename="b.py")], create_branch=True, author=AUTHOR + ) + + assert client.request.call_count == 1 + + def test_a_failed_commit_removes_the_branch_it_created( + self, provider: CursorOriginProvider, client: unittest.mock.MagicMock + ) -> None: + client.request.side_effect = [ + _response({"message": "not found"}, status_code=404), + _response({"ref": "refs/heads/fix", "object": {"sha": "parent123", "type": "commit"}}), + _response({"message": "unchanged tree"}, status_code=400), + _response({"ref": "refs/heads/fix", "object": {"sha": "parent123", "type": "commit"}}), + _response({}, status_code=204), + ] + + with pytest.raises(ResourceBadRequest): + provider.create_commit( + "fix", "parent123", "m", [DeleteCommitAction(filename="b.py")], create_branch=True, author=AUTHOR + ) + + assert client.request.call_args.kwargs["method"] == "DELETE" + assert client.request.call_args.kwargs["path"] == f"/repos/{REPO}/git/refs/heads/fix" + + def test_a_branch_a_commit_reached_is_kept( + self, provider: CursorOriginProvider, client: unittest.mock.MagicMock + ) -> None: + """A timeout can hide a commit that landed, so a branch past the parent is left alone.""" + client.request.side_effect = [ + _response({"message": "not found"}, status_code=404), + _response({"ref": "refs/heads/fix", "object": {"sha": "parent123", "type": "commit"}}), + _response({"message": "gateway timeout"}, status_code=504), + _response({"ref": "refs/heads/fix", "object": {"sha": "new123", "type": "commit"}}), + ] + + with pytest.raises(ResourceGatewayTimeout): + provider.create_commit( + "fix", "parent123", "m", [DeleteCommitAction(filename="b.py")], create_branch=True, author=AUTHOR + ) + + assert client.request.call_count == 4 + assert client.request.call_args.kwargs["method"] == "GET" + + def test_a_move_and_a_mode_change_rewrite_the_file( + self, provider: CursorOriginProvider, client: unittest.mock.MagicMock + ) -> None: + client.request.side_effect = [_response(FILE_RAW), _response(FILE_RAW), _response(CREATED)] + + provider.create_commit( + "fix", + "parent123", + "m", + [ + MoveCommitAction(old_filename="src/app.py", new_filename="src/main.py"), + ChmodCommitAction(executable=True, filename="src/app.py"), + ], + author=AUTHOR, + ) + + assert client.request.call_args_list[0].kwargs["params"] == {"path": "src/app.py", "ref": "parent123"} + assert client.request.call_args.kwargs["data"]["files"] == [ + {"path": "src/app.py", "delete": True}, + {"path": "src/main.py", "content": FILE_RAW["content"], "encoding": "base64"}, + {"path": "src/app.py", "content": FILE_RAW["content"], "encoding": "base64", "mode": "executable"}, + ] + + def test_a_commit_without_an_author_is_the_apps( + self, provider: CursorOriginProvider, client: unittest.mock.MagicMock + ) -> None: + client.request.side_effect = [_response({"id": "app_01example", "displayName": "Sentry"}), _response(CREATED)] + + provider.create_commit("fix", "parent123", "m", [DeleteCommitAction(filename="b.py")]) + + app_call, commit_call = client.request.call_args_list + assert app_call.kwargs["path"] == "/app" + assert app_call.kwargs["credentials_set"] == "application" + assert commit_call.kwargs["data"]["author"] == {"name": "Sentry", "email": "noreply@sentry.io"} + + def test_a_branch_cannot_be_forced(self, provider: CursorOriginProvider) -> None: + with pytest.raises(ResourceBadRequest): + provider.create_commit("fix", "parent123", "m", [], force=True, author=AUTHOR) + + def test_the_expected_head_must_be_the_parent(self, provider: CursorOriginProvider) -> None: + with pytest.raises(ResourceBadRequest): + provider.create_commit("fix", "parent123", "m", [], author=AUTHOR, expected_head_sha="other") + + def test_a_moved_head_is_a_stale_branch( + self, provider: CursorOriginProvider, client: unittest.mock.MagicMock + ) -> None: + client.request.side_effect = [ + _response({"message": "branch moved"}, status_code=400), + _response({"ref": "refs/heads/fix", "object": {"sha": "moved123", "type": "commit"}}), + ] + + with pytest.raises(StaleBranchHead): + provider.create_commit( + "fix", + "parent123", + "m", + [DeleteCommitAction(filename="b.py")], + author=AUTHOR, + expected_head_sha="parent123", + ) + + def test_a_refusal_at_the_expected_head_is_not_stale( + self, provider: CursorOriginProvider, client: unittest.mock.MagicMock + ) -> None: + client.request.side_effect = [ + _response({"message": "unchanged tree"}, status_code=400), + _response({"ref": "refs/heads/fix", "object": {"sha": "parent123", "type": "commit"}}), + ] + + with pytest.raises(ResourceBadRequest): + provider.create_commit( + "fix", + "parent123", + "m", + [DeleteCommitAction(filename="b.py")], + author=AUTHOR, + expected_head_sha="parent123", + )