From 3b9a4653cb1c6806d8a497aad638fe8c09ee7c91 Mon Sep 17 00:00:00 2001 From: Datata1 <> Date: Mon, 9 Feb 2026 23:21:50 +0100 Subject: [PATCH 1/4] feat(git): add git resource --- .../resources/workspace/__init__.py | 11 +- .../resources/workspace/git/__init__.py | 4 + .../resources/workspace/git/models.py | 42 ++++++ .../resources/workspace/git/operations.py | 28 ++++ .../resources/workspace/git/schema.py | 7 + .../resources/workspace/pipeline/resources.py | 0 src/codesphere/resources/workspace/schemas.py | 7 + tests/integration/test_git.py | 55 ++++++++ .../resources/workspace/git/__init__.py | 0 tests/resources/workspace/git/test_git.py | 123 ++++++++++++++++++ 10 files changed, 273 insertions(+), 4 deletions(-) create mode 100644 src/codesphere/resources/workspace/git/__init__.py create mode 100644 src/codesphere/resources/workspace/git/operations.py delete mode 100644 src/codesphere/resources/workspace/pipeline/resources.py create mode 100644 tests/integration/test_git.py rename src/codesphere/resources/workspace/pipeline/models.py => tests/resources/workspace/git/__init__.py (100%) create mode 100644 tests/resources/workspace/git/test_git.py diff --git a/src/codesphere/resources/workspace/__init__.py b/src/codesphere/resources/workspace/__init__.py index 74608a1..d80e4f1 100644 --- a/src/codesphere/resources/workspace/__init__.py +++ b/src/codesphere/resources/workspace/__init__.py @@ -1,12 +1,13 @@ +from .git import GitHead, WorkspaceGitManager +from .resources import WorkspacesResource from .schemas import ( + CommandInput, + CommandOutput, Workspace, WorkspaceCreate, - WorkspaceUpdate, WorkspaceStatus, - CommandInput, - CommandOutput, + WorkspaceUpdate, ) -from .resources import WorkspacesResource __all__ = [ "Workspace", @@ -16,4 +17,6 @@ "WorkspacesResource", "CommandInput", "CommandOutput", + "WorkspaceGitManager", + "GitHead", ] diff --git a/src/codesphere/resources/workspace/git/__init__.py b/src/codesphere/resources/workspace/git/__init__.py new file mode 100644 index 0000000..ea90a86 --- /dev/null +++ b/src/codesphere/resources/workspace/git/__init__.py @@ -0,0 +1,4 @@ +from .models import WorkspaceGitManager +from .schema import GitHead + +__all__ = ["WorkspaceGitManager", "GitHead"] diff --git a/src/codesphere/resources/workspace/git/models.py b/src/codesphere/resources/workspace/git/models.py index e69de29..0492a72 100644 --- a/src/codesphere/resources/workspace/git/models.py +++ b/src/codesphere/resources/workspace/git/models.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import logging +from typing import Optional + +from ....core.handler import _APIOperationExecutor +from ....http_client import APIHttpClient +from .operations import ( + _GET_HEAD_OP, + _PULL_OP, + _PULL_WITH_REMOTE_AND_BRANCH_OP, + _PULL_WITH_REMOTE_OP, +) +from .schema import GitHead + +log = logging.getLogger(__name__) + + +class WorkspaceGitManager(_APIOperationExecutor): + """Manager for git operations on a workspace.""" + + def __init__(self, http_client: APIHttpClient, workspace_id: int): + self._http_client = http_client + self._workspace_id = workspace_id + self.id = workspace_id + + async def get_head(self) -> GitHead: + return await self._execute_operation(_GET_HEAD_OP) + + async def pull( + self, + remote: Optional[str] = None, + branch: Optional[str] = None, + ) -> None: + if remote is not None and branch is not None: + await self._execute_operation( + _PULL_WITH_REMOTE_AND_BRANCH_OP, remote=remote, branch=branch + ) + elif remote is not None: + await self._execute_operation(_PULL_WITH_REMOTE_OP, remote=remote) + else: + await self._execute_operation(_PULL_OP) diff --git a/src/codesphere/resources/workspace/git/operations.py b/src/codesphere/resources/workspace/git/operations.py new file mode 100644 index 0000000..be12694 --- /dev/null +++ b/src/codesphere/resources/workspace/git/operations.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from ....core.operations import APIOperation +from .schema import GitHead + +_GET_HEAD_OP = APIOperation( + method="GET", + endpoint_template="/workspaces/{id}/git/head", + response_model=GitHead, +) + +_PULL_OP = APIOperation( + method="POST", + endpoint_template="/workspaces/{id}/git/pull", + response_model=type(None), +) + +_PULL_WITH_REMOTE_OP = APIOperation( + method="POST", + endpoint_template="/workspaces/{id}/git/pull/{remote}", + response_model=type(None), +) + +_PULL_WITH_REMOTE_AND_BRANCH_OP = APIOperation( + method="POST", + endpoint_template="/workspaces/{id}/git/pull/{remote}/{branch}", + response_model=type(None), +) diff --git a/src/codesphere/resources/workspace/git/schema.py b/src/codesphere/resources/workspace/git/schema.py index e69de29..adda47d 100644 --- a/src/codesphere/resources/workspace/git/schema.py +++ b/src/codesphere/resources/workspace/git/schema.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from ....core.base import CamelModel + + +class GitHead(CamelModel): + head: str diff --git a/src/codesphere/resources/workspace/pipeline/resources.py b/src/codesphere/resources/workspace/pipeline/resources.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/codesphere/resources/workspace/schemas.py b/src/codesphere/resources/workspace/schemas.py index 938bb5e..4d67a58 100644 --- a/src/codesphere/resources/workspace/schemas.py +++ b/src/codesphere/resources/workspace/schemas.py @@ -9,6 +9,7 @@ from ...core.base import CamelModel from ...utils import update_model_fields from .envVars import EnvVar, WorkspaceEnvVarManager +from .git import WorkspaceGitManager from .landscape import WorkspaceLandscapeManager log = logging.getLogger(__name__) @@ -137,3 +138,9 @@ def landscape(self) -> WorkspaceLandscapeManager: """Manager for landscape operations (Multi Server Deployments).""" http_client = self.validate_http_client() return WorkspaceLandscapeManager(http_client, workspace_id=self.id) + + @cached_property + def git(self) -> WorkspaceGitManager: + """Manager for git operations (head, pull).""" + http_client = self.validate_http_client() + return WorkspaceGitManager(http_client, workspace_id=self.id) diff --git a/tests/integration/test_git.py b/tests/integration/test_git.py new file mode 100644 index 0000000..b9177ce --- /dev/null +++ b/tests/integration/test_git.py @@ -0,0 +1,55 @@ +from typing import AsyncGenerator + +import pytest + +from codesphere import CodesphereSDK +from codesphere.resources.workspace import Workspace, WorkspaceCreate + +pytestmark = pytest.mark.integration + + +@pytest.fixture(scope="module") +async def git_workspace( + module_sdk_client: CodesphereSDK, + test_team_id: int, + test_plan_id: int, +) -> AsyncGenerator[Workspace, None]: + payload = WorkspaceCreate( + team_id=test_team_id, + name="sdk-git-integration-test", + plan_id=test_plan_id, + git_url="https://github.com/octocat/Hello-World.git", + ) + + workspace = await module_sdk_client.workspaces.create(payload=payload) + + try: + await workspace.wait_until_running(timeout=120.0) + yield workspace + finally: + try: + await workspace.delete() + except Exception: + pass + + +class TestGitIntegration: + @pytest.mark.asyncio + async def test_get_head(self, git_workspace: Workspace): + result = await git_workspace.git.get_head() + + assert result.head is not None + assert len(result.head) > 0 + + @pytest.mark.asyncio + async def test_pull_default(self, git_workspace: Workspace): + # This should not raise an exception + await git_workspace.git.pull() + + @pytest.mark.asyncio + async def test_pull_with_remote(self, git_workspace: Workspace): + await git_workspace.git.pull(remote="origin") + + @pytest.mark.asyncio + async def test_pull_with_remote_and_branch(self, git_workspace: Workspace): + await git_workspace.git.pull(remote="origin", branch="master") diff --git a/src/codesphere/resources/workspace/pipeline/models.py b/tests/resources/workspace/git/__init__.py similarity index 100% rename from src/codesphere/resources/workspace/pipeline/models.py rename to tests/resources/workspace/git/__init__.py diff --git a/tests/resources/workspace/git/test_git.py b/tests/resources/workspace/git/test_git.py new file mode 100644 index 0000000..622aafc --- /dev/null +++ b/tests/resources/workspace/git/test_git.py @@ -0,0 +1,123 @@ +import pytest + +from codesphere.resources.workspace.git import GitHead, WorkspaceGitManager + + +class TestWorkspaceGitManager: + @pytest.fixture + def git_manager(self, mock_http_client_for_resource): + def _create(response_data): + mock_client = mock_http_client_for_resource(response_data) + manager = WorkspaceGitManager(http_client=mock_client, workspace_id=72678) + return manager, mock_client + + return _create + + @pytest.mark.asyncio + async def test_get_head(self, git_manager): + manager, mock_client = git_manager({"head": "abc123def456"}) + + result = await manager.get_head() + + assert isinstance(result, GitHead) + assert result.head == "abc123def456" + mock_client.request.assert_awaited_once() + call_args = mock_client.request.call_args + assert call_args.kwargs.get("method") == "GET" + assert call_args.kwargs.get("endpoint") == "/workspaces/72678/git/head" + + @pytest.mark.asyncio + async def test_pull_without_arguments(self, git_manager): + manager, mock_client = git_manager(None) + + await manager.pull() + + mock_client.request.assert_awaited_once() + call_args = mock_client.request.call_args + assert call_args.kwargs.get("method") == "POST" + assert call_args.kwargs.get("endpoint") == "/workspaces/72678/git/pull" + + @pytest.mark.asyncio + async def test_pull_with_remote(self, git_manager): + manager, mock_client = git_manager(None) + + await manager.pull(remote="origin") + + mock_client.request.assert_awaited_once() + call_args = mock_client.request.call_args + assert call_args.kwargs.get("method") == "POST" + assert call_args.kwargs.get("endpoint") == "/workspaces/72678/git/pull/origin" + + @pytest.mark.asyncio + async def test_pull_with_remote_and_branch(self, git_manager): + manager, mock_client = git_manager(None) + + await manager.pull(remote="origin", branch="main") + + mock_client.request.assert_awaited_once() + call_args = mock_client.request.call_args + assert call_args.kwargs.get("method") == "POST" + assert ( + call_args.kwargs.get("endpoint") == "/workspaces/72678/git/pull/origin/main" + ) + + @pytest.mark.asyncio + async def test_pull_with_branch_only_ignores_branch(self, git_manager): + manager, mock_client = git_manager(None) + + # Branch without remote should be ignored per the implementation + await manager.pull(branch="main") + + mock_client.request.assert_awaited_once() + call_args = mock_client.request.call_args + assert call_args.kwargs.get("method") == "POST" + assert call_args.kwargs.get("endpoint") == "/workspaces/72678/git/pull" + + @pytest.mark.asyncio + async def test_pull_with_custom_remote(self, git_manager): + """pull should work with custom remote names.""" + manager, mock_client = git_manager(None) + + await manager.pull(remote="upstream") + + mock_client.request.assert_awaited_once() + call_args = mock_client.request.call_args + assert call_args.kwargs.get("endpoint") == "/workspaces/72678/git/pull/upstream" + + @pytest.mark.asyncio + async def test_pull_with_feature_branch(self, git_manager): + manager, mock_client = git_manager(None) + + await manager.pull(remote="origin", branch="feature/my-feature") + + mock_client.request.assert_awaited_once() + call_args = mock_client.request.call_args + assert ( + call_args.kwargs.get("endpoint") + == "/workspaces/72678/git/pull/origin/feature/my-feature" + ) + + +class TestGitHeadModel: + def test_create_git_head(self): + git_head = GitHead(head="abc123def456") + + assert git_head.head == "abc123def456" + + def test_git_head_from_dict(self): + git_head = GitHead.model_validate({"head": "abc123def456"}) + + assert git_head.head == "abc123def456" + + def test_git_head_dump(self): + git_head = GitHead(head="abc123def456") + dumped = git_head.model_dump() + + assert dumped == {"head": "abc123def456"} + + def test_git_head_with_full_sha(self): + full_sha = "a" * 40 + git_head = GitHead(head=full_sha) + + assert git_head.head == full_sha + assert len(git_head.head) == 40 From a2865aa8df7bcf5009270c73012127f349e0c1de Mon Sep 17 00:00:00 2001 From: Datata1 <> Date: Mon, 9 Feb 2026 23:28:40 +0100 Subject: [PATCH 2/4] fix test --- tests/integration/test_git.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/integration/test_git.py b/tests/integration/test_git.py index b9177ce..7ed88cd 100644 --- a/tests/integration/test_git.py +++ b/tests/integration/test_git.py @@ -8,9 +8,9 @@ pytestmark = pytest.mark.integration -@pytest.fixture(scope="module") +@pytest.fixture(scope="session") async def git_workspace( - module_sdk_client: CodesphereSDK, + session_sdk_client: CodesphereSDK, test_team_id: int, test_plan_id: int, ) -> AsyncGenerator[Workspace, None]: @@ -21,7 +21,7 @@ async def git_workspace( git_url="https://github.com/octocat/Hello-World.git", ) - workspace = await module_sdk_client.workspaces.create(payload=payload) + workspace = await session_sdk_client.workspaces.create(payload=payload) try: await workspace.wait_until_running(timeout=120.0) From 62006e9a465eef2c0e0a9ab9df5ce9e398d6347e Mon Sep 17 00:00:00 2001 From: Datata1 <> Date: Mon, 9 Feb 2026 23:39:27 +0100 Subject: [PATCH 3/4] fix test --- tests/integration/test_git.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/test_git.py b/tests/integration/test_git.py index 7ed88cd..81f442b 100644 --- a/tests/integration/test_git.py +++ b/tests/integration/test_git.py @@ -16,7 +16,7 @@ async def git_workspace( ) -> AsyncGenerator[Workspace, None]: payload = WorkspaceCreate( team_id=test_team_id, - name="sdk-git-integration-test", + name="sdk-git-integration-test-git", plan_id=test_plan_id, git_url="https://github.com/octocat/Hello-World.git", ) From 33debcb961274235dab083532eabc9bcf0de128f Mon Sep 17 00:00:00 2001 From: Datata1 <> Date: Tue, 10 Feb 2026 00:01:44 +0100 Subject: [PATCH 4/4] fix test --- tests/integration/conftest.py | 33 ++++++++++++++++++------ tests/integration/test_git.py | 47 +++++++---------------------------- 2 files changed, 35 insertions(+), 45 deletions(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 6050573..2ba2ee1 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -16,7 +16,6 @@ def pytest_addoption(parser): - """Add custom command line options for integration tests.""" parser.addoption( "--run-integration", action="store_true", @@ -26,14 +25,12 @@ def pytest_addoption(parser): def pytest_configure(config): - """Register custom markers.""" config.addinivalue_line( "markers", "integration: mark test as integration test (requires API token)" ) def pytest_collection_modifyitems(config, items): - """Skip integration tests unless --run-integration is passed.""" if config.getoption("--run-integration"): return @@ -121,19 +118,27 @@ async def test_workspaces( ) -> AsyncGenerator[List[Workspace], None]: created_workspaces: List[Workspace] = [] - for i in range(2): - workspace_name = f"{TEST_WORKSPACE_PREFIX}-{i + 1}" + workspace_configs = [ + {"name": f"{TEST_WORKSPACE_PREFIX}-1", "git_url": None}, + { + "name": f"{TEST_WORKSPACE_PREFIX}-git", + "git_url": "https://github.com/octocat/Hello-World.git", + }, + ] + + for config in workspace_configs: payload = WorkspaceCreate( team_id=test_team_id, - name=workspace_name, + name=config["name"], plan_id=test_plan_id, + git_url=config["git_url"], ) try: workspace = await session_sdk_client.workspaces.create(payload=payload) created_workspaces.append(workspace) log.info(f"Created test workspace: {workspace.name} (ID: {workspace.id})") except Exception as e: - log.error(f"Failed to create test workspace {workspace_name}: {e}") + log.error(f"Failed to create test workspace {config['name']}: {e}") for ws in created_workspaces: try: await ws.delete() @@ -155,3 +160,17 @@ async def test_workspaces( @pytest.fixture(scope="session") async def test_workspace(test_workspaces: List[Workspace]) -> Workspace: return test_workspaces[0] + + +@pytest.fixture(scope="session") +def git_workspace_id(test_workspaces: List[Workspace]) -> int: + return test_workspaces[1].id + + +@pytest.fixture +async def workspace_with_git( + sdk_client: CodesphereSDK, git_workspace_id: int +) -> Workspace: + workspace = await sdk_client.workspaces.get(workspace_id=git_workspace_id) + await workspace.wait_until_running(timeout=120.0) + return workspace diff --git a/tests/integration/test_git.py b/tests/integration/test_git.py index 81f442b..f15d839 100644 --- a/tests/integration/test_git.py +++ b/tests/integration/test_git.py @@ -1,55 +1,26 @@ -from typing import AsyncGenerator - import pytest -from codesphere import CodesphereSDK -from codesphere.resources.workspace import Workspace, WorkspaceCreate +from codesphere.resources.workspace import Workspace pytestmark = pytest.mark.integration -@pytest.fixture(scope="session") -async def git_workspace( - session_sdk_client: CodesphereSDK, - test_team_id: int, - test_plan_id: int, -) -> AsyncGenerator[Workspace, None]: - payload = WorkspaceCreate( - team_id=test_team_id, - name="sdk-git-integration-test-git", - plan_id=test_plan_id, - git_url="https://github.com/octocat/Hello-World.git", - ) - - workspace = await session_sdk_client.workspaces.create(payload=payload) - - try: - await workspace.wait_until_running(timeout=120.0) - yield workspace - finally: - try: - await workspace.delete() - except Exception: - pass - - class TestGitIntegration: @pytest.mark.asyncio - async def test_get_head(self, git_workspace: Workspace): - result = await git_workspace.git.get_head() + async def test_get_head(self, workspace_with_git: Workspace): + result = await workspace_with_git.git.get_head() assert result.head is not None assert len(result.head) > 0 @pytest.mark.asyncio - async def test_pull_default(self, git_workspace: Workspace): - # This should not raise an exception - await git_workspace.git.pull() + async def test_pull_default(self, workspace_with_git: Workspace): + await workspace_with_git.git.pull() @pytest.mark.asyncio - async def test_pull_with_remote(self, git_workspace: Workspace): - await git_workspace.git.pull(remote="origin") + async def test_pull_with_remote(self, workspace_with_git: Workspace): + await workspace_with_git.git.pull(remote="origin") @pytest.mark.asyncio - async def test_pull_with_remote_and_branch(self, git_workspace: Workspace): - await git_workspace.git.pull(remote="origin", branch="master") + async def test_pull_with_remote_and_branch(self, workspace_with_git: Workspace): + await workspace_with_git.git.pull(remote="origin", branch="master")