Coverage for src/features/gitlab.py: 12.17%
411 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 21:20 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 21:20 +0000
1#!/usr/bin/env python3
3# Standard libraries
4from time import sleep
5from typing import Dict, List, Optional
7# Modules libraries
8from gitlab import Gitlab
9from gitlab.const import AccessLevel as GitLabAccessLevel
10from gitlab.exceptions import (
11 GitlabDeleteError,
12 GitlabGetError,
13 GitlabJobEraseError,
14 GitlabListError,
15)
16from gitlab.v4.objects import (
17 Group,
18 GroupLabel,
19 Namespace,
20 Project,
21 ProjectBoard,
22 ProjectBoardList,
23 ProjectLabel,
24 User,
25)
27# Components
28from ..types.gitlab import (
29 AccessLevels,
30 ProjectFeatures,
31 ProtectionLevels,
32 RolesCreateProjects,
33 RolesCreateSubgroups,
34)
36# GitLabFeature class, pylint: disable=too-many-lines,too-many-public-methods
37class GitLabFeature:
39 # Constants
40 TIMEOUT_DELETION: int = 300
42 # Members
43 __dry_run: bool = False
44 __gitlab: Gitlab
46 # Constructor, pylint: disable=too-many-arguments,too-many-positional-arguments
47 def __init__(
48 self,
49 url: str,
50 private_token: str,
51 job_token: str,
52 ssl_verify: bool | str = True,
53 dry_run: bool = False,
54 ) -> None:
56 # Initialize members
57 self.__dry_run = dry_run
59 # Create GitLab client
60 if private_token:
61 self.__gitlab = Gitlab(
62 url=url,
63 private_token=private_token,
64 ssl_verify=ssl_verify,
65 )
66 elif job_token:
67 self.__gitlab = Gitlab(
68 url=url,
69 job_token=job_token,
70 ssl_verify=ssl_verify,
71 )
72 else:
73 self.__gitlab = Gitlab(
74 url=url,
75 ssl_verify=ssl_verify,
76 )
78 # Authenticate if available
79 if self.__gitlab.private_token or self.__gitlab.oauth_token:
80 self.__gitlab.auth()
82 # Group
83 def group(
84 self,
85 criteria: str,
86 ) -> Group:
87 return self.__gitlab.groups.get(criteria)
89 # Group delete
90 def group_delete(
91 self,
92 criteria: str,
93 ) -> None:
95 # Acquire group
96 group = self.group(criteria)
98 # Unarchive projects
99 if not self.__dry_run:
100 projects = sorted(
101 group.projects.list(
102 get_all=True,
103 with_shared=False,
104 include_subgroups=True,
105 order_by='path',
106 sort='asc',
107 ),
108 key=lambda item: item.path_with_namespace,
109 )
110 for project in projects:
111 if project.archived:
112 self.project(project.id).unarchive()
114 # Delete group
115 if not self.__dry_run:
116 group.delete()
117 sleep(1)
118 try:
119 group = self.group(criteria)
120 group.delete(query_data={
121 'full_path': group.full_path,
122 'permanently_remove': 'true',
123 })
124 except (GitlabDeleteError, GitlabGetError):
125 pass
127 # Wait for deletion
128 for _ in range(GitLabFeature.TIMEOUT_DELETION):
129 sleep(1)
130 try:
131 group = self.group(criteria)
132 if group.marked_for_deletion_on:
133 break
134 except AttributeError:
135 pass
136 except GitlabGetError:
137 break
139 # Delay for deletion
140 sleep(3)
142 # Group get labels
143 def group_get_labels(
144 self,
145 criteria: str,
146 ) -> List[GroupLabel]:
148 # Get group labels
149 group = self.group(criteria)
150 return group.labels.list(get_all=True)
152 # Group reset members
153 def group_reset_members(
154 self,
155 criteria: str,
156 ) -> None:
158 # Remove group members
159 group = self.group(criteria)
160 for member in sorted(
161 group.members.list(get_all=True),
162 key=lambda item: item.access_level,
163 ):
164 if not self.__dry_run:
165 try:
166 group.members.delete(member.id)
167 except GitlabDeleteError as exception:
168 if member.access_level == GitLabAccessLevel.OWNER \
169 and len(group.members.list(get_all=True)) == 1:
170 continue
171 raise exception
173 # Save group
174 if not self.__dry_run:
175 group.save()
177 # Group set avatar
178 def group_set_avatar(
179 self,
180 criteria: str,
181 file: str,
182 ) -> None:
184 # Set group avatar
185 group = self.group(criteria)
186 if not self.__dry_run:
187 with open(file, 'rb') as avatar:
188 group.avatar = avatar
190 # Save group
191 group.save()
193 # Group set description
194 def group_set_description(
195 self,
196 criteria: str,
197 description: str,
198 ) -> bool:
200 # Variables
201 changed: bool = False
203 # Set group description
204 group = self.group(criteria)
205 if not self.__dry_run and group.description != description:
206 group.description = description
207 changed = True
209 # Save group
210 group.save()
212 # Result
213 return changed
215 # Group set label, pylint: disable=too-many-arguments,too-many-positional-arguments
216 def group_set_label(
217 self,
218 criteria: str,
219 name: str,
220 description: str,
221 text_color: str,
222 color: str,
223 ) -> GroupLabel:
225 # Create group label
226 group = self.group(criteria)
227 label: GroupLabel
228 try:
229 label = group.labels.get(name, include_ancestor_groups=False)
230 except GitlabGetError:
231 group.labels.create({
232 'name': name,
233 'description': description,
234 'text_color': text_color,
235 'color': color,
236 })
237 label = group.labels.get(name, include_ancestor_groups=False)
239 # Update group label
240 label.description = description
241 label.text_color = text_color
242 label.color = color
243 label.save()
245 # Result
246 return label
248 # Group set roles create projects
249 def group_set_roles_create_projects(
250 self,
251 criteria: str,
252 level: str,
253 ) -> bool:
255 # Variables
256 changed: bool = False
258 # Validate level
259 if level not in RolesCreateProjects.names():
260 raise SyntaxError(
261 f'Unknown role level: {level} ({",".join(RolesCreateProjects.names())})')
263 # Set roles allowed to create projects
264 group = self.group(criteria)
265 if not self.__dry_run and group.project_creation_level != level:
266 group.project_creation_level = level
267 changed = True
269 # Save group
270 group.save()
272 # Result
273 return changed
275 # Group set roles create subgroups
276 def group_set_roles_create_subgroups(
277 self,
278 criteria: str,
279 level: str,
280 ) -> bool:
282 # Variables
283 changed: bool = False
285 # Validate level
286 if level not in RolesCreateSubgroups.names():
287 raise SyntaxError(
288 f'Unknown role level: {level} ({",".join(RolesCreateSubgroups.names())})')
290 # Set roles allowed to create subgroups
291 group = self.group(criteria)
292 if not self.__dry_run and group.subgroup_creation_level != level:
293 group.subgroup_creation_level = level
294 changed = True
296 # Save group
297 group.save()
299 # Result
300 return changed
302 # Namespace
303 def namespace(
304 self,
305 criteria: str,
306 ) -> Namespace:
307 return self.__gitlab.namespaces.get(criteria)
309 # Project
310 def project(
311 self,
312 criteria: str,
313 ) -> Project:
314 return self.__gitlab.projects.get(criteria)
316 # Project delete
317 def project_delete(
318 self,
319 criteria: str,
320 ) -> None:
322 # Acquire project
323 project = self.project(criteria)
325 # Delete snippets
326 try:
327 snippets = project.snippets.list(all=True)
328 if snippets:
329 if project.archived:
330 project.unarchive()
331 for snippet in snippets:
332 snippet.delete()
333 except GitlabListError:
334 pass
336 # Delete project
337 if not self.__dry_run:
338 project.delete()
339 sleep(1)
340 try:
341 project = self.project(criteria)
342 project.delete(query_data={
343 'full_path': project.path_with_namespace,
344 'permanently_remove': 'true',
345 })
346 except (GitlabDeleteError, GitlabGetError):
347 pass
349 # Wait for deletion
350 for _ in range(GitLabFeature.TIMEOUT_DELETION):
351 sleep(1)
352 try:
353 project = self.project(criteria)
354 if project.marked_for_deletion_on:
355 break
356 except AttributeError:
357 pass
358 except GitlabGetError:
359 break
361 # Delay for deletion
362 sleep(3)
364 # Project erase jobs artifacts
365 def project_erase_jobs_artifacts(
366 self,
367 criteria: str,
368 ) -> None:
370 # Erase project jobs artifacts
371 project = self.project(criteria)
372 if not self.__dry_run:
373 for job in project.jobs.list():
374 if job.artifacts_expire_at is not None:
375 job.delete_artifacts()
377 # Project erase jobs artifacts and traces
378 def project_erase_jobs_contents(
379 self,
380 criteria: str,
381 ) -> None:
383 # Erase project jobs artifacts and traces
384 project = self.project(criteria)
385 if not self.__dry_run:
386 for job in project.jobs.list():
387 if job.artifacts_expire_at is not None:
388 try:
389 job.erase()
390 except GitlabJobEraseError:
391 pass
393 # Add path to project CI/CD job token allowlist
394 def project_job_allowlist_add(
395 self,
396 criteria: str,
397 path: str,
398 ) -> None:
400 # Add path to project CI/CD job token allowlist
401 project = self.project(criteria)
402 try:
403 path_project = self.project(path)
404 if not any(path_project.id == allowlist.get_id()
405 for allowlist in project.job_token_scope.get().allowlist.list() #
406 ) and not self.__dry_run:
407 project.job_token_scope.get().allowlist.create({
408 'target_project_id': path_project.id,
409 })
410 except GitlabGetError:
411 path_group = self.group(path)
412 if not any(path_group.id == allowlist.get_id() for allowlist in
413 project.job_token_scope.get().groups_allowlist.list() #
414 ) and not self.__dry_run:
415 project.job_token_scope.get().groups_allowlist.create({
416 'target_project_id': path_group.id,
417 })
419 # Remove path from project CI/CD job token allowlist
420 def project_job_allowlist_remove(
421 self,
422 criteria: str,
423 path: str,
424 ) -> None:
426 # Remove path from project CI/CD job token allowlist
427 project = self.project(criteria)
428 try:
429 path_project = self.project(path)
430 if any(path_project.id == allowlist.get_id()
431 for allowlist in project.job_token_scope.get().allowlist.list() #
432 ) and not self.__dry_run:
433 project.job_token_scope.get().allowlist.delete(path_project.id)
434 except GitlabGetError:
435 path_group = self.group(path)
436 if any(path_group.id == allowlist.get_id() for allowlist in
437 project.job_token_scope.get().groups_allowlist.list() #
438 ) and not self.__dry_run:
439 project.job_token_scope.get().groups_allowlist.delete(path_group.id)
441 # Project protect branches
442 def project_protect_branches(
443 self,
444 criteria: str,
445 ) -> List[str]:
447 # Validate project feature
448 result: List[str] = []
449 project = self.project(criteria)
450 try:
451 assert project.branches.list(get_all=True)
452 except (AssertionError, GitlabListError):
453 return result
455 # Acquire project, branches and protected branches
456 branches = [branch.name for branch in project.branches.list(get_all=True)]
457 protectedbranches = [
458 protectedbranch.name
459 for protectedbranch in project.protectedbranches.list(get_all=True)
460 ]
462 # Protect main/master
463 for branch in ['main', 'master']:
464 if branch in branches and branch not in protectedbranches:
465 if not self.__dry_run:
466 project.protectedbranches.create({
467 'name': branch,
468 'merge_access_level': 40,
469 'push_access_level': 40,
470 'allow_force_push': False
471 })
472 result += [branch]
474 # Protect develop
475 for branch in ['develop']:
476 if branch in branches and branch not in protectedbranches:
477 if not self.__dry_run:
478 project.protectedbranches.create({
479 'name': branch,
480 'merge_access_level': 40,
481 'push_access_level': 40,
482 'allow_force_push': True
483 })
484 result += [branch]
486 # Protect staging
487 for branch in ['staging']:
488 if branch in branches and branch not in protectedbranches:
489 if not self.__dry_run:
490 project.protectedbranches.create({
491 'name': branch,
492 'merge_access_level': 30,
493 'push_access_level': 30,
494 'allow_force_push': True
495 })
496 result += [branch]
498 # Save project
499 if not self.__dry_run:
500 project.save()
502 # Result
503 return result
505 # Project parse features
506 @staticmethod
507 def project_features_parse(input_string: str) -> List[str]:
509 # Handle empty input
510 if not input_string:
511 return []
513 # Parse features from input
514 return [
515 key # Key
516 for search in input_string.split(',') # Input features
517 for key in ProjectFeatures.keys() # GitLab features
518 if ProjectFeatures.get(key).name.lower().startswith(search.strip().lower())
519 ]
521 # Project disable features
522 def project_features_disable(
523 self,
524 criteria: str,
525 features: List[str],
526 ) -> List[str]:
528 # Variables
529 changed: bool
530 result: List[str] = []
531 project = self.__gitlab.projects.get(criteria, statistics=True)
533 # Iterate through features
534 for key in features:
535 if key in ProjectFeatures.keys():
536 changed = False
537 feature = ProjectFeatures.get(key)
539 # Disable 'access_level' feature
540 for level in feature.access_level:
541 if hasattr(project, level.key) \
542 and getattr(project, level.key) != AccessLevels.DISABLED:
543 changed = True
544 setattr(
545 project,
546 level.key,
547 AccessLevels.DISABLED,
548 )
550 # Disable 'enabled' feature
551 for subkey in feature.enabled:
552 if hasattr(project, key) \
553 and getattr(project, subkey):
554 changed = True
555 setattr(
556 project,
557 subkey,
558 False,
559 )
561 # Add changed feature
562 if changed:
563 result.append(feature.name)
565 # Save project
566 if not self.__dry_run:
567 project.save()
569 # Result
570 return result
572 # Project enable features
573 def project_features_enable(
574 self,
575 criteria: str,
576 features: List[str],
577 ) -> List[str]:
579 # Variables
580 changed: bool
581 result: List[str] = []
582 project = self.__gitlab.projects.get(criteria, statistics=True)
584 # Iterate through features
585 for key in features:
586 if key in ProjectFeatures.keys():
587 changed = False
588 feature = ProjectFeatures.get(key)
590 # Enable 'access_level' feature
591 for level in feature.access_level:
592 if hasattr(project, level.key) \
593 and getattr(project, level.key) == AccessLevels.DISABLED:
594 changed = True
595 setattr(
596 project,
597 level.key,
598 level.settings[project.visibility],
599 )
601 # Enable 'enabled' feature
602 for subkey in feature.enabled:
603 if hasattr(project, subkey) \
604 and not getattr(project, subkey):
605 changed = True
606 setattr(
607 project,
608 subkey,
609 True,
610 )
612 # Add changed feature
613 if changed:
614 result.append(feature.name)
616 # Save project
617 if not self.__dry_run:
618 project.save()
620 # Result
621 return result
623 # Project reset features
624 def project_features_reset(
625 self,
626 criteria: str,
627 keep_features: List[str],
628 ) -> List[str]:
630 # Variables
631 changed: bool
632 result: List[str] = []
633 project = self.__gitlab.projects.get(criteria, statistics=True)
635 # Iterate through features
636 for key in ProjectFeatures.keys():
637 if key not in keep_features:
638 changed = False
639 feature = ProjectFeatures.get(key)
641 # Disable 'access_level' feature
642 for level in feature.access_level:
643 if changed or (hasattr(project, level.key) \
644 and getattr(project, level.key) != AccessLevels.DISABLED \
645 and not ProjectFeatures.test(self.__gitlab, project, feature.tests)):
646 changed = True
647 setattr(
648 project,
649 level.key,
650 AccessLevels.DISABLED,
651 )
653 # Disable 'enabled' feature
654 for subkey in feature.enabled:
655 if changed or (hasattr(project, subkey) \
656 and getattr(project, subkey) \
657 and not ProjectFeatures.test(self.__gitlab, project, feature.tests)):
658 changed = True
659 setattr(
660 project,
661 subkey,
662 False,
663 )
665 # Add changed feature
666 if changed:
667 result.append(feature.name)
669 # Save project
670 if not self.__dry_run:
671 project.save()
673 # Result
674 return result
676 # Project get issues boards
677 def project_get_issues_boards(
678 self,
679 criteria: str,
680 ) -> List[ProjectBoard]:
682 # Get project issues boards
683 project = self.project(criteria)
684 return project.boards.list(get_all=True)
686 # Project get labels
687 def project_get_labels(
688 self,
689 criteria: str,
690 ) -> List[ProjectLabel]:
692 # Get project labels
693 project = self.project(criteria)
694 return [
695 label for label in project.labels.list(get_all=True) if label.is_project_label
696 ]
698 # Project protect tags, pylint: disable=too-many-branches
699 def project_protect_tags(
700 self,
701 criteria: str,
702 protect_level: str,
703 ) -> List[str]:
705 # Validate project feature
706 result: List[str] = []
707 project = self.project(criteria)
708 try:
709 assert project.tags.list(get_all=True)
710 except (AssertionError, GitlabListError):
711 return result
713 # Prepare access level
714 access_level: int
715 if protect_level == ProtectionLevels.NO_ONE:
716 access_level = 0
717 elif protect_level == ProtectionLevels.ADMINS:
718 access_level = 60
719 elif protect_level == ProtectionLevels.MAINTAINERS:
720 access_level = 40
721 elif protect_level == ProtectionLevels.DEVELOPERS:
722 access_level = 30
723 else:
724 raise SyntaxError(f'Unknown protection level: {protect_level}')
726 # Acquire protected tags
727 protectedtags = [
728 protectedtag.name for protectedtag in project.protectedtags.list(get_all=True)
729 ]
731 # Update protected tags
732 for protectedtag in project.protectedtags.list(get_all=True):
733 protectedtag_level = protectedtag.create_access_levels[0]['access_level']
734 if protectedtag_level != 0 and (access_level == 0
735 or protectedtag_level < access_level):
736 name = protectedtag.name
737 if not self.__dry_run:
738 protectedtag.delete()
739 project.protectedtags.create({
740 'name': name,
741 'create_access_level': access_level
742 })
743 result += [name]
745 # Protect unprotected tags
746 for tag in project.tags.list(get_all=True):
747 if tag.name not in protectedtags:
748 if not self.__dry_run:
749 project.protectedtags.create({
750 'name': tag.name,
751 'create_access_level': access_level
752 })
753 result += [tag.name]
755 # Save project
756 if not self.__dry_run:
757 project.save()
759 # Result
760 result.sort()
761 return result
763 # Project prune unreachable objects
764 def project_prune_unreachable_objects(
765 self,
766 criteria: str,
767 ) -> None:
769 # Prune project unreachable objects
770 project = self.project(criteria)
771 if not self.__dry_run:
772 project.housekeeping(post_data={'task': 'prune'})
774 # Project reset members
775 def project_reset_members(
776 self,
777 criteria: str,
778 ) -> None:
780 # Remove project members
781 project = self.project(criteria)
782 if not self.__dry_run:
783 for member in project.members.list(get_all=True):
784 try:
785 project.members.delete(member.id)
786 except GitlabDeleteError:
787 pass
789 # Save project
790 project.save()
792 # Project run housekeeping
793 def project_run_housekeeping(
794 self,
795 criteria: str,
796 ) -> None:
798 # Run project housekeeping
799 project = self.project(criteria)
800 if not self.__dry_run:
801 project.housekeeping()
803 # Project set archive
804 def project_set_archive(
805 self,
806 criteria: str,
807 enabled: bool,
808 ) -> bool:
810 # Variables
811 changed: bool = False
813 # Archive project
814 project = self.project(criteria)
815 if not self.__dry_run and enabled:
816 if not project.archived:
817 project.archive()
818 changed = True
820 # Unarchive project
821 elif not self.__dry_run:
822 if project.archived:
823 project.unarchive()
824 changed = True
826 # Result
827 return changed
829 # Project set attribute
830 def project_set_attribute(
831 self,
832 criteria: str,
833 name: str,
834 value: bool | str,
835 ) -> Optional[bool]:
837 # Variables
838 changed: bool = False
840 # Check project attribute
841 project = self.project(criteria)
842 if not self.__dry_run and not hasattr(project, name):
843 return None
845 # Set project attribute
846 if not self.__dry_run and getattr(project, name) != value:
847 setattr(project, name, value)
848 changed = True
850 # Save project
851 project.save()
853 # Result
854 return changed
856 # Project set avatar
857 def project_set_avatar(
858 self,
859 criteria: str,
860 file: str,
861 ) -> None:
863 # Set project avatar
864 project = self.project(criteria)
865 if not self.__dry_run:
866 with open(file, 'rb') as avatar:
867 project.avatar = avatar
869 # Save project
870 project.save()
872 # Project set description
873 def project_set_description(
874 self,
875 criteria: str,
876 description: str,
877 ) -> bool:
879 # Variables
880 changed: bool = False
882 # Set project description
883 project = self.project(criteria)
884 if not self.__dry_run and project.description != description:
885 project.description = description
886 changed = True
888 # Save project
889 project.save()
891 # Result
892 return changed
894 # Project set issues_board, pylint: disable=too-many-arguments,too-many-positional-arguments
895 def project_set_issues_board(
896 self,
897 criteria: str,
898 name: str,
899 hide_backlog_list: bool,
900 hide_closed_list: bool,
901 lists: List[Dict[str, str]],
902 ) -> ProjectBoard:
904 # Create project issues board
905 project = self.project(criteria)
906 issues_board: ProjectBoard
907 try:
908 issues_board = next(board for board in project.boards.list(get_all=True)
909 if board.name == name and isinstance(board, ProjectBoard))
910 except (GitlabGetError, StopIteration):
911 project.boards.create({
912 'name': name,
913 })
914 issues_board = next(board for board in project.boards.list(get_all=True)
915 if board.name == name and isinstance(board, ProjectBoard))
917 # Update project issues board
918 issues_board.hide_backlog_list = hide_backlog_list
919 issues_board.hide_closed_list = hide_closed_list
920 issues_board.save()
922 # Iterate through issue board lists
923 for list_json in lists:
925 # Create project issues board list
926 board_list: ProjectBoardList
927 try:
928 board_list = next(board_list
929 for board_list in issues_board.lists.list(get_all=True)
930 if board_list.label['name'] == list_json['label']
931 and isinstance(board_list, ProjectBoardList))
932 except (GitlabGetError, IndexError, StopIteration):
933 label = project.labels.get(list_json['label'],
934 include_ancestor_groups=True)
935 issues_board.lists.create({
936 'label_id': label.id,
937 })
938 board_list = next(board_list
939 for board_list in issues_board.lists.list(get_all=True)
940 if board_list.label['name'] == list_json['label']
941 and isinstance(board_list, ProjectBoardList))
943 # Update project issues board list
944 if board_list.position != list_json['position']:
945 board_list.position = list_json['position']
946 board_list.save()
948 # Prune project issues board lists
949 for board_list in [
950 board_list for board_list in issues_board.lists.list(get_all=True)
951 if isinstance(board_list, ProjectBoardList)
952 and board_list.label['name'] not in [item['label'] for item in lists]
953 ]:
954 board_list.delete()
956 # Result
957 return issues_board
959 # Project set label, pylint: disable=too-many-arguments,too-many-positional-arguments
960 def project_set_label(
961 self,
962 criteria: str,
963 name: str,
964 description: str,
965 text_color: str,
966 color: str,
967 priority: int | None,
968 ) -> ProjectLabel:
970 # Create project label
971 project = self.project(criteria)
972 label: ProjectLabel
973 try:
974 label = project.labels.get(name, include_ancestor_groups=False)
975 except GitlabGetError:
976 project.labels.create({
977 'name': name,
978 'description': description,
979 'text_color': text_color,
980 'color': color,
981 'priority': priority,
982 })
983 label = project.labels.get(name, include_ancestor_groups=False)
985 # Update project label
986 label.description = description
987 label.text_color = text_color
988 label.color = color
989 label.priority = priority
990 label.save()
992 # Result
993 return label
995 # URL
996 @property
997 def url(self) -> str:
998 return str(self.__gitlab.api_url)
1000 # User
1001 def user(
1002 self,
1003 criteria: str,
1004 ) -> User:
1005 users = self.__gitlab.users.list(all=True, iterator=True, username=criteria)
1006 for user in users:
1007 return user
1008 raise RuntimeError(f'Could not find user {criteria}')
1010 # User name
1011 @property
1012 def username(self) -> str:
1013 if self.__gitlab.user:
1014 return str(self.__gitlab.user.username)
1015 return '/'