Repository: SFDO-Tooling/CumulusCI
Pull request #3460 (2022-12-08 21:44:38)

Allow Injection of Arbitrary Ids and Current Username during `deploy` 
* CumulusCI now offers the ability to inject arbitrary Id's into a project's metadata components. See the [Find-and-Replace Id Injection](https://cumulusci.readthedocs.io/en/latest/deploy.html#find-and-replace-id-injection) source transform for more information.

* CumulusCI now allows for injecting the current running user's username into a project's metadata components. See the [Find-and-Replace Id Injection](https://cumulusci.readthedocs.io/en/latest/deploy.html#find-and-replace-current-username-injection) source transform for more information.

(The above doc link won't work until this is merged, see `deploy.md` on this PR.)

jstvz: Oh, one more thing, this is working:
```yaml
    deploy:
        options:
            transforms:
                - transform: find_replace
                  options:
                      patterns:
                          - find: USER_NAME
                            replace: baz
                          - find: special_string
                            replace_record_id_query: SELECT Id from User WHERE name='User User'
                            api: rest
```

But this:
```yaml
    deploy:
        options:
            transforms:
                - transform: find_replace
                  options:
                      patterns:
                          - find: USER_NAME
                            inject_username: true
                          - find: special_string
                            replace_record_id_query: SELECT Id from User WHERE name='User User'
                            api: rest
```

throws the following validation error:
```
Error: The transform spec is not valid. See CumulusCI documentation for details of how to specify transforms. The validation
error was 3 validation errors for SourceTransformList
__root__ -> 0 -> __root__ -> patterns -> 0 -> replace
  field required (type=value_error.missing)
__root__ -> 0 -> __root__ -> patterns -> 0 -> replace_env
  field required (type=value_error.missing)
__root__ -> 0 -> __root__ -> patterns -> 0 -> replace_record_id_query
  field required (type=value_error.missing)
```
TheBitShepherd: @jstvz - Odd, I can't reproduce the error where listing multiple `patterns` causes a validation error 🤔 . I'm using the following config:
```yaml
deploy:
    options:
        transforms:
            - transform: find_replace
              options:
                  patterns:
                      - find: USER_NAME
                        inject_username: true
                      - find: SPECIAL_ACCOUNT_ID
                        replace_record_id_query: SELECT Id FROM User WHERE name='User User'
```
jstvz: What if you add api: rest?

On Wed, Dec 14, 2022 at 10:20 AM Brandon Parker ***@***.***>
wrote:

> @jstvz <https://github.com/jstvz> - Odd, I can't reproduce the error
> where listing multiple patterns causes a validation error 🤔 . I'm using
> the following config:
>
> deploy:
>
>     options:
>
>         transforms:
>
>             - transform: find_replace
>
>               options:
>
>                   patterns:
>
>                       - find: USER_NAME
>
>                         inject_username: true
>
>                       - find: SPECIAL_ACCOUNT_ID
>
>                         replace_record_id_query: SELECT Id FROM User WHERE name='User User'
>
> —
> Reply to this email directly, view it on GitHub
> <https://github.com/SFDO-Tooling/CumulusCI/pull/3460#issuecomment-1351904737>,
> or unsubscribe
> <https://github.com/notifications/unsubscribe-auth/AAFWX5G7W2L44CJYYXOA7O3WNIFWTANCNFSM6AAAAAASYT5IU4>
> .
> You are receiving this because you were mentioned.Message ID:
> ***@***.***>
>


-- 
James Estevez

jstvz: Right, thanks. So this'd typecheck okay, but won't work with Pydantic w/o a
hack
<https://github.com/pydantic/pydantic/issues/1932#issuecomment-700945983>,
so it's not really worth it.

On Wed, Dec 14, 2022 at 10:33 AM Brandon Parker ***@***.***>
wrote:

> ***@***.**** commented on this pull request.
> ------------------------------
>
> In cumulusci/core/source_transforms/transforms.py
> <https://github.com/SFDO-Tooling/CumulusCI/pull/3460#discussion_r1048829564>
> :
>
> > +    patterns: T.List[
> +        T.Union[
> +            FindReplaceSpec,
> +            FindReplaceEnvSpec,
> +            FindReplaceIdSpec,
> +            FindReplaceCurrentUserSpec,
> +        ]
> +    ]
>
> E   pydantic.error_wrappers.ValidationError: 1 validation error for FindReplaceTransformOptions
> E   patterns -> 0
> E     Can't instantiate abstract class FindReplaceBaseSpec with abstract method get_replace_string (type=type_error)
>
> —
> Reply to this email directly, view it on GitHub
> <https://github.com/SFDO-Tooling/CumulusCI/pull/3460#discussion_r1048829564>,
> or unsubscribe
> <https://github.com/notifications/unsubscribe-auth/AAFWX5CSYOMKA5OQ3LLC74TWNIHIZANCNFSM6AAAAAASYT5IU4>
> .
> You are receiving this because you were mentioned.Message ID:
> ***@***.***>
>


-- 
James Estevez

TheBitShepherd: > What if you add api: rest?
> […](#)
> On Wed, Dec 14, 2022 at 10:20 AM Brandon Parker ***@***.***> wrote: @jstvz <https://github.com/jstvz> - Odd, I can't reproduce the error where listing multiple patterns causes a validation error 🤔 . I'm using the following config: deploy: options: transforms: - transform: find_replace options: patterns: - find: USER_NAME inject_username: true - find: SPECIAL_ACCOUNT_ID replace_record_id_query: SELECT Id FROM User WHERE name='User User' — Reply to this email directly, view it on GitHub <[#3460 (comment)](https://github.com/SFDO-Tooling/CumulusCI/pull/3460#issuecomment-1351904737)>, or unsubscribe <https://github.com/notifications/unsubscribe-auth/AAFWX5G7W2L44CJYYXOA7O3WNIFWTANCNFSM6AAAAAASYT5IU4> . You are receiving this because you were mentioned.Message ID: ***@***.***>
> -- James Estevez

Still works for me 🤷‍♂️ 
jstvz: Okay, I pulled and can't repro either. Weird.

Patch:
diff --git a/cumulusci/core/dependencies/dependencies.py b/cumulusci/core/dependencies/dependencies.py
index fe1c8a985..7a08dbdc6 100644
--- a/cumulusci/core/dependencies/dependencies.py
+++ b/cumulusci/core/dependencies/dependencies.py
@@ -559,9 +559,9 @@ class UnmanagedDependency(StaticDependency, abc.ABC):
         pass
 
     def get_metadata_package_zip_builder(
-        self, context: BaseProjectConfig, org: OrgConfig
+        self, project_config: BaseProjectConfig, org: OrgConfig
     ) -> MetadataPackageZipBuilder:
-        zip_src = self._get_zip_src(context)
+        zip_src = self._get_zip_src(project_config)
         # Determine whether to inject namespace prefixes or not
         # If and only if we have no explicit configuration.
 
@@ -605,10 +605,11 @@ class UnmanagedDependency(StaticDependency, abc.ABC):
                 stack.enter_context(temporary_dir(chdir=True))
                 zip_src.extractall()
                 real_path = stack.enter_context(
-                    convert_sfdx_source(self.subfolder, None, context.logger)
+                    convert_sfdx_source(self.subfolder, None, project_config.logger)
                 )
                 zip_src = None  # Don't use the zipfile if we converted source.
 
+            context = TaskContext(org, project_config, project_config.logger)
             # We now know what to send to MetadataPackageZipBuilder
             # Note that subfolder logic is applied either by subsetting the zip
             # (for MDAPI) or by the conversion (for SFDX format)
@@ -617,7 +618,7 @@ class UnmanagedDependency(StaticDependency, abc.ABC):
                 zip_src,
                 path=real_path,
                 options=options,
-                logger=context.logger,
+                context=context,
             )
 
         return package_zip
diff --git a/cumulusci/core/dependencies/utils.py b/cumulusci/core/dependencies/utils.py
index 79a1fe77e..63b9dae69 100644
--- a/cumulusci/core/dependencies/utils.py
+++ b/cumulusci/core/dependencies/utils.py
@@ -1,4 +1,13 @@
-from collections import namedtuple
+from logging import Logger
+from typing import NamedTuple
+
+from cumulusci.core.config.org_config import OrgConfig
+from cumulusci.core.config.project_config import BaseProjectConfig
 
 # data structure mimicking a task for use with the metadata API classes
-TaskContext = namedtuple("TaskContext", ["org_config", "project_config", "logger"])
+
+
+class TaskContext(NamedTuple):
+    org_config: OrgConfig
+    project_config: BaseProjectConfig
+    logger: Logger
diff --git a/cumulusci/core/source_transforms/transforms.py b/cumulusci/core/source_transforms/transforms.py
index 1da2428c2..2815c07ba 100644
--- a/cumulusci/core/source_transforms/transforms.py
+++ b/cumulusci/core/source_transforms/transforms.py
@@ -1,17 +1,18 @@
 import abc
+import enum
 import functools
 import io
 import os
 import shutil
 import typing as T
 import zipfile
-from logging import Logger
 from pathlib import Path
 from zipfile import ZipFile
 
 from pydantic import BaseModel, root_validator
 
-from cumulusci.core.exceptions import TaskOptionsError
+from cumulusci.core.dependencies.utils import TaskContext
+from cumulusci.core.exceptions import CumulusCIException, TaskOptionsError
 from cumulusci.tasks.metadata.package import RemoveSourceComponents
 from cumulusci.utils import (
     cd,
@@ -35,7 +36,7 @@ class SourceTransform(abc.ABC):
         ...
 
     @abc.abstractmethod
-    def process(self, zf: ZipFile, logger: Logger) -> ZipFile:
+    def process(self, zf: ZipFile, context: TaskContext) -> ZipFile:
         ...
 
 
@@ -108,9 +109,9 @@ class NamespaceInjectionTransform(SourceTransform):
     def __init__(self, options: NamespaceInjectionOptions):
         self.options = options
 
-    def process(self, zf: ZipFile, logger: Logger) -> ZipFile:
+    def process(self, zf: ZipFile, context: TaskContext) -> ZipFile:
         if self.options.namespace_tokenize:
-            logger.info(
+            context.logger.info(
                 f"Tokenizing namespace prefix {self.options.namespace_tokenize}__"
             )
             zf = process_text_in_zipfile(
@@ -118,18 +119,18 @@ class NamespaceInjectionTransform(SourceTransform):
                 functools.partial(
                     tokenize_namespace,
                     namespace=self.options.namespace_tokenize,
-                    logger=logger,
+                    logger=context.logger,
                 ),
             )
         if self.options.namespace_inject:
             managed = not self.options.unmanaged
             if managed:
-                logger.info(
+                context.logger.info(
                     "Replacing namespace tokens from metadata with namespace prefix  "
                     f"{self.options.namespace_inject}__"
                 )
             else:
-                logger.info(
+                context.logger.info(
                     "Stripping namespace tokens from metadata for unmanaged deployment"
                 )
             zf = process_text_in_zipfile(
@@ -139,17 +140,17 @@ class NamespaceInjectionTransform(SourceTransform):
                     namespace=self.options.namespace_inject,
                     managed=managed,
                     namespaced_org=self.options.namespaced_org,
-                    logger=logger,
+                    logger=context.logger,
                 ),
             )
         if self.options.namespace_strip:
-            logger.info("Stripping namespace tokens from metadata")
+            context.logger.info("Stripping namespace tokens from metadata")
             zf = process_text_in_zipfile(
                 zf,
                 functools.partial(
                     strip_namespace,
                     namespace=self.options.namespace_strip,
-                    logger=logger,
+                    logger=context.logger,
                 ),
             )
 
@@ -163,7 +164,7 @@ class RemoveFeatureParametersTransform(SourceTransform):
 
     identifier = "remove_feature_parameters"
 
-    def process(self, zf: ZipFile, logger: Logger) -> ZipFile:
+    def process(self, zf: ZipFile, context: TaskContext) -> ZipFile:
         package_xml = None
         zip_dest = ZipFile(io.BytesIO(), "w", zipfile.ZIP_DEFLATED)
         for name in zf.namelist():
@@ -171,7 +172,9 @@ class RemoveFeatureParametersTransform(SourceTransform):
                 package_xml = zf.open(name)
             elif name.startswith("featureParameters/"):
                 # skip feature parameters
-                logger.info(f"Skipping {name} because Feature Parameters are omitted.")
+                context.logger.info(
+                    f"Skipping {name} because Feature Parameters are omitted."
+                )
             else:
                 content = zf.read(name)
                 zip_dest.writestr(name, content)
@@ -200,8 +203,10 @@ class CleanMetaXMLTransform(SourceTransform):
 
     identifier = "clean_meta_xml"
 
-    def process(self, zf: ZipFile, logger: Logger) -> ZipFile:
-        logger.info("Cleaning meta.xml files of packageVersion elements for deploy")
+    def process(self, zf: ZipFile, context: TaskContext) -> ZipFile:
+        context.logger.info(
+            "Cleaning meta.xml files of packageVersion elements for deploy"
+        )
         return zip_clean_metaxml(zf)
 
 
@@ -219,7 +224,7 @@ class BundleStaticResourcesTransform(SourceTransform):
     def __init__(self, options: BundleStaticResourcesOptions):
         self.options = options
 
-    def process(self, zf: ZipFile, logger: Logger) -> ZipFile:
+    def process(self, zf: ZipFile, context: TaskContext) -> ZipFile:
         path = os.path.realpath(self.options.static_resource_path)
 
         # Copy existing files to new zipfile
@@ -244,7 +249,9 @@ class BundleStaticResourcesTransform(SourceTransform):
                 bundle_path = os.path.join(path, name)
                 if not os.path.isdir(bundle_path):
                     continue
-                logger.info(f"Zipping {bundle_relpath} to add to staticresources")
+                context.logger.info(
+                    f"Zipping {bundle_relpath} to add to staticresources"
+                )
 
                 # Add resource-meta.xml file
                 meta_name = f"{name}.resource-meta.xml"
@@ -285,21 +292,21 @@ class FindReplaceBaseSpec(BaseModel, abc.ABC):
     paths: T.Optional[T.List[Path]] = None
 
     @abc.abstractmethod
-    def get_replace_string(self) -> str:
+    def get_replace_string(self, context: TaskContext) -> str:
         ...
 
 
 class FindReplaceSpec(FindReplaceBaseSpec):
     replace: str
 
-    def get_replace_string(self) -> str:
+    def get_replace_string(self, context: TaskContext) -> str:
         return self.replace
 
 
 class FindReplaceEnvSpec(FindReplaceBaseSpec):
     replace_env: str
 
-    def get_replace_string(self) -> str:
+    def get_replace_string(self, context: TaskContext) -> str:
         try:
             return os.environ[self.replace_env]
         except KeyError:
@@ -308,8 +315,58 @@ class FindReplaceEnvSpec(FindReplaceBaseSpec):
             )
 
 
+class FindReplaceIdAPI(str, enum.Enum):
+    REST = "rest"
+    TOOLING = "tooling"
+
+
+class FindReplaceIdSpec(FindReplaceBaseSpec):
+    replace_record_id_query: str
+    api: FindReplaceIdAPI = FindReplaceIdAPI.REST
+
+    def get_replace_string(self, context: TaskContext) -> str:
+        org = context.org_config
+
+        if self.api is FindReplaceIdAPI.REST:
+            results = org.salesforce_client.query(self.replace_record_id_query)
+        else:
+            results = org.tooling.query(self.replace_record_id_query)
+
+        if results["totalSize"] != 1:
+            raise CumulusCIException(
+                f"The find-replace query {self.replace_record_id_query} returned {results['totalSize']} results. Exactly 1 result is required"
+            )
+
+        try:
+            record_id = results["records"][0]["Id"]
+        except KeyError:
+            raise CumulusCIException(
+                "Results from the replace_record_id_query did not include an 'Id'. Please ensure the 'Id' field is included in your query's SELECT clause."
+            )
+        return record_id
+
+
+class FindReplaceCurrentUserSpec(FindReplaceBaseSpec):
+    inject_username: bool = True
+
+    def get_replace_string(self, context: TaskContext) -> str:
+        if not self.inject_username:  # pragma: no cover
+            self.logger.warning(
+                "The inject_username value for the find_replace transform is set to False. Skipping transform."
+            )
+            return self.find
+        return context.org_config.username
+
+
 class FindReplaceTransformOptions(BaseModel):
-    patterns: T.List[T.Union[FindReplaceSpec, FindReplaceEnvSpec]]
+    patterns: T.List[
+        T.Union[
+            FindReplaceSpec,
+            FindReplaceEnvSpec,
+            FindReplaceIdSpec,
+            FindReplaceCurrentUserSpec,
+        ]
+    ]
 
 
 class FindReplaceTransform(SourceTransform):
@@ -323,14 +380,16 @@ class FindReplaceTransform(SourceTransform):
     def __init__(self, options: FindReplaceTransformOptions):
         self.options = options
 
-    def process(self, zf: ZipFile, logger: Logger) -> ZipFile:
+    def process(self, zf: ZipFile, context: TaskContext) -> ZipFile:
         def process_file(filename: str, content: str) -> T.Tuple[str, str]:
             path = Path(filename)
             for spec in self.options.patterns:
                 if not spec.paths or any(
                     parent in path.parents for parent in spec.paths
                 ):
-                    content = content.replace(spec.find, spec.get_replace_string())
+                    content = content.replace(
+                        spec.find, spec.get_replace_string(context)
+                    )
 
             return (filename, content)
 
@@ -349,14 +408,14 @@ class StripUnwantedComponentTransform(SourceTransform):
     def __init__(self, options: StripUnwantedComponentsOptions):
         self.options = options
 
-    def process(self, zf: ZipFile, logger: Logger) -> ZipFile:
+    def process(self, zf: ZipFile, context: TaskContext) -> ZipFile:
         package_xml_path = os.path.abspath(os.path.expanduser(self.options.package_xml))
 
         zip_dest = zipfile.ZipFile(io.BytesIO(), "w", zipfile.ZIP_DEFLATED)
         with temporary_dir():
             zf.extractall()
             RemoveSourceComponents(
-                os.getcwd(), package_xml_path, api_version=None, logger=logger
+                os.getcwd(), package_xml_path, api_version=None, logger=context.logger
             )()
             shutil.copy(package_xml_path, "package.xml")
             for root, _, files in os.walk("."):
diff --git a/cumulusci/salesforce_api/package_zip.py b/cumulusci/salesforce_api/package_zip.py
index da54b9719..5e6fbefdf 100644
--- a/cumulusci/salesforce_api/package_zip.py
+++ b/cumulusci/salesforce_api/package_zip.py
@@ -8,6 +8,7 @@ import zipfile
 from base64 import b64encode
 from xml.sax.saxutils import escape
 
+from cumulusci.core.dependencies.utils import TaskContext
 from cumulusci.core.source_transforms.transforms import (
     BundleStaticResourcesOptions,
     BundleStaticResourcesTransform,
@@ -86,6 +87,7 @@ class BasePackageZipBuilder(object):
 class MetadataPackageZipBuilder(BasePackageZipBuilder):
     """Build a package zip from a metadata folder in either Metadata API or Salesforce DX format."""
 
+    context: TaskContext = None
     transforms: T.List[SourceTransform] = []
 
     def __init__(
@@ -97,10 +99,11 @@ class MetadataPackageZipBuilder(BasePackageZipBuilder):
         logger=None,
         name=None,
         transforms: T.Optional[T.List[SourceTransform]] = None,
+        context: TaskContext,
     ):
         self.options = options or {}
         self.logger = logger or DEFAULT_LOGGER
-
+        self.context = context
         self.zf = zf
 
         if self.zf is None:
@@ -116,6 +119,7 @@ class MetadataPackageZipBuilder(BasePackageZipBuilder):
     def from_zipfile(
         cls,
         zf: zipfile.ZipFile,
+        context: TaskContext,
         *,
         path=None,
         options=None,
@@ -124,7 +128,12 @@ class MetadataPackageZipBuilder(BasePackageZipBuilder):
     ):
         """Start with an existing zipfile rather than a filesystem folder."""
         return cls(
-            zf=zf, path=path, options=options, logger=logger, transforms=transforms
+            zf=zf,
+            path=path,
+            options=options,
+            logger=logger,
+            transforms=transforms,
+            context=context,
         )
 
     def _add_files_to_package(self, path):
@@ -198,7 +207,7 @@ class MetadataPackageZipBuilder(BasePackageZipBuilder):
             fp = self.zf.fp
             self.zf.close()
             self.zf = zipfile.ZipFile(fp, "r")
-            new_zipfile = t.process(self.zf, self.logger)
+            new_zipfile = t.process(self.zf, self.context)
             if new_zipfile != self.zf:
                 # Ensure that zipfiles are closed (in case they're filesystem resources)
                 try:
diff --git a/cumulusci/tasks/create_package_version.py b/cumulusci/tasks/create_package_version.py
index 04b5bac7b..69ab9a48a 100644
--- a/cumulusci/tasks/create_package_version.py
+++ b/cumulusci/tasks/create_package_version.py
@@ -17,6 +17,7 @@ from cumulusci.core.dependencies.dependencies import (
     UnmanagedGitHubRefDependency,
 )
 from cumulusci.core.dependencies.resolvers import get_static_dependencies
+from cumulusci.core.dependencies.utils import TaskContext
 from cumulusci.core.exceptions import (
     CumulusCIUsageError,
     DependencyLookupError,
@@ -184,6 +185,7 @@ class CreatePackageVersion(BaseSalesforceApiTask):
             api_version=self.api_version,
             base_url="tooling",
         )
+        self.context = TaskContext(self.org_config, self.project_config, self.logger)
 
     def _run_task(self):
         """Creates a new 2GP package version.
@@ -216,7 +218,7 @@ class CreatePackageVersion(BaseSalesforceApiTask):
                 path=path,
                 name=self.package_config.package_name,
                 options=options,
-                logger=self.logger,
+                context=self.context,
             )
 
         ancestor_id = self._resolve_ancestor_id(self.options.get("ancestor_id"))
@@ -402,7 +404,9 @@ class CreatePackageVersion(BaseSalesforceApiTask):
                     scratch_org_def.get("objectSettings"),
                     self.api_version,
                 ) as path:
-                    settings_zip_builder = MetadataPackageZipBuilder(path=path)
+                    settings_zip_builder = MetadataPackageZipBuilder(
+                        path=path, context=self.context
+                    )
                     version_info.writestr(
                         "settings.zip", settings_zip_builder.as_bytes()
                     )
@@ -680,7 +684,7 @@ class CreatePackageVersion(BaseSalesforceApiTask):
         )
         with convert_sfdx_source(path, package_name, self.logger) as src_path:
             package_zip_builder = MetadataPackageZipBuilder(
-                path=src_path, name=package_name, logger=self.logger
+                path=src_path, name=package_name, context=self.context
             )
             package_config = PackageConfig(
                 package_name=package_name,
diff --git a/cumulusci/tasks/salesforce/Deploy.py b/cumulusci/tasks/salesforce/Deploy.py
index a93e3c342..f84ae3ae2 100644
--- a/cumulusci/tasks/salesforce/Deploy.py
+++ b/cumulusci/tasks/salesforce/Deploy.py
@@ -3,6 +3,7 @@ from typing import List, Optional
 
 from pydantic import ValidationError
 
+from cumulusci.core.dependencies.utils import TaskContext
 from cumulusci.core.exceptions import TaskOptionsError
 from cumulusci.core.sfdx import convert_sfdx_source
 from cumulusci.core.source_transforms.transforms import (
@@ -145,10 +146,11 @@ class Deploy(BaseSalesforceMetadataApiTask):
         }
         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,
-                logger=self.logger,
                 transforms=self.transforms,
             )
 
diff --git a/docs/deploy.md b/docs/deploy.md
index 95db2d9ac..f06e0ac39 100644
--- a/docs/deploy.md
+++ b/docs/deploy.md
@@ -119,6 +119,40 @@ options:
                     replace: bar
 ```
 
+#### Find-and-Replace Id Injection
+
+Some metadata components contain ID references. CumulusCI offers a way to insert the ID of an existing record from the target org directly into such components during a deployment. Specify a SOQL query with the `replace_record_id_query` option as follows:
+
+```yaml
+task: deploy
+options:
+    transforms:
+        - transform: find_replace
+          options:
+              patterns:
+                  - find: special_string
+                    replace_record_id_query: SELECT Id from Account WHERE name='Specific Account'
+                    api: rest
+```
+
+Available values for `api` include `rest` and `tooling`.
+
+#### Find-and-Replace Current Username Injection
+
+CumulusCI can replace a given token with the username of the current running user in the target Salesforce org.
+All that is needed is to specify a value for `find` and set `inject_username: True`:
+
+```yaml
+task: deploy
+options:
+    transforms:
+        - transform: find_replace
+          options:
+              patterns:
+                  - find: special_string
+                    inject_username: True
+```
+
 ### Stripping Components with a `package.xml` Manifest
 
 This transformation allows you to deploy a subset of a metadata directory based on a `package.xml` manifest by removing unwanted components. It will compare components available in the source folder with a provided `package.xml` file and delete/modify component files which are not found.


Test patch:
diff --git a/cumulusci/conftest.py b/cumulusci/conftest.py
index a55f4d98a..f153f883b 100644
--- a/cumulusci/conftest.py
+++ b/cumulusci/conftest.py
@@ -2,6 +2,7 @@ import io
 import os
 from contextlib import contextmanager
 from http.client import HTTPMessage
+from logging import getLogger
 from pathlib import Path
 from tempfile import TemporaryDirectory
 from unittest import mock
@@ -12,6 +13,7 @@ from pytest import fixture
 from sqlalchemy import create_engine
 from sqlalchemy.orm import sessionmaker
 
+from cumulusci.core.dependencies.utils import TaskContext
 from cumulusci.core.github import get_github_api
 from cumulusci.salesforce_api.org_schema_models import Base
 from cumulusci.tasks.salesforce.tests.util import create_task_fixture
@@ -154,3 +156,10 @@ def global_describe(cumulusci_test_repo_root):
 @pytest.fixture(scope="session")
 def shared_vcr_cassettes(cumulusci_test_repo_root):
     return Path(cumulusci_test_repo_root / "cumulusci/tests/shared_cassettes")
+
+
+@pytest.fixture
+def task_context(org_config, project_config):
+    return TaskContext(
+        org_config=org_config, project_config=project_config, logger=getLogger()
+    )
diff --git a/cumulusci/core/dependencies/tests/test_dependencies.py b/cumulusci/core/dependencies/tests/test_dependencies.py
index 046393e2d..862254583 100644
--- a/cumulusci/core/dependencies/tests/test_dependencies.py
+++ b/cumulusci/core/dependencies/tests/test_dependencies.py
@@ -634,7 +634,7 @@ class TestUnmanagedGitHubRefDependency:
                 "namespace_inject": None,
                 "namespace_strip": None,
             },
-            logger=mock.ANY,  # the logger
+            context=mock.ANY,
         )
         api_deploy_mock.assert_called_once_with(
             mock.ANY,  # The context object is checked below
@@ -722,7 +722,7 @@ class TestUnmanagedZipURLDependency:
                 "namespace_strip": None,
             },
             path=None,
-            logger=mock.ANY,  # the logger
+            context=mock.ANY,
         )
         api_deploy_mock.assert_called_once_with(
             mock.ANY,  # The context object is checked below
@@ -791,7 +791,7 @@ class TestUnmanagedZipURLDependency:
                 "namespace_inject": None,
                 "namespace_strip": None,
             },
-            logger=context.logger,
+            context=mock.ANY,
         )
 
     @mock.patch("cumulusci.core.dependencies.dependencies.MetadataPackageZipBuilder")
@@ -825,7 +825,7 @@ class TestUnmanagedZipURLDependency:
                 "namespace_inject": None,
                 "namespace_strip": None,
             },
-            logger=context.logger,
+            context=mock.ANY,
         )
 
     @mock.patch("cumulusci.core.dependencies.dependencies.MetadataPackageZipBuilder")
@@ -858,7 +858,7 @@ class TestUnmanagedZipURLDependency:
                 "namespace_inject": None,
                 "namespace_strip": None,
             },
-            logger=context.logger,
+            context=mock.ANY,
         )
         sfdx_mock.assert_called_once_with(
             "force:source:convert",
diff --git a/cumulusci/core/source_transforms/tests/test_transforms.py b/cumulusci/core/source_transforms/tests/test_transforms.py
index f4bde1c40..cf255eb97 100644
--- a/cumulusci/core/source_transforms/tests/test_transforms.py
+++ b/cumulusci/core/source_transforms/tests/test_transforms.py
@@ -9,9 +9,10 @@ from zipfile import ZipFile
 import pytest
 from pydantic import ValidationError
 
-from cumulusci.core.exceptions import TaskOptionsError
+from cumulusci.core.exceptions import CumulusCIException, TaskOptionsError
 from cumulusci.core.source_transforms.transforms import (
     CleanMetaXMLTransform,
+    FindReplaceIdAPI,
     FindReplaceTransform,
     FindReplaceTransformOptions,
     NamespaceInjectionTransform,
@@ -81,7 +82,7 @@ class ZipFileSpec:
             return False
 
 
-def test_namespace_inject():
+def test_namespace_inject(task_context):
     builder = MetadataPackageZipBuilder.from_zipfile(
         ZipFileSpec(
             {
@@ -91,21 +92,23 @@ def test_namespace_inject():
             }
         ).as_zipfile(),
         options={"namespace_inject": "ns", "unmanaged": False},
+        context=task_context,
     )
     assert ZipFileSpec({Path("ns__Foo.cls"): "System.debug('ns__blah');"}) == builder.zf
 
 
-def test_namespace_inject__unmanaged():
+def test_namespace_inject__unmanaged(task_context):
     builder = MetadataPackageZipBuilder.from_zipfile(
         ZipFileSpec(
             {Path("___NAMESPACE___Foo.cls"): "System.debug('%%%NAMESPACE%%%blah');"}
         ).as_zipfile(),
         options={"namespace_inject": "ns"},
+        context=task_context,
     )
     assert ZipFileSpec({Path("Foo.cls"): "System.debug('blah');"}) == builder.zf
 
 
-def test_namespace_inject__namespaced_org():
+def test_namespace_inject__namespaced_org(task_context):
     builder = MetadataPackageZipBuilder.from_zipfile(
         ZipFileSpec(
             {
@@ -115,22 +118,25 @@ def test_namespace_inject__namespaced_org():
             }
         ).as_zipfile(),
         options={"namespace_inject": "ns", "namespaced_org": True},
+        context=task_context,
     )
     assert ZipFileSpec({Path("Foo.cls"): "System.debug('ns__blah');"}) == builder.zf
 
 
-def test_namespace_strip():
+def test_namespace_strip(task_context):
     builder = MetadataPackageZipBuilder.from_zipfile(
         ZipFileSpec({Path("ns__Foo.cls"): "System.debug('ns__blah');"}).as_zipfile(),
         options={"namespace_strip": "ns", "unmanaged": False},
+        context=task_context,
     )
     assert ZipFileSpec({Path("Foo.cls"): "System.debug('blah');"}) == builder.zf
 
 
-def test_namespace_tokenize():
+def test_namespace_tokenize(task_context):
     builder = MetadataPackageZipBuilder.from_zipfile(
         ZipFileSpec({Path("ns__Foo.cls"): "System.debug('ns__blah');"}).as_zipfile(),
         options={"namespace_tokenize": "ns", "unmanaged": False},
+        context=task_context,
     )
     assert (
         ZipFileSpec(
@@ -140,7 +146,7 @@ def test_namespace_tokenize():
     )
 
 
-def test_namespace_injection_ignores_binary():
+def test_namespace_injection_ignores_binary(task_context):
     builder = MetadataPackageZipBuilder.from_zipfile(
         ZipFileSpec(
             {
@@ -149,6 +155,7 @@ def test_namespace_injection_ignores_binary():
             }
         ).as_zipfile(),
         options={"namespace_tokenize": "ns", "unmanaged": False},
+        context=task_context,
     )
     assert (
         ZipFileSpec(
@@ -161,7 +168,7 @@ def test_namespace_injection_ignores_binary():
     )
 
 
-def test_clean_meta_xml():
+def test_clean_meta_xml(task_context):
     xml_data = """<?xml version="1.0" encoding="UTF-8"?>
 <ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
     <apiVersion>56.0</apiVersion>
@@ -178,12 +185,13 @@ def test_clean_meta_xml():
     </ApexClass>"""
 
     builder = MetadataPackageZipBuilder.from_zipfile(
-        ZipFileSpec({Path("classes/Foo.cls-meta.xml"): xml_data}).as_zipfile()
+        ZipFileSpec({Path("classes/Foo.cls-meta.xml"): xml_data}).as_zipfile(),
+        context=task_context,
     )
     assert ZipFileSpec({Path("classes/Foo.cls-meta.xml"): xml_data_clean}) == builder.zf
 
 
-def test_clean_meta_xml__inactive():
+def test_clean_meta_xml__inactive(task_context):
     xml_data = """<?xml version="1.0" encoding="UTF-8"?>
 <ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
     <apiVersion>56.0</apiVersion>
@@ -198,11 +206,12 @@ def test_clean_meta_xml__inactive():
     builder = MetadataPackageZipBuilder.from_zipfile(
         ZipFileSpec({Path("classes") / "Foo.cls-meta.xml": xml_data}).as_zipfile(),
         options={"clean_meta_xml": False},
+        context=task_context,
     )
     assert ZipFileSpec({Path("classes") / "Foo.cls-meta.xml": xml_data}) == builder.zf
 
 
-def test_remove_feature_parameters():
+def test_remove_feature_parameters(task_context):
     xml_data = """<?xml version="1.0" encoding="UTF-8"?>
 <Package xmlns="http://soap.sforce.com/2006/04/metadata">
     <fullName>TestPackage</fullName>
@@ -237,6 +246,7 @@ def test_remove_feature_parameters():
             }
         ).as_zipfile(),
         options={"package_type": "Unlocked"},
+        context=task_context,
     )
     assert (
         ZipFileSpec(
@@ -249,7 +259,7 @@ def test_remove_feature_parameters():
     )
 
 
-def test_remove_feature_parameters__inactive():
+def test_remove_feature_parameters__inactive(task_context):
     xml_data = """<?xml version="1.0" encoding="UTF-8"?>
 <Package xmlns="http://soap.sforce.com/2006/04/metadata">
     <fullName>TestPackage</fullName>
@@ -272,6 +282,7 @@ def test_remove_feature_parameters__inactive():
                 Path("classes") / "MyClass.cls": "blah",
             }
         ).as_zipfile(),
+        context=task_context,
     )
     assert (
         ZipFileSpec(
@@ -285,7 +296,7 @@ def test_remove_feature_parameters__inactive():
     )
 
 
-def test_bundle_static_resources():
+def test_bundle_static_resources(task_context):
     xml_data = """<?xml version="1.0" encoding="UTF-8"?>
 <Package xmlns="http://soap.sforce.com/2006/04/metadata">
     <fullName>TestPackage</fullName>
@@ -337,6 +348,7 @@ def test_bundle_static_resources():
                 }
             ).as_zipfile(),
             options={"static_resource_path": str(statics_dir)},
+            context=task_context,
         )
 
         foo_spec = ZipFileSpec(
@@ -364,13 +376,14 @@ def test_bundle_static_resources():
         assert compare_spec == zf
 
 
-def test_find_replace_static():
+def test_find_replace_static(task_context):
     builder = MetadataPackageZipBuilder.from_zipfile(
         ZipFileSpec(
             {
                 Path("Foo.cls"): "System.debug('blah');",
             }
         ).as_zipfile(),
+        context=task_context,
         transforms=[
             FindReplaceTransform(
                 FindReplaceTransformOptions.parse_obj(
@@ -390,7 +403,7 @@ def test_find_replace_static():
     )
 
 
-def test_find_replace_environ():
+def test_find_replace_environ(task_context):
     with mock.patch.dict(os.environ, {"INSERT_TEXT": "ye"}):
         builder = MetadataPackageZipBuilder.from_zipfile(
             ZipFileSpec(
@@ -398,6 +411,7 @@ def test_find_replace_environ():
                     Path("Foo.cls"): "System.debug('blah');",
                 }
             ).as_zipfile(),
+            context=task_context,
             transforms=[
                 FindReplaceTransform(
                     FindReplaceTransformOptions.parse_obj(
@@ -417,7 +431,7 @@ def test_find_replace_environ():
         )
 
 
-def test_find_replace_environ__not_found():
+def test_find_replace_environ__not_found(task_context):
     assert "INSERT_TEXT" not in os.environ
     with pytest.raises(TaskOptionsError):
         MetadataPackageZipBuilder.from_zipfile(
@@ -426,6 +440,7 @@ def test_find_replace_environ__not_found():
                     Path("Foo.cls"): "System.debug('blah');",
                 }
             ).as_zipfile(),
+            context=task_context,
             transforms=[
                 FindReplaceTransform(
                     FindReplaceTransformOptions.parse_obj(
@@ -436,7 +451,7 @@ def test_find_replace_environ__not_found():
         )
 
 
-def test_find_replace_filtered():
+def test_find_replace_filtered(task_context):
     builder = MetadataPackageZipBuilder.from_zipfile(
         ZipFileSpec(
             {
@@ -444,6 +459,7 @@ def test_find_replace_filtered():
                 Path("Bar.cls"): "System.debug('blah');",
             }
         ).as_zipfile(),
+        context=task_context,
         transforms=[
             FindReplaceTransform(
                 FindReplaceTransformOptions.parse_obj(
@@ -468,7 +484,7 @@ def test_find_replace_filtered():
     )
 
 
-def test_find_replace_multiple():
+def test_find_replace_multiple(task_context):
     builder = MetadataPackageZipBuilder.from_zipfile(
         ZipFileSpec(
             {
@@ -476,6 +492,7 @@ def test_find_replace_multiple():
                 Path("Bar.cls"): "System.debug('blah');",
             }
         ).as_zipfile(),
+        context=task_context,
         transforms=[
             FindReplaceTransform(
                 FindReplaceTransformOptions.parse_obj(
@@ -501,6 +518,132 @@ def test_find_replace_multiple():
     )
 
 
+def test_find_replace_current_user(task_context):
+    options = FindReplaceTransformOptions.parse_obj(
+        {
+            "patterns": [
+                {
+                    "find": "%%%CURRENT_USER%%%",
+                },
+            ]
+        }
+    )
+    builder = MetadataPackageZipBuilder.from_zipfile(
+        ZipFileSpec(
+            {
+                Path("classes") / "Foo.cls": "System.debug('%%%CURRENT_USER%%%');",
+                Path("Bar.cls"): "System.debug('blah');",
+            }
+        ).as_zipfile(),
+        context=task_context,
+        transforms=[FindReplaceTransform(options)],
+    )
+
+    expected_username = task_context.org_config.username
+    assert (
+        ZipFileSpec(
+            {
+                Path("classes") / "Foo.cls": f"System.debug('{expected_username}');",
+                Path("Bar.cls"): "System.debug('blah');",
+            }
+        )
+        == builder.zf
+    )
+
+
+@pytest.mark.parametrize("api", [FindReplaceIdAPI.REST, FindReplaceIdAPI.TOOLING])
+def test_find_replace_id(api):
+    context = mock.Mock()
+    result = {"totalSize": 1, "records": [{"Id": "00D"}]}
+    context.org_config.salesforce_client.query.return_value = result
+    context.org_config.tooling.query.return_value = result
+    options = FindReplaceTransformOptions.parse_obj(
+        {
+            "patterns": [
+                {
+                    "find": "00Y",
+                    "replace_record_id_query": "SELECT Id FROM Account WHERE name='Initech Corp.'",
+                    "api": api,
+                },
+            ]
+        }
+    )
+    builder = MetadataPackageZipBuilder.from_zipfile(
+        ZipFileSpec(
+            {
+                Path("classes") / "Foo.cls": "System.debug('00Y');",
+                Path("Bar.cls"): "System.debug('blah');",
+            }
+        ).as_zipfile(),
+        context=context,
+        transforms=[FindReplaceTransform(options)],
+    )
+
+    assert (
+        ZipFileSpec(
+            {
+                Path("classes") / "Foo.cls": "System.debug('00D');",
+                Path("Bar.cls"): "System.debug('blah');",
+            }
+        )
+        == builder.zf
+    )
+
+
+def test_find_replace_id__bad_query_result():
+    context = mock.Mock()
+    result = {"totalSize": 0}
+    context.org_config.salesforce_client.query.return_value = result
+    options = FindReplaceTransformOptions.parse_obj(
+        {
+            "patterns": [
+                {
+                    "find": "00Y",
+                    "replace_record_id_query": "SELECT Id FROM Account WHERE name='Initech Corp.'",
+                },
+            ]
+        }
+    )
+    with pytest.raises(CumulusCIException):
+        MetadataPackageZipBuilder.from_zipfile(
+            ZipFileSpec(
+                {
+                    Path("classes") / "Foo.cls": "System.debug('00Y');",
+                    Path("Bar.cls"): "System.debug('blah');",
+                }
+            ).as_zipfile(),
+            context=context,
+            transforms=[FindReplaceTransform(options)],
+        )
+
+
+def test_find_replace_id__no_id_returned():
+    context = mock.Mock()
+    result = {"totalSize": 1, "records": [{"name": "foo"}]}
+    context.org_config.salesforce_client.query.return_value = result
+    options = FindReplaceTransformOptions.parse_obj(
+        {
+            "patterns": [
+                {
+                    "find": "00Y",
+                    "replace_record_id_query": "SELECT Id FROM Account WHERE name='Initech Corp.'",
+                },
+            ]
+        }
+    )
+    with pytest.raises(CumulusCIException):
+        MetadataPackageZipBuilder.from_zipfile(
+            ZipFileSpec(
+                {
+                    Path("classes") / "Foo.cls": "System.debug('00Y');",
+                    Path("Bar.cls"): "System.debug('blah');",
+                }
+            ).as_zipfile(),
+            context=context,
+            transforms=[FindReplaceTransform(options)],
+        )
+
+
 def test_source_transform_parsing():
     tl = SourceTransformList.parse_obj(
         [
@@ -536,7 +679,7 @@ def test_source_transform_parsing__bad_transform():
         assert "destroy_the_things is not valid" in str(e)
 
 
-def test_strip_unwanted_files():
+def test_strip_unwanted_files(task_context):
     """
     This test covers all strip options available during deployment.
     It covers below scenarios:
@@ -641,6 +784,7 @@ def test_strip_unwanted_files():
                     Path(os.path.join("lwc", "lwcBundle2", "lwc2.cmp")): "Comp 2",
                 }
             ).as_zipfile(),
+            context=task_context,
             transforms=[
                 StripUnwantedComponentTransform(
                     StripUnwantedComponentsOptions.parse_obj(
diff --git a/cumulusci/salesforce_api/tests/test_package_install.py b/cumulusci/salesforce_api/tests/test_package_install.py
index 523c012a8..b568feb49 100644
--- a/cumulusci/salesforce_api/tests/test_package_install.py
+++ b/cumulusci/salesforce_api/tests/test_package_install.py
@@ -1,4 +1,5 @@
 import logging
+from logging import getLogger
 from unittest import mock
 
 import pytest
@@ -128,7 +129,6 @@ def test_install_package_by_namespace_version(zip_builder, api_deploy):
         ),
     )
 
-    task = TaskContext(org_config=org, project_config=pc, logger=mock.ANY)
     zip_builder.assert_called_once_with(
         namespace="foo",
         version="1.0",
@@ -136,7 +136,12 @@ def test_install_package_by_namespace_version(zip_builder, api_deploy):
         password="foobar",
         securityType="PUSH",
     )
-    api_deploy.assert_called_once_with(task, mock.ANY, purge_on_delete=False)
+    context = TaskContext(
+        org_config=org,
+        project_config=pc,
+        logger=getLogger("cumulusci.salesforce_api.package_install"),
+    )
+    api_deploy.assert_called_once_with(context, mock.ANY, purge_on_delete=False)
     api_deploy.return_value.assert_called_once()
 
 
@@ -158,11 +163,15 @@ def test_install_package_by_namespace_version__retry(zip_builder, api_deploy):
         PackageInstallOptions(),
     )
 
-    task = TaskContext(org_config=org, project_config=pc, logger=mock.ANY)
+    context = TaskContext(
+        org_config=org,
+        project_config=pc,
+        logger=getLogger("cumulusci.salesforce_api.package_install"),
+    )
     api_deploy.assert_has_calls(
         [
-            mock.call(task, mock.ANY, purge_on_delete=False),
-            mock.call(task, mock.ANY, purge_on_delete=False),
+            mock.call(context, mock.ANY, purge_on_delete=False),
+            mock.call(context, mock.ANY, purge_on_delete=False),
         ],
         any_order=True,
     )
diff --git a/cumulusci/salesforce_api/tests/test_package_zip.py b/cumulusci/salesforce_api/tests/test_package_zip.py
index 7dd9921c2..56e78ed95 100644
--- a/cumulusci/salesforce_api/tests/test_package_zip.py
+++ b/cumulusci/salesforce_api/tests/test_package_zip.py
@@ -35,7 +35,7 @@ class TestBasePackageZipBuilder:
 
 
 class TestMetadataPackageZipBuilder:
-    def test_builder(self):
+    def test_builder(self, task_context):
         with temporary_dir() as path:
 
             # add package.xml
@@ -133,6 +133,7 @@ class TestMetadataPackageZipBuilder:
                     "namespace_inject": "ns",
                     "namespace_strip": "ns",
                 },
+                context=task_context,
             )
 
             # make sure result can be read as a zipfile
@@ -153,7 +154,7 @@ class TestMetadataPackageZipBuilder:
                 "objects/does-not-exist-in-schema/some.file",
             }
 
-    def test_add_files_to_package(self):
+    def test_add_files_to_package(self, task_context):
         with temporary_dir() as path:
             expected = []
 
@@ -250,15 +251,15 @@ class TestMetadataPackageZipBuilder:
                 expected.append("objects/does-not-exist-in-schema/some.file")
 
             # test
-            builder = MetadataPackageZipBuilder()
+            builder = MetadataPackageZipBuilder(context=task_context)
 
             expected_set = set(expected)
             builder._add_files_to_package(path)
             actual_set = set(builder.zf.namelist())
             assert expected_set == actual_set
 
-    def test_include_directory(self):
-        builder = MetadataPackageZipBuilder()
+    def test_include_directory(self, task_context):
+        builder = MetadataPackageZipBuilder(context=task_context)
 
         # include root directory
         assert builder._include_directory([]) is True
@@ -286,8 +287,8 @@ class TestMetadataPackageZipBuilder:
         assert builder._include_directory(["not-lwc", "sub-1"]) is True
         assert builder._include_directory(["not-lwc", "sub-1", "sub-2"]) is True
 
-    def test_include_file(self):
-        builder = MetadataPackageZipBuilder()
+    def test_include_file(self, task_context):
+        builder = MetadataPackageZipBuilder(context=task_context)
 
         lwc_component_directories = [
             ["lwc"],
@@ -316,7 +317,7 @@ class TestMetadataPackageZipBuilder:
             for d in non_lwc_component_directories:
                 assert builder._include_file(d, "file_name" + file_ending)
 
-    def test_removes_feature_parameters_from_unlocked_package(self):
+    def test_removes_feature_parameters_from_unlocked_package(self, task_context):
         with temporary_dir() as path:
             pathlib.Path(path, "package.xml").write_text(
                 """<?xml version="1.0" encoding="utf-8"?>
@@ -330,7 +331,7 @@ class TestMetadataPackageZipBuilder:
             featureParameters.mkdir()
             (featureParameters / "test.featureParameterInteger").touch()
             builder = MetadataPackageZipBuilder(
-                path=path, options={"package_type": "Unlocked"}
+                path=path, options={"package_type": "Unlocked"}, context=task_context
             )
             assert (
                 "featureParameters/test.featureParameterInteger"