From 7f6e383374374a4e2636fe6414d4688f82e5cc9d Mon Sep 17 00:00:00 2001 From: edavidaja Date: Wed, 24 Dec 2025 16:38:47 -0500 Subject: [PATCH] `rsconnect deploy git` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds support for creating git-backed deployments supersedes #501 Features: - New `rsconnect deploy git` command with --repository, --branch, and --subdirectory options - Comprehensive test coverage for CLI and API methods 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- docs/CHANGELOG.md | 3 +- rsconnect/api.py | 282 +++++++++++++++++++++++++++++ rsconnect/main.py | 317 ++++++++++++++++++++++++++++++++- rsconnect/models.py | 19 ++ tests/test_api.py | 422 ++++++++++++++++++++++++++++++++++++++++++++ tests/test_main.py | 61 +++++++ 6 files changed, 1098 insertions(+), 6 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index a66cc122..5063736f 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added `rsconnect deploy git` command to create a [git-backed deployment](https://docs.posit.co/connect/user/git-backed/). + Use `--branch` to specify a branch (default: main) and `--subdirectory` to deploy content from a subdirectory. - `rsconnect content get-lockfile` command allows fetching a lockfile with the dependencies installed by connect to run the deployed content - `rsconnect content venv` command recreates a local python environment @@ -22,7 +24,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 or override detected values. Use `--no-metadata` to disable automatic detection. (#736) supply an explicit requirements file instead of detecting the environment. - ## [1.28.2] - 2025-12-05 ### Fixed diff --git a/rsconnect/api.py b/rsconnect/api.py index 8753012c..66fa7aa5 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -78,6 +78,8 @@ DeleteOutputDTO, ListEntryOutputDTO, PyInfo, + RepositoryBundleOutput, + RepositoryInfo, ServerSettings, TaskStatusV1, UserRecord, @@ -592,6 +594,225 @@ def content_deploy( response = self._server.handle_bad_response(response) return response + def get_repository(self, content_guid: str) -> Optional[RepositoryInfo]: + """Get git repository configuration for a content item. + + GET /v1/content/{guid}/repository + + :param content_guid: The GUID of the content item + :return: Repository configuration if git-managed, None otherwise + """ + response = self.get("v1/content/%s/repository" % content_guid) + if isinstance(response, HTTPResponse): + # 404 means not git-managed, which is not an error + if response.status == 404: + return None + self._server.handle_bad_response(response) + return cast(RepositoryInfo, response) + + def set_repository( + self, + content_guid: str, + repository: str, + branch: str = "main", + directory: str = ".", + polling: bool = True, + ) -> RepositoryInfo: + """Create or overwrite git repository configuration for a content item. + + PUT /v1/content/{guid}/repository + + :param content_guid: The GUID of the content item + :param repository: URL of the git repository (https:// only) + :param branch: Branch to deploy from (default: main) + :param directory: Directory containing manifest.json (default: .) + :param polling: Enable auto-redeploy when commits are pushed (default: True) + :return: The repository configuration + """ + body = { + "repository": repository, + "branch": branch, + "directory": directory, + "polling": polling, + } + response = cast( + Union[RepositoryInfo, HTTPResponse], + self.put("v1/content/%s/repository" % content_guid, body=body), + ) + response = self._server.handle_bad_response(response) + return response + + def update_repository( + self, + content_guid: str, + repository: Optional[str] = None, + branch: Optional[str] = None, + directory: Optional[str] = None, + polling: Optional[bool] = None, + ) -> RepositoryInfo: + """Partially update git repository configuration for a content item. + + PATCH /v1/content/{guid}/repository + + Only fields that are provided will be updated. + + :param content_guid: The GUID of the content item + :param repository: URL of the git repository (https:// only) + :param branch: Branch to deploy from + :param directory: Directory containing manifest.json + :param polling: Enable auto-redeploy when commits are pushed + :return: The updated repository configuration + """ + body: dict[str, str | bool] = {} + if repository is not None: + body["repository"] = repository + if branch is not None: + body["branch"] = branch + if directory is not None: + body["directory"] = directory + if polling is not None: + body["polling"] = polling + + response = cast( + Union[RepositoryInfo, HTTPResponse], + self.patch("v1/content/%s/repository" % content_guid, body=body), + ) + response = self._server.handle_bad_response(response) + return response + + def delete_repository(self, content_guid: str) -> None: + """Remove git repository configuration from a content item. + + DELETE /v1/content/{guid}/repository + + :param content_guid: The GUID of the content item + """ + response = self.delete("v1/content/%s/repository" % content_guid) + if isinstance(response, HTTPResponse): + self._server.handle_bad_response(response, is_httpresponse=True) + + def create_bundle_from_repository( + self, + content_guid: str, + repository: Optional[str] = None, + ref: Optional[str] = None, + directory: Optional[str] = None, + ) -> RepositoryBundleOutput: + """Create a bundle from a git repository location. + + POST /v1/content/{guid}/repository/bundle + + This triggers Connect to clone the repository and create a bundle. + If the content item has existing git configuration, those values are used + as defaults; provided parameters will override them. + + :param content_guid: The GUID of the content item + :param repository: URL of the git repository (uses existing config if not provided) + :param ref: Git ref to bundle from (branch, tag, or commit; uses existing branch if not provided) + :param directory: Directory containing manifest.json (uses existing config if not provided) + :return: Bundle creation result with bundle_id and task_id + """ + body: dict[str, str] = {} + if repository is not None: + body["repository"] = repository + if ref is not None: + body["ref"] = ref + if directory is not None: + body["directory"] = directory + + response = cast( + Union[RepositoryBundleOutput, HTTPResponse], + self.post("v1/content/%s/repository/bundle" % content_guid, body=body), + ) + response = self._server.handle_bad_response(response) + return response + + def deploy_git( + self, + app_id: Optional[str], + name: str, + repository: str, + branch: str, + subdirectory: str, + title: Optional[str], + env_vars: Optional[dict[str, str]], + polling: bool = True, + ) -> RSConnectClientDeployResult: + """Deploy content from a git repository. + + Creates or updates a git-backed content item in Posit Connect. Connect will clone + the repository and automatically redeploy when commits are pushed (if polling is enabled). + + :param app_id: Existing content ID/GUID to update, or None to create new content + :param name: Name for the content item (used if creating new) + :param repository: URL of the git repository (https:// only) + :param branch: Branch to deploy from + :param subdirectory: Subdirectory containing manifest.json + :param title: Title for the content + :param env_vars: Environment variables to set + :param polling: Enable auto-redeploy when commits are pushed (default: True) + :return: Deployment result with task_id, app info, etc. + """ + # Create or get existing content + if app_id is None: + app = self.content_create(name) + else: + try: + app = self.get_content_by_id(app_id) + except RSConnectException as e: + raise RSConnectException( + f"{e} Try setting the --new flag or omit --app-id to create new content." + ) from e + + app_guid = app["guid"] + + # Map subdirectory to directory (API uses "directory" field) + directory = subdirectory if subdirectory else "." + + # Check if content already has git configuration + existing_repo = self.get_repository(app_guid) + + if existing_repo: + # Update existing git configuration using PATCH + self.update_repository( + app_guid, + repository=repository, + branch=branch, + directory=directory, + polling=polling, + ) + else: + # Create new git configuration using PUT + self.set_repository( + app_guid, + repository=repository, + branch=branch, + directory=directory, + polling=polling, + ) + + # Update title if provided (and different from current) + if title and app.get("title") != title: + self.patch("v1/content/%s" % app_guid, body={"title": title}) + + # Set environment variables + if env_vars: + result = self.add_environment_vars(app_guid, list(env_vars.items())) + self._server.handle_bad_response(result) + + # Trigger deployment (bundle_id=None uses the latest bundle from git clone) + task = self.content_deploy(app_guid, bundle_id=None) + + return RSConnectClientDeployResult( + app_id=str(app["id"]), + app_guid=app_guid, + app_url=app["content_url"], + task_id=task["task_id"], + title=title or app.get("title"), + dashboard_url=app["dashboard_url"], + draft_url=None, + ) + def system_caches_runtime_list(self) -> list[ListEntryOutputDTO]: response = cast(Union[List[ListEntryOutputDTO], HTTPResponse], self.get("v1/system/caches/runtime")) response = self._server.handle_bad_response(response) @@ -784,6 +1005,10 @@ def __init__( disable_env_management: Optional[bool] = None, env_vars: Optional[dict[str, str]] = None, metadata: Optional[dict[str, str]] = None, + repository: Optional[str] = None, + branch: Optional[str] = None, + subdirectory: Optional[str] = None, + polling: bool = True, ) -> None: self.remote_server: TargetableServer self.client: RSConnectClient | PositClient @@ -805,6 +1030,12 @@ def __init__( self.title_is_default: bool = not title self.deployment_name: str | None = None + # Git deployment parameters + self.repository: str | None = repository + self.branch: str | None = branch + self.subdirectory: str | None = subdirectory + self.polling: bool = polling + self.bundle: IO[bytes] | None = None self.deployed_info: RSConnectClientDeployResult | None = None @@ -847,6 +1078,10 @@ def fromConnectServer( disable_env_management: Optional[bool] = None, env_vars: Optional[dict[str, str]] = None, metadata: Optional[dict[str, str]] = None, + repository: Optional[str] = None, + branch: Optional[str] = None, + subdirectory: Optional[str] = None, + polling: bool = True, ): return cls( ctx=ctx, @@ -870,6 +1105,10 @@ def fromConnectServer( disable_env_management=disable_env_management, env_vars=env_vars, metadata=metadata, + repository=repository, + branch=branch, + subdirectory=subdirectory, + polling=polling, ) def output_overlap_header(self, previous: bool) -> bool: @@ -1169,6 +1408,49 @@ def deploy_bundle(self, activate: bool = True): ) return self + @cls_logged("Creating git-backed deployment ...") + def deploy_git(self): + """Deploy content from a remote git repository. + + Creates a git-backed content item in Posit Connect. Connect will clone + the repository and automatically redeploy when commits are pushed. + """ + if not isinstance(self.client, RSConnectClient): + raise RSConnectException( + "Git deployment is only supported for Posit Connect servers, " "not shinyapps.io or Posit Cloud." + ) + + if not self.repository: + raise RSConnectException("Repository URL is required for git deployment.") + + # Generate a valid deployment name from the title + # This sanitizes characters like "/" that aren't allowed in names + force_unique_name = self.app_id is None + deployment_name = self.make_deployment_name(self.title, force_unique_name) + + try: + result = self.client.deploy_git( + app_id=self.app_id, + name=deployment_name, + repository=self.repository, + branch=self.branch or "main", + subdirectory=self.subdirectory or "", + title=self.title, + env_vars=self.env_vars, + polling=self.polling, + ) + except RSConnectException as e: + # Check for 404 on /repo endpoint (git not enabled) + if "404" in str(e) and "repo" in str(e).lower(): + raise RSConnectException( + "Git-backed deployment is not enabled on this Connect server. " + "Contact your administrator to enable Git support." + ) from e + raise + + self.deployed_info = result + return self + def emit_task_log( self, log_callback: logging.Logger = connect_logger, diff --git a/rsconnect/main.py b/rsconnect/main.py index e939ab30..5b600d4c 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -3,12 +3,12 @@ import functools import json import os -import sys -import textwrap -import traceback import shutil import subprocess +import sys import tempfile +import textwrap +import traceback from functools import wraps from os.path import abspath, dirname, exists, isdir, join from typing import ( @@ -91,7 +91,7 @@ write_tensorflow_manifest_json, write_voila_manifest_json, ) -from .environment import Environment, fake_module_file_from_directory +from .environment import Environment, PackageInstaller, fake_module_file_from_directory from .exception import RSConnectException from .git_metadata import detect_git_metadata from .json_web_token import ( @@ -113,7 +113,6 @@ VersionSearchFilter, VersionSearchFilterParamType, ) -from .environment import PackageInstaller from .shiny_express import escape_to_var_name, is_express_app from .utils_package import fix_starlette_requirements @@ -327,6 +326,23 @@ def prepare_deploy_metadata( return None +def _generate_git_title(repository: str, subdirectory: str) -> str: + """Generate a title from repository URL and subdirectory. + + :param repository: URL of the git repository + :param subdirectory: Subdirectory within the repository + :return: Generated title string + """ + # Extract repo name from URL (e.g., "https://github.com/user/repo" -> "repo") + repo_name = repository.rstrip("/").split("/")[-1] + if repo_name.endswith(".git"): + repo_name = repo_name[:-4] + + if subdirectory and subdirectory != "/" and subdirectory.strip("/"): + return f"{repo_name}/{subdirectory.strip('/')}" + return repo_name + + def content_args(func: Callable[P, T]) -> Callable[P, T]: @click.option( "--new", @@ -1474,6 +1490,99 @@ def deploy_manifest( ce.verify_deployment() +@deploy.command( + name="git", + short_help="Deploy content from a Git repository to Posit Connect.", + help=( + "Deploy content to Posit Connect directly from a remote Git repository. " + "The repository must contain a manifest.json file (in the root or specified subdirectory). " + "Connect will periodically check for updates and redeploy automatically when commits are pushed." + "\n\n" + "This command creates a new git-backed content item. To update an existing git-backed " + "content item, use the --app-id option with the content's GUID." + ), +) +@server_args +@spcs_args +@content_args +@click.option( + "--repository", + "-r", + required=True, + help="URL of the Git repository (https:// URLs only).", +) +@click.option( + "--branch", + "-b", + default="main", + help="Branch to deploy from. Connect auto-deploys when commits are pushed. [default: main]", +) +@click.option( + "--subdirectory", + "-d", + default="", + help="Subdirectory containing manifest.json. Use path syntax (e.g., 'path/to/content').", +) +@click.option( + "--polling/--no-polling", + default=True, + help="Enable/disable automatic redeployment when commits are pushed to the repository. [default: enabled]", +) +@cli_exception_handler +@click.pass_context +def deploy_git( + ctx: click.Context, + name: Optional[str], + server: Optional[str], + api_key: Optional[str], + snowflake_connection_name: Optional[str], + insecure: bool, + cacert: Optional[str], + verbose: int, + new: bool, + app_id: Optional[str], + title: Optional[str], + env_vars: dict[str, str], + no_verify: bool, + draft: bool, + metadata: tuple[str, ...], + no_metadata: bool, + repository: str, + branch: str, + subdirectory: str, + polling: bool, +): + set_verbosity(verbose) + output_params(ctx, locals().items()) + + # Generate title if not provided + if not title: + title = _generate_git_title(repository, subdirectory) + + ce = RSConnectExecutor( + ctx=ctx, + name=name, + api_key=api_key, + snowflake_connection_name=snowflake_connection_name, + insecure=insecure, + cacert=cacert, + server=server, + new=new, + app_id=app_id, + title=title, + env_vars=env_vars, + repository=repository, + branch=branch, + subdirectory=subdirectory.strip("/") if subdirectory else "", + polling=polling, + ) + + ce.validate_server().deploy_git().emit_task_log() + + if not no_verify: + ce.verify_deployment() + + # noinspection SpellCheckingInspection,DuplicatedCode @deploy.command( name="quarto", @@ -3208,6 +3317,204 @@ def _guid_for_current_server(server_url: str) -> Optional[str]: logger.info("Environment ready. Activate with: source %s/bin/activate" % env_path) +@content.group(no_args_is_help=True, help="Manage git repository configuration for content items.") +def repository(): + pass + + +@repository.command( + name="show", + short_help="Show git repository configuration for a content item.", +) +@server_args +@spcs_args +@click.option( + "--guid", + "-g", + required=True, + type=StrippedStringParamType(), + metavar="GUID", + help="The GUID of the content item.", +) +@cli_exception_handler +@click.pass_context +def repository_show( + ctx: click.Context, + name: Optional[str], + server: Optional[str], + api_key: Optional[str], + snowflake_connection_name: Optional[str], + insecure: bool, + cacert: Optional[str], + guid: str, + verbose: int, +): + """Show the git repository configuration for a content item.""" + set_verbosity(verbose) + output_params(ctx, locals().items()) + with cli_feedback("", stderr=True): + ce = RSConnectExecutor( + ctx=ctx, + name=name, + server=server, + api_key=api_key, + snowflake_connection_name=snowflake_connection_name, + insecure=insecure, + cacert=cacert, + logger=None, + ).validate_server() + if not isinstance(ce.client, RSConnectClient): + raise RSConnectException("`rsconnect content repository show` requires a Posit Connect server.") + + repo_info = ce.client.get_repository(guid) + if repo_info is None: + click.echo("Content item is not git-managed.", err=True) + ctx.exit(1) + else: + json.dump(repo_info, sys.stdout, indent=2) + click.echo() # newline after JSON + + +@repository.command( + name="delete", + short_help="Remove git repository configuration from a content item.", +) +@server_args +@spcs_args +@click.option( + "--guid", + "-g", + required=True, + type=StrippedStringParamType(), + metavar="GUID", + help="The GUID of the content item.", +) +@click.option( + "--force", + "-f", + is_flag=True, + help="Skip confirmation prompt.", +) +@cli_exception_handler +@click.pass_context +def repository_delete( + ctx: click.Context, + name: Optional[str], + server: Optional[str], + api_key: Optional[str], + snowflake_connection_name: Optional[str], + insecure: bool, + cacert: Optional[str], + guid: str, + force: bool, + verbose: int, +): + """Remove git repository configuration from a content item. + + This will stop automatic redeployment on git commits. The content item + will remain deployed but will no longer be git-managed. + """ + set_verbosity(verbose) + output_params(ctx, locals().items()) + + if not force: + click.confirm( + f"Are you sure you want to remove git configuration from content {guid}?", + abort=True, + ) + + with cli_feedback("Removing git repository configuration..."): + ce = RSConnectExecutor( + ctx=ctx, + name=name, + server=server, + api_key=api_key, + snowflake_connection_name=snowflake_connection_name, + insecure=insecure, + cacert=cacert, + logger=None, + ).validate_server() + if not isinstance(ce.client, RSConnectClient): + raise RSConnectException("`rsconnect content repository delete` requires a Posit Connect server.") + + ce.client.delete_repository(guid) + click.echo("Git repository configuration removed successfully.") + + +@repository.command( + name="redeploy", + short_help="Trigger a new deployment from the git repository.", +) +@server_args +@spcs_args +@click.option( + "--guid", + "-g", + required=True, + type=StrippedStringParamType(), + metavar="GUID", + help="The GUID of the content item.", +) +@click.option( + "--ref", + "-r", + default=None, + help="Git ref to deploy from (branch, tag, or commit SHA). Uses the configured branch if not specified.", +) +@cli_exception_handler +@click.pass_context +def repository_redeploy( + ctx: click.Context, + name: Optional[str], + server: Optional[str], + api_key: Optional[str], + snowflake_connection_name: Optional[str], + insecure: bool, + cacert: Optional[str], + guid: str, + ref: Optional[str], + verbose: int, +): + """Trigger a new deployment from the git repository. + + This creates a new bundle from the git repository and deploys it. + Optionally specify a different ref (branch, tag, or commit) to deploy from. + """ + set_verbosity(verbose) + output_params(ctx, locals().items()) + + with cli_feedback("Creating bundle from git repository..."): + ce = RSConnectExecutor( + ctx=ctx, + name=name, + server=server, + api_key=api_key, + snowflake_connection_name=snowflake_connection_name, + insecure=insecure, + cacert=cacert, + logger=None, + ).validate_server() + if not isinstance(ce.client, RSConnectClient): + raise RSConnectException("`rsconnect content repository redeploy` requires a Posit Connect server.") + + result = ce.client.create_bundle_from_repository(guid, ref=ref) + + click.echo(f"Bundle created: {result['bundle_id']}") + click.echo(f"Task ID: {result['task_id']}") + + # Wait for the bundling task to complete, then deploy + click.echo("Waiting for bundle creation...") + _, task_status = ce.client.wait_for_task(result["task_id"], log_callback=lambda msg: logger.info(msg)) + + if task_status.get("error"): + raise RSConnectException(f"Bundle creation failed: {task_status.get('error')}") + + click.echo("Deploying bundle...") + deploy_result = ce.client.content_deploy(guid, bundle_id=result["bundle_id"]) + + click.echo(f"Deployment task started: {deploy_result['task_id']}") + + @content.group(no_args_is_help=True, help="Build content on Posit Connect. Requires Connect >= 2021.11.1") def build(): pass diff --git a/rsconnect/models.py b/rsconnect/models.py index 49cea38d..f88bbe9f 100644 --- a/rsconnect/models.py +++ b/rsconnect/models.py @@ -610,3 +610,22 @@ class UserRecord(TypedDict): guid: str preferences: dict[str, object] privileges: list[str] + + +class RepositoryInfo(TypedDict): + """Git repository configuration for content items.""" + + repository: str + branch: str + directory: str + polling: bool + last_error: str + last_known_commit: str + + +class RepositoryBundleOutput(TypedDict): + """Output from creating a bundle from a git repository.""" + + bundle_id: str + task_id: str + location: dict[str, str] diff --git a/tests/test_api.py b/tests/test_api.py index b42f816c..02c45828 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -408,3 +408,425 @@ def test_exchange_token_empty_response(self, mock_fmt_payload, mock_token_endpoi RSConnectException, match="Failed to exchange Snowflake token: Token exchange returned empty response" ): server.exchange_token() + + +class RSConnectClientDeployGitTestCase(TestCase): + """Tests for RSConnectClient.deploy_git() method.""" + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_deploy_git_creates_new_content(self): + """Test that deploy_git creates new content when app_id is None.""" + server = RSConnectServer("http://test-server", "api_key") + client = RSConnectClient(server) + + # Mock content creation + httpretty.register_uri( + httpretty.POST, + "http://test-server/__api__/v1/content", + body=json.dumps({ + "id": 123, + "guid": "abc-123", + "name": "test-app", + "title": None, + "content_url": "http://test-server/content/abc-123/", + "dashboard_url": "http://test-server/connect/#/apps/abc-123", + }), + status=200, + forcing_headers={"Content-Type": "application/json"}, + ) + + # Mock GET repository config (404 = not git-managed yet) + httpretty.register_uri( + httpretty.GET, + "http://test-server/__api__/v1/content/abc-123/repository", + body=json.dumps({"code": 4, "error": "Not Found"}), + status=404, + forcing_headers={"Content-Type": "application/json"}, + ) + + # Mock PUT repository configuration (for new content) + httpretty.register_uri( + httpretty.PUT, + "http://test-server/__api__/v1/content/abc-123/repository", + body=json.dumps({ + "repository": "https://github.com/user/repo", + "branch": "main", + "directory": ".", + "polling": True, + "last_error": "", + "last_known_commit": "", + }), + status=200, + forcing_headers={"Content-Type": "application/json"}, + ) + + # Mock title update + httpretty.register_uri( + httpretty.PATCH, + "http://test-server/__api__/v1/content/abc-123", + body=json.dumps({"title": "Test App"}), + status=200, + forcing_headers={"Content-Type": "application/json"}, + ) + + # Mock content deploy + httpretty.register_uri( + httpretty.POST, + "http://test-server/__api__/v1/content/abc-123/deploy", + body=json.dumps({"task_id": "task-456"}), + status=200, + forcing_headers={"Content-Type": "application/json"}, + ) + + result = client.deploy_git( + app_id=None, + name="test-app", + repository="https://github.com/user/repo", + branch="main", + subdirectory="", + title="Test App", + env_vars=None, + ) + + self.assertEqual(result["app_id"], "123") + self.assertEqual(result["app_guid"], "abc-123") + self.assertEqual(result["task_id"], "task-456") + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_deploy_git_updates_existing_content(self): + """Test that deploy_git updates existing git-managed content when app_id is provided.""" + server = RSConnectServer("http://test-server", "api_key") + client = RSConnectClient(server) + + # Mock get existing content + httpretty.register_uri( + httpretty.GET, + "http://test-server/__api__/v1/content/abc-123", + body=json.dumps({ + "id": 123, + "guid": "abc-123", + "name": "existing-app", + "title": "Old Title", + "content_url": "http://test-server/content/abc-123/", + "dashboard_url": "http://test-server/connect/#/apps/abc-123", + }), + status=200, + forcing_headers={"Content-Type": "application/json"}, + ) + + # Mock GET repository config (200 = already git-managed) + httpretty.register_uri( + httpretty.GET, + "http://test-server/__api__/v1/content/abc-123/repository", + body=json.dumps({ + "repository": "https://github.com/user/repo", + "branch": "main", + "directory": ".", + "polling": True, + "last_error": "", + "last_known_commit": "abc123", + }), + status=200, + forcing_headers={"Content-Type": "application/json"}, + ) + + # Mock PATCH repository configuration (for updating existing) + httpretty.register_uri( + httpretty.PATCH, + "http://test-server/__api__/v1/content/abc-123/repository", + body=json.dumps({ + "repository": "https://github.com/user/repo", + "branch": "feature", + "directory": "app", + "polling": True, + "last_error": "", + "last_known_commit": "abc123", + }), + status=200, + forcing_headers={"Content-Type": "application/json"}, + ) + + # Mock title update + httpretty.register_uri( + httpretty.PATCH, + "http://test-server/__api__/v1/content/abc-123", + body=json.dumps({"title": "New Title"}), + status=200, + forcing_headers={"Content-Type": "application/json"}, + ) + + # Mock content deploy + httpretty.register_uri( + httpretty.POST, + "http://test-server/__api__/v1/content/abc-123/deploy", + body=json.dumps({"task_id": "task-789"}), + status=200, + forcing_headers={"Content-Type": "application/json"}, + ) + + result = client.deploy_git( + app_id="abc-123", + name="existing-app", + repository="https://github.com/user/repo", + branch="feature", + subdirectory="app", + title="New Title", + env_vars=None, + ) + + self.assertEqual(result["app_id"], "123") + self.assertEqual(result["app_guid"], "abc-123") + self.assertEqual(result["task_id"], "task-789") + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_deploy_git_with_polling_disabled(self): + """Test that deploy_git respects the polling parameter.""" + server = RSConnectServer("http://test-server", "api_key") + client = RSConnectClient(server) + + # Mock content creation + httpretty.register_uri( + httpretty.POST, + "http://test-server/__api__/v1/content", + body=json.dumps({ + "id": 123, + "guid": "abc-123", + "name": "test-app", + "title": None, + "content_url": "http://test-server/content/abc-123/", + "dashboard_url": "http://test-server/connect/#/apps/abc-123", + }), + status=200, + forcing_headers={"Content-Type": "application/json"}, + ) + + # Mock GET repository config (404 = not git-managed yet) + httpretty.register_uri( + httpretty.GET, + "http://test-server/__api__/v1/content/abc-123/repository", + status=404, + forcing_headers={"Content-Type": "application/json"}, + ) + + # Mock PUT repository configuration - expect polling=False + def check_polling_request(request, uri, response_headers): + body = json.loads(request.body) + assert body["polling"] is False, "Expected polling to be False" + return [200, response_headers, json.dumps({ + "repository": "https://github.com/user/repo", + "branch": "main", + "directory": ".", + "polling": False, + "last_error": "", + "last_known_commit": "", + })] + + httpretty.register_uri( + httpretty.PUT, + "http://test-server/__api__/v1/content/abc-123/repository", + body=check_polling_request, + forcing_headers={"Content-Type": "application/json"}, + ) + + # Mock content deploy + httpretty.register_uri( + httpretty.POST, + "http://test-server/__api__/v1/content/abc-123/deploy", + body=json.dumps({"task_id": "task-456"}), + status=200, + forcing_headers={"Content-Type": "application/json"}, + ) + + result = client.deploy_git( + app_id=None, + name="test-app", + repository="https://github.com/user/repo", + branch="main", + subdirectory="", + title=None, + env_vars=None, + polling=False, + ) + + self.assertEqual(result["app_guid"], "abc-123") + + +class RSConnectClientRepositoryTestCase(TestCase): + """Tests for RSConnectClient repository management methods.""" + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_get_repository_returns_config(self): + """Test that get_repository returns repository configuration.""" + server = RSConnectServer("http://test-server", "api_key") + client = RSConnectClient(server) + + httpretty.register_uri( + httpretty.GET, + "http://test-server/__api__/v1/content/abc-123/repository", + body=json.dumps({ + "repository": "https://github.com/user/repo", + "branch": "main", + "directory": ".", + "polling": True, + "last_error": "", + "last_known_commit": "abc123", + }), + status=200, + forcing_headers={"Content-Type": "application/json"}, + ) + + result = client.get_repository("abc-123") + + self.assertIsNotNone(result) + self.assertEqual(result["repository"], "https://github.com/user/repo") + self.assertEqual(result["branch"], "main") + self.assertTrue(result["polling"]) + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_get_repository_returns_none_for_non_git_content(self): + """Test that get_repository returns None for non-git-managed content.""" + server = RSConnectServer("http://test-server", "api_key") + client = RSConnectClient(server) + + httpretty.register_uri( + httpretty.GET, + "http://test-server/__api__/v1/content/abc-123/repository", + body=json.dumps({"code": 4, "error": "Not Found"}), + status=404, + forcing_headers={"Content-Type": "application/json"}, + ) + + result = client.get_repository("abc-123") + + self.assertIsNone(result) + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_set_repository(self): + """Test that set_repository creates new repository configuration.""" + server = RSConnectServer("http://test-server", "api_key") + client = RSConnectClient(server) + + httpretty.register_uri( + httpretty.PUT, + "http://test-server/__api__/v1/content/abc-123/repository", + body=json.dumps({ + "repository": "https://github.com/user/repo", + "branch": "main", + "directory": ".", + "polling": True, + "last_error": "", + "last_known_commit": "", + }), + status=200, + forcing_headers={"Content-Type": "application/json"}, + ) + + result = client.set_repository( + "abc-123", + repository="https://github.com/user/repo", + branch="main", + directory=".", + polling=True, + ) + + self.assertEqual(result["repository"], "https://github.com/user/repo") + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_update_repository(self): + """Test that update_repository updates existing configuration.""" + server = RSConnectServer("http://test-server", "api_key") + client = RSConnectClient(server) + + httpretty.register_uri( + httpretty.PATCH, + "http://test-server/__api__/v1/content/abc-123/repository", + body=json.dumps({ + "repository": "https://github.com/user/repo", + "branch": "feature", + "directory": "app", + "polling": False, + "last_error": "", + "last_known_commit": "abc123", + }), + status=200, + forcing_headers={"Content-Type": "application/json"}, + ) + + result = client.update_repository( + "abc-123", + branch="feature", + directory="app", + polling=False, + ) + + self.assertEqual(result["branch"], "feature") + self.assertEqual(result["directory"], "app") + self.assertFalse(result["polling"]) + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_delete_repository(self): + """Test that delete_repository removes repository configuration.""" + server = RSConnectServer("http://test-server", "api_key") + client = RSConnectClient(server) + + httpretty.register_uri( + httpretty.DELETE, + "http://test-server/__api__/v1/content/abc-123/repository", + status=204, + ) + + # Should not raise an exception + client.delete_repository("abc-123") + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_create_bundle_from_repository(self): + """Test that create_bundle_from_repository creates a bundle.""" + server = RSConnectServer("http://test-server", "api_key") + client = RSConnectClient(server) + + httpretty.register_uri( + httpretty.POST, + "http://test-server/__api__/v1/content/abc-123/repository/bundle", + body=json.dumps({ + "bundle_id": "456", + "task_id": "task-789", + "location": { + "repository": "https://github.com/user/repo", + "ref": "main", + "directory": ".", + }, + }), + status=200, + forcing_headers={"Content-Type": "application/json"}, + ) + + result = client.create_bundle_from_repository("abc-123", ref="main") + + self.assertEqual(result["bundle_id"], "456") + self.assertEqual(result["task_id"], "task-789") + + +class RSConnectExecutorDeployGitTestCase(TestCase): + """Tests for RSConnectExecutor.deploy_git() method.""" + + def test_deploy_git_rejects_non_connect_server(self): + """Test that deploy_git raises error for non-Connect servers.""" + # Create an executor with a PositClient (shinyapps.io) + executor = Mock() + executor.client = Mock(spec=PositClient) + executor.repository = "https://github.com/user/repo" + executor.logger = None # Needed for @cls_logged decorator + + # Call the real deploy_git method + with pytest.raises(RSConnectException, match="only supported for Posit Connect"): + RSConnectExecutor.deploy_git(executor) + + def test_deploy_git_requires_repository(self): + """Test that deploy_git raises error when repository is not set.""" + executor = Mock() + executor.client = Mock(spec=RSConnectClient) + executor.repository = None + executor.logger = None # Needed for @cls_logged decorator + + with pytest.raises(RSConnectException, match="Repository URL is required"): + RSConnectExecutor.deploy_git(executor) diff --git a/tests/test_main.py b/tests/test_main.py index dd3410c1..b8c478a8 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1052,3 +1052,64 @@ def test_boostrap_raw_output_nonsuccess(self): self.assertEqual(result.exit_code, 0, result.output) self.assertEqual(result.output.find("Error:"), -1) + + +class TestDeployGit(TestCase): + """Tests for deploy git CLI command.""" + + def test_deploy_git_help(self): + """Test that deploy git --help works.""" + runner = CliRunner() + result = runner.invoke(cli, ["deploy", "git", "--help"]) + assert result.exit_code == 0, result.output + assert "Deploy content to Posit Connect directly from a remote Git repository" in result.output + assert "--repository" in result.output + assert "--branch" in result.output + assert "--subdirectory" in result.output + + def test_deploy_git_requires_repository(self): + """Test that --repository is required.""" + runner = CliRunner() + result = runner.invoke(cli, ["deploy", "git", "-s", "http://example.com", "-k", "key"]) + assert result.exit_code != 0 + assert "Missing option" in result.output or "required" in result.output.lower() + + +class TestGenerateGitTitle: + """Tests for _generate_git_title helper function.""" + + def test_simple_repo_url(self): + from rsconnect.main import _generate_git_title + + result = _generate_git_title("https://github.com/user/myrepo", "") + assert result == "myrepo" + + def test_repo_url_with_git_suffix(self): + from rsconnect.main import _generate_git_title + + result = _generate_git_title("https://github.com/user/myrepo.git", "") + assert result == "myrepo" + + def test_repo_with_subdirectory(self): + from rsconnect.main import _generate_git_title + + result = _generate_git_title("https://github.com/user/myrepo", "apps/dashboard") + assert result == "myrepo/apps/dashboard" + + def test_repo_with_subdirectory_leading_slash(self): + from rsconnect.main import _generate_git_title + + result = _generate_git_title("https://github.com/user/myrepo", "/apps/dashboard/") + assert result == "myrepo/apps/dashboard" + + def test_repo_with_root_subdirectory(self): + from rsconnect.main import _generate_git_title + + result = _generate_git_title("https://github.com/user/myrepo", "/") + assert result == "myrepo" + + def test_repo_url_with_trailing_slash(self): + from rsconnect.main import _generate_git_title + + result = _generate_git_title("https://github.com/user/myrepo/", "subdir") + assert result == "myrepo/subdir"