Repository: SFDO-Tooling/CumulusCI
Pull request #3667 (2023-10-05 06:47:44)

Feature/add collision-check option to  deploy task
Hey @jkasturi-sf Raising it for review.


jain-naman-sf: Hey @jkasturi-sf We have added a new option collision_check for Deploy Task. Added Tests as well.
Please review it.
jain-naman-sf: Hey @jkasturi-sf Removed that table header. Please approve it once again


Patch:
diff --git a/cumulusci/tasks/salesforce/Deploy.py b/cumulusci/tasks/salesforce/Deploy.py
index 4f42a8a14..bf27b86f5 100644
--- a/cumulusci/tasks/salesforce/Deploy.py
+++ b/cumulusci/tasks/salesforce/Deploy.py
@@ -1,8 +1,10 @@
 import pathlib
-from typing import List, Optional
+from typing import List, Optional, Union
 
+from defusedxml.minidom import parseString
 from pydantic import ValidationError
 
+from cumulusci.cli.ui import CliTable
 from cumulusci.core.dependencies.utils import TaskContext
 from cumulusci.core.exceptions import TaskOptionsError
 from cumulusci.core.sfdx import convert_sfdx_source
@@ -11,16 +13,18 @@ from cumulusci.core.source_transforms.transforms import (
     SourceTransformList,
 )
 from cumulusci.core.utils import process_bool_arg, process_list_arg
-from cumulusci.salesforce_api.metadata import ApiDeploy
+from cumulusci.salesforce_api.metadata import ApiDeploy, ApiRetrieveUnpackaged
 from cumulusci.salesforce_api.package_zip import MetadataPackageZipBuilder
 from cumulusci.salesforce_api.rest_deploy import RestDeploy
 from cumulusci.tasks.salesforce.BaseSalesforceMetadataApiTask import (
     BaseSalesforceMetadataApiTask,
 )
+from cumulusci.utils.xml import metadata_tree
 
 
 class Deploy(BaseSalesforceMetadataApiTask):
     api_class = ApiDeploy
+    api_retrieve_unpackaged = ApiRetrieveUnpackaged
     task_options = {
         "path": {
             "description": "The path to the metadata source to be deployed",
@@ -38,6 +42,9 @@ class Deploy(BaseSalesforceMetadataApiTask):
         "check_only": {
             "description": "If True, performs a test deployment (validation) of components without saving the components in the target org"
         },
+        "collision_check": {
+            "description": "If True, performs a collision check with metadata already present in the target org"
+        },
         "test_level": {
             "description": "Specifies which tests are run as part of a deployment. Valid values: NoTestRun, RunLocalTests, RunAllTestsInOrg, RunSpecifiedTests."
         },
@@ -89,6 +96,8 @@ class Deploy(BaseSalesforceMetadataApiTask):
             self.options.get("namespace_inject")
             or self.project_config.project__package__namespace
         )
+        if "collision_check" not in self.options:
+            self.options["collision_check"] = False
 
         if "transforms" in self.options:
             try:
@@ -109,7 +118,22 @@ class Deploy(BaseSalesforceMetadataApiTask):
             path = self.options.get("path")
 
         package_zip = self._get_package_zip(path)
-        if package_zip is not None:
+
+        if isinstance(package_zip, dict):
+            self.logger.warning(
+                "Deploy getting aborted due to collision of following components"
+            )
+            table_header_row = ["Type", "Component API Name"]
+            table_data = [table_header_row]
+            for type in package_zip.keys():
+                for component_name in package_zip[type]:
+                    table_data.append([type, component_name])
+            table = CliTable(
+                table_data,
+            )
+            table.echo()
+            return None
+        elif package_zip is not None:
             self.logger.info("Payload size: {} bytes".format(len(package_zip)))
         else:
             self.logger.warning("Deployment package is empty; skipping deployment.")
@@ -138,7 +162,54 @@ class Deploy(BaseSalesforceMetadataApiTask):
             return process_bool_arg(self.options.get("namespaced_org", False))
         return bool(ns) and ns == self.org_config.namespace
 
-    def _get_package_zip(self, path) -> Optional[str]:
+    def _create_api_object(self, package_xml, api_version):
+        api_retrieve_unpackaged_object = self.api_retrieve_unpackaged(
+            self, package_xml, api_version
+        )
+        return api_retrieve_unpackaged_object
+
+    def _collision_check(self, src_path):
+        xml_map = {}
+        is_collision = False
+        package_xml = open(f"{src_path}/package.xml", "r")
+        source_xml_tree = metadata_tree.parse(f"{src_path}/package.xml")
+
+        for type in source_xml_tree.types:
+            members = []
+            try:
+                for member in type.members:
+                    members.append(member.text)
+            except AttributeError:  # Exception if there are no members for a type
+                pass
+            xml_map[type["name"].text] = members
+
+        api_retrieve_unpackaged_response = self._create_api_object(
+            package_xml.read(), source_xml_tree.version.text
+        )
+
+        messages = parseString(
+            api_retrieve_unpackaged_response._get_response().content
+        ).getElementsByTagName("messages")
+
+        for i in range(len(messages)):
+            # print(messages[i])
+            message_list = messages[
+                i
+            ].firstChild.nextSibling.firstChild.nodeValue.split("'")
+
+            if message_list[3] in xml_map[message_list[1]]:
+                xml_map[message_list[1]].remove(message_list[3])
+                if len(xml_map[message_list[1]]) == 0:
+                    del xml_map[message_list[1]]
+
+        for type, api_names in xml_map.items():
+            if len(api_names) != 0:
+                is_collision = True
+                break
+
+        return is_collision, xml_map
+
+    def _get_package_zip(self, path) -> Union[str, dict, None]:
         assert path, f"Path should be specified for {self.__class__.name}"
         if not pathlib.Path(path).exists():
             self.logger.warning(f"{path} not found.")
@@ -154,19 +225,28 @@ class Deploy(BaseSalesforceMetadataApiTask):
             "namespaced_org": self._is_namespaced_org(namespace),
         }
         package_zip = None
+
         with convert_sfdx_source(path, None, self.logger) as src_path:
-            context = TaskContext(self.org_config, self.project_config, self.logger)
-            package_zip = MetadataPackageZipBuilder(
-                path=src_path,
-                context=context,
-                options=options,
-                transforms=self.transforms,
-            )
+            ##############
+            is_collision = False
+            if "collision_check" in options and options["collision_check"]:
+                is_collision, xml_map = self._collision_check(src_path)
+            #############
+            if not is_collision:
+                context = TaskContext(self.org_config, self.project_config, self.logger)
+                package_zip = MetadataPackageZipBuilder(
+                    path=src_path,
+                    context=context,
+                    options=options,
+                    transforms=self.transforms,
+                )
 
-        # If the package is empty, do nothing.
-        if not package_zip.zf.namelist():
-            return
-        return package_zip.as_base64()
+                # If the package is empty, do nothing.
+                if not package_zip.zf.namelist():
+                    return
+                return package_zip.as_base64()
+            else:
+                return xml_map
 
     def freeze(self, step):
         steps = super().freeze(step)


Test patch:
diff --git a/cumulusci/tasks/salesforce/tests/test_Deploy.py b/cumulusci/tasks/salesforce/tests/test_Deploy.py
index d971f3a23..a37f9b33f 100644
--- a/cumulusci/tasks/salesforce/tests/test_Deploy.py
+++ b/cumulusci/tasks/salesforce/tests/test_Deploy.py
@@ -2,6 +2,7 @@ import base64
 import io
 import os
 import zipfile
+from unittest import mock
 
 import pytest
 
@@ -36,6 +37,167 @@ class TestDeploy:
             assert "package.xml" in zf.namelist()
             zf.close()
 
+    def test_create_api_object(self):
+        with temporary_dir() as path:
+            task = create_task(
+                Deploy,
+                {
+                    "path": path,
+                    "namespace_tokenize": "ns",
+                    "namespace_inject": "ns",
+                    "namespace_strip": "ns",
+                    "unmanaged": True,
+                    "collision_check": True,
+                },
+            )
+            package_xml = """<?xml version="1.0" encoding="UTF-8"?>
+    <Package xmlns="http://soap.sforce.com/2006/04/metadata">
+        <types>
+            <members>Delivery__c.Supplier__c</members>
+            <members>Delivery__c.Scheduled_Date__c</members>
+            <name>CustomField</name>
+        </types>
+        <types>
+            <name>CustomObject</name>
+        </types>
+        <types>
+            <name>CustomTab</name>
+        </types>
+        <types>
+            <name>Layout</name>
+        </types>
+        <types>
+            <name>ListView</name>
+        </types>
+        <version>58.0</version>
+    </Package>"""
+            expected_package_xml = """<types><members>Delivery__c.Supplier__c</members><members>Delivery__c.Scheduled_Date__c</members><name>CustomField</name></types><types><name>CustomObject</name></types><types><name>CustomTab</name></types><types><name>Layout</name></types><types><name>ListView</name></types><version>58.0</version>    """
+            api_object = task._create_api_object(package_xml, "58.0")
+
+            assert api_object.package_xml.split() == expected_package_xml.split()
+            assert api_object.api_version == "58.0"
+
+    def test_collision_check_positive(self):
+        with temporary_dir() as path:
+            touch("package.xml")
+            with open("package.xml", "w") as f:
+                f.write(
+                    """<?xml version="1.0" encoding="UTF-8"?>
+    <Package xmlns="http://soap.sforce.com/2006/04/metadata">
+        <types>
+            <members>Delivery__c.Supplier__c</members>
+            <members>Delivery__c.Scheduled_Date__c</members>
+            <name>CustomField</name>
+        </types>
+        <types>
+            <name>CustomObject</name>
+        </types>
+        <types>
+            <name>CustomTab</name>
+        </types>
+        <types>
+            <name>Layout</name>
+        </types>
+        <types>
+            <name>ListView</name>
+        </types>
+        <version>58.0</version>
+    </Package>"""
+                )
+            task = create_task(
+                Deploy,
+                {
+                    "path": path,
+                    "namespace_tokenize": "ns",
+                    "namespace_inject": "ns",
+                    "namespace_strip": "ns",
+                    "unmanaged": True,
+                    "collision_check": True,
+                },
+            )
+
+            task._create_api_object = mock.Mock(
+                return_value=mock.Mock(
+                    _get_response=mock.Mock(
+                        return_value=mock.Mock(
+                            content='<?xml version="1.0" encoding="UTF-8"?> <soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns="http://soap.sforce.com/2006/04/metadata"><soapenv:Body><checkRetrieveStatusResponse><result><done>true</done><fileProperties><createdById>0051m0000069dpxAAA</createdById><createdByName>User User</createdByName><createdDate>2023-10-09T08:47:44.875Z</createdDate><fileName>unpackaged/package.xml</fileName><fullName>unpackaged/package.xml</fullName><id></id><lastModifiedById>0051m0000069dpxAAA</lastModifiedById><lastModifiedByName>User User</lastModifiedByName><lastModifiedDate>2023-10-09T08:47:44.875Z</lastModifiedDate><manageableState>unmanaged</manageableState><type>Package</type></fileProperties><id>09S1m000001EKBkEAO</id><messages><fileName>unpackaged/package.xml</fileName><problem>Entity of type &apos;CustomField&apos; named &apos;Delivery_Item__c.Food_Expiration_Date__c&apos; cannot be found</problem></messages><messages><fileName>unpackaged/package.xml</fileName><problem>Entity of type &apos;CustomField&apos; named &apos;Delivery__c.Status__c&apos; cannot be found</problem></messages><messages><fileName>unpackaged/package.xml</fileName><problem>Entity of type &apos;CustomField&apos; named &apos;Delivery_Item__c.Food_Storage__c&apos; cannot be found</problem></messages><messages><fileName>unpackaged/package.xml</fileName><problem>Entity of type &apos;CustomField&apos; named &apos;Delivery_Item__c.Delivery__c&apos; cannot be found</problem></messages><messages><fileName>unpackaged/package.xml</fileName><problem>Entity of type &apos;CustomField&apos; named &apos;Delivery__c.Scheduled_Date__c&apos; cannot be found</problem></messages><messages><fileName>unpackaged/package.xml</fileName><problem>Entity of type &apos;CustomField&apos; named &apos;Delivery__c.Supplier__c&apos; cannot be found</problem></messages><messages><fileName>unpackaged/package.xml</fileName><problem>Entity of type &apos;CustomObject&apos; named &apos;Delivery__c&apos; cannot be found</problem></messages><messages><fileName>unpackaged/package.xml</fileName><problem>Entity of type &apos;CustomObject&apos; named &apos;Delivery_Item__c&apos; cannot be found</problem></messages><messages><fileName>unpackaged/package.xml</fileName><problem>Entity of type &apos;CustomTab&apos; named &apos;Delivery__c&apos; cannot be found</problem></messages><messages><fileName>unpackaged/package.xml</fileName><problem>Entity of type &apos;Layout&apos; named &apos;Delivery_Item__c-Delivery Item Layout&apos; cannot be found</problem></messages><messages><fileName>unpackaged/package.xml</fileName><problem>Entity of type &apos;Layout&apos; named &apos;Delivery__c-Delivery Layout&apos; cannot be found</problem></messages><messages><fileName>unpackaged/package.xml</fileName><problem>Entity of type &apos;ListView&apos; named &apos;Delivery__c.All&apos; cannot be found</problem></messages><status>Succeeded</status><success>true</success><zipFile>UEsDBBQACAgIAPZFSVcAAAAAAAAAAAAAAAAWAAAAdW5wYWNrYWdlZC9wYWNrYWdlLnhtbKVTTU/DMAy991dUva8pCNCE0kyIUQkJCaQNrlGami2QNFXjwvrvyT7KuFTraE7Ji5/fsy3T2cbo8Atqp2yZRhdxEoVQSluocpVGr8tsMo1mLKAvQn6KFYQ+unRptEasbglxVlSxe7e1hFhaQy6T5IYkV8QAikKgiFgQ+kOxrcDt77u3AZN7STYHrbx2yx8RDOcy/gW4pKSLGsDLrC34w6ZStUBfCJ8LhH/lWKCtfZ1DuZ62kGsoGg3FWaJbIgps3DmEpqq0grqHUgoD7L5xaE2mQBeU7JD9BMifEQwex0BjJ8w85x8gcYSbkwpLkY8vdtIB4RYIn0RrGxzYgSO3n7bz132P6EV8p3V/fuXwTcF3v8Jh1dn1NE4o6V4BJYcNZ8EPUEsHCGtr2HwiAQAAEwQAAFBLAQIUABQACAgIAPZFSVdra9h8IgEAABMEAAAWAAAAAAAAAAAAAAAAAAAAAAB1bnBhY2thZ2VkL3BhY2thZ2UueG1sUEsFBgAAAAABAAEARAAAAGYBAAAAAA==</zipFile></result></checkRetrieveStatusResponse></soapenv:Body></soapenv:Envelope>'
+                        )
+                    )
+                )
+            )
+
+            is_collision, xml_map = task._collision_check(path)
+
+            assert is_collision is False
+            print(xml_map)
+            assert xml_map == {
+                "CustomObject": [],
+                "CustomTab": [],
+                "Layout": [],
+                "ListView": [],
+            }
+
+    def test_collision_check_negative(self):
+
+        with temporary_dir() as path:
+            touch("package.xml")
+            with open("package.xml", "w") as f:
+                f.write(
+                    """<?xml version="1.0" encoding="UTF-8"?>
+    <Package xmlns="http://soap.sforce.com/2006/04/metadata">
+       <types>
+            <members>Delivery__c.Supplier__c</members>
+            <members>Delivery__c.Scheduled_Date__c</members>
+            <name>CustomField</name>
+        </types>
+        <types>
+            <name>CustomObject</name>
+        </types>
+        <types>
+            <name>CustomTab</name>
+        </types>
+        <types>
+            <name>Layout</name>
+        </types>
+        <types>
+            <name>ListView</name>
+        </types>
+        <version>58.0</version>
+    </Package>"""
+                )
+            task = create_task(
+                Deploy,
+                {
+                    "path": path,
+                    "namespace_tokenize": "ns",
+                    "namespace_inject": "ns",
+                    "namespace_strip": "ns",
+                    "unmanaged": True,
+                    "collision_check": True,
+                },
+            )
+
+            task._create_api_object = mock.Mock(
+                return_value=mock.Mock(
+                    _get_response=mock.Mock(
+                        return_value=mock.Mock(
+                            content='<?xml version="1.0" encoding="UTF-8"?> <soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns="http://soap.sforce.com/2006/04/metadata"><soapenv:Body><checkRetrieveStatusResponse><result><done>true</done><fileProperties><createdById>0051m0000069dpxAAA</createdById><createdByName>User User</createdByName><createdDate>2023-10-09T08:47:44.875Z</createdDate><fileName>unpackaged/package.xml</fileName><fullName>unpackaged/package.xml</fullName><id></id><lastModifiedById>0051m0000069dpxAAA</lastModifiedById><lastModifiedByName>User User</lastModifiedByName><lastModifiedDate>2023-10-09T08:47:44.875Z</lastModifiedDate><manageableState>unmanaged</manageableState><type>Package</type></fileProperties><id>09S1m000001EKBkEAO</id><messages><fileName>unpackaged/package.xml</fileName><problem>Entity of type &apos;CustomField&apos; named &apos;Delivery_Item__c.Food_Expiration_Date__c&apos; cannot be found</problem></messages><messages><fileName>unpackaged/package.xml</fileName><problem>Entity of type &apos;CustomField&apos; named &apos;Delivery__c.Status__c&apos; cannot be found</problem></messages><messages><fileName>unpackaged/package.xml</fileName><problem>Entity of type &apos;CustomField&apos; named &apos;Delivery_Item__c.Food_Storage__c&apos; cannot be found</problem></messages><messages><fileName>unpackaged/package.xml</fileName><problem>Entity of type &apos;CustomField&apos; named &apos;Delivery_Item__c.Delivery__c&apos; cannot be found</problem></messages><messages><fileName>unpackaged/package.xml</fileName><problem>Entity of type &apos;CustomField&apos; named &apos;Delivery__c.Supplier__c&apos; cannot be found</problem></messages><messages><fileName>unpackaged/package.xml</fileName><problem>Entity of type &apos;CustomObject&apos; named &apos;Delivery__c&apos; cannot be found</problem></messages><messages><fileName>unpackaged/package.xml</fileName><problem>Entity of type &apos;CustomObject&apos; named &apos;Delivery_Item__c&apos; cannot be found</problem></messages><messages><fileName>unpackaged/package.xml</fileName><problem>Entity of type &apos;CustomTab&apos; named &apos;Delivery__c&apos; cannot be found</problem></messages><messages><fileName>unpackaged/package.xml</fileName><problem>Entity of type &apos;Layout&apos; named &apos;Delivery_Item__c-Delivery Item Layout&apos; cannot be found</problem></messages><messages><fileName>unpackaged/package.xml</fileName><problem>Entity of type &apos;Layout&apos; named &apos;Delivery__c-Delivery Layout&apos; cannot be found</problem></messages><messages><fileName>unpackaged/package.xml</fileName><problem>Entity of type &apos;ListView&apos; named &apos;Delivery__c.All&apos; cannot be found</problem></messages><status>Succeeded</status><success>true</success><zipFile>UEsDBBQACAgIAPZFSVcAAAAAAAAAAAAAAAAWAAAAdW5wYWNrYWdlZC9wYWNrYWdlLnhtbKVTTU/DMAy991dUva8pCNCE0kyIUQkJCaQNrlGami2QNFXjwvrvyT7KuFTraE7Ji5/fsy3T2cbo8Atqp2yZRhdxEoVQSluocpVGr8tsMo1mLKAvQn6KFYQ+unRptEasbglxVlSxe7e1hFhaQy6T5IYkV8QAikKgiFgQ+kOxrcDt77u3AZN7STYHrbx2yx8RDOcy/gW4pKSLGsDLrC34w6ZStUBfCJ8LhH/lWKCtfZ1DuZ62kGsoGg3FWaJbIgps3DmEpqq0grqHUgoD7L5xaE2mQBeU7JD9BMifEQwex0BjJ8w85x8gcYSbkwpLkY8vdtIB4RYIn0RrGxzYgSO3n7bz132P6EV8p3V/fuXwTcF3v8Jh1dn1NE4o6V4BJYcNZ8EPUEsHCGtr2HwiAQAAEwQAAFBLAQIUABQACAgIAPZFSVdra9h8IgEAABMEAAAWAAAAAAAAAAAAAAAAAAAAAAB1bnBhY2thZ2VkL3BhY2thZ2UueG1sUEsFBgAAAAABAAEARAAAAGYBAAAAAA==</zipFile></result></checkRetrieveStatusResponse></soapenv:Body></soapenv:Envelope>'
+                        )
+                    )
+                )
+            )
+
+            is_collision, xml_map = task._collision_check(path)
+
+            assert is_collision is True
+            assert xml_map == {
+                "CustomField": ["Delivery__c.Scheduled_Date__c"],
+                "CustomObject": [],
+                "CustomTab": [],
+                "Layout": [],
+                "ListView": [],
+            }
+
     @pytest.mark.parametrize("rest_deploy", [True, False])
     def test_get_api__managed(self, rest_deploy):
         with temporary_dir() as path:
@@ -164,6 +326,24 @@ class TestDeploy:
             api = task._get_api()
             assert api is None
 
+    def test_get_api__collision_detected(self):
+        with temporary_dir() as path:
+            task = create_task(
+                Deploy,
+                {
+                    "path": path,
+                    "namespace_tokenize": "ns",
+                    "namespace_inject": "ns",
+                    "namespace_strip": "ns",
+                    "unmanaged": True,
+                    "collision_check": True,
+                },
+            )
+            task._get_package_zip = mock.Mock(return_value={"Custom Field": []})
+
+            api = task._get_api()
+            assert api is None
+
     @pytest.mark.parametrize("rest_deploy", [True, False])
     def test_init_options(self, rest_deploy):
         with pytest.raises(TaskOptionsError):
diff --git a/cumulusci/tasks/salesforce/tests/test_DeployBundles.py b/cumulusci/tasks/salesforce/tests/test_DeployBundles.py
index 6ccd8eb12..df9424868 100644
--- a/cumulusci/tasks/salesforce/tests/test_DeployBundles.py
+++ b/cumulusci/tasks/salesforce/tests/test_DeployBundles.py
@@ -40,7 +40,9 @@ class TestDeployBundles:
                 task_class=None,
                 project_config=task.project_config,
             )
+
             steps = task.freeze(step)
+            print(steps)
             assert [
                 {
                     "is_required": True,
@@ -57,6 +59,7 @@ class TestDeployBundles:
                                     "ref": task.project_config.repo_commit,
                                     "github": "https://github.com/TestOwner/TestRepo",
                                     "subfolder": "unpackaged/test",
+                                    "collision_check": False,
                                     "namespace_inject": None,
                                 }
                             ]