7878 DeleteOutputDTO ,
7979 ListEntryOutputDTO ,
8080 PyInfo ,
81+ RepositoryBundleOutput ,
82+ RepositoryInfo ,
8183 ServerSettings ,
8284 TaskStatusV1 ,
8385 UserRecord ,
@@ -592,6 +594,225 @@ def content_deploy(
592594 response = self ._server .handle_bad_response (response )
593595 return response
594596
597+ def get_repository (self , content_guid : str ) -> Optional [RepositoryInfo ]:
598+ """Get git repository configuration for a content item.
599+
600+ GET /v1/content/{guid}/repository
601+
602+ :param content_guid: The GUID of the content item
603+ :return: Repository configuration if git-managed, None otherwise
604+ """
605+ response = self .get ("v1/content/%s/repository" % content_guid )
606+ if isinstance (response , HTTPResponse ):
607+ # 404 means not git-managed, which is not an error
608+ if response .status == 404 :
609+ return None
610+ self ._server .handle_bad_response (response )
611+ return cast (RepositoryInfo , response )
612+
613+ def set_repository (
614+ self ,
615+ content_guid : str ,
616+ repository : str ,
617+ branch : str = "main" ,
618+ directory : str = "." ,
619+ polling : bool = True ,
620+ ) -> RepositoryInfo :
621+ """Create or overwrite git repository configuration for a content item.
622+
623+ PUT /v1/content/{guid}/repository
624+
625+ :param content_guid: The GUID of the content item
626+ :param repository: URL of the git repository (https:// only)
627+ :param branch: Branch to deploy from (default: main)
628+ :param directory: Directory containing manifest.json (default: .)
629+ :param polling: Enable auto-redeploy when commits are pushed (default: True)
630+ :return: The repository configuration
631+ """
632+ body = {
633+ "repository" : repository ,
634+ "branch" : branch ,
635+ "directory" : directory ,
636+ "polling" : polling ,
637+ }
638+ response = cast (
639+ Union [RepositoryInfo , HTTPResponse ],
640+ self .put ("v1/content/%s/repository" % content_guid , body = body ),
641+ )
642+ response = self ._server .handle_bad_response (response )
643+ return response
644+
645+ def update_repository (
646+ self ,
647+ content_guid : str ,
648+ repository : Optional [str ] = None ,
649+ branch : Optional [str ] = None ,
650+ directory : Optional [str ] = None ,
651+ polling : Optional [bool ] = None ,
652+ ) -> RepositoryInfo :
653+ """Partially update git repository configuration for a content item.
654+
655+ PATCH /v1/content/{guid}/repository
656+
657+ Only fields that are provided will be updated.
658+
659+ :param content_guid: The GUID of the content item
660+ :param repository: URL of the git repository (https:// only)
661+ :param branch: Branch to deploy from
662+ :param directory: Directory containing manifest.json
663+ :param polling: Enable auto-redeploy when commits are pushed
664+ :return: The updated repository configuration
665+ """
666+ body : dict [str , str | bool ] = {}
667+ if repository is not None :
668+ body ["repository" ] = repository
669+ if branch is not None :
670+ body ["branch" ] = branch
671+ if directory is not None :
672+ body ["directory" ] = directory
673+ if polling is not None :
674+ body ["polling" ] = polling
675+
676+ response = cast (
677+ Union [RepositoryInfo , HTTPResponse ],
678+ self .patch ("v1/content/%s/repository" % content_guid , body = body ),
679+ )
680+ response = self ._server .handle_bad_response (response )
681+ return response
682+
683+ def delete_repository (self , content_guid : str ) -> None :
684+ """Remove git repository configuration from a content item.
685+
686+ DELETE /v1/content/{guid}/repository
687+
688+ :param content_guid: The GUID of the content item
689+ """
690+ response = self .delete ("v1/content/%s/repository" % content_guid )
691+ if isinstance (response , HTTPResponse ):
692+ self ._server .handle_bad_response (response , is_httpresponse = True )
693+
694+ def create_bundle_from_repository (
695+ self ,
696+ content_guid : str ,
697+ repository : Optional [str ] = None ,
698+ ref : Optional [str ] = None ,
699+ directory : Optional [str ] = None ,
700+ ) -> RepositoryBundleOutput :
701+ """Create a bundle from a git repository location.
702+
703+ POST /v1/content/{guid}/repository/bundle
704+
705+ This triggers Connect to clone the repository and create a bundle.
706+ If the content item has existing git configuration, those values are used
707+ as defaults; provided parameters will override them.
708+
709+ :param content_guid: The GUID of the content item
710+ :param repository: URL of the git repository (uses existing config if not provided)
711+ :param ref: Git ref to bundle from (branch, tag, or commit; uses existing branch if not provided)
712+ :param directory: Directory containing manifest.json (uses existing config if not provided)
713+ :return: Bundle creation result with bundle_id and task_id
714+ """
715+ body : dict [str , str ] = {}
716+ if repository is not None :
717+ body ["repository" ] = repository
718+ if ref is not None :
719+ body ["ref" ] = ref
720+ if directory is not None :
721+ body ["directory" ] = directory
722+
723+ response = cast (
724+ Union [RepositoryBundleOutput , HTTPResponse ],
725+ self .post ("v1/content/%s/repository/bundle" % content_guid , body = body ),
726+ )
727+ response = self ._server .handle_bad_response (response )
728+ return response
729+
730+ def deploy_git (
731+ self ,
732+ app_id : Optional [str ],
733+ name : str ,
734+ repository : str ,
735+ branch : str ,
736+ subdirectory : str ,
737+ title : Optional [str ],
738+ env_vars : Optional [dict [str , str ]],
739+ polling : bool = True ,
740+ ) -> RSConnectClientDeployResult :
741+ """Deploy content from a git repository.
742+
743+ Creates or updates a git-backed content item in Posit Connect. Connect will clone
744+ the repository and automatically redeploy when commits are pushed (if polling is enabled).
745+
746+ :param app_id: Existing content ID/GUID to update, or None to create new content
747+ :param name: Name for the content item (used if creating new)
748+ :param repository: URL of the git repository (https:// only)
749+ :param branch: Branch to deploy from
750+ :param subdirectory: Subdirectory containing manifest.json
751+ :param title: Title for the content
752+ :param env_vars: Environment variables to set
753+ :param polling: Enable auto-redeploy when commits are pushed (default: True)
754+ :return: Deployment result with task_id, app info, etc.
755+ """
756+ # Create or get existing content
757+ if app_id is None :
758+ app = self .content_create (name )
759+ else :
760+ try :
761+ app = self .get_content_by_id (app_id )
762+ except RSConnectException as e :
763+ raise RSConnectException (
764+ f"{ e } Try setting the --new flag or omit --app-id to create new content."
765+ ) from e
766+
767+ app_guid = app ["guid" ]
768+
769+ # Map subdirectory to directory (API uses "directory" field)
770+ directory = subdirectory if subdirectory else "."
771+
772+ # Check if content already has git configuration
773+ existing_repo = self .get_repository (app_guid )
774+
775+ if existing_repo :
776+ # Update existing git configuration using PATCH
777+ self .update_repository (
778+ app_guid ,
779+ repository = repository ,
780+ branch = branch ,
781+ directory = directory ,
782+ polling = polling ,
783+ )
784+ else :
785+ # Create new git configuration using PUT
786+ self .set_repository (
787+ app_guid ,
788+ repository = repository ,
789+ branch = branch ,
790+ directory = directory ,
791+ polling = polling ,
792+ )
793+
794+ # Update title if provided (and different from current)
795+ if title and app .get ("title" ) != title :
796+ self .patch ("v1/content/%s" % app_guid , body = {"title" : title })
797+
798+ # Set environment variables
799+ if env_vars :
800+ result = self .add_environment_vars (app_guid , list (env_vars .items ()))
801+ self ._server .handle_bad_response (result )
802+
803+ # Trigger deployment (bundle_id=None uses the latest bundle from git clone)
804+ task = self .content_deploy (app_guid , bundle_id = None )
805+
806+ return RSConnectClientDeployResult (
807+ app_id = str (app ["id" ]),
808+ app_guid = app_guid ,
809+ app_url = app ["content_url" ],
810+ task_id = task ["task_id" ],
811+ title = title or app .get ("title" ),
812+ dashboard_url = app ["dashboard_url" ],
813+ draft_url = None ,
814+ )
815+
595816 def system_caches_runtime_list (self ) -> list [ListEntryOutputDTO ]:
596817 response = cast (Union [List [ListEntryOutputDTO ], HTTPResponse ], self .get ("v1/system/caches/runtime" ))
597818 response = self ._server .handle_bad_response (response )
@@ -784,6 +1005,10 @@ def __init__(
7841005 disable_env_management : Optional [bool ] = None ,
7851006 env_vars : Optional [dict [str , str ]] = None ,
7861007 metadata : Optional [dict [str , str ]] = None ,
1008+ repository : Optional [str ] = None ,
1009+ branch : Optional [str ] = None ,
1010+ subdirectory : Optional [str ] = None ,
1011+ polling : bool = True ,
7871012 ) -> None :
7881013 self .remote_server : TargetableServer
7891014 self .client : RSConnectClient | PositClient
@@ -805,6 +1030,12 @@ def __init__(
8051030 self .title_is_default : bool = not title
8061031 self .deployment_name : str | None = None
8071032
1033+ # Git deployment parameters
1034+ self .repository : str | None = repository
1035+ self .branch : str | None = branch
1036+ self .subdirectory : str | None = subdirectory
1037+ self .polling : bool = polling
1038+
8081039 self .bundle : IO [bytes ] | None = None
8091040 self .deployed_info : RSConnectClientDeployResult | None = None
8101041
@@ -847,6 +1078,10 @@ def fromConnectServer(
8471078 disable_env_management : Optional [bool ] = None ,
8481079 env_vars : Optional [dict [str , str ]] = None ,
8491080 metadata : Optional [dict [str , str ]] = None ,
1081+ repository : Optional [str ] = None ,
1082+ branch : Optional [str ] = None ,
1083+ subdirectory : Optional [str ] = None ,
1084+ polling : bool = True ,
8501085 ):
8511086 return cls (
8521087 ctx = ctx ,
@@ -870,6 +1105,10 @@ def fromConnectServer(
8701105 disable_env_management = disable_env_management ,
8711106 env_vars = env_vars ,
8721107 metadata = metadata ,
1108+ repository = repository ,
1109+ branch = branch ,
1110+ subdirectory = subdirectory ,
1111+ polling = polling ,
8731112 )
8741113
8751114 def output_overlap_header (self , previous : bool ) -> bool :
@@ -1169,6 +1408,49 @@ def deploy_bundle(self, activate: bool = True):
11691408 )
11701409 return self
11711410
1411+ @cls_logged ("Creating git-backed deployment ..." )
1412+ def deploy_git (self ):
1413+ """Deploy content from a remote git repository.
1414+
1415+ Creates a git-backed content item in Posit Connect. Connect will clone
1416+ the repository and automatically redeploy when commits are pushed.
1417+ """
1418+ if not isinstance (self .client , RSConnectClient ):
1419+ raise RSConnectException (
1420+ "Git deployment is only supported for Posit Connect servers, " "not shinyapps.io or Posit Cloud."
1421+ )
1422+
1423+ if not self .repository :
1424+ raise RSConnectException ("Repository URL is required for git deployment." )
1425+
1426+ # Generate a valid deployment name from the title
1427+ # This sanitizes characters like "/" that aren't allowed in names
1428+ force_unique_name = self .app_id is None
1429+ deployment_name = self .make_deployment_name (self .title , force_unique_name )
1430+
1431+ try :
1432+ result = self .client .deploy_git (
1433+ app_id = self .app_id ,
1434+ name = deployment_name ,
1435+ repository = self .repository ,
1436+ branch = self .branch or "main" ,
1437+ subdirectory = self .subdirectory or "" ,
1438+ title = self .title ,
1439+ env_vars = self .env_vars ,
1440+ polling = self .polling ,
1441+ )
1442+ except RSConnectException as e :
1443+ # Check for 404 on /repo endpoint (git not enabled)
1444+ if "404" in str (e ) and "repo" in str (e ).lower ():
1445+ raise RSConnectException (
1446+ "Git-backed deployment is not enabled on this Connect server. "
1447+ "Contact your administrator to enable Git support."
1448+ ) from e
1449+ raise
1450+
1451+ self .deployed_info = result
1452+ return self
1453+
11721454 def emit_task_log (
11731455 self ,
11741456 log_callback : logging .Logger = connect_logger ,
0 commit comments