gitlabform 5.0.2__tar.gz → 5.1.1__tar.gz

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 (85) hide show
  1. {gitlabform-5.0.2 → gitlabform-5.1.1}/PKG-INFO +9 -9
  2. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/configuration/core.py +24 -12
  3. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/gitlab/core.py +5 -0
  4. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/processors/group/group_hooks_processor.py +14 -3
  5. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/processors/project/branches_processor.py +178 -126
  6. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform.egg-info/PKG-INFO +9 -9
  7. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform.egg-info/requires.txt +8 -8
  8. {gitlabform-5.0.2 → gitlabform-5.1.1}/pyproject.toml +9 -9
  9. {gitlabform-5.0.2 → gitlabform-5.1.1}/LICENSE +0 -0
  10. {gitlabform-5.0.2 → gitlabform-5.1.1}/README.md +0 -0
  11. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/__init__.py +0 -0
  12. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/configuration/__init__.py +0 -0
  13. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/configuration/common.py +0 -0
  14. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/configuration/groups.py +0 -0
  15. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/configuration/projects.py +0 -0
  16. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/configuration/transform.py +0 -0
  17. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/constants.py +0 -0
  18. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/gitlab/__init__.py +0 -0
  19. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/gitlab/commits.py +0 -0
  20. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/gitlab/group_badges.py +0 -0
  21. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/gitlab/group_ldap_links.py +0 -0
  22. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/gitlab/groups.py +0 -0
  23. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/gitlab/merge_requests.py +0 -0
  24. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/gitlab/pipelines.py +0 -0
  25. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/gitlab/project_badges.py +0 -0
  26. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/gitlab/project_deploy_keys.py +0 -0
  27. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/gitlab/project_merge_requests_approvals.py +0 -0
  28. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/gitlab/project_protected_environments.py +0 -0
  29. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/gitlab/projects.py +0 -0
  30. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/gitlab/python_gitlab.py +0 -0
  31. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/gitlab/variables.py +0 -0
  32. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/lists/__init__.py +0 -0
  33. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/lists/filter.py +0 -0
  34. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/lists/groups.py +0 -0
  35. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/lists/projects.py +0 -0
  36. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/output.py +0 -0
  37. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/processors/__init__.py +0 -0
  38. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/processors/abstract_processor.py +0 -0
  39. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/processors/application/__init__.py +0 -0
  40. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/processors/application/application_settings_processor.py +0 -0
  41. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/processors/defining_keys.py +0 -0
  42. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/processors/group/__init__.py +0 -0
  43. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/processors/group/group_badges_processor.py +0 -0
  44. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/processors/group/group_labels_processor.py +0 -0
  45. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/processors/group/group_ldap_links_processor.py +0 -0
  46. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/processors/group/group_members_processor.py +0 -0
  47. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/processors/group/group_push_rules_processor.py +0 -0
  48. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/processors/group/group_saml_links_processor.py +0 -0
  49. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/processors/group/group_settings_processor.py +0 -0
  50. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/processors/group/group_variables_processor.py +0 -0
  51. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/processors/multiple_entities_processor.py +0 -0
  52. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/processors/project/__init__.py +0 -0
  53. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/processors/project/badges_processor.py +0 -0
  54. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/processors/project/deploy_keys_processor.py +0 -0
  55. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/processors/project/files_processor.py +0 -0
  56. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/processors/project/hooks_processor.py +0 -0
  57. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/processors/project/integrations_processor.py +0 -0
  58. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/processors/project/job_token_scope_processor.py +0 -0
  59. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/processors/project/members_processor.py +0 -0
  60. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/processors/project/merge_requests_approval_rules.py +0 -0
  61. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/processors/project/merge_requests_approvals.py +0 -0
  62. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/processors/project/project_labels_processor.py +0 -0
  63. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/processors/project/project_processor.py +0 -0
  64. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/processors/project/project_push_rules_processor.py +0 -0
  65. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/processors/project/project_security_settings.py +0 -0
  66. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/processors/project/project_settings_processor.py +0 -0
  67. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/processors/project/project_variables_processor.py +0 -0
  68. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/processors/project/remote_mirrors_processor.py +0 -0
  69. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/processors/project/resource_groups_processor.py +0 -0
  70. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/processors/project/schedules_processor.py +0 -0
  71. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/processors/project/tags_processor.py +0 -0
  72. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/processors/shared/__init__.py +0 -0
  73. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/processors/shared/protected_environments_processor.py +0 -0
  74. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/processors/util/__init__.py +0 -0
  75. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/processors/util/decorators.py +0 -0
  76. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/processors/util/difference_logger.py +0 -0
  77. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/processors/util/labels_processor.py +0 -0
  78. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/processors/util/variables_processor.py +0 -0
  79. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/run.py +0 -0
  80. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform/util.py +0 -0
  81. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform.egg-info/SOURCES.txt +0 -0
  82. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform.egg-info/dependency_links.txt +0 -0
  83. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform.egg-info/entry_points.txt +0 -0
  84. {gitlabform-5.0.2 → gitlabform-5.1.1}/gitlabform.egg-info/top_level.txt +0 -0
  85. {gitlabform-5.0.2 → gitlabform-5.1.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitlabform
3
- Version: 5.0.2
3
+ Version: 5.1.1
4
4
  Summary: 🏗 Specialized configuration as a code tool for GitLab projects, groups and more using hierarchical configuration written in YAML
5
5
  Author: Greg Dubicki and Contributors
6
6
  Project-URL: Homepage, https://gitlabform.github.io/gitlabform/
@@ -28,23 +28,23 @@ Requires-Dist: luddite==1.0.4
28
28
  Requires-Dist: MarkupSafe==3.0.3
29
29
  Requires-Dist: mergedeep==1.3.4
30
30
  Requires-Dist: packaging==26.0
31
- Requires-Dist: python-gitlab==8.1.0
32
- Requires-Dist: python-gitlab[graphql]==8.1.0
33
- Requires-Dist: requests==2.32.5
31
+ Requires-Dist: python-gitlab==8.2.0
32
+ Requires-Dist: python-gitlab[graphql]==8.2.0
33
+ Requires-Dist: requests==2.33.1
34
34
  Requires-Dist: ruamel.yaml==0.17.21
35
35
  Requires-Dist: yamlpath==3.8.2
36
36
  Provides-Extra: test
37
37
  Requires-Dist: coverage==7.13.5; extra == "test"
38
- Requires-Dist: cryptography==46.0.5; extra == "test"
39
- Requires-Dist: deepdiff==8.6.2; extra == "test"
40
- Requires-Dist: mypy==1.19.1; extra == "test"
38
+ Requires-Dist: cryptography==46.0.6; extra == "test"
39
+ Requires-Dist: deepdiff==9.0.0; extra == "test"
40
+ Requires-Dist: mypy==1.20.0; extra == "test"
41
41
  Requires-Dist: mypy-extensions==1.1.0; extra == "test"
42
42
  Requires-Dist: pre-commit==2.21.0; extra == "test"
43
43
  Requires-Dist: pytest==9.0.2; extra == "test"
44
44
  Requires-Dist: pytest-cov==7.1.0; extra == "test"
45
45
  Requires-Dist: pytest-rerunfailures==16.1; extra == "test"
46
- Requires-Dist: types-requests==2.32.4.20260107; extra == "test"
47
- Requires-Dist: types-setuptools==82.0.0.20260210; extra == "test"
46
+ Requires-Dist: types-requests==2.33.0.20260402; extra == "test"
47
+ Requires-Dist: types-setuptools==82.0.0.20260402; extra == "test"
48
48
  Requires-Dist: xkcdpass==1.30.0; extra == "test"
49
49
  Provides-Extra: docs
50
50
  Requires-Dist: mkdocs; extra == "docs"
@@ -179,11 +179,19 @@ class ConfigurationCore(ABC):
179
179
 
180
180
  merged_dict = merge({}, more_general_config, more_specific_config)
181
181
 
182
- def break_inheritance(specific_config, parent_key=""):
182
+ def break_inheritance(specific_config, parent_path=()):
183
+ """
184
+ Walk the specific config tree and replace only the exact nested section
185
+ that declares ``inherit: false``.
186
+
187
+ ``parent_path`` stores the full key path to the current section because
188
+ the same section name, like ``variables``, can appear in multiple branches.
189
+ Replacing by section name alone can therefore update the wrong subtree.
190
+ """
183
191
  for key, value in specific_config.items():
184
192
  if "inherit" == key:
185
193
  if not value:
186
- replace_config_sections(merged_dict, parent_key, specific_config)
194
+ replace_config_section(merged_dict, parent_path, specific_config)
187
195
  break
188
196
  elif value:
189
197
  fatal(
@@ -191,16 +199,20 @@ class ConfigurationCore(ABC):
191
199
  exit_code=EXIT_INVALID_INPUT,
192
200
  )
193
201
  elif type(value) in [CommentedMap, dict]:
194
- break_inheritance(value, key)
195
-
196
- def replace_config_sections(merged_config, specific_key, specific_config):
197
- for key, value in merged_config.items():
198
- if specific_key == key:
199
- del specific_config["inherit"]
200
- merged_config[key] = specific_config
201
- break
202
- elif type(value) in [CommentedMap, dict]:
203
- replace_config_sections(value, specific_key, specific_config)
202
+ break_inheritance(value, parent_path + (key,))
203
+
204
+ def replace_config_section(merged_config, parent_path, specific_config):
205
+ """
206
+ Replace the merged section at ``parent_path`` with the specific section
207
+ that requested an inheritance break, dropping the control flag itself.
208
+ """
209
+ target_config = merged_config
210
+ for key in parent_path[:-1]:
211
+ target_config = target_config[key]
212
+
213
+ replacement_config = deepcopy(specific_config)
214
+ replacement_config.pop("inherit", None)
215
+ target_config[parent_path[-1]] = replacement_config
204
216
 
205
217
  break_inheritance(more_specific_config)
206
218
 
@@ -79,6 +79,11 @@ class GitLabCore:
79
79
  self.version = version_response["version"]
80
80
  self.enterprise = version_response["enterprise"]
81
81
 
82
+ if self.is_version_less_than("16"):
83
+ warning(
84
+ f"Support for GitLab version {self.version} is Deprecated. See Requirements: https://gitlabform.github.io/gitlabform/requirements/"
85
+ )
86
+
82
87
  current_user = self._make_requests_to_api("user")
83
88
  if current_user.get("is_admin", False):
84
89
  self.admin = True
@@ -1,4 +1,4 @@
1
- from logging import debug
1
+ from logging import debug, warning
2
2
  from typing import Dict, Any, List
3
3
 
4
4
  from gitlab.base import RESTObject, RESTObjectList
@@ -13,13 +13,24 @@ class GroupHooksProcessor(AbstractProcessor):
13
13
  def __init__(self, gitlab: GitLab):
14
14
  super().__init__("group_hooks", gitlab)
15
15
 
16
+ def _can_proceed(self, project_or_group: str, configuration: dict):
17
+ if not self.gitlab.enterprise:
18
+ hooks_in_config = configuration["group_hooks"]
19
+ if hooks_in_config is not None and len(hooks_in_config) > 0:
20
+ # Only raise error if user has defined hooks in config, otherwise exit silently out of processor
21
+ warning("GitLab Community Edition does not support Group Webhooks")
22
+
23
+ return False
24
+
25
+ return True
26
+
16
27
  def _process_configuration(self, group_path_and_name: str, configuration: dict):
28
+ hooks_in_config: tuple[str, ...] = tuple(x for x in sorted(configuration["group_hooks"]) if x != "enforce")
29
+
17
30
  debug("Processing group hooks...")
18
31
  group: Group = self.gl.get_group_by_path_cached(group_path_and_name)
19
32
  group_hooks: list[GroupHook] = group.hooks.list(get_all=True)
20
33
 
21
- hooks_in_config: tuple[str, ...] = tuple(x for x in sorted(configuration["group_hooks"]) if x != "enforce")
22
-
23
34
  for hook in hooks_in_config:
24
35
  hook_in_gitlab: RESTObject | None = next((h for h in group_hooks if h.url == hook), None)
25
36
  hook_config = {"url": hook}
@@ -14,6 +14,16 @@ from gitlabform.processors.abstract_processor import AbstractProcessor
14
14
 
15
15
 
16
16
  class BranchesProcessor(AbstractProcessor):
17
+ """
18
+ Processor for branch protection settings.
19
+
20
+ This processor is complex because GitLab's Protected Branches API uses different
21
+ data structures for Create (POST), Get (GET), and Update (PATCH) operations.
22
+
23
+ It implements 'Additive Design' (existing rules are preserved) and
24
+ 'Raw Parameter Passing' (arbitrary keys in config are sent to the API).
25
+ """
26
+
17
27
  def __init__(self, gitlab: GitLab, strict: bool):
18
28
  super().__init__("branches", gitlab)
19
29
  self.strict = strict
@@ -37,7 +47,7 @@ class BranchesProcessor(AbstractProcessor):
37
47
  def _process_configuration(self, project_and_group: str, configuration: dict):
38
48
  """
39
49
  Called from process defined in abstract_processor.py after checking self._can_proceed
40
- Processes the branches configuration
50
+ Iterates through all branches defined in the configuration and applies protection rules.
41
51
  """
42
52
 
43
53
  project: Project = self.gl.get_project_by_path_cached(project_and_group)
@@ -49,7 +59,13 @@ class BranchesProcessor(AbstractProcessor):
49
59
 
50
60
  def process_branch_protection(self, project: Project, branch_name: str, branch_config: dict):
51
61
  """
52
- Process branch protection according to gitlabform config.
62
+ High-level logic for processing branch protection.
63
+
64
+ 1. Validates branch existence (unless wildcard).
65
+ 2. Handles 'protected: false' (unprotecting).
66
+ 3. Handles 'protected: true':
67
+ - If not currently protected: Create protection.
68
+ - If protected: Update using PATCH (EE > 15.6) or Unprotect/Reprotect (CE/Old EE).
53
69
  """
54
70
  protected_branch: Optional[ProjectProtectedBranch] = None
55
71
 
@@ -76,7 +92,7 @@ class BranchesProcessor(AbstractProcessor):
76
92
 
77
93
  if branch_config.get("protected"):
78
94
  if not protected_branch:
79
- info(f"Creating branch protection for {branch_name}")
95
+ verbose(f"Creating branch protection for {branch_name}")
80
96
  self.protect_branch(project, branch_name, branch_config, False)
81
97
  return
82
98
 
@@ -102,20 +118,23 @@ class BranchesProcessor(AbstractProcessor):
102
118
  # compare states easier to determine what PATCH request we need to send (if any)
103
119
  transformed_branch_config = self.map_config_to_protected_branch_get_data(branch_config)
104
120
 
105
- verbose("Creating data to update code_owner_approval_required as necessary")
106
- # We only build PATCH data for items requiring updates, e.g. if a merge_access_level has been changed or removed,
107
- # or if the code_owner_approval_required state has changed.
108
- # If we send everything the PATCH endpoint will return a 200 but not apply any updates.
109
121
  protected_branch_api_patch_data: dict = {}
110
122
 
111
- code_owner_approval_required_config = transformed_branch_config.get("code_owner_approval_required")
112
- if code_owner_approval_required_config is not None:
113
- protected_branch_api_patch_data["code_owner_approval_required"] = code_owner_approval_required_config
114
-
115
- verbose("Creating data to update allow_force_push as necessary")
116
- allow_force_push_config = transformed_branch_config.get("allow_force_push")
117
- if allow_force_push_config is not None:
118
- protected_branch_api_patch_data["allow_force_push"] = allow_force_push_config
123
+ # RAW PARAMETER PASSING (Top-Level):
124
+ # Iterate through any top-level flags (e.g., allow_force_push, code_owner_approval_required)
125
+ # and include them in the PATCH request if they differ from the current GitLab state.
126
+ special_list_keys = [
127
+ "merge_access_levels",
128
+ "push_access_levels",
129
+ "unprotect_access_levels",
130
+ ]
131
+ for key, value in transformed_branch_config.items():
132
+ if key not in special_list_keys:
133
+ # Check if this attribute exists on the GitLab object and needs an update
134
+ existing_value = getattr(protected_branch, key, None)
135
+ if existing_value != value:
136
+ verbose(f"Creating data to update {key} as necessary")
137
+ protected_branch_api_patch_data[key] = value
119
138
 
120
139
  verbose("Creating data to update merge_access_levels as necessary")
121
140
  merge_access_items_patch_data = self.build_patch_request_data(
@@ -145,18 +164,18 @@ class BranchesProcessor(AbstractProcessor):
145
164
 
146
165
  if protected_branch_api_patch_data != {}:
147
166
  # We have some updates to make
148
- info(f"Updating protected branch {branch_name} with {protected_branch_api_patch_data}")
167
+ verbose(f"Updating protected branch {branch_name} with {protected_branch_api_patch_data}")
149
168
  self.protect_branch(project, branch_name, protected_branch_api_patch_data, True)
150
169
 
151
170
  elif protected_branch and not branch_config.get("protected"):
152
- info(f"Removing branch protection for {branch_name}")
171
+ verbose(f"Removing branch protection for {branch_name}")
153
172
  self.unprotect_branch(protected_branch)
154
173
 
155
174
  def process_branch_config_gitlab_under_15_6_0_or_ce(self, branch_config, branch_name, project, protected_branch):
156
175
  """
157
176
  Processes the branches configuration for gitlab version <=15.6.0 or Community Edition,
158
- first checking if the branch needs to be updated, if it does, then the branch will be unprotected prior to being
159
- reprotected with the YAML configuration.
177
+ where in-place updates (PATCH) are not supported or effective.
178
+ If a change is detected, the branch is unprotected and then reprotected from scratch.
160
179
  """
161
180
 
162
181
  # Gitlab returns the allowed_to_merge etc data in a different format from GET endpoint than it takes in
@@ -224,8 +243,8 @@ class BranchesProcessor(AbstractProcessor):
224
243
 
225
244
  def convert_user_and_group_names_to_ids(self, branch_config: dict):
226
245
  """
227
- The branch configuration in gitlabform supports passing users or group using username
228
- or group name but GitLab only supports their id. This method will transform the
246
+ Pre-processor to resolve names to IDs.
247
+ Translates 'user: username' or 'group: name' into 'user_id' or 'group_id' as
229
248
  config by replacing them with ids.
230
249
  """
231
250
  verbose("Transforming User and Group names in Branch configuration to Ids")
@@ -235,10 +254,11 @@ class BranchesProcessor(AbstractProcessor):
235
254
  for item in branch_config[key]:
236
255
  if isinstance(item, dict):
237
256
  if "user" in item:
238
- user_id = self.gl.get_user_id_cached(item.pop("user"))
257
+ username = item.pop("user")
258
+ user_id = self.gl.get_user_id_cached(username)
239
259
  if user_id is None:
240
260
  raise GitlabGetError(
241
- f"transform_branch_config - No users found when searching for username {item.pop("user")}",
261
+ f"transform_branch_config - No users found when searching for username {username}",
242
262
  404,
243
263
  )
244
264
  item["user_id"] = user_id
@@ -250,11 +270,13 @@ class BranchesProcessor(AbstractProcessor):
250
270
  @staticmethod
251
271
  def map_config_to_protected_branch_get_data(our_branch_config: dict):
252
272
  """
253
- Branch protection CRUD API in python-gitlab (and GitLab itself) is
254
- inconsistent, the structure needed to create a branch protection rule is
255
- different from structure needed to update a rule in place.
273
+ Normalizes the user-provided YAML config into the format returned by GitLab's GET endpoint.
256
274
 
257
- Also, "protected" attribute is missing from GitLab side of things.
275
+ GitLab API Mappings:
276
+ - 'merge_access_level' (Standard) -> 'merge_access_levels' (List)
277
+ - 'allowed_to_merge' (Premium) -> 'merge_access_levels' (List)
278
+
279
+ This transformation allows for a direct comparison between the desired state and current state.
258
280
  This method will normalize gitlabform branch_config to accommodate this.
259
281
 
260
282
  Args:
@@ -277,29 +299,40 @@ class BranchesProcessor(AbstractProcessor):
277
299
  new_branch_config = our_branch_config.copy()
278
300
  for key in our_branch_config:
279
301
  if key in local_keys_to_gitlab_keys_map.keys():
302
+ target_key = local_keys_to_gitlab_keys_map[key]
280
303
  # *_access_level in gitlabform will have been transformed to it's int representation already if defined
281
304
  # by the user as "merge_access_level: Maintainer"
282
305
  if isinstance(our_branch_config[key], int):
283
306
  access_level = new_branch_config.pop(key)
284
- new_branch_config[local_keys_to_gitlab_keys_map[key]] = [
285
- {"id": None, "access_level": access_level, "user_id": None, "group_id": None}
307
+ new_branch_config[target_key] = [
308
+ {
309
+ "id": None,
310
+ "access_level": access_level,
311
+ "user_id": None,
312
+ "group_id": None,
313
+ "deploy_key_id": None,
314
+ }
286
315
  ]
287
316
  # allowed_to_* are lists...
288
317
  elif isinstance(our_branch_config[key], list):
289
- new_branch_config[local_keys_to_gitlab_keys_map[key]] = []
318
+ mapped_list = []
290
319
  for item in our_branch_config[key]:
291
- if "access_level" in item:
292
- new_branch_config[local_keys_to_gitlab_keys_map[key]].append(
293
- {"id": None, "access_level": item["access_level"], "user_id": None, "group_id": None}
294
- )
295
- elif "group_id" in item:
296
- new_branch_config[local_keys_to_gitlab_keys_map[key]].append(
297
- {"id": None, "access_level": None, "user_id": None, "group_id": item["group_id"]}
298
- )
299
- elif "user_id" in item:
300
- new_branch_config[local_keys_to_gitlab_keys_map[key]].append(
301
- {"id": None, "access_level": None, "user_id": item["user_id"], "group_id": None}
302
- )
320
+ # RAW PARAMETER PASSING: We ensure the core identity keys exist for comparison,
321
+ # but we preserve all other arbitrary keys provided by the user.
322
+ mapped_item = {
323
+ "id": None,
324
+ "access_level": item.get("access_level"),
325
+ "user_id": item.get("user_id"),
326
+ "group_id": item.get("group_id"),
327
+ "deploy_key_id": item.get("deploy_key_id"),
328
+ **{
329
+ k: v
330
+ for k, v in item.items()
331
+ if k not in ["access_level", "user_id", "group_id", "deploy_key_id", "id"]
332
+ },
333
+ }
334
+ mapped_list.append(mapped_item)
335
+ new_branch_config[target_key] = mapped_list
303
336
  new_branch_config.pop(key)
304
337
 
305
338
  # this key is not present in
@@ -311,13 +344,13 @@ class BranchesProcessor(AbstractProcessor):
311
344
  @staticmethod
312
345
  def build_patch_request_data(transformed_access_levels: list[dict] | None, existing_records: tuple) -> list[dict]:
313
346
  """
314
- Compares the access_levels configuration (transformed to match the Gitlab GET response) to the existing access_level record in Gitlab.
315
- If there are no changes for a given access_level record an empty list is returned.
316
- Otherwise, data is returned to add/update access_level records and remove any outdated records.
347
+ Calculates the specific payload for the PATCH (update) API.
317
348
 
318
- Gitlab supports merge_access_level for users with a Standard license and allowed_to_merge etc for users with Premium
319
- or Ultimate licenses.
320
- We need to support both options, and potentially blended configuration for users with Premium+ licenses.
349
+ Matching Logic:
350
+ 1. For each rule in the config, check if an identical rule already exists in GitLab.
351
+ 2. Rule Identity is determined by: user_id OR group_id OR deploy_key_id OR (role-only access_level).
352
+ 3. If a match is found, the rule is skipped (idempotency).
353
+ 4. If 'No Access' (0) is requested but other roles exist, the existing roles are marked for destruction.
321
354
 
322
355
  args:
323
356
  transformed_access_levels (list[dict|None]): transformed merge_access_levels or push_access_levels or unprotect_access_levels configration generated by transform_branch_config_access_levels
@@ -330,43 +363,11 @@ class BranchesProcessor(AbstractProcessor):
330
363
 
331
364
  if transformed_access_levels is not None:
332
365
  # User has defined in configuration some access level for this resource on the protected branch
333
- # Create Patch Data for the defined configuration and then determine what existing records need deleting or
334
- # just updating
366
+ # Prepare patch data using raw parameters from config.
367
+ # We only send non-None values and omit the internal 'id' placeholder used for mapping.
335
368
  for configuration in transformed_access_levels:
336
- configured_access_level = configuration.get("access_level")
337
- configured_user_id = configuration.get("user_id")
338
- configured_group_id = configuration.get("group_id")
339
-
340
- if configured_access_level is not None:
341
- # Entry to configure an access level e.g.
342
- # push_access_level: Maintainer
343
- #
344
- # or:
345
- # allowed_to_push:
346
- # - access_level: Maintainer
347
- patch_data.append(
348
- {
349
- "access_level": configured_access_level,
350
- }
351
- )
352
- elif configured_user_id is not None:
353
- # Entry to configure a user to have access, only available for users with "Premium" or "Ultimate" e.g.
354
- # allowed_to_push:
355
- # - user: tim-knight
356
- patch_data.append(
357
- {
358
- "user_id": configured_user_id,
359
- }
360
- )
361
- else:
362
- # Entry to configure a group to have access, only available for users with "Premium" or "Ultimate" e.g.
363
- # allowed_to_push:
364
- # - group_id: 15
365
- patch_data.append(
366
- {
367
- "group_id": configured_group_id,
368
- }
369
- )
369
+ item_to_add = {k: v for k, v in configuration.items() if v is not None and k != "id"}
370
+ patch_data.append(item_to_add)
370
371
 
371
372
  # Check if we need to make the update (e.g. an existing record for that user_id or access_level exists)
372
373
  # and mark existing records for deletion if the access_level does not match
@@ -376,8 +377,10 @@ class BranchesProcessor(AbstractProcessor):
376
377
  existing_records_access_level = existing_record.get("access_level")
377
378
  existing_records_user_id = existing_record.get("user_id")
378
379
  existing_records_group_id = existing_record.get("group_id")
380
+ existing_records_deploy_key_id = existing_record.get("deploy_key_id")
379
381
 
380
- matching_item_to_be_created = None
382
+ is_match = False
383
+ matching_item_in_patch = None
381
384
 
382
385
  for item in patch_data:
383
386
  # We prioritize user_id and group_id matches over access_level.
@@ -385,24 +388,41 @@ class BranchesProcessor(AbstractProcessor):
385
388
  # If we matched by access_level first, we might incorrectly pair a specific user's rule
386
389
  # with a generic role-based rule from the config.
387
390
  if existing_records_user_id is not None and item.get("user_id") == existing_records_user_id:
388
- matching_item_to_be_created = item
389
- break
391
+ is_match = True
390
392
  elif existing_records_group_id is not None and item.get("group_id") == existing_records_group_id:
391
- matching_item_to_be_created = item
392
- break
393
+ is_match = True
394
+ elif (
395
+ existing_records_deploy_key_id is not None
396
+ and item.get("deploy_key_id") == existing_records_deploy_key_id
397
+ ):
398
+ is_match = True
393
399
  elif (
394
- existing_records_access_level is not None
395
- and item.get("access_level") == existing_records_access_level
400
+ item.get("user_id") is None
401
+ and item.get("group_id") is None
402
+ and item.get("deploy_key_id") is None
396
403
  ):
397
- matching_item_to_be_created = item
404
+ # Role-based rule logic
405
+ local_level = item.get("access_level")
406
+ if local_level == existing_records_access_level:
407
+ is_match = True
408
+ elif local_level == 0 or existing_records_access_level == 0:
409
+ # "No Access" (0) is mutually exclusive with any other role.
410
+ # Mark existing for destruction so the new state can be applied.
411
+ record_id = existing_record.get("id")
412
+ patch_data.append({"id": record_id, "_destroy": True})
413
+ break
414
+
415
+ if is_match:
416
+ matching_item_in_patch = item
417
+ break
418
+
419
+ if matching_item_in_patch:
420
+ # Rule already exists in GitLab, remove from the patch list to maintain idempotency
421
+ patch_data.remove(matching_item_in_patch)
422
+
423
+ # NOTE: GitLabForm is additive. We do not destroy existing records unless they
424
+ # conflict with a 'No Access' rule.
398
425
 
399
- if matching_item_to_be_created is not None:
400
- # If we found an existing item matching one that has been configured, remove the item
401
- # to be created and don't mark the existing record for deletion
402
- patch_data.remove(matching_item_to_be_created)
403
- else:
404
- record_id = existing_record.get("id")
405
- patch_data.append({"id": record_id, "_destroy": True})
406
426
  else:
407
427
  verbose("No configuration defined for this access level. No changes will be made.")
408
428
 
@@ -410,36 +430,68 @@ class BranchesProcessor(AbstractProcessor):
410
430
 
411
431
  @staticmethod
412
432
  def naive_access_level_diff_analyzer(_, cfg_in_gitlab: list, local_cfg: list):
433
+ """
434
+ Custom diff analyzer for branch rules.
435
+
436
+ Following GitLabForm's ADDITIVE DESIGN, an update is needed if any rule
437
+ defined in the local configuration is missing from GitLab.
438
+ """
439
+ # 1. Check if any local rule is missing from GitLab (Additive check)
440
+ for local_item in local_cfg:
441
+ found = False
442
+ for gl_item in cfg_in_gitlab:
443
+ # User Match
444
+ if local_item.get("user_id") is not None:
445
+ if local_item.get("user_id") == gl_item.get("user_id"):
446
+ found = True
447
+ break
448
+ # Group Match
449
+ elif local_item.get("group_id") is not None:
450
+ if local_item.get("group_id") == gl_item.get("group_id"):
451
+ found = True
452
+ break
453
+ # Deploy Key Match
454
+ elif local_item.get("deploy_key_id") is not None:
455
+ if local_item.get("deploy_key_id") == gl_item.get("deploy_key_id"):
456
+ found = True
457
+ break
458
+ # Role Match (Ensure GL item is also a role rule)
459
+ if (
460
+ local_item.get("access_level") is not None
461
+ and local_item.get("access_level") == gl_item.get("access_level")
462
+ and gl_item.get("user_id") is None
463
+ and gl_item.get("group_id") is None
464
+ and gl_item.get("deploy_key_id") is None
465
+ ):
466
+ found = True
467
+ break
468
+
469
+ if not found:
470
+ verbose("naive_access_level_diff_analyzer - needs_update: True (missing rule found)")
471
+ return True
472
+
473
+ # 2. Role exclusivity check (No Access handling)
474
+ # Even if all local rules are "found", we need an update if GitLab has a "No Access" rule
475
+ # while we want specific roles, or vice-versa.
476
+ gl_role_levels = {
477
+ r.get("access_level")
478
+ for r in cfg_in_gitlab
479
+ if r.get("user_id") is None and r.get("group_id") is None and r.get("deploy_key_id") is None
480
+ }
481
+ local_role_levels = {
482
+ r.get("access_level")
483
+ for r in local_cfg
484
+ if r.get("user_id") is None and r.get("group_id") is None and r.get("deploy_key_id") is None
485
+ }
413
486
 
414
- if len(cfg_in_gitlab) != len(local_cfg):
487
+ if (0 in gl_role_levels and any(lev > 0 for lev in local_role_levels)) or (
488
+ 0 in local_role_levels and any(lev > 0 for lev in gl_role_levels)
489
+ ):
490
+ verbose("naive_access_level_diff_analyzer - needs_update: True (No Access / Roles conflict)")
415
491
  return True
416
492
 
417
- # GitLab UI, API, python-gitlab and GitLabForm itself make it impossible
418
- # to set "access_level", "user_id" and/or "group_id" at the same time,
419
- # so we take a naive approach here and kind of expect it will always be
420
- # either one of those, but not a combination
421
- needs_update = False
422
- changes_found = 0
423
- for item in local_cfg:
424
- if item["access_level"] and item["access_level"] >= 0:
425
- access_level = item["access_level"]
426
- for gl_item in cfg_in_gitlab:
427
- if gl_item["access_level"] != access_level:
428
- changes_found += 1
429
- elif item["user_id"] and item["user_id"] >= 0:
430
- user_id = item["user_id"]
431
- for gl_item in cfg_in_gitlab:
432
- if gl_item["user_id"] != user_id:
433
- changes_found += 1
434
- elif item["group_id"] and item["group_id"] >= 0:
435
- group_id = item["group_id"]
436
- for gl_item in cfg_in_gitlab:
437
- if gl_item["group_id"] != group_id:
438
- changes_found += 1
439
- if changes_found > 0:
440
- needs_update = True
441
- verbose(f"naive_access_level_diff_analyzer - needs_update: {needs_update}, changes_found: {changes_found}")
442
- return needs_update
493
+ verbose("naive_access_level_diff_analyzer - needs_update: False")
494
+ return False
443
495
 
444
496
  @staticmethod
445
497
  def branch_name_contains_supported_wildcard(branch):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitlabform
3
- Version: 5.0.2
3
+ Version: 5.1.1
4
4
  Summary: 🏗 Specialized configuration as a code tool for GitLab projects, groups and more using hierarchical configuration written in YAML
5
5
  Author: Greg Dubicki and Contributors
6
6
  Project-URL: Homepage, https://gitlabform.github.io/gitlabform/
@@ -28,23 +28,23 @@ Requires-Dist: luddite==1.0.4
28
28
  Requires-Dist: MarkupSafe==3.0.3
29
29
  Requires-Dist: mergedeep==1.3.4
30
30
  Requires-Dist: packaging==26.0
31
- Requires-Dist: python-gitlab==8.1.0
32
- Requires-Dist: python-gitlab[graphql]==8.1.0
33
- Requires-Dist: requests==2.32.5
31
+ Requires-Dist: python-gitlab==8.2.0
32
+ Requires-Dist: python-gitlab[graphql]==8.2.0
33
+ Requires-Dist: requests==2.33.1
34
34
  Requires-Dist: ruamel.yaml==0.17.21
35
35
  Requires-Dist: yamlpath==3.8.2
36
36
  Provides-Extra: test
37
37
  Requires-Dist: coverage==7.13.5; extra == "test"
38
- Requires-Dist: cryptography==46.0.5; extra == "test"
39
- Requires-Dist: deepdiff==8.6.2; extra == "test"
40
- Requires-Dist: mypy==1.19.1; extra == "test"
38
+ Requires-Dist: cryptography==46.0.6; extra == "test"
39
+ Requires-Dist: deepdiff==9.0.0; extra == "test"
40
+ Requires-Dist: mypy==1.20.0; extra == "test"
41
41
  Requires-Dist: mypy-extensions==1.1.0; extra == "test"
42
42
  Requires-Dist: pre-commit==2.21.0; extra == "test"
43
43
  Requires-Dist: pytest==9.0.2; extra == "test"
44
44
  Requires-Dist: pytest-cov==7.1.0; extra == "test"
45
45
  Requires-Dist: pytest-rerunfailures==16.1; extra == "test"
46
- Requires-Dist: types-requests==2.32.4.20260107; extra == "test"
47
- Requires-Dist: types-setuptools==82.0.0.20260210; extra == "test"
46
+ Requires-Dist: types-requests==2.33.0.20260402; extra == "test"
47
+ Requires-Dist: types-setuptools==82.0.0.20260402; extra == "test"
48
48
  Requires-Dist: xkcdpass==1.30.0; extra == "test"
49
49
  Provides-Extra: docs
50
50
  Requires-Dist: mkdocs; extra == "docs"
@@ -6,9 +6,9 @@ luddite==1.0.4
6
6
  MarkupSafe==3.0.3
7
7
  mergedeep==1.3.4
8
8
  packaging==26.0
9
- python-gitlab==8.1.0
10
- python-gitlab[graphql]==8.1.0
11
- requests==2.32.5
9
+ python-gitlab==8.2.0
10
+ python-gitlab[graphql]==8.2.0
11
+ requests==2.33.1
12
12
  ruamel.yaml==0.17.21
13
13
  yamlpath==3.8.2
14
14
 
@@ -18,14 +18,14 @@ mkdocs-material
18
18
 
19
19
  [test]
20
20
  coverage==7.13.5
21
- cryptography==46.0.5
22
- deepdiff==8.6.2
23
- mypy==1.19.1
21
+ cryptography==46.0.6
22
+ deepdiff==9.0.0
23
+ mypy==1.20.0
24
24
  mypy-extensions==1.1.0
25
25
  pre-commit==2.21.0
26
26
  pytest==9.0.2
27
27
  pytest-cov==7.1.0
28
28
  pytest-rerunfailures==16.1
29
- types-requests==2.32.4.20260107
30
- types-setuptools==82.0.0.20260210
29
+ types-requests==2.33.0.20260402
30
+ types-setuptools==82.0.0.20260402
31
31
  xkcdpass==1.30.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gitlabform"
7
- version = "5.0.2"
7
+ version = "5.1.1"
8
8
  authors = [{ name = "Greg Dubicki and Contributors" }]
9
9
  description = "🏗 Specialized configuration as a code tool for GitLab projects, groups and more using hierarchical configuration written in YAML"
10
10
  keywords = ["cli", "yaml", "gitlab", "configuration-as-code"]
@@ -31,9 +31,9 @@ dependencies = [
31
31
  "MarkupSafe==3.0.3",
32
32
  "mergedeep==1.3.4",
33
33
  "packaging==26.0",
34
- "python-gitlab==8.1.0",
35
- "python-gitlab[graphql]==8.1.0",
36
- "requests==2.32.5",
34
+ "python-gitlab==8.2.0",
35
+ "python-gitlab[graphql]==8.2.0",
36
+ "requests==2.33.1",
37
37
  "ruamel.yaml==0.17.21",
38
38
  "yamlpath==3.8.2",
39
39
  ]
@@ -51,16 +51,16 @@ content-type = "text/markdown"
51
51
  [project.optional-dependencies]
52
52
  test = [
53
53
  "coverage==7.13.5",
54
- "cryptography==46.0.5",
55
- "deepdiff==8.6.2",
56
- "mypy==1.19.1",
54
+ "cryptography==46.0.6",
55
+ "deepdiff==9.0.0",
56
+ "mypy==1.20.0",
57
57
  "mypy-extensions==1.1.0",
58
58
  "pre-commit==2.21.0",
59
59
  "pytest==9.0.2",
60
60
  "pytest-cov==7.1.0",
61
61
  "pytest-rerunfailures==16.1",
62
- "types-requests==2.32.4.20260107",
63
- "types-setuptools==82.0.0.20260210",
62
+ "types-requests==2.33.0.20260402",
63
+ "types-setuptools==82.0.0.20260402",
64
64
  "xkcdpass==1.30.0",
65
65
  ]
66
66
  docs = ["mkdocs", "mkdocs-material"]
File without changes
File without changes
File without changes
File without changes