7878 DeleteOutputDTO ,
7979 ListEntryOutputDTO ,
8080 PyInfo ,
81+ RepositoryBundleOutput ,
82+ RepositoryInfo ,
8183 ServerSettings ,
8284 TaskStatusV1 ,
8385 UserRecord ,
@@ -720,6 +722,225 @@ def content_deploy(
720722 response = self ._server .handle_bad_response (response )
721723 return response
722724
725+ def get_repository (self , content_guid : str ) -> Optional [RepositoryInfo ]:
726+ """Get git repository configuration for a content item.
727+
728+ GET /v1/content/{guid}/repository
729+
730+ :param content_guid: The GUID of the content item
731+ :return: Repository configuration if git-managed, None otherwise
732+ """
733+ response = self .get ("v1/content/%s/repository" % content_guid )
734+ if isinstance (response , HTTPResponse ):
735+ # 404 means not git-managed, which is not an error
736+ if response .status == 404 :
737+ return None
738+ self ._server .handle_bad_response (response )
739+ return cast (RepositoryInfo , response )
740+
741+ def set_repository (
742+ self ,
743+ content_guid : str ,
744+ repository : str ,
745+ branch : str = "main" ,
746+ directory : str = "." ,
747+ polling : bool = True ,
748+ ) -> RepositoryInfo :
749+ """Create or overwrite git repository configuration for a content item.
750+
751+ PUT /v1/content/{guid}/repository
752+
753+ :param content_guid: The GUID of the content item
754+ :param repository: URL of the git repository (https:// only)
755+ :param branch: Branch to deploy from (default: main)
756+ :param directory: Directory containing manifest.json (default: .)
757+ :param polling: Enable auto-redeploy when commits are pushed (default: True)
758+ :return: The repository configuration
759+ """
760+ body = {
761+ "repository" : repository ,
762+ "branch" : branch ,
763+ "directory" : directory ,
764+ "polling" : polling ,
765+ }
766+ response = cast (
767+ Union [RepositoryInfo , HTTPResponse ],
768+ self .put ("v1/content/%s/repository" % content_guid , body = body ),
769+ )
770+ response = self ._server .handle_bad_response (response )
771+ return response
772+
773+ def update_repository (
774+ self ,
775+ content_guid : str ,
776+ repository : Optional [str ] = None ,
777+ branch : Optional [str ] = None ,
778+ directory : Optional [str ] = None ,
779+ polling : Optional [bool ] = None ,
780+ ) -> RepositoryInfo :
781+ """Partially update git repository configuration for a content item.
782+
783+ PATCH /v1/content/{guid}/repository
784+
785+ Only fields that are provided will be updated.
786+
787+ :param content_guid: The GUID of the content item
788+ :param repository: URL of the git repository (https:// only)
789+ :param branch: Branch to deploy from
790+ :param directory: Directory containing manifest.json
791+ :param polling: Enable auto-redeploy when commits are pushed
792+ :return: The updated repository configuration
793+ """
794+ body : dict [str , str | bool ] = {}
795+ if repository is not None :
796+ body ["repository" ] = repository
797+ if branch is not None :
798+ body ["branch" ] = branch
799+ if directory is not None :
800+ body ["directory" ] = directory
801+ if polling is not None :
802+ body ["polling" ] = polling
803+
804+ response = cast (
805+ Union [RepositoryInfo , HTTPResponse ],
806+ self .patch ("v1/content/%s/repository" % content_guid , body = body ),
807+ )
808+ response = self ._server .handle_bad_response (response )
809+ return response
810+
811+ def delete_repository (self , content_guid : str ) -> None :
812+ """Remove git repository configuration from a content item.
813+
814+ DELETE /v1/content/{guid}/repository
815+
816+ :param content_guid: The GUID of the content item
817+ """
818+ response = self .delete ("v1/content/%s/repository" % content_guid )
819+ if isinstance (response , HTTPResponse ):
820+ self ._server .handle_bad_response (response , is_httpresponse = True )
821+
822+ def create_bundle_from_repository (
823+ self ,
824+ content_guid : str ,
825+ repository : Optional [str ] = None ,
826+ ref : Optional [str ] = None ,
827+ directory : Optional [str ] = None ,
828+ ) -> RepositoryBundleOutput :
829+ """Create a bundle from a git repository location.
830+
831+ POST /v1/content/{guid}/repository/bundle
832+
833+ This triggers Connect to clone the repository and create a bundle.
834+ If the content item has existing git configuration, those values are used
835+ as defaults; provided parameters will override them.
836+
837+ :param content_guid: The GUID of the content item
838+ :param repository: URL of the git repository (uses existing config if not provided)
839+ :param ref: Git ref to bundle from (branch, tag, or commit; uses existing branch if not provided)
840+ :param directory: Directory containing manifest.json (uses existing config if not provided)
841+ :return: Bundle creation result with bundle_id and task_id
842+ """
843+ body : dict [str , str ] = {}
844+ if repository is not None :
845+ body ["repository" ] = repository
846+ if ref is not None :
847+ body ["ref" ] = ref
848+ if directory is not None :
849+ body ["directory" ] = directory
850+
851+ response = cast (
852+ Union [RepositoryBundleOutput , HTTPResponse ],
853+ self .post ("v1/content/%s/repository/bundle" % content_guid , body = body ),
854+ )
855+ response = self ._server .handle_bad_response (response )
856+ return response
857+
858+ def deploy_git (
859+ self ,
860+ app_id : Optional [str ],
861+ name : str ,
862+ repository : str ,
863+ branch : str ,
864+ subdirectory : str ,
865+ title : Optional [str ],
866+ env_vars : Optional [dict [str , str ]],
867+ polling : bool = True ,
868+ ) -> RSConnectClientDeployResult :
869+ """Deploy content from a git repository.
870+
871+ Creates or updates a git-backed content item in Posit Connect. Connect will clone
872+ the repository and automatically redeploy when commits are pushed (if polling is enabled).
873+
874+ :param app_id: Existing content ID/GUID to update, or None to create new content
875+ :param name: Name for the content item (used if creating new)
876+ :param repository: URL of the git repository (https:// only)
877+ :param branch: Branch to deploy from
878+ :param subdirectory: Subdirectory containing manifest.json
879+ :param title: Title for the content
880+ :param env_vars: Environment variables to set
881+ :param polling: Enable auto-redeploy when commits are pushed (default: True)
882+ :return: Deployment result with task_id, app info, etc.
883+ """
884+ # Create or get existing content
885+ if app_id is None :
886+ app = self .content_create (name )
887+ else :
888+ try :
889+ app = self .get_content_by_id (app_id )
890+ except RSConnectException as e :
891+ raise RSConnectException (
892+ f"{ e } Try setting the --new flag or omit --app-id to create new content."
893+ ) from e
894+
895+ app_guid = app ["guid" ]
896+
897+ # Map subdirectory to directory (API uses "directory" field)
898+ directory = subdirectory if subdirectory else "."
899+
900+ # Check if content already has git configuration
901+ existing_repo = self .get_repository (app_guid )
902+
903+ if existing_repo :
904+ # Update existing git configuration using PATCH
905+ self .update_repository (
906+ app_guid ,
907+ repository = repository ,
908+ branch = branch ,
909+ directory = directory ,
910+ polling = polling ,
911+ )
912+ else :
913+ # Create new git configuration using PUT
914+ self .set_repository (
915+ app_guid ,
916+ repository = repository ,
917+ branch = branch ,
918+ directory = directory ,
919+ polling = polling ,
920+ )
921+
922+ # Update title if provided (and different from current)
923+ if title and app .get ("title" ) != title :
924+ self .patch ("v1/content/%s" % app_guid , body = {"title" : title })
925+
926+ # Set environment variables
927+ if env_vars :
928+ result = self .add_environment_vars (app_guid , list (env_vars .items ()))
929+ self ._server .handle_bad_response (result )
930+
931+ # Trigger deployment (bundle_id=None uses the latest bundle from git clone)
932+ task = self .content_deploy (app_guid , bundle_id = None )
933+
934+ return RSConnectClientDeployResult (
935+ app_id = str (app ["id" ]),
936+ app_guid = app_guid ,
937+ app_url = app ["content_url" ],
938+ task_id = task ["task_id" ],
939+ title = title or app .get ("title" ),
940+ dashboard_url = app ["dashboard_url" ],
941+ draft_url = None ,
942+ )
943+
723944 def system_caches_runtime_list (self ) -> list [ListEntryOutputDTO ]:
724945 response = cast (Union [List [ListEntryOutputDTO ], HTTPResponse ], self .get ("v1/system/caches/runtime" ))
725946 response = self ._server .handle_bad_response (response )
@@ -912,6 +1133,10 @@ def __init__(
9121133 disable_env_management : Optional [bool ] = None ,
9131134 env_vars : Optional [dict [str , str ]] = None ,
9141135 metadata : Optional [dict [str , str ]] = None ,
1136+ repository : Optional [str ] = None ,
1137+ branch : Optional [str ] = None ,
1138+ subdirectory : Optional [str ] = None ,
1139+ polling : bool = True ,
9151140 ) -> None :
9161141 self .remote_server : TargetableServer
9171142 self .client : RSConnectClient | PositClient
@@ -933,6 +1158,12 @@ def __init__(
9331158 self .title_is_default : bool = not title
9341159 self .deployment_name : str | None = None
9351160
1161+ # Git deployment parameters
1162+ self .repository : str | None = repository
1163+ self .branch : str | None = branch
1164+ self .subdirectory : str | None = subdirectory
1165+ self .polling : bool = polling
1166+
9361167 self .bundle : IO [bytes ] | None = None
9371168 self .deployed_info : RSConnectClientDeployResult | None = None
9381169
@@ -975,6 +1206,10 @@ def fromConnectServer(
9751206 disable_env_management : Optional [bool ] = None ,
9761207 env_vars : Optional [dict [str , str ]] = None ,
9771208 metadata : Optional [dict [str , str ]] = None ,
1209+ repository : Optional [str ] = None ,
1210+ branch : Optional [str ] = None ,
1211+ subdirectory : Optional [str ] = None ,
1212+ polling : bool = True ,
9781213 ):
9791214 return cls (
9801215 ctx = ctx ,
@@ -998,6 +1233,10 @@ def fromConnectServer(
9981233 disable_env_management = disable_env_management ,
9991234 env_vars = env_vars ,
10001235 metadata = metadata ,
1236+ repository = repository ,
1237+ branch = branch ,
1238+ subdirectory = subdirectory ,
1239+ polling = polling ,
10011240 )
10021241
10031242 def output_overlap_header (self , previous : bool ) -> bool :
@@ -1319,6 +1558,49 @@ def deploy_bundle(self, activate: bool = True):
13191558 )
13201559 return self
13211560
1561+ @cls_logged ("Creating git-backed deployment ..." )
1562+ def deploy_git (self ):
1563+ """Deploy content from a remote git repository.
1564+
1565+ Creates a git-backed content item in Posit Connect. Connect will clone
1566+ the repository and automatically redeploy when commits are pushed.
1567+ """
1568+ if not isinstance (self .client , RSConnectClient ):
1569+ raise RSConnectException (
1570+ "Git deployment is only supported for Posit Connect servers, " "not shinyapps.io or Posit Cloud."
1571+ )
1572+
1573+ if not self .repository :
1574+ raise RSConnectException ("Repository URL is required for git deployment." )
1575+
1576+ # Generate a valid deployment name from the title
1577+ # This sanitizes characters like "/" that aren't allowed in names
1578+ force_unique_name = self .app_id is None
1579+ deployment_name = self .make_deployment_name (self .title , force_unique_name )
1580+
1581+ try :
1582+ result = self .client .deploy_git (
1583+ app_id = self .app_id ,
1584+ name = deployment_name ,
1585+ repository = self .repository ,
1586+ branch = self .branch or "main" ,
1587+ subdirectory = self .subdirectory or "" ,
1588+ title = self .title ,
1589+ env_vars = self .env_vars ,
1590+ polling = self .polling ,
1591+ )
1592+ except RSConnectException as e :
1593+ # Check for 404 on /repo endpoint (git not enabled)
1594+ if "404" in str (e ) and "repo" in str (e ).lower ():
1595+ raise RSConnectException (
1596+ "Git-backed deployment is not enabled on this Connect server. "
1597+ "Contact your administrator to enable Git support."
1598+ ) from e
1599+ raise
1600+
1601+ self .deployed_info = result
1602+ return self
1603+
13221604 def emit_task_log (
13231605 self ,
13241606 log_callback : logging .Logger = connect_logger ,
0 commit comments