8383 EnvironmentV1 ,
8484 ListEntryOutputDTO ,
8585 PyInfo ,
86+ RepositoryBundleOutput ,
87+ RepositoryInfo ,
8688 ServerSettings ,
8789 TaskStatusV1 ,
8890 UserRecord ,
@@ -725,6 +727,225 @@ def content_deploy(
725727 response = self ._server .handle_bad_response (response )
726728 return response
727729
730+ def get_repository (self , content_guid : str ) -> Optional [RepositoryInfo ]:
731+ """Get git repository configuration for a content item.
732+
733+ GET /v1/content/{guid}/repository
734+
735+ :param content_guid: The GUID of the content item
736+ :return: Repository configuration if git-managed, None otherwise
737+ """
738+ response = self .get ("v1/content/%s/repository" % content_guid )
739+ if isinstance (response , HTTPResponse ):
740+ # 404 means not git-managed, which is not an error
741+ if response .status == 404 :
742+ return None
743+ self ._server .handle_bad_response (response )
744+ return cast (RepositoryInfo , response )
745+
746+ def set_repository (
747+ self ,
748+ content_guid : str ,
749+ repository : str ,
750+ branch : str = "main" ,
751+ directory : str = "." ,
752+ polling : bool = True ,
753+ ) -> RepositoryInfo :
754+ """Create or overwrite git repository configuration for a content item.
755+
756+ PUT /v1/content/{guid}/repository
757+
758+ :param content_guid: The GUID of the content item
759+ :param repository: URL of the git repository (https:// only)
760+ :param branch: Branch to deploy from (default: main)
761+ :param directory: Directory containing manifest.json (default: .)
762+ :param polling: Enable auto-redeploy when commits are pushed (default: True)
763+ :return: The repository configuration
764+ """
765+ body = {
766+ "repository" : repository ,
767+ "branch" : branch ,
768+ "directory" : directory ,
769+ "polling" : polling ,
770+ }
771+ response = cast (
772+ Union [RepositoryInfo , HTTPResponse ],
773+ self .put ("v1/content/%s/repository" % content_guid , body = body ),
774+ )
775+ response = self ._server .handle_bad_response (response )
776+ return response
777+
778+ def update_repository (
779+ self ,
780+ content_guid : str ,
781+ repository : Optional [str ] = None ,
782+ branch : Optional [str ] = None ,
783+ directory : Optional [str ] = None ,
784+ polling : Optional [bool ] = None ,
785+ ) -> RepositoryInfo :
786+ """Partially update git repository configuration for a content item.
787+
788+ PATCH /v1/content/{guid}/repository
789+
790+ Only fields that are provided will be updated.
791+
792+ :param content_guid: The GUID of the content item
793+ :param repository: URL of the git repository (https:// only)
794+ :param branch: Branch to deploy from
795+ :param directory: Directory containing manifest.json
796+ :param polling: Enable auto-redeploy when commits are pushed
797+ :return: The updated repository configuration
798+ """
799+ body : dict [str , str | bool ] = {}
800+ if repository is not None :
801+ body ["repository" ] = repository
802+ if branch is not None :
803+ body ["branch" ] = branch
804+ if directory is not None :
805+ body ["directory" ] = directory
806+ if polling is not None :
807+ body ["polling" ] = polling
808+
809+ response = cast (
810+ Union [RepositoryInfo , HTTPResponse ],
811+ self .patch ("v1/content/%s/repository" % content_guid , body = body ),
812+ )
813+ response = self ._server .handle_bad_response (response )
814+ return response
815+
816+ def delete_repository (self , content_guid : str ) -> None :
817+ """Remove git repository configuration from a content item.
818+
819+ DELETE /v1/content/{guid}/repository
820+
821+ :param content_guid: The GUID of the content item
822+ """
823+ response = self .delete ("v1/content/%s/repository" % content_guid )
824+ if isinstance (response , HTTPResponse ):
825+ self ._server .handle_bad_response (response , is_httpresponse = True )
826+
827+ def create_bundle_from_repository (
828+ self ,
829+ content_guid : str ,
830+ repository : Optional [str ] = None ,
831+ ref : Optional [str ] = None ,
832+ directory : Optional [str ] = None ,
833+ ) -> RepositoryBundleOutput :
834+ """Create a bundle from a git repository location.
835+
836+ POST /v1/content/{guid}/repository/bundle
837+
838+ This triggers Connect to clone the repository and create a bundle.
839+ If the content item has existing git configuration, those values are used
840+ as defaults; provided parameters will override them.
841+
842+ :param content_guid: The GUID of the content item
843+ :param repository: URL of the git repository (uses existing config if not provided)
844+ :param ref: Git ref to bundle from (branch, tag, or commit; uses existing branch if not provided)
845+ :param directory: Directory containing manifest.json (uses existing config if not provided)
846+ :return: Bundle creation result with bundle_id and task_id
847+ """
848+ body : dict [str , str ] = {}
849+ if repository is not None :
850+ body ["repository" ] = repository
851+ if ref is not None :
852+ body ["ref" ] = ref
853+ if directory is not None :
854+ body ["directory" ] = directory
855+
856+ response = cast (
857+ Union [RepositoryBundleOutput , HTTPResponse ],
858+ self .post ("v1/content/%s/repository/bundle" % content_guid , body = body ),
859+ )
860+ response = self ._server .handle_bad_response (response )
861+ return response
862+
863+ def deploy_git (
864+ self ,
865+ app_id : Optional [str ],
866+ name : str ,
867+ repository : str ,
868+ branch : str ,
869+ subdirectory : str ,
870+ title : Optional [str ],
871+ env_vars : Optional [dict [str , str ]],
872+ polling : bool = True ,
873+ ) -> RSConnectClientDeployResult :
874+ """Deploy content from a git repository.
875+
876+ Creates or updates a git-backed content item in Posit Connect. Connect will clone
877+ the repository and automatically redeploy when commits are pushed (if polling is enabled).
878+
879+ :param app_id: Existing content ID/GUID to update, or None to create new content
880+ :param name: Name for the content item (used if creating new)
881+ :param repository: URL of the git repository (https:// only)
882+ :param branch: Branch to deploy from
883+ :param subdirectory: Subdirectory containing manifest.json
884+ :param title: Title for the content
885+ :param env_vars: Environment variables to set
886+ :param polling: Enable auto-redeploy when commits are pushed (default: True)
887+ :return: Deployment result with task_id, app info, etc.
888+ """
889+ # Create or get existing content
890+ if app_id is None :
891+ app = self .content_create (name )
892+ else :
893+ try :
894+ app = self .get_content_by_id (app_id )
895+ except RSConnectException as e :
896+ raise RSConnectException (
897+ f"{ e } Try setting the --new flag or omit --app-id to create new content."
898+ ) from e
899+
900+ app_guid = app ["guid" ]
901+
902+ # Map subdirectory to directory (API uses "directory" field)
903+ directory = subdirectory if subdirectory else "."
904+
905+ # Check if content already has git configuration
906+ existing_repo = self .get_repository (app_guid )
907+
908+ if existing_repo :
909+ # Update existing git configuration using PATCH
910+ self .update_repository (
911+ app_guid ,
912+ repository = repository ,
913+ branch = branch ,
914+ directory = directory ,
915+ polling = polling ,
916+ )
917+ else :
918+ # Create new git configuration using PUT
919+ self .set_repository (
920+ app_guid ,
921+ repository = repository ,
922+ branch = branch ,
923+ directory = directory ,
924+ polling = polling ,
925+ )
926+
927+ # Update title if provided (and different from current)
928+ if title and app .get ("title" ) != title :
929+ self .patch ("v1/content/%s" % app_guid , body = {"title" : title })
930+
931+ # Set environment variables
932+ if env_vars :
933+ result = self .add_environment_vars (app_guid , list (env_vars .items ()))
934+ self ._server .handle_bad_response (result )
935+
936+ # Trigger deployment (bundle_id=None uses the latest bundle from git clone)
937+ task = self .content_deploy (app_guid , bundle_id = None )
938+
939+ return RSConnectClientDeployResult (
940+ app_id = str (app ["id" ]),
941+ app_guid = app_guid ,
942+ app_url = app ["content_url" ],
943+ task_id = task ["task_id" ],
944+ title = title or app .get ("title" ),
945+ dashboard_url = app ["dashboard_url" ],
946+ draft_url = None ,
947+ )
948+
728949 def system_caches_runtime_list (self ) -> list [ListEntryOutputDTO ]:
729950 response = cast (Union [List [ListEntryOutputDTO ], HTTPResponse ], self .get ("v1/system/caches/runtime" ))
730951 response = self ._server .handle_bad_response (response )
@@ -964,6 +1185,10 @@ def __init__(
9641185 disable_env_management : Optional [bool ] = None ,
9651186 env_vars : Optional [dict [str , str ]] = None ,
9661187 metadata : Optional [dict [str , str ]] = None ,
1188+ repository : Optional [str ] = None ,
1189+ branch : Optional [str ] = None ,
1190+ subdirectory : Optional [str ] = None ,
1191+ polling : bool = True ,
9671192 ) -> None :
9681193 self .remote_server : TargetableServer
9691194 self .client : RSConnectClient | PositClient
@@ -985,6 +1210,12 @@ def __init__(
9851210 self .title_is_default : bool = not title
9861211 self .deployment_name : str | None = None
9871212
1213+ # Git deployment parameters
1214+ self .repository : str | None = repository
1215+ self .branch : str | None = branch
1216+ self .subdirectory : str | None = subdirectory
1217+ self .polling : bool = polling
1218+
9881219 self .bundle : IO [bytes ] | None = None
9891220 self .deployed_info : RSConnectClientDeployResult | None = None
9901221
@@ -1027,6 +1258,10 @@ def fromConnectServer(
10271258 disable_env_management : Optional [bool ] = None ,
10281259 env_vars : Optional [dict [str , str ]] = None ,
10291260 metadata : Optional [dict [str , str ]] = None ,
1261+ repository : Optional [str ] = None ,
1262+ branch : Optional [str ] = None ,
1263+ subdirectory : Optional [str ] = None ,
1264+ polling : bool = True ,
10301265 ):
10311266 return cls (
10321267 ctx = ctx ,
@@ -1050,6 +1285,10 @@ def fromConnectServer(
10501285 disable_env_management = disable_env_management ,
10511286 env_vars = env_vars ,
10521287 metadata = metadata ,
1288+ repository = repository ,
1289+ branch = branch ,
1290+ subdirectory = subdirectory ,
1291+ polling = polling ,
10531292 )
10541293
10551294 def output_overlap_header (self , previous : bool ) -> bool :
@@ -1371,6 +1610,49 @@ def deploy_bundle(self, activate: bool = True):
13711610 )
13721611 return self
13731612
1613+ @cls_logged ("Creating git-backed deployment ..." )
1614+ def deploy_git (self ):
1615+ """Deploy content from a remote git repository.
1616+
1617+ Creates a git-backed content item in Posit Connect. Connect will clone
1618+ the repository and automatically redeploy when commits are pushed.
1619+ """
1620+ if not isinstance (self .client , RSConnectClient ):
1621+ raise RSConnectException (
1622+ "Git deployment is only supported for Posit Connect servers, " "not shinyapps.io or Posit Cloud."
1623+ )
1624+
1625+ if not self .repository :
1626+ raise RSConnectException ("Repository URL is required for git deployment." )
1627+
1628+ # Generate a valid deployment name from the title
1629+ # This sanitizes characters like "/" that aren't allowed in names
1630+ force_unique_name = self .app_id is None
1631+ deployment_name = self .make_deployment_name (self .title , force_unique_name )
1632+
1633+ try :
1634+ result = self .client .deploy_git (
1635+ app_id = self .app_id ,
1636+ name = deployment_name ,
1637+ repository = self .repository ,
1638+ branch = self .branch or "main" ,
1639+ subdirectory = self .subdirectory or "" ,
1640+ title = self .title ,
1641+ env_vars = self .env_vars ,
1642+ polling = self .polling ,
1643+ )
1644+ except RSConnectException as e :
1645+ # Check for 404 on /repo endpoint (git not enabled)
1646+ if "404" in str (e ) and "repo" in str (e ).lower ():
1647+ raise RSConnectException (
1648+ "Git-backed deployment is not enabled on this Connect server. "
1649+ "Contact your administrator to enable Git support."
1650+ ) from e
1651+ raise
1652+
1653+ self .deployed_info = result
1654+ return self
1655+
13741656 def emit_task_log (
13751657 self ,
13761658 log_callback : logging .Logger = connect_logger ,
0 commit comments