8787 OAuthIntegrationUpdate ,
8888 OAuthTemplate ,
8989 PyInfo ,
90+ RepositoryBundleOutput ,
91+ RepositoryInfo ,
9092 ServerSettings ,
9193 TaskStatusV1 ,
9294 UserRecord ,
@@ -729,6 +731,225 @@ def content_deploy(
729731 response = self ._server .handle_bad_response (response )
730732 return response
731733
734+ def get_repository (self , content_guid : str ) -> Optional [RepositoryInfo ]:
735+ """Get git repository configuration for a content item.
736+
737+ GET /v1/content/{guid}/repository
738+
739+ :param content_guid: The GUID of the content item
740+ :return: Repository configuration if git-managed, None otherwise
741+ """
742+ response = self .get ("v1/content/%s/repository" % content_guid )
743+ if isinstance (response , HTTPResponse ):
744+ # 404 means not git-managed, which is not an error
745+ if response .status == 404 :
746+ return None
747+ self ._server .handle_bad_response (response )
748+ return cast (RepositoryInfo , response )
749+
750+ def set_repository (
751+ self ,
752+ content_guid : str ,
753+ repository : str ,
754+ branch : str = "main" ,
755+ directory : str = "." ,
756+ polling : bool = True ,
757+ ) -> RepositoryInfo :
758+ """Create or overwrite git repository configuration for a content item.
759+
760+ PUT /v1/content/{guid}/repository
761+
762+ :param content_guid: The GUID of the content item
763+ :param repository: URL of the git repository (https:// only)
764+ :param branch: Branch to deploy from (default: main)
765+ :param directory: Directory containing manifest.json (default: .)
766+ :param polling: Enable auto-redeploy when commits are pushed (default: True)
767+ :return: The repository configuration
768+ """
769+ body = {
770+ "repository" : repository ,
771+ "branch" : branch ,
772+ "directory" : directory ,
773+ "polling" : polling ,
774+ }
775+ response = cast (
776+ Union [RepositoryInfo , HTTPResponse ],
777+ self .put ("v1/content/%s/repository" % content_guid , body = body ),
778+ )
779+ response = self ._server .handle_bad_response (response )
780+ return response
781+
782+ def update_repository (
783+ self ,
784+ content_guid : str ,
785+ repository : Optional [str ] = None ,
786+ branch : Optional [str ] = None ,
787+ directory : Optional [str ] = None ,
788+ polling : Optional [bool ] = None ,
789+ ) -> RepositoryInfo :
790+ """Partially update git repository configuration for a content item.
791+
792+ PATCH /v1/content/{guid}/repository
793+
794+ Only fields that are provided will be updated.
795+
796+ :param content_guid: The GUID of the content item
797+ :param repository: URL of the git repository (https:// only)
798+ :param branch: Branch to deploy from
799+ :param directory: Directory containing manifest.json
800+ :param polling: Enable auto-redeploy when commits are pushed
801+ :return: The updated repository configuration
802+ """
803+ body : dict [str , str | bool ] = {}
804+ if repository is not None :
805+ body ["repository" ] = repository
806+ if branch is not None :
807+ body ["branch" ] = branch
808+ if directory is not None :
809+ body ["directory" ] = directory
810+ if polling is not None :
811+ body ["polling" ] = polling
812+
813+ response = cast (
814+ Union [RepositoryInfo , HTTPResponse ],
815+ self .patch ("v1/content/%s/repository" % content_guid , body = body ),
816+ )
817+ response = self ._server .handle_bad_response (response )
818+ return response
819+
820+ def delete_repository (self , content_guid : str ) -> None :
821+ """Remove git repository configuration from a content item.
822+
823+ DELETE /v1/content/{guid}/repository
824+
825+ :param content_guid: The GUID of the content item
826+ """
827+ response = self .delete ("v1/content/%s/repository" % content_guid )
828+ if isinstance (response , HTTPResponse ):
829+ self ._server .handle_bad_response (response , is_httpresponse = True )
830+
831+ def create_bundle_from_repository (
832+ self ,
833+ content_guid : str ,
834+ repository : Optional [str ] = None ,
835+ ref : Optional [str ] = None ,
836+ directory : Optional [str ] = None ,
837+ ) -> RepositoryBundleOutput :
838+ """Create a bundle from a git repository location.
839+
840+ POST /v1/content/{guid}/repository/bundle
841+
842+ This triggers Connect to clone the repository and create a bundle.
843+ If the content item has existing git configuration, those values are used
844+ as defaults; provided parameters will override them.
845+
846+ :param content_guid: The GUID of the content item
847+ :param repository: URL of the git repository (uses existing config if not provided)
848+ :param ref: Git ref to bundle from (branch, tag, or commit; uses existing branch if not provided)
849+ :param directory: Directory containing manifest.json (uses existing config if not provided)
850+ :return: Bundle creation result with bundle_id and task_id
851+ """
852+ body : dict [str , str ] = {}
853+ if repository is not None :
854+ body ["repository" ] = repository
855+ if ref is not None :
856+ body ["ref" ] = ref
857+ if directory is not None :
858+ body ["directory" ] = directory
859+
860+ response = cast (
861+ Union [RepositoryBundleOutput , HTTPResponse ],
862+ self .post ("v1/content/%s/repository/bundle" % content_guid , body = body ),
863+ )
864+ response = self ._server .handle_bad_response (response )
865+ return response
866+
867+ def deploy_git (
868+ self ,
869+ app_id : Optional [str ],
870+ name : str ,
871+ repository : str ,
872+ branch : str ,
873+ subdirectory : str ,
874+ title : Optional [str ],
875+ env_vars : Optional [dict [str , str ]],
876+ polling : bool = True ,
877+ ) -> RSConnectClientDeployResult :
878+ """Deploy content from a git repository.
879+
880+ Creates or updates a git-backed content item in Posit Connect. Connect will clone
881+ the repository and automatically redeploy when commits are pushed (if polling is enabled).
882+
883+ :param app_id: Existing content ID/GUID to update, or None to create new content
884+ :param name: Name for the content item (used if creating new)
885+ :param repository: URL of the git repository (https:// only)
886+ :param branch: Branch to deploy from
887+ :param subdirectory: Subdirectory containing manifest.json
888+ :param title: Title for the content
889+ :param env_vars: Environment variables to set
890+ :param polling: Enable auto-redeploy when commits are pushed (default: True)
891+ :return: Deployment result with task_id, app info, etc.
892+ """
893+ # Create or get existing content
894+ if app_id is None :
895+ app = self .content_create (name )
896+ else :
897+ try :
898+ app = self .get_content_by_id (app_id )
899+ except RSConnectException as e :
900+ raise RSConnectException (
901+ f"{ e } Try setting the --new flag or omit --app-id to create new content."
902+ ) from e
903+
904+ app_guid = app ["guid" ]
905+
906+ # Map subdirectory to directory (API uses "directory" field)
907+ directory = subdirectory if subdirectory else "."
908+
909+ # Check if content already has git configuration
910+ existing_repo = self .get_repository (app_guid )
911+
912+ if existing_repo :
913+ # Update existing git configuration using PATCH
914+ self .update_repository (
915+ app_guid ,
916+ repository = repository ,
917+ branch = branch ,
918+ directory = directory ,
919+ polling = polling ,
920+ )
921+ else :
922+ # Create new git configuration using PUT
923+ self .set_repository (
924+ app_guid ,
925+ repository = repository ,
926+ branch = branch ,
927+ directory = directory ,
928+ polling = polling ,
929+ )
930+
931+ # Update title if provided (and different from current)
932+ if title and app .get ("title" ) != title :
933+ self .patch ("v1/content/%s" % app_guid , body = {"title" : title })
934+
935+ # Set environment variables
936+ if env_vars :
937+ result = self .add_environment_vars (app_guid , list (env_vars .items ()))
938+ self ._server .handle_bad_response (result )
939+
940+ # Trigger deployment (bundle_id=None uses the latest bundle from git clone)
941+ task = self .content_deploy (app_guid , bundle_id = None )
942+
943+ return RSConnectClientDeployResult (
944+ app_id = str (app ["id" ]),
945+ app_guid = app_guid ,
946+ app_url = app ["content_url" ],
947+ task_id = task ["task_id" ],
948+ title = title or app .get ("title" ),
949+ dashboard_url = app ["dashboard_url" ],
950+ draft_url = None ,
951+ )
952+
732953 def system_caches_runtime_list (self ) -> list [ListEntryOutputDTO ]:
733954 response = cast (Union [List [ListEntryOutputDTO ], HTTPResponse ], self .get ("v1/system/caches/runtime" ))
734955 response = self ._server .handle_bad_response (response )
@@ -1002,6 +1223,10 @@ def __init__(
10021223 disable_env_management : Optional [bool ] = None ,
10031224 env_vars : Optional [dict [str , str ]] = None ,
10041225 metadata : Optional [dict [str , str ]] = None ,
1226+ repository : Optional [str ] = None ,
1227+ branch : Optional [str ] = None ,
1228+ subdirectory : Optional [str ] = None ,
1229+ polling : bool = True ,
10051230 ) -> None :
10061231 self .remote_server : TargetableServer
10071232 self .client : RSConnectClient | PositClient
@@ -1023,6 +1248,12 @@ def __init__(
10231248 self .title_is_default : bool = not title
10241249 self .deployment_name : str | None = None
10251250
1251+ # Git deployment parameters
1252+ self .repository : str | None = repository
1253+ self .branch : str | None = branch
1254+ self .subdirectory : str | None = subdirectory
1255+ self .polling : bool = polling
1256+
10261257 self .bundle : IO [bytes ] | None = None
10271258 self .deployed_info : RSConnectClientDeployResult | None = None
10281259
@@ -1065,6 +1296,10 @@ def fromConnectServer(
10651296 disable_env_management : Optional [bool ] = None ,
10661297 env_vars : Optional [dict [str , str ]] = None ,
10671298 metadata : Optional [dict [str , str ]] = None ,
1299+ repository : Optional [str ] = None ,
1300+ branch : Optional [str ] = None ,
1301+ subdirectory : Optional [str ] = None ,
1302+ polling : bool = True ,
10681303 ):
10691304 return cls (
10701305 ctx = ctx ,
@@ -1088,6 +1323,10 @@ def fromConnectServer(
10881323 disable_env_management = disable_env_management ,
10891324 env_vars = env_vars ,
10901325 metadata = metadata ,
1326+ repository = repository ,
1327+ branch = branch ,
1328+ subdirectory = subdirectory ,
1329+ polling = polling ,
10911330 )
10921331
10931332 def output_overlap_header (self , previous : bool ) -> bool :
@@ -1409,6 +1648,49 @@ def deploy_bundle(self, activate: bool = True):
14091648 )
14101649 return self
14111650
1651+ @cls_logged ("Creating git-backed deployment ..." )
1652+ def deploy_git (self ):
1653+ """Deploy content from a remote git repository.
1654+
1655+ Creates a git-backed content item in Posit Connect. Connect will clone
1656+ the repository and automatically redeploy when commits are pushed.
1657+ """
1658+ if not isinstance (self .client , RSConnectClient ):
1659+ raise RSConnectException (
1660+ "Git deployment is only supported for Posit Connect servers, " "not shinyapps.io or Posit Cloud."
1661+ )
1662+
1663+ if not self .repository :
1664+ raise RSConnectException ("Repository URL is required for git deployment." )
1665+
1666+ # Generate a valid deployment name from the title
1667+ # This sanitizes characters like "/" that aren't allowed in names
1668+ force_unique_name = self .app_id is None
1669+ deployment_name = self .make_deployment_name (self .title , force_unique_name )
1670+
1671+ try :
1672+ result = self .client .deploy_git (
1673+ app_id = self .app_id ,
1674+ name = deployment_name ,
1675+ repository = self .repository ,
1676+ branch = self .branch or "main" ,
1677+ subdirectory = self .subdirectory or "" ,
1678+ title = self .title ,
1679+ env_vars = self .env_vars ,
1680+ polling = self .polling ,
1681+ )
1682+ except RSConnectException as e :
1683+ # Check for 404 on /repo endpoint (git not enabled)
1684+ if "404" in str (e ) and "repo" in str (e ).lower ():
1685+ raise RSConnectException (
1686+ "Git-backed deployment is not enabled on this Connect server. "
1687+ "Contact your administrator to enable Git support."
1688+ ) from e
1689+ raise
1690+
1691+ self .deployed_info = result
1692+ return self
1693+
14121694 def emit_task_log (
14131695 self ,
14141696 log_callback : logging .Logger = connect_logger ,
0 commit comments