cumulusci-plus 5.0.19__py3-none-any.whl → 5.0.35__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. cumulusci/__about__.py +1 -1
  2. cumulusci/cli/logger.py +2 -2
  3. cumulusci/cli/service.py +20 -0
  4. cumulusci/cli/task.py +17 -0
  5. cumulusci/cli/tests/test_error.py +3 -1
  6. cumulusci/cli/tests/test_flow.py +279 -2
  7. cumulusci/cli/tests/test_service.py +15 -12
  8. cumulusci/cli/tests/test_task.py +88 -2
  9. cumulusci/cli/tests/utils.py +1 -4
  10. cumulusci/core/config/base_task_flow_config.py +26 -1
  11. cumulusci/core/config/project_config.py +2 -20
  12. cumulusci/core/config/tests/test_config_expensive.py +9 -3
  13. cumulusci/core/config/universal_config.py +3 -4
  14. cumulusci/core/dependencies/base.py +1 -1
  15. cumulusci/core/dependencies/dependencies.py +1 -1
  16. cumulusci/core/dependencies/github.py +1 -2
  17. cumulusci/core/dependencies/resolvers.py +1 -1
  18. cumulusci/core/dependencies/tests/test_dependencies.py +1 -1
  19. cumulusci/core/dependencies/tests/test_resolvers.py +1 -1
  20. cumulusci/core/flowrunner.py +90 -6
  21. cumulusci/core/github.py +1 -1
  22. cumulusci/core/sfdx.py +3 -1
  23. cumulusci/core/source_transforms/tests/test_transforms.py +1 -1
  24. cumulusci/core/source_transforms/transforms.py +1 -1
  25. cumulusci/core/tasks.py +13 -2
  26. cumulusci/core/tests/test_flowrunner.py +100 -0
  27. cumulusci/core/tests/test_tasks.py +65 -0
  28. cumulusci/core/utils.py +3 -1
  29. cumulusci/core/versions.py +1 -1
  30. cumulusci/cumulusci.yml +55 -0
  31. cumulusci/oauth/client.py +1 -1
  32. cumulusci/plugins/plugin_base.py +5 -3
  33. cumulusci/robotframework/pageobjects/ObjectManagerPageObject.py +1 -1
  34. cumulusci/salesforce_api/rest_deploy.py +1 -1
  35. cumulusci/schema/cumulusci.jsonschema.json +64 -0
  36. cumulusci/tasks/apex/anon.py +1 -1
  37. cumulusci/tasks/apex/testrunner.py +416 -142
  38. cumulusci/tasks/apex/tests/test_apex_tasks.py +917 -1
  39. cumulusci/tasks/bulkdata/extract.py +0 -1
  40. cumulusci/tasks/bulkdata/extract_dataset_utils/extract_yml.py +1 -1
  41. cumulusci/tasks/bulkdata/extract_dataset_utils/synthesize_extract_declarations.py +1 -1
  42. cumulusci/tasks/bulkdata/extract_dataset_utils/tests/test_extract_yml.py +1 -1
  43. cumulusci/tasks/bulkdata/generate_and_load_data.py +136 -12
  44. cumulusci/tasks/bulkdata/mapping_parser.py +139 -44
  45. cumulusci/tasks/bulkdata/select_utils.py +1 -1
  46. cumulusci/tasks/bulkdata/snowfakery.py +100 -25
  47. cumulusci/tasks/bulkdata/tests/test_generate_and_load.py +159 -0
  48. cumulusci/tasks/bulkdata/tests/test_load.py +0 -2
  49. cumulusci/tasks/bulkdata/tests/test_mapping_parser.py +763 -1
  50. cumulusci/tasks/bulkdata/tests/test_select_utils.py +26 -0
  51. cumulusci/tasks/bulkdata/tests/test_snowfakery.py +133 -0
  52. cumulusci/tasks/create_package_version.py +190 -16
  53. cumulusci/tasks/datadictionary.py +1 -1
  54. cumulusci/tasks/metadata_etl/base.py +7 -3
  55. cumulusci/tasks/metadata_etl/layouts.py +1 -1
  56. cumulusci/tasks/metadata_etl/permissions.py +1 -1
  57. cumulusci/tasks/metadata_etl/remote_site_settings.py +2 -2
  58. cumulusci/tasks/push/README.md +15 -17
  59. cumulusci/tasks/release_notes/README.md +13 -13
  60. cumulusci/tasks/release_notes/generator.py +13 -8
  61. cumulusci/tasks/robotframework/tests/test_robotframework.py +6 -1
  62. cumulusci/tasks/salesforce/Deploy.py +53 -2
  63. cumulusci/tasks/salesforce/SfPackageCommands.py +363 -0
  64. cumulusci/tasks/salesforce/__init__.py +1 -0
  65. cumulusci/tasks/salesforce/assign_ps_psg.py +448 -0
  66. cumulusci/tasks/salesforce/composite.py +1 -1
  67. cumulusci/tasks/salesforce/custom_settings_wait.py +1 -1
  68. cumulusci/tasks/salesforce/enable_prediction.py +5 -1
  69. cumulusci/tasks/salesforce/getPackageVersion.py +89 -0
  70. cumulusci/tasks/salesforce/profiles.py +13 -9
  71. cumulusci/tasks/salesforce/sourcetracking.py +1 -1
  72. cumulusci/tasks/salesforce/tests/test_Deploy.py +316 -1
  73. cumulusci/tasks/salesforce/tests/test_SfPackageCommands.py +554 -0
  74. cumulusci/tasks/salesforce/tests/test_assign_ps_psg.py +1055 -0
  75. cumulusci/tasks/salesforce/tests/test_getPackageVersion.py +651 -0
  76. cumulusci/tasks/salesforce/tests/test_profiles.py +43 -3
  77. cumulusci/tasks/salesforce/tests/test_update_dependencies.py +1 -1
  78. cumulusci/tasks/salesforce/tests/test_update_external_credential.py +912 -0
  79. cumulusci/tasks/salesforce/tests/test_update_named_credential.py +1042 -0
  80. cumulusci/tasks/salesforce/update_dependencies.py +2 -2
  81. cumulusci/tasks/salesforce/update_external_credential.py +562 -0
  82. cumulusci/tasks/salesforce/update_named_credential.py +441 -0
  83. cumulusci/tasks/salesforce/update_profile.py +17 -13
  84. cumulusci/tasks/salesforce/users/permsets.py +62 -5
  85. cumulusci/tasks/salesforce/users/tests/test_permsets.py +237 -11
  86. cumulusci/tasks/sfdmu/__init__.py +0 -0
  87. cumulusci/tasks/sfdmu/sfdmu.py +363 -0
  88. cumulusci/tasks/sfdmu/tests/__init__.py +1 -0
  89. cumulusci/tasks/sfdmu/tests/test_runner.py +212 -0
  90. cumulusci/tasks/sfdmu/tests/test_sfdmu.py +1012 -0
  91. cumulusci/tasks/tests/test_create_package_version.py +716 -1
  92. cumulusci/tasks/tests/test_util.py +42 -0
  93. cumulusci/tasks/util.py +37 -1
  94. cumulusci/tasks/utility/copyContents.py +402 -0
  95. cumulusci/tasks/utility/credentialManager.py +256 -0
  96. cumulusci/tasks/utility/directoryRecreator.py +30 -0
  97. cumulusci/tasks/utility/env_management.py +1 -1
  98. cumulusci/tasks/utility/secretsToEnv.py +135 -0
  99. cumulusci/tasks/utility/tests/test_copyContents.py +1719 -0
  100. cumulusci/tasks/utility/tests/test_credentialManager.py +564 -0
  101. cumulusci/tasks/utility/tests/test_directoryRecreator.py +439 -0
  102. cumulusci/tasks/utility/tests/test_secretsToEnv.py +1091 -0
  103. cumulusci/tests/test_integration_infrastructure.py +3 -1
  104. cumulusci/tests/test_utils.py +70 -6
  105. cumulusci/utils/__init__.py +54 -9
  106. cumulusci/utils/classutils.py +5 -2
  107. cumulusci/utils/http/tests/cassettes/ManualEditTestCompositeParallelSalesforce.test_http_headers.yaml +31 -30
  108. cumulusci/utils/options.py +23 -1
  109. cumulusci/utils/parallel/task_worker_queues/parallel_worker.py +1 -1
  110. cumulusci/utils/yaml/cumulusci_yml.py +7 -3
  111. cumulusci/utils/yaml/model_parser.py +2 -2
  112. cumulusci/utils/yaml/tests/test_cumulusci_yml.py +1 -1
  113. cumulusci/utils/yaml/tests/test_model_parser.py +3 -3
  114. cumulusci/vcs/base.py +23 -15
  115. cumulusci/vcs/bootstrap.py +5 -4
  116. cumulusci/vcs/utils/list_modified_files.py +189 -0
  117. cumulusci/vcs/utils/tests/test_list_modified_files.py +588 -0
  118. {cumulusci_plus-5.0.19.dist-info → cumulusci_plus-5.0.35.dist-info}/METADATA +12 -10
  119. {cumulusci_plus-5.0.19.dist-info → cumulusci_plus-5.0.35.dist-info}/RECORD +123 -98
  120. {cumulusci_plus-5.0.19.dist-info → cumulusci_plus-5.0.35.dist-info}/WHEEL +0 -0
  121. {cumulusci_plus-5.0.19.dist-info → cumulusci_plus-5.0.35.dist-info}/entry_points.txt +0 -0
  122. {cumulusci_plus-5.0.19.dist-info → cumulusci_plus-5.0.35.dist-info}/licenses/AUTHORS.rst +0 -0
  123. {cumulusci_plus-5.0.19.dist-info → cumulusci_plus-5.0.35.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,448 @@
1
+ import json
2
+ from inspect import signature
3
+ from typing import Dict, List
4
+
5
+ from pydantic.v1 import create_model
6
+
7
+ from cumulusci.core.exceptions import SalesforceException, TaskOptionsError
8
+ from cumulusci.core.utils import determine_managed_mode
9
+ from cumulusci.tasks.salesforce import BaseSalesforceApiTask
10
+ from cumulusci.utils import inject_namespace
11
+ from cumulusci.utils.options import CCIOptions, CCIOptionType, Field
12
+
13
+
14
+ class PermissionSetGroupAssignmentsOption(CCIOptionType):
15
+ """Parses a dictionary of Permission Set Group to Permission Sets assignments.
16
+
17
+ Supports formats:
18
+ - JSON: {"PermissionSetGroup1": ["PermissionSet1", "PermissionSet2"]}
19
+ - YAML: PermissionSetGroup1: [PermissionSet1, PermissionSet2]
20
+ - Command line: "PermissionSetGroup1:PermissionSet1,PermissionSet2;PermissionSetGroup2:PermissionSet3,PermissionSet4"
21
+ """
22
+
23
+ @classmethod
24
+ def validate(cls, v):
25
+ """Validate and convert a value to Dict[str, List[str]]."""
26
+ # Handle dict input (from YAML/JSON)
27
+ if isinstance(v, dict):
28
+ # Normalize: ensure all values are lists
29
+ normalized = {k: v if isinstance(v, list) else [v] for k, v in v.items()}
30
+ # Handle string input
31
+ elif isinstance(v, str):
32
+ # If it's a JSON string, parse it
33
+ if v.startswith("{") or v.startswith("["):
34
+ parsed = json.loads(v)
35
+ if isinstance(parsed, dict):
36
+ normalized = {
37
+ k: v if isinstance(v, list) else [v] for k, v in parsed.items()
38
+ }
39
+ else:
40
+ raise TaskOptionsError(
41
+ f"Expected dict in JSON string, got {type(parsed)}"
42
+ )
43
+ else:
44
+ # Handle command line format: PSG1:PS1,PS2;PSG2:PS3,PS4
45
+ normalized = cls.from_str(v)
46
+ else:
47
+ raise TaskOptionsError(
48
+ f"Invalid format for assignments. Expected dict or string in format "
49
+ f"'PSG1:PS1,PS2;PSG2:PS3,PS4' or JSON dict, got: {type(v)}"
50
+ )
51
+
52
+ # Validate with Pydantic using the return type annotation
53
+ target_type = signature(cls.from_str).return_annotation
54
+ Dummy = create_model(cls.name or cls.__name__, __root__=(target_type, ...))
55
+ return Dummy.parse_obj(normalized).__root__
56
+
57
+ @classmethod
58
+ def from_str(cls, v) -> Dict[str, List[str]]:
59
+ """Parse command line format: PSG1:PS1,PS2;PSG2:PS3,PS4 or PSG1:PS1"""
60
+ # Handle command line format: presence of colon indicates command line format
61
+ if ":" in v:
62
+ result = {}
63
+ # Split by semicolon to get groups (if multiple groups)
64
+ # If no semicolon, treat entire string as single group assignment
65
+ group_assignments = v.split(";") if ";" in v else [v]
66
+
67
+ for group_assignment in group_assignments:
68
+ group_assignment = group_assignment.strip()
69
+ if ":" in group_assignment:
70
+ psg_name, ps_names = group_assignment.split(":", maxsplit=1)
71
+ psg_name = psg_name.strip()
72
+ # Split permission sets by comma (if multiple permission sets)
73
+ # If no comma, treat entire string as single permission set
74
+ ps_list = [ps.strip() for ps in ps_names.split(",") if ps.strip()]
75
+ if psg_name:
76
+ result[psg_name] = ps_list
77
+ return result
78
+
79
+ raise TaskOptionsError(
80
+ "Invalid format for assignments. Expected string in format "
81
+ "'PSG1:PS1' or 'PSG1:PS1,PS2;PSG2:PS3,PS4'"
82
+ )
83
+
84
+
85
+ class AssignPermissionSetToPermissionSetGroup(BaseSalesforceApiTask):
86
+ """Assign Permission Sets to Permission Set Groups.
87
+
88
+ This task creates PermissionSetGroupComponent records to associate
89
+ Permission Sets with Permission Set Groups using the Composite API.
90
+
91
+ Task options:
92
+ - assignments: A dictionary where:
93
+ key: Permission Set Group API name (DeveloperName)
94
+ - value: List of Permission Set API names (Name) to assign to the Permission Set Group
95
+
96
+ Example 1: Passed as JSON
97
+ "PermissionSetGroup1": ["PermissionSet1", "PermissionSet2"]
98
+ "PermissionSetGroup2": ["PermissionSet3", "PermissionSet4"]
99
+
100
+ Example 2: Passed as YAML
101
+ PermissionSetGroup1:
102
+ - PermissionSet1
103
+ - PermissionSet2
104
+ PermissionSetGroup2:
105
+ - PermissionSet3
106
+ - PermissionSet4
107
+
108
+ Example 3: Passed in command line arguments
109
+ - --assignments "PermissionSetGroup1:PermissionSet1,PermissionSet2;PermissionSetGroup2:PermissionSet3,PermissionSet4"
110
+ - --assignments "PermissionSetGroup1:PermissionSet1;"
111
+ """
112
+
113
+ class Options(CCIOptions):
114
+ assignments: PermissionSetGroupAssignmentsOption = Field(
115
+ ...,
116
+ description=(
117
+ "Dictionary mapping Permission Set Group API names to lists of "
118
+ "Permission Set API names. Supports JSON, YAML, or command line format."
119
+ ),
120
+ )
121
+ namespace_inject: str = Field(
122
+ None,
123
+ description="Namespace to use for Permission Set names. If not provided, the namespace from the project config will be used.",
124
+ )
125
+ managed: bool = Field(
126
+ None,
127
+ description="Whether the deployment is managed. If not provided, the managed mode will be determined based on the org config.",
128
+ )
129
+ fail_on_error: bool = Field(
130
+ True,
131
+ description="Whether the task should fail if any Permission Set Group Component creation fails. If set to False, the task will continue even if some assignments fail.",
132
+ )
133
+
134
+ parsed_options: Options
135
+
136
+ def _init_options(self, kwargs):
137
+ super()._init_options(kwargs)
138
+
139
+ if self.parsed_options.namespace_inject is None:
140
+ self.parsed_options.namespace_inject = (
141
+ self.project_config.project__package__namespace
142
+ )
143
+
144
+ if self.parsed_options.managed is None:
145
+ self.parsed_options.managed = determine_managed_mode(
146
+ self.parsed_options, self.project_config, self.org_config
147
+ )
148
+
149
+ self.namespaced_org = bool(
150
+ self.parsed_options.namespace_inject
151
+ ) and self.parsed_options.namespace_inject == getattr(
152
+ self.org_config, "namespace", None
153
+ )
154
+
155
+ self.psg_names_sanitized = {}
156
+ self.ps_names_sanitized = {}
157
+
158
+ def _run_task(self):
159
+ """Execute the task to assign Permission Sets to Permission Set Groups."""
160
+ assignments = self.parsed_options.assignments
161
+
162
+ if not assignments:
163
+ self.logger.warning("No assignments provided. Nothing to do.")
164
+ return
165
+
166
+ # Step 1: Query Permission Set Groups to get their IDs
167
+ try:
168
+ self._get_permission_set_group_ids(list(assignments.keys()))
169
+ except Exception as e:
170
+ raise SalesforceException(
171
+ f"Error querying Permission Set Groups: {str(e)}"
172
+ ) from e
173
+
174
+ # Log missing groups
175
+ missing = set(self.psg_names_sanitized.values()) - set(self.psg_ids.keys())
176
+ if missing:
177
+ msg = f"Permission Set Groups not found in the org: {', '.join(missing)}"
178
+ if self.parsed_options.fail_on_error:
179
+ raise SalesforceException(msg)
180
+ self.logger.warning(msg)
181
+
182
+ # Step 2: Query Permission Sets to get their IDs
183
+ all_ps_names = []
184
+ for ps_list in assignments.values():
185
+ all_ps_names.extend(ps_list)
186
+
187
+ try:
188
+ self._get_permission_set_ids(all_ps_names)
189
+ except Exception as e:
190
+ msg = f"Error querying Permission Sets: {str(e)}"
191
+ if self.parsed_options.fail_on_error:
192
+ raise SalesforceException(msg)
193
+ self.logger.error(msg)
194
+
195
+ # Log missing permission sets
196
+ missing = set(self.ps_names_sanitized.values()) - set(self.ps_ids.keys())
197
+ if missing:
198
+ msg = f"Permission Sets not found in the org: {', '.join(missing)}"
199
+ if self.parsed_options.fail_on_error:
200
+ raise SalesforceException(msg)
201
+ self.logger.warning(msg)
202
+
203
+ # Step 3: Build composite request to create PermissionSetGroupComponent records
204
+ records = []
205
+ for psg_name, ps_names in assignments.items():
206
+ psg_id = self.psg_ids.get(self.psg_names_sanitized[psg_name])
207
+ if not psg_id:
208
+ self.logger.warning(
209
+ f"Permission Set Group '{psg_name}' not found in the org. Skipping assignment creation."
210
+ )
211
+ continue
212
+
213
+ for ps_name in ps_names:
214
+ ps_id = self.ps_ids.get(self.ps_names_sanitized[ps_name])
215
+ if not ps_id:
216
+ self.logger.warning(
217
+ f"Permission Set '{ps_name}' not found in the org. Skipping assignment creation."
218
+ )
219
+ continue
220
+
221
+ records.append(
222
+ {
223
+ "attributes": {"type": "PermissionSetGroupComponent"},
224
+ "PermissionSetGroupId": psg_id,
225
+ "PermissionSetId": ps_id,
226
+ }
227
+ )
228
+
229
+ if not records:
230
+ self.logger.warning("No valid records to create. Nothing to do.")
231
+ return
232
+
233
+ # Step 4: Use Composite API to create records in batches of 200
234
+ for i in range(0, len(records), 200):
235
+ batch = records[i : i + 200]
236
+ self.logger.info(f"Processing batch {i // 200 + 1} ({len(batch)} records)")
237
+ try:
238
+ self._create_permission_set_group_components(batch)
239
+ except Exception as e:
240
+ self.logger.error(f"Error processing batch {i // 200 + 1}: {str(e)}")
241
+ if self.parsed_options.fail_on_error:
242
+ raise e
243
+
244
+ def _get_permission_set_group_ids(self, psg_names: List[str]):
245
+ """Query Permission Set Groups by DeveloperName and return mapping of name to ID."""
246
+ self.psg_ids = {}
247
+ if not psg_names:
248
+ self.logger.warning("No Permission Set Groups provided. Nothing to do.")
249
+ return
250
+
251
+ self.psg_names_sanitized = self._process_namespaces(psg_names)
252
+
253
+ name_conditions, name_mapping = build_name_conditions(
254
+ list(self.psg_names_sanitized.values()), field_name="DeveloperName"
255
+ )
256
+
257
+ query = (
258
+ f"SELECT Id, DeveloperName, NamespacePrefix FROM PermissionSetGroup "
259
+ f"WHERE ({' OR '.join(name_conditions)})"
260
+ )
261
+
262
+ result = self.sf.query(query)
263
+ for record in result.get("records", []):
264
+ record_name = record["DeveloperName"]
265
+ namespace_prefix = record.get("NamespacePrefix")
266
+ key = (record_name, namespace_prefix)
267
+ if key in name_mapping:
268
+ original_name = name_mapping[key]
269
+ self.psg_ids[original_name] = record["Id"]
270
+ elif (record_name, None) in name_mapping:
271
+ original_name = name_mapping[(record_name, None)]
272
+ self.psg_ids[original_name] = record["Id"]
273
+
274
+ def _process_namespaces(self, names: List[str]):
275
+ """Process namespace tokens in names."""
276
+ # names_json_string = json.dumps(names)
277
+ names_processed = {}
278
+ for name in names:
279
+ _, name_processed = inject_namespace(
280
+ "",
281
+ name,
282
+ namespace=self.parsed_options.namespace_inject,
283
+ managed=self.parsed_options.managed,
284
+ namespaced_org=self.namespaced_org,
285
+ logger=self.logger,
286
+ )
287
+ names_processed[name] = name_processed
288
+ return names_processed
289
+
290
+ def _get_permission_set_ids(self, ps_names: List[str]):
291
+ """Query Permission Sets by Name and return mapping of name to ID.
292
+
293
+ Handles namespace tokens (%%%NAMESPACE%%%) in Permission Set names by:
294
+ 1. Replacing tokens with actual namespace prefix
295
+ 2. Querying using both Name and NamespacePrefix fields
296
+ """
297
+ self.ps_ids = {}
298
+ if not ps_names:
299
+ self.logger.warning("No Permission Sets provided. Nothing to do.")
300
+ return
301
+
302
+ # Remove duplicates while preserving order
303
+ unique_ps_names = list(dict.fromkeys(ps_names))
304
+
305
+ # Process namespace tokens in Permission Set names
306
+ self.ps_names_sanitized = self._process_namespaces(unique_ps_names)
307
+
308
+ # Replace namespace tokens in names and build query conditions
309
+ name_conditions, name_mapping = build_name_conditions(
310
+ list(self.ps_names_sanitized.values())
311
+ )
312
+
313
+ # Build SOQL query with namespace handling
314
+ query = (
315
+ f"SELECT Id, Name, NamespacePrefix FROM PermissionSet "
316
+ f"WHERE IsOwnedByProfile = false AND ({' OR '.join(name_conditions)})"
317
+ )
318
+ result = self.sf.query(query)
319
+
320
+ # Build mapping considering namespace prefix
321
+ for record in result.get("records", []):
322
+ record_name = record["Name"]
323
+ namespace_prefix = record.get("NamespacePrefix")
324
+
325
+ # Try to match by (name, namespace_prefix) tuple
326
+ key = (record_name, namespace_prefix)
327
+ if key in name_mapping:
328
+ original_name = name_mapping[key]
329
+ self.ps_ids[original_name] = record["Id"]
330
+ # Fallback: match by name only if namespace_prefix is None
331
+ elif (record_name, None) in name_mapping:
332
+ original_name = name_mapping[(record_name, None)]
333
+ self.ps_ids[original_name] = record["Id"]
334
+
335
+ def _create_permission_set_group_components(self, records: List[Dict]):
336
+ """Create PermissionSetGroupComponent records using Composite API."""
337
+ request_body = {
338
+ "allOrNone": False,
339
+ "records": records,
340
+ }
341
+
342
+ try:
343
+ result = self.sf.restful(
344
+ "composite/sobjects", method="POST", data=json.dumps(request_body)
345
+ )
346
+
347
+ # Process response
348
+ composite_response = isinstance(result, list) and result or []
349
+ success_count = 0
350
+ error_count = 0
351
+
352
+ for i, response in enumerate(composite_response):
353
+ success = response.get("success", False)
354
+ if success is True:
355
+ success_count += 1
356
+ record_id = response.get("id", "Unknown")
357
+ self.logger.debug(
358
+ f"Created PermissionSetGroupComponent record: {record_id}"
359
+ )
360
+ else:
361
+
362
+ errors = response.get("errors", [])
363
+ is_duplicate_error = any(
364
+ err.get("statusCode")
365
+ for err in errors
366
+ if isinstance(err, dict)
367
+ and err.get("statusCode", "Unknown status code")
368
+ == "DUPLICATE_VALUE"
369
+ )
370
+
371
+ error_messages = [
372
+ f"{err.get('message', 'Unknown error')} ({err.get('statusCode', 'Unknown status code')})"
373
+ for err in errors
374
+ if isinstance(err, dict)
375
+ ]
376
+ psg_name = next(
377
+ (
378
+ key
379
+ for key, value in self.psg_ids.items()
380
+ if value
381
+ == records[i].get("PermissionSetGroupId", "Unknown")
382
+ ),
383
+ None,
384
+ )
385
+ ps_name = next(
386
+ (
387
+ key
388
+ for key, value in self.ps_ids.items()
389
+ if value == records[i].get("PermissionSetId", "Unknown")
390
+ ),
391
+ None,
392
+ )
393
+
394
+ if is_duplicate_error:
395
+ self.logger.info(
396
+ f"Permission Set '{ps_name}' is already assigned to Permission Set Group '{self.psg_names_sanitized.get(psg_name, psg_name)}'. Skipping assignment creation."
397
+ )
398
+ else:
399
+ error_count += 1
400
+ self.logger.error(
401
+ f"Failed to create PermissionSetGroupComponent for Permission Set Group '{self.psg_names_sanitized.get(psg_name, psg_name)}' and Permission Set '{self.ps_names_sanitized.get(ps_name, ps_name)}': {', '.join(error_messages)}"
402
+ )
403
+
404
+ self.logger.info(
405
+ f"Permission Set Group Assignments results: {success_count} succeeded, {error_count} failed"
406
+ )
407
+
408
+ if error_count > 0:
409
+ raise SalesforceException(
410
+ f"Failed to create {error_count} PermissionSetGroupComponent record(s)"
411
+ )
412
+
413
+ except Exception as e:
414
+ raise SalesforceException(
415
+ f"Error creating PermissionSetGroupComponent records: {str(e)}"
416
+ ) from e
417
+
418
+
419
+ def build_name_conditions(names: List[str], field_name: str = "Name"):
420
+ name_conditions = []
421
+ name_mapping = (
422
+ {}
423
+ ) # Maps (original_name, namespace_prefix) tuple back to original name
424
+ for name in names:
425
+ # Check if name contains namespace prefix (format: namespace__Name)
426
+ if "__" in name:
427
+ parts = name.split("__", 1)
428
+ if len(parts) == 2:
429
+ ns_prefix, ps_name = parts
430
+ # Query with namespace prefix
431
+ escaped_ns = "'" + ns_prefix.replace("'", "''") + "'"
432
+ escaped_name = "'" + ps_name.replace("'", "''") + "'"
433
+ name_conditions.append(
434
+ f"(NamespacePrefix = {escaped_ns} AND {field_name} = {escaped_name})"
435
+ )
436
+ name_mapping[(ps_name, ns_prefix)] = name
437
+ else:
438
+ # Fallback: query by name only
439
+ escaped_name = "'" + name.replace("'", "''") + "'"
440
+ name_conditions.append(f"{field_name} = {escaped_name}")
441
+ name_mapping[(name, None)] = name
442
+ else:
443
+ # No namespace prefix in name
444
+ escaped_name = "'" + name.replace("'", "''") + "'"
445
+ name_conditions.append(f"{field_name} = {escaped_name}")
446
+ name_mapping[(name, None)] = name
447
+
448
+ return name_conditions, name_mapping
@@ -6,7 +6,7 @@ from pathlib import Path
6
6
 
7
7
  from cumulusci.cli.ui import CliTable
8
8
  from cumulusci.core.exceptions import SalesforceException
9
- from cumulusci.core.utils import process_bool_arg, process_list_arg, determine_managed_mode
9
+ from cumulusci.core.utils import determine_managed_mode, process_list_arg
10
10
  from cumulusci.tasks.salesforce import BaseSalesforceApiTask
11
11
  from cumulusci.utils import inject_namespace
12
12
 
@@ -3,7 +3,7 @@
3
3
  from simple_salesforce.exceptions import SalesforceError
4
4
 
5
5
  from cumulusci.core.exceptions import TaskOptionsError
6
- from cumulusci.core.utils import process_bool_arg, determine_managed_mode
6
+ from cumulusci.core.utils import determine_managed_mode, process_bool_arg
7
7
  from cumulusci.tasks.salesforce import BaseSalesforceApiTask
8
8
 
9
9
 
@@ -1,7 +1,11 @@
1
1
  from simple_salesforce.exceptions import SalesforceError
2
2
 
3
3
  from cumulusci.core.exceptions import CumulusCIException
4
- from cumulusci.core.utils import process_bool_arg, process_list_arg, determine_managed_mode
4
+ from cumulusci.core.utils import (
5
+ determine_managed_mode,
6
+ process_bool_arg,
7
+ process_list_arg,
8
+ )
5
9
  from cumulusci.tasks.salesforce import BaseSalesforceApiTask
6
10
  from cumulusci.utils import inject_namespace
7
11
  from cumulusci.utils.http.requests_utils import safe_json_from_response
@@ -0,0 +1,89 @@
1
+ from cumulusci.core.config.util import get_devhub_config
2
+ from cumulusci.core.exceptions import SalesforceDXException
3
+ from cumulusci.core.tasks import BaseTask
4
+ from cumulusci.core.versions import PackageType, PackageVersionNumber
5
+ from cumulusci.salesforce_api.utils import get_simple_salesforce_connection
6
+ from cumulusci.utils.options import CCIOptions, Field
7
+
8
+
9
+ class GetPackageVersion(BaseTask):
10
+ """Custom task to get package version ID"""
11
+
12
+ class Options(CCIOptions):
13
+ package_name: str = Field(
14
+ ..., description="Package name to get package version ID for."
15
+ )
16
+ package_version: str = Field(
17
+ ..., description="Package version to get package version ID for."
18
+ )
19
+ prefix: str = Field(
20
+ "",
21
+ description="Prefix to add to the package name.",
22
+ )
23
+ suffix: str = Field(
24
+ "",
25
+ description="Suffix to add to the package name.",
26
+ )
27
+ fail_on_error: bool = Field(
28
+ False, description="Fail on error. [default to False]"
29
+ )
30
+
31
+ parsed_options: Options
32
+
33
+ def _init_options(self, kwargs):
34
+ super()._init_options(kwargs)
35
+ self.parsed_options.package_version = PackageVersionNumber.parse(
36
+ self.parsed_options.package_version, package_type=PackageType.SECOND_GEN
37
+ )
38
+
39
+ def _init_task(self):
40
+ self.tooling = get_simple_salesforce_connection(
41
+ self.project_config,
42
+ get_devhub_config(self.project_config),
43
+ api_version=self.project_config.project__package__api_version,
44
+ base_url="tooling",
45
+ )
46
+
47
+ def _run_task(self):
48
+ package_name = f"{self.parsed_options.prefix}{self.parsed_options.package_name}{self.parsed_options.suffix}".strip()
49
+
50
+ query = (
51
+ f"SELECT Id, SubscriberPackageVersionId FROM Package2Version WHERE Package2.Name='{package_name}' AND "
52
+ f"MajorVersion={self.parsed_options.package_version.MajorVersion} AND "
53
+ f"MinorVersion={self.parsed_options.package_version.MinorVersion} AND "
54
+ f"PatchVersion={self.parsed_options.package_version.PatchVersion} AND "
55
+ f"BuildNumber={self.parsed_options.package_version.BuildNumber}"
56
+ )
57
+
58
+ res = self.tooling.query(query)
59
+ if res["size"] == 0:
60
+ msg = f"Package version {package_name} {self.parsed_options.package_version} not found"
61
+ self.logger.warning(msg)
62
+ if self.parsed_options.fail_on_error:
63
+ raise SalesforceDXException(msg)
64
+ return
65
+
66
+ if res["size"] > 1:
67
+ msg = f"Multiple package versions found for {package_name} {self.parsed_options.package_version}"
68
+ self.logger.warning(msg)
69
+ if self.parsed_options.fail_on_error:
70
+ raise SalesforceDXException(msg)
71
+
72
+ self.return_values["package_version_id"] = res["records"][0]["Id"]
73
+ self.return_values["subscriber_package_version_id"] = res["records"][0][
74
+ "SubscriberPackageVersionId"
75
+ ]
76
+ self.return_values["package_name"] = package_name
77
+ self.return_values["package_version"] = self.parsed_options.package_version
78
+
79
+ self.logger.info(
80
+ f"Package version {package_name} {self.parsed_options.package_version} found"
81
+ )
82
+ self.logger.info(
83
+ f"Package version id: {self.return_values['package_version_id']}"
84
+ )
85
+ self.logger.info(
86
+ f"SubscriberPackageVersion Id: {self.return_values['subscriber_package_version_id']}"
87
+ )
88
+
89
+ return self.return_values
@@ -23,8 +23,9 @@ class CreateBlankProfile(BaseSalesforceMetadataApiTask):
23
23
  "description": "The description of the the new profile",
24
24
  "required": False,
25
25
  },
26
- "collision_check": {
27
- "description": "Performs a collision check with metadata already present in the target org. Defaults to True"
26
+ "skip_if_exists": {
27
+ "description": "Skip if the profile already exists in the target org. Defaults to True",
28
+ "required": False,
28
29
  },
29
30
  }
30
31
 
@@ -43,14 +44,17 @@ class CreateBlankProfile(BaseSalesforceMetadataApiTask):
43
44
  self.description = self.options.get("description") or ""
44
45
  self.license_id = self.options.get("license_id")
45
46
 
46
- if self.options.get("collision_check", True):
47
- profile_id = self._get_profile_id(self.name)
48
- if profile_id:
49
- self.logger.info(
47
+ profile_id = self._get_profile_id(self.name)
48
+ if profile_id:
49
+ if not self.options.get("skip_if_exists", True):
50
+ raise TaskOptionsError(
50
51
  f"Profile '{self.name}' already exists with id: {profile_id}"
51
52
  )
52
- self.return_values = {"profile_id": profile_id}
53
- return profile_id
53
+ self.logger.info(
54
+ f"Profile '{self.name}' already exists with id: {profile_id}"
55
+ )
56
+ self.return_values = {"profile_id": profile_id}
57
+ return profile_id
54
58
 
55
59
  if not self.license_id:
56
60
  self.license_id = self._get_user_license_id(self.license)
@@ -64,7 +68,7 @@ class CreateBlankProfile(BaseSalesforceMetadataApiTask):
64
68
  def _get_profile_id(self, profile_name):
65
69
  """Returns the Id of a Profile from a given Name"""
66
70
  res = self._query_sf(
67
- f"SELECT Id, Name FROM Profile WHERE FullName = '{profile_name}' LIMIT 1"
71
+ f"SELECT Id, Name FROM Profile WHERE Name = '{profile_name}' LIMIT 1"
68
72
  )
69
73
  if res["records"]:
70
74
  return res["records"][0]["Id"]
@@ -312,7 +312,7 @@ def retrieve_components(
312
312
  "5",
313
313
  "--ignore-conflicts",
314
314
  ]
315
-
315
+
316
316
  # Only add --output-dir if output_dir was specified
317
317
  if output_dir:
318
318
  sfdx_args.extend(["--output-dir", retrieve_target])