gitlabform 0.0.540a0__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 (79) hide show
  1. gitlabform/__init__.py +719 -0
  2. gitlabform/configuration/__init__.py +12 -0
  3. gitlabform/configuration/common.py +19 -0
  4. gitlabform/configuration/core.py +323 -0
  5. gitlabform/configuration/groups.py +127 -0
  6. gitlabform/configuration/projects.py +73 -0
  7. gitlabform/configuration/transform.py +259 -0
  8. gitlabform/constants.py +7 -0
  9. gitlabform/gitlab/__init__.py +108 -0
  10. gitlabform/gitlab/commits.py +39 -0
  11. gitlabform/gitlab/core.py +334 -0
  12. gitlabform/gitlab/group_badges.py +50 -0
  13. gitlabform/gitlab/group_ldap_links.py +40 -0
  14. gitlabform/gitlab/groups.py +96 -0
  15. gitlabform/gitlab/merge_requests.py +57 -0
  16. gitlabform/gitlab/pipelines.py +23 -0
  17. gitlabform/gitlab/project_badges.py +52 -0
  18. gitlabform/gitlab/project_deploy_keys.py +102 -0
  19. gitlabform/gitlab/project_merge_requests_approvals.py +94 -0
  20. gitlabform/gitlab/project_protected_environments.py +37 -0
  21. gitlabform/gitlab/projects.py +151 -0
  22. gitlabform/gitlab/python_gitlab.py +251 -0
  23. gitlabform/gitlab/variables.py +47 -0
  24. gitlabform/lists/__init__.py +62 -0
  25. gitlabform/lists/filter.py +99 -0
  26. gitlabform/lists/groups.py +87 -0
  27. gitlabform/lists/projects.py +239 -0
  28. gitlabform/output.py +46 -0
  29. gitlabform/processors/__init__.py +43 -0
  30. gitlabform/processors/abstract_processor.py +187 -0
  31. gitlabform/processors/application/__init__.py +17 -0
  32. gitlabform/processors/application/application_settings_processor.py +39 -0
  33. gitlabform/processors/defining_keys.py +152 -0
  34. gitlabform/processors/group/__init__.py +48 -0
  35. gitlabform/processors/group/group_badges_processor.py +17 -0
  36. gitlabform/processors/group/group_hooks_processor.py +75 -0
  37. gitlabform/processors/group/group_labels_processor.py +28 -0
  38. gitlabform/processors/group/group_ldap_links_processor.py +16 -0
  39. gitlabform/processors/group/group_members_processor.py +287 -0
  40. gitlabform/processors/group/group_push_rules_processor.py +44 -0
  41. gitlabform/processors/group/group_saml_links_processor.py +65 -0
  42. gitlabform/processors/group/group_settings_processor.py +90 -0
  43. gitlabform/processors/group/group_variables_processor.py +26 -0
  44. gitlabform/processors/multiple_entities_processor.py +171 -0
  45. gitlabform/processors/project/__init__.py +80 -0
  46. gitlabform/processors/project/badges_processor.py +17 -0
  47. gitlabform/processors/project/branches_processor.py +514 -0
  48. gitlabform/processors/project/deploy_keys_processor.py +18 -0
  49. gitlabform/processors/project/files_processor.py +301 -0
  50. gitlabform/processors/project/hooks_processor.py +64 -0
  51. gitlabform/processors/project/integrations_processor.py +33 -0
  52. gitlabform/processors/project/job_token_scope_processor.py +216 -0
  53. gitlabform/processors/project/members_processor.py +204 -0
  54. gitlabform/processors/project/merge_requests_approval_rules.py +17 -0
  55. gitlabform/processors/project/merge_requests_approvals.py +59 -0
  56. gitlabform/processors/project/project_labels_processor.py +27 -0
  57. gitlabform/processors/project/project_processor.py +62 -0
  58. gitlabform/processors/project/project_push_rules_processor.py +52 -0
  59. gitlabform/processors/project/project_security_settings.py +66 -0
  60. gitlabform/processors/project/project_settings_processor.py +239 -0
  61. gitlabform/processors/project/project_variables_processor.py +94 -0
  62. gitlabform/processors/project/remote_mirrors_processor.py +278 -0
  63. gitlabform/processors/project/resource_groups_processor.py +48 -0
  64. gitlabform/processors/project/schedules_processor.py +208 -0
  65. gitlabform/processors/project/tags_processor.py +108 -0
  66. gitlabform/processors/shared/__init__.py +0 -0
  67. gitlabform/processors/shared/protected_environments_processor.py +20 -0
  68. gitlabform/processors/util/__init__.py +0 -0
  69. gitlabform/processors/util/decorators.py +44 -0
  70. gitlabform/processors/util/difference_logger.py +70 -0
  71. gitlabform/processors/util/labels_processor.py +120 -0
  72. gitlabform/processors/util/variables_processor.py +143 -0
  73. gitlabform/run.py +9 -0
  74. gitlabform/util.py +7 -0
  75. gitlabform-0.0.540a0.dist-info/METADATA +54 -0
  76. gitlabform-0.0.540a0.dist-info/RECORD +79 -0
  77. gitlabform-0.0.540a0.dist-info/WHEEL +4 -0
  78. gitlabform-0.0.540a0.dist-info/entry_points.txt +9 -0
  79. gitlabform-0.0.540a0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,208 @@
1
+ import random
2
+ import re
3
+ from logging import debug
4
+ from typing import Dict, List
5
+
6
+ from gitlab.base import RESTObjectList, RESTObject
7
+ from gitlab.v4.objects import Project, ProjectPipelineSchedule
8
+
9
+ from gitlabform.gitlab import GitLab
10
+ from gitlabform.processors.abstract_processor import AbstractProcessor
11
+
12
+
13
+ class SchedulesProcessor(AbstractProcessor):
14
+ def __init__(self, gitlab: GitLab):
15
+ super().__init__("schedules", gitlab)
16
+
17
+ def _process_configuration(self, project_and_group: str, configuration: Dict):
18
+ configured_schedules = configuration.get("schedules", {})
19
+
20
+ enforce_schedules = configuration.get("schedules|enforce", False)
21
+
22
+ # Remove 'enforce' key from the config so that it's not treated as a "schedule"
23
+ if enforce_schedules:
24
+ configured_schedules.pop("enforce")
25
+
26
+ project: Project = self.gl.get_project_by_path_cached(project_and_group)
27
+ existing_schedules: list[ProjectPipelineSchedule] = project.pipelineschedules.list(get_all=True)
28
+
29
+ schedule_ids_by_description: Dict = self._group_schedule_ids_by_description(existing_schedules)
30
+
31
+ for schedule_description in sorted(configured_schedules):
32
+ schedule_ids = schedule_ids_by_description.get(schedule_description)
33
+
34
+ if configured_schedules[schedule_description].get("delete"):
35
+ if schedule_ids:
36
+ debug("Deleting pipeline schedules '%s'", schedule_description)
37
+ for schedule_id in schedule_ids:
38
+ project.pipelineschedules.get(schedule_id).delete()
39
+ else:
40
+ debug(
41
+ "Not deleting pipeline schedules '%s', because none exist",
42
+ schedule_description,
43
+ )
44
+ else:
45
+ entity_config = configured_schedules[schedule_description]
46
+
47
+ if schedule_ids and len(schedule_ids) == 1:
48
+ schedule = project.pipelineschedules.get(schedule_ids[0])
49
+ self._update_existing_schedule(entity_config, project, schedule, schedule_description)
50
+ elif schedule_ids:
51
+ debug(
52
+ "Replacing existing pipeline schedules '%s'",
53
+ schedule_description,
54
+ )
55
+ for schedule_id in schedule_ids:
56
+ project.pipelineschedules.get(schedule_id).delete()
57
+
58
+ self._create_schedule_with_variables(entity_config, project, schedule_description)
59
+ else:
60
+ debug("Creating pipeline schedule '%s'", schedule_description)
61
+ self._create_schedule_with_variables(entity_config, project, schedule_description)
62
+
63
+ if enforce_schedules:
64
+ debug("Delete unconfigured schedules because enforce is enabled")
65
+
66
+ self._delete_schedules_no_longer_in_config(configured_schedules, existing_schedules, project)
67
+
68
+ def _update_existing_schedule(
69
+ self,
70
+ entity_config: Dict,
71
+ project: Project,
72
+ schedule_in_gitlab: ProjectPipelineSchedule,
73
+ schedule_description: str,
74
+ ):
75
+ entity_config["cron"] = _replace_extended_cron_pattern(project.id, entity_config["cron"])
76
+ if self._needs_update(schedule_in_gitlab.asdict(), entity_config):
77
+ debug("Changing existing pipeline schedule '%s'", schedule_description)
78
+ # In order to edit a Schedule created by someone else we need to take ownership:
79
+ # https://docs.gitlab.com/ee/ci/pipelines/schedules.html#take-ownership
80
+ shedule = project.pipelineschedules.get(schedule_in_gitlab.id)
81
+ shedule.take_ownership()
82
+ project.pipelineschedules.update(
83
+ schedule_in_gitlab.id,
84
+ {"description": schedule_description, **entity_config},
85
+ )
86
+ configured_variables = entity_config.get("variables")
87
+ if configured_variables is not None:
88
+ self._set_schedule_variables(
89
+ schedule_in_gitlab,
90
+ configured_variables,
91
+ )
92
+ else:
93
+ debug("No update required for pipeline schedule '%s'", schedule_description)
94
+
95
+ def _create_schedule_with_variables(
96
+ self,
97
+ entity_config: Dict,
98
+ project: Project,
99
+ schedule_description: str,
100
+ ):
101
+ entity_config["cron"] = _replace_extended_cron_pattern(project.id, entity_config["cron"])
102
+ schedule_data = {"description": schedule_description, **entity_config}
103
+ debug("Creating pipeline schedule using data: '%s'", schedule_data)
104
+ created_schedule_id = project.pipelineschedules.create(schedule_data).id
105
+ created_schedule = project.pipelineschedules.get(created_schedule_id)
106
+
107
+ configured_variables = entity_config.get("variables")
108
+ if configured_variables is not None:
109
+ self._set_schedule_variables(
110
+ created_schedule,
111
+ configured_variables,
112
+ )
113
+
114
+ created_schedule.save()
115
+
116
+ @staticmethod
117
+ def _set_schedule_variables(schedule: ProjectPipelineSchedule, variables: Dict) -> None:
118
+ attributes = schedule.attributes
119
+ existing_variables = attributes.get("variables")
120
+ if existing_variables:
121
+ debug("Deleting variables for pipeline schedule '%s'", schedule.description)
122
+
123
+ for variable in existing_variables:
124
+ schedule.variables.delete(variable.get("key"))
125
+
126
+ if variables:
127
+ for variable_key, variable_data in variables.items():
128
+ schedule.variables.create({"key": variable_key, **variable_data})
129
+
130
+ @staticmethod
131
+ def _group_schedule_ids_by_description(
132
+ schedules,
133
+ ) -> Dict[str, List[str]]:
134
+ schedule_ids_by_description: Dict[str, List[str]] = {}
135
+
136
+ for schedule in schedules:
137
+ schedule_ids_by_description.setdefault(schedule.description, []).append(schedule.id)
138
+
139
+ return schedule_ids_by_description
140
+
141
+ @staticmethod
142
+ def _delete_schedules_no_longer_in_config(configured_schedules: Dict, existing_schedules, project: Project) -> None:
143
+ schedule: ProjectPipelineSchedule
144
+ for schedule in existing_schedules:
145
+ schedule_description = schedule.description
146
+ schedule_id = schedule.id
147
+
148
+ debug(f"processing {schedule_id}: {schedule_description}")
149
+ if schedule_description not in configured_schedules:
150
+ debug(
151
+ "Deleting pipeline schedule named '%s', because it is not in gitlabform configuration",
152
+ schedule_description,
153
+ )
154
+ project.pipelineschedules.get(schedule_id).delete()
155
+
156
+
157
+ class ExtendedCronPattern:
158
+
159
+ def __init__(self, project_id: int, cron_expression: str):
160
+ self._h_pattern = re.compile(r"H(?:(\((?P<start>\d+)-(?P<end>\d+)\))|(?P<interval>/\d+))?")
161
+ # We do use random here to achieve a stable pseudo-random value, this is not security relevant
162
+ # Seeding with project_id always returns the same numbers, that is what we want here.
163
+ self._random = random.Random() # nosec B311
164
+ self._random.seed(project_id, 2)
165
+ self._cron_parts = cron_expression.split()
166
+ if len(self._cron_parts) != 5:
167
+ raise ValueError(f"Expected 5 parts in the cron expression, got {self._cron_parts}")
168
+
169
+ def render(self) -> str:
170
+ self._cron_parts[0] = self._detect_and_replace_h(self._cron_parts[0], 60)
171
+ self._cron_parts[1] = self._detect_and_replace_h(self._cron_parts[1], 24)
172
+ self._cron_parts[4] = self._detect_and_replace_h(self._cron_parts[4], 7)
173
+ return " ".join(self._cron_parts)
174
+
175
+ def _detect_and_replace_h(self, cron_part: str, default_max: int):
176
+ parts = cron_part.split(",")
177
+ for i, _ in enumerate(parts):
178
+ match = self._h_pattern.match(parts[i])
179
+ if match:
180
+ self._replace_h(i, parts, match, default_max)
181
+ return ",".join(parts)
182
+
183
+ def _replace_h(self, i: int, parts: List[str], match: re.Match, default_max: int):
184
+ interval = match.group("interval") or ""
185
+ if interval:
186
+ interval = int(interval[1:])
187
+ times = int(default_max / interval)
188
+ first = self._random.randint(0, interval)
189
+ result = [str(first)]
190
+ result.extend(str(first + i * interval) for i in range(1, times))
191
+ else:
192
+ start = int(match.group("start") or 0)
193
+ end = int(match.group("end") or default_max - 1)
194
+ result = [str(self._random.randint(start, end))]
195
+ parts[i] = parts[i].replace(match.string, ",".join(result))
196
+
197
+
198
+ EXTENDED_CRON_PATTERN_ALIASES = {
199
+ "@hourly": "H * * * *",
200
+ "@daily": "H H * * *",
201
+ "@weekly": "H H * * H",
202
+ "@nightly": "H H(00-06) * * *",
203
+ }
204
+
205
+
206
+ def _replace_extended_cron_pattern(project_id: int, cron_expression: str) -> str:
207
+ cron_expression = EXTENDED_CRON_PATTERN_ALIASES.get(cron_expression.lower(), cron_expression)
208
+ return ExtendedCronPattern(project_id, cron_expression).render()
@@ -0,0 +1,108 @@
1
+ import sys
2
+ from logging import debug, warning, critical, error
3
+
4
+ from gitlabform.constants import EXIT_PROCESSING_ERROR
5
+ from gitlabform.gitlab import GitLab
6
+ from gitlab import GitlabDeleteError, GitlabGetError
7
+ from gitlabform.processors.abstract_processor import AbstractProcessor
8
+
9
+
10
+ class TagsProcessor(AbstractProcessor):
11
+ def __init__(self, gitlab: GitLab, strict: bool):
12
+ super().__init__("tags", gitlab)
13
+ self.strict = strict
14
+
15
+ def _process_configuration(self, project_and_group: str, configuration: dict):
16
+ project = self.gl.get_project_by_path_cached(name=project_and_group, lazy=True)
17
+
18
+ for tag in sorted(configuration["tags"]):
19
+ try:
20
+ if configuration["tags"][tag]["protected"]:
21
+ allowed_to_create = []
22
+
23
+ if "allowed_to_create" in configuration["tags"][tag]:
24
+ access_levels = set()
25
+ user_ids = set()
26
+ group_ids = set()
27
+
28
+ requested_configuration = configuration["tags"][tag]["allowed_to_create"]
29
+
30
+ for config in requested_configuration:
31
+ if "access_level" in config:
32
+ access_levels.add(config["access_level"])
33
+ elif "user_id" in config:
34
+ user_ids.add(config["user_id"])
35
+ elif "user" in config:
36
+ user_id = self.gl.get_user_id_cached(config["user"])
37
+ if user_id is None:
38
+ error(
39
+ f"Could not find User '{config["user"]}' on the Instance, cannot Protect "
40
+ f"Tag with them"
41
+ )
42
+ raise GitlabGetError(
43
+ f"_process_configuration - No users found when searching for username {config["user"]}",
44
+ 404,
45
+ )
46
+
47
+ user_ids.add(user_id)
48
+ elif "group_id" in config:
49
+ group_ids.add(config["group_id"])
50
+ elif "group" in config:
51
+ try:
52
+ gitlab_group = self.gl.get_group_by_path_cached(config["group"])
53
+ except GitlabGetError as e:
54
+ error(
55
+ f"Could not find Group '{config["group"]}' on the Instance, cannot Protect "
56
+ f"Tag with them"
57
+ )
58
+ raise e
59
+
60
+ group_ids.add(gitlab_group.get_id())
61
+
62
+ for val in access_levels:
63
+ allowed_to_create.append({"access_level": val})
64
+
65
+ for val in user_ids:
66
+ allowed_to_create.append({"user_id": val})
67
+
68
+ for val in group_ids:
69
+ allowed_to_create.append({"group_id": val})
70
+
71
+ create_access_level = (
72
+ configuration["tags"][tag]["create_access_level"]
73
+ if "create_access_level" in configuration["tags"][tag]
74
+ else None
75
+ )
76
+
77
+ debug("Setting tag '%s' as *protected*", tag)
78
+ try:
79
+ # try to unprotect first
80
+ project.protectedtags.delete(tag)
81
+ except GitlabDeleteError:
82
+ pass
83
+
84
+ data = {}
85
+ data["name"] = tag
86
+ if allowed_to_create is not None:
87
+ data["allowed_to_create"] = allowed_to_create
88
+ if create_access_level is not None:
89
+ data["create_access_level"] = create_access_level
90
+ project.protectedtags.create(data)
91
+ else:
92
+ debug("Setting tag '%s' as *unprotected*", tag)
93
+ project.protectedtags.delete(tag)
94
+ except GitlabDeleteError:
95
+ message = f"Tag '{tag}' not found when trying to unprotect it!"
96
+ if self.strict:
97
+ critical(message)
98
+ sys.exit(EXIT_PROCESSING_ERROR)
99
+ else:
100
+ warning(message)
101
+ except GitlabGetError as e:
102
+ if self.strict:
103
+ critical(
104
+ e,
105
+ )
106
+ sys.exit(EXIT_PROCESSING_ERROR)
107
+ else:
108
+ warning(message)
File without changes
@@ -0,0 +1,20 @@
1
+ from gitlabform.gitlab import GitLab
2
+ from gitlabform.processors.defining_keys import Key, And
3
+ from gitlabform.processors.multiple_entities_processor import MultipleEntitiesProcessor
4
+
5
+
6
+ class ProtectedEnvironmentsProcessor(MultipleEntitiesProcessor):
7
+ """https://docs.gitlab.com/ee/api/protected_environments.html#protect-repository-environments"""
8
+
9
+ def __init__(self, gitlab: GitLab):
10
+ super().__init__(
11
+ "protected_environments",
12
+ gitlab,
13
+ list_method_name=gitlab.list_protected_environments,
14
+ add_method_name=gitlab.protect_a_repository_environment,
15
+ delete_method_name=gitlab.unprotect_environment,
16
+ defining=Key("name"),
17
+ required_to_create_or_update=And(Key("name"), Key("deploy_access_levels")),
18
+ )
19
+
20
+ self.custom_diff_analyzers["deploy_access_levels"] = self.recursive_diff_analyzer
File without changes
@@ -0,0 +1,44 @@
1
+ from functools import wraps
2
+
3
+
4
+ class SafeDict(dict):
5
+ """
6
+ A dict that a "get" method that allows to use a path-like reference to its subdict values.
7
+
8
+ For example with a dict like {"key": {"subkey": {"subsubkey": "value"}}}
9
+ you can use a string 'key|subkey|subsubkey' to get the 'value'.
10
+
11
+ The default value is returned if ANY of the subelements does not exist.
12
+
13
+ Code based on https://stackoverflow.com/a/44859638/2693875
14
+ """
15
+
16
+ def get(self, path, default=None):
17
+ keys = path.split("|")
18
+ val = None
19
+
20
+ for key in keys:
21
+ if val:
22
+ if isinstance(val, list):
23
+ val = [v.get(key, default) if v else None for v in val]
24
+ else:
25
+ val = val.get(key, default)
26
+ else:
27
+ val = dict.get(self, key, default)
28
+
29
+ if not val:
30
+ break
31
+
32
+ return val
33
+
34
+
35
+ def configuration_to_safe_dict(method):
36
+ """
37
+ This wrapper function calls the method with the configuration converted from a regular dict into a SafeDict
38
+ """
39
+
40
+ @wraps(method)
41
+ def method_wrapper(self, project_and_group, configuration, *args):
42
+ return method(self, project_and_group, SafeDict(configuration), *args)
43
+
44
+ return method_wrapper
@@ -0,0 +1,70 @@
1
+ import hashlib
2
+ import json
3
+ from itertools import starmap
4
+
5
+ from logging import info
6
+
7
+
8
+ # Simple function to create strings for values which should be hidden
9
+ # example: <secret b2c1a982>
10
+ def hide(text: str):
11
+ return f"<secret {hashlib.sha256(text.encode('utf-8')).hexdigest()[:8]}>"
12
+
13
+
14
+ class DifferenceLogger:
15
+ @staticmethod
16
+ def log_diff(
17
+ subject,
18
+ current_config,
19
+ config_to_apply,
20
+ only_changed=False,
21
+ hide_entries=None,
22
+ test=False,
23
+ ):
24
+ # Compose values in list of `[key, from_config, from_server]``
25
+ changes = [
26
+ [
27
+ k,
28
+ json.dumps(current_config.get(k, "???") if isinstance(current_config, dict) else "???"),
29
+ json.dumps(v),
30
+ ]
31
+ for k, v in config_to_apply.items()
32
+ ]
33
+
34
+ # Remove unchanged if needed
35
+ if only_changed:
36
+ # due to `filter` returning an iterator, we have to wrap it
37
+ # in `list()` to get the values and assign back to `changes`,
38
+ # otherwise `changes` is not what we expect it to be later :)
39
+ changes = list(filter(lambda i: i[1] != i[2], changes))
40
+
41
+ # Hide secrets
42
+ if hide_entries:
43
+ changes = list(
44
+ map(
45
+ lambda i: ([i[0], hide(i[1]), hide(i[2])] if i[0] in hide_entries else i),
46
+ changes,
47
+ )
48
+ )
49
+
50
+ # There is the potential that no changes need to be shown, which
51
+ # results in the calls to max() later on to fail. Instead, opting
52
+ # to return early with an emtpy string, since no changes were identified
53
+ if len(changes) == 0:
54
+ return ""
55
+
56
+ # calculate field size for nice formatting
57
+ max_key_len = str(max(map(lambda i: len(i[0]), changes)))
58
+ max_val_1 = str(max(map(lambda i: len(i[1]), changes)))
59
+ max_val_2 = str(max(map(lambda i: len(i[2]), changes)))
60
+
61
+ # generate placeholders for output pattern: ` value: before => after `
62
+ pattern = "{:>" + max_key_len + "}: {:<" + max_val_1 + "} => {:<" + max_val_2 + "}"
63
+
64
+ # create string
65
+ text = "{subject}:\n{diff}".format(subject=subject, diff="\n".join(starmap(pattern.format, changes)))
66
+
67
+ if test:
68
+ return text
69
+ else:
70
+ info(text)
@@ -0,0 +1,120 @@
1
+ from logging import info, warning
2
+ from typing import Dict, List, Callable, Union
3
+
4
+ from gitlab.v4.objects import Group, Project, ProjectLabel, GroupLabel
5
+
6
+
7
+ class LabelsProcessor:
8
+
9
+ # Groups and Projects share the same API for .labels within python-gitlab
10
+ def process_labels(
11
+ self,
12
+ configured_labels: Dict,
13
+ enforce: bool,
14
+ group_or_project: Group | Project,
15
+ needs_update: Callable, # self._needs_update passed from AbstractProcessor called process_labels
16
+ ):
17
+ # Only get Labels created directly on the project/group
18
+ existing_group_labels = group_or_project.labels.list(get_all=True, include_ancestor_groups=False)
19
+ existing_group_and_parent_labels = group_or_project.labels.list(get_all=True)
20
+ existing_label_keys: List = []
21
+
22
+ if isinstance(group_or_project, Group):
23
+ parent_object_type = "Group"
24
+ else:
25
+ parent_object_type = "Project"
26
+
27
+ gitlab_labels_to_delete: List = []
28
+
29
+ for label_to_update in existing_group_labels:
30
+ label_name_in_gl = label_to_update.name
31
+ updated_label = False
32
+ info(f"Checking if {label_name_in_gl} is in Configuration to update or delete")
33
+
34
+ for key, configured_label in configured_labels.items():
35
+ configured_label_name = configured_label.get("name")
36
+ # Key in YAML may not match the "name" value in Gitlab or YAML, so we must match on both
37
+ if self.configured_label_matches_gitlab_label(configured_label_name, key, label_name_in_gl):
38
+ # label exists in GL, so update
39
+ existing_label_keys.append(key)
40
+ updated_label = True
41
+
42
+ if needs_update(label_to_update.asdict(), configured_label):
43
+ self.update_existing_label(
44
+ configured_label,
45
+ self.get_label(group_or_project, label_to_update),
46
+ parent_object_type,
47
+ )
48
+ else:
49
+ info(f"No update required for label: {label_name_in_gl}")
50
+ break
51
+
52
+ if not updated_label:
53
+ gitlab_labels_to_delete.append(label_to_update)
54
+
55
+ # Delete labels no longer in config
56
+ for label_to_delete in gitlab_labels_to_delete:
57
+ label_name_in_gl = label_to_delete.name
58
+
59
+ info(f"{label_name_in_gl} not in configured labels")
60
+ # only delete labels when enforce is true, because user's maybe automatically applying labels based
61
+ # on Repo state, for example: Compliance Framework labels based on language or CI-template status
62
+ if enforce:
63
+ info(f"Removing {label_name_in_gl} from {parent_object_type}")
64
+ self.get_label(group_or_project, label_to_delete).delete()
65
+
66
+ # add new labels
67
+
68
+ for label_key in configured_labels.keys():
69
+ if label_key not in existing_label_keys:
70
+ info(f"Creating new label with key: {label_key}, on {parent_object_type}")
71
+ self.create_new_label(
72
+ configured_labels, group_or_project, label_key, parent_object_type, existing_group_and_parent_labels
73
+ )
74
+
75
+ @staticmethod
76
+ def configured_label_matches_gitlab_label(configured_label_name: str, key: str, label_name_in_gl: str):
77
+ return (
78
+ configured_label_name is not None and label_name_in_gl == configured_label_name
79
+ ) or label_name_in_gl == key
80
+
81
+ @staticmethod
82
+ def get_label(group_or_project, listed_label) -> GroupLabel | ProjectLabel:
83
+ return group_or_project.labels.get(listed_label.id)
84
+
85
+ @staticmethod
86
+ def update_existing_label(
87
+ configured_label,
88
+ full_label: GroupLabel | ProjectLabel,
89
+ parent_object_type: str,
90
+ ):
91
+ info(f"Updating {full_label.name} on {parent_object_type}")
92
+
93
+ # label APIs in python-gitlab do not supply an update() method
94
+ for key in configured_label:
95
+ full_label.__setattr__(key, configured_label[key])
96
+
97
+ full_label.save()
98
+
99
+ def create_new_label(
100
+ self,
101
+ configured_labels,
102
+ group_or_project: Group | Project,
103
+ label_key: str,
104
+ parent_object_type: str,
105
+ existing_group_and_parent_labels: Union[List[GroupLabel], List[ProjectLabel]],
106
+ ):
107
+ label = configured_labels.get(label_key)
108
+ configured_label_name = label.get("name")
109
+ found_existing_label = False
110
+ for existing_label in existing_group_and_parent_labels:
111
+ if self.configured_label_matches_gitlab_label(configured_label_name, label_key, existing_label.name):
112
+ warning(
113
+ f"Label {existing_label.name} already exists either in {group_or_project.name} or on Parent Group, so will not create"
114
+ )
115
+ found_existing_label = True
116
+ break
117
+
118
+ if not found_existing_label:
119
+ info(f"Adding label with key: {label_key} to {parent_object_type}")
120
+ group_or_project.labels.create({"name": label_key, **label})