Coverage for src/cli/main.py: 96.38%
138 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 argparse import (
5 _ArgumentGroup,
6 _MutuallyExclusiveGroup,
7 ArgumentParser,
8 Namespace,
9 RawTextHelpFormatter,
10)
11from os import environ
12from shutil import get_terminal_size
13from sys import exit as sys_exit
15# Components
16from ..package.bundle import Bundle
17from ..package.settings import Settings
18from ..package.updates import Updates
19from ..package.version import Version
20from ..prints.colors import Colors
21from ..system.platform import Platform
22from ..types.environments import Environments
23from ..types.gitlab import (
24 MergeRequestsMethod,
25 MergeRequestsPipelines,
26 MergeRequestsResolved,
27 MergeRequestsSkipped,
28 MergeRequestsSquash,
29 ProtectionLevels,
30 RolesCreateProjects,
31 RolesCreateSubgroups,
32)
33from .entrypoint import Entrypoint
35# Constants
36HELP_POSITION: int = 39
38# Exception
39def exception(error: BaseException) -> None:
40 raise error
42# Main, pylint: disable=too-many-branches,too-many-statements
43def main() -> None:
45 # Variables
46 environments: Environments
47 group: _ArgumentGroup
48 result: Entrypoint.Result = Entrypoint.Result.ERROR
49 subgroup: _MutuallyExclusiveGroup
51 # Configure environment variables
52 environments = Environments()
53 environments.group = 'environment variables'
54 environments.add(
55 'gitlab_token',
56 Bundle.ENV_GITLAB_TOKEN,
57 'GitLab API token environment variable',
58 )
59 environments.add(
60 'ci_job_token',
61 Bundle.ENV_CI_JOB_TOKEN,
62 'GitLab CI job token environment variable (CI only)',
63 )
65 # Arguments creation
66 parser: ArgumentParser = ArgumentParser(
67 prog=Bundle.NAME,
68 description=f'{Bundle.NAME}: {Bundle.DESCRIPTION}',
69 epilog=environments.help(HELP_POSITION),
70 add_help=False,
71 formatter_class=lambda prog: RawTextHelpFormatter(
72 prog,
73 max_help_position=HELP_POSITION,
74 width=min(
75 120,
76 get_terminal_size().columns - 2,
77 ),
78 ),
79 )
81 # Arguments internal definitions
82 group = parser.add_argument_group('internal arguments')
83 group.add_argument(
84 '-h',
85 '--help',
86 dest='help',
87 action='store_true',
88 help='Show this help message',
89 )
90 group.add_argument(
91 '--version',
92 dest='version',
93 action='store_true',
94 help='Show the current version',
95 )
96 group.add_argument(
97 '--no-color',
98 dest='no_color',
99 action='store_true',
100 help=f'Disable colors outputs with \'{Bundle.ENV_NO_COLOR}=1\'\n'
101 '(or default settings: [themes] > no_color)',
102 )
103 group.add_argument(
104 '--update-check',
105 dest='update_check',
106 action='store_true',
107 help='Check for newer package updates',
108 )
109 group.add_argument(
110 '--settings',
111 dest='settings',
112 action='store_true',
113 help='Show the current settings path and contents',
114 )
115 group.add_argument(
116 '--set',
117 dest='set',
118 action='store',
119 metavar=('GROUP', 'KEY', 'VAL'),
120 nargs=3,
121 help='Set settings specific \'VAL\' value to [GROUP] > KEY\n' \
122 'or unset by using \'UNSET\' as \'VAL\'',
123 )
125 # Arguments credentials definitions
126 group = parser.add_argument_group('credentials arguments')
127 group.add_argument(
128 '-c',
129 '--config',
130 dest='configs',
131 action='append',
132 metavar='FILES',
133 help=f'Python GitLab configuration files'
134 f' (default: {Bundle.ENV_PYTHON_GITLAB_CFG} environment)',
135 )
137 # Arguments common settings definitions
138 group = parser.add_argument_group('common settings arguments')
139 group.add_argument(
140 '--confirm',
141 dest='confirm',
142 action='store_true',
143 help='Automatically confirm all removal and contents warnings',
144 )
145 group.add_argument(
146 '--dry-run',
147 dest='dry_run',
148 action='store_true',
149 help='Enable dry run mode to check without saving',
150 )
151 group.add_argument(
152 '--dump',
153 dest='dump',
154 action='store_true',
155 help='Dump Python objects of groups and projects',
156 )
157 group.add_argument(
158 '--exclude-group',
159 dest='exclude_group',
160 action='store_true',
161 help='Exclude parent group settings',
162 )
163 group.add_argument(
164 '--exclude-subgroups',
165 dest='exclude_subgroups',
166 action='store_true',
167 help='Exclude children subgroups settings',
168 )
169 group.add_argument(
170 '--exclude-projects',
171 dest='exclude_projects',
172 action='store_true',
173 help='Exclude children projects settings',
174 )
176 # Arguments general settings definitions
177 group = parser.add_argument_group('general settings arguments')
178 group.add_argument(
179 '--available-features',
180 dest='available_features',
181 action='store_true',
182 help='List the available GitLab project features known by the tool',
183 )
184 group.add_argument(
185 '--reset-features',
186 dest='reset_features',
187 action='store',
188 metavar='KEEP_FEATURES',
189 nargs='?',
190 const='',
191 help='Reset features of GitLab projects based on usage\n'
192 '(Optionally keep features separated by ",")',
193 )
194 group.add_argument(
195 '--disable-features',
196 dest='disable_features',
197 metavar='FEATURES',
198 help='List of features to disable separated by ","',
199 )
200 group.add_argument(
201 '--enable-features',
202 dest='enable_features',
203 metavar='FEATURES',
204 help='List of features to enable separated by ","',
205 )
206 group.add_argument(
207 '--reset-members',
208 dest='reset_members',
209 action='store_true',
210 help='Reset members of GitLab projects and groups',
211 )
212 group.add_argument(
213 '--set-avatar',
214 dest='set_avatar',
215 action='store',
216 metavar='FILE',
217 help='Set avatar of GitLab projects and groups',
218 )
219 group.add_argument(
220 '--set-description',
221 dest='set_description',
222 action='store',
223 metavar='TEXT',
224 help='Set description of GitLab projects and groups',
225 )
226 group.add_argument(
227 '--update-descriptions',
228 dest='update_description',
229 action='store_true',
230 help='Update description of GitLab projects and groups automatically',
231 )
233 # Arguments group settings definitions
234 group = parser.add_argument_group('group settings arguments')
235 group.add_argument(
236 '--set-roles-create-projects',
237 dest='set_roles_create_projects',
238 action='store',
239 metavar='ROLE',
240 nargs='?',
241 const=RolesCreateProjects.default(),
242 help=
243 f'Set roles allowed to create projects [{",".join(RolesCreateProjects.names())}]'
244 ' (default: %(const)s)',
245 )
246 group.add_argument(
247 '--set-roles-create-subgroups',
248 dest='set_roles_create_subgroups',
249 action='store',
250 metavar='ROLE',
251 nargs='?',
252 const=RolesCreateSubgroups.default(),
253 help=
254 f'Set roles allowed to create subgroups [{",".join(RolesCreateSubgroups.names())}]'
255 ' (default: %(const)s)',
256 )
258 # Arguments advanced settings definitions
259 group = parser.add_argument_group('advanced settings arguments')
260 subgroup = group.add_mutually_exclusive_group()
261 subgroup.add_argument(
262 '--run-housekeeping',
263 dest='run_housekeeping',
264 action='store_true',
265 help='Run housekeeping of GitLab project or projects in groups',
266 )
267 subgroup.add_argument(
268 '--prune-unreachable-objects',
269 dest='prune_unreachable_objects',
270 action='store_true',
271 help='Prune unreachable objects of GitLab project or projects in groups',
272 )
273 subgroup = group.add_mutually_exclusive_group()
274 subgroup.add_argument(
275 '--archive-projects',
276 dest='archive_project',
277 action='store_true',
278 help='Archive project or projects in GitLab groups',
279 )
280 subgroup.add_argument(
281 '--unarchive-projects',
282 dest='unarchive_project',
283 action='store_true',
284 help='Unarchive project or projects in GitLab groups',
285 )
286 group.add_argument(
287 '--delete-groups',
288 dest='delete_group',
289 action='store_true',
290 help='Delete group or groups in GitLab groups',
291 )
292 group.add_argument(
293 '--delete-projects',
294 dest='delete_project',
295 action='store_true',
296 help='Delete project or projects in GitLab groups',
297 )
299 # Arguments repository settings definitions
300 group = parser.add_argument_group('repository settings arguments')
301 group.add_argument(
302 '--protect-branches',
303 dest='protect_branches',
304 action='store_true',
305 help='Protect branches with default master/main, develop and staging',
306 )
307 group.add_argument(
308 '--protect-tags',
309 dest='protect_tags',
310 action='store',
311 metavar='LEVEL',
312 nargs='?',
313 const=ProtectionLevels.default(),
314 help=f'Protect tags at level [{",".join(ProtectionLevels.names())}]'
315 ' (default: %(const)s)',
316 )
318 # Arguments merge requests settings definitions
319 group = parser.add_argument_group('merge requests settings arguments')
320 group.add_argument(
321 '--set-merge-method',
322 dest='set_merge_method',
323 metavar='METHOD',
324 nargs='?',
325 type=lambda value: (
326 value if value in MergeRequestsMethod.VALUES # Keys
327 else
328 [key for key, _val in MergeRequestsMethod.VALUES.items() if _val == value][0]
329 if value in MergeRequestsMethod.VALUES.values() # Values
330 else exception(ValueError(value)) # Unknown
331 ),
332 const=MergeRequestsMethod.DEFAULT,
333 help='Set project merge requests method'
334 f' ({", ".join(MergeRequestsMethod.VALUES.keys())}, default: %(const)s)',
335 )
336 group.add_argument(
337 '--set-merge-squash',
338 dest='set_merge_squash',
339 metavar='SQUASH',
340 nargs='?',
341 type=lambda value: (
342 value if value in MergeRequestsSquash.VALUES # Keys
343 else
344 [key for key, _val in MergeRequestsSquash.VALUES.items() if _val == value][0]
345 if value in MergeRequestsSquash.VALUES.values() # Values
346 else exception(ValueError(value)) # Unknown
347 ),
348 const=MergeRequestsSquash.DEFAULT,
349 help='Set project merge requests squashing'
350 f' ({", ".join(MergeRequestsSquash.VALUES.keys())}, default: %(const)s)',
351 )
352 group.add_argument(
353 '--set-merge-pipelines',
354 dest='set_merge_pipelines',
355 metavar='CHECK',
356 nargs='?',
357 type=lambda x: (str(x).lower() in ('true', 't', 'yes', 'y', 'on', '1')),
358 const=MergeRequestsPipelines.DEFAULT,
359 help='Set project merge requests check for successful pipelines'
360 ' (true, false, default: %(const)s)',
361 )
362 group.add_argument(
363 '--set-merge-skipped',
364 dest='set_merge_skipped',
365 metavar='CHECK',
366 nargs='?',
367 type=lambda x: (str(x).lower() in ('true', 't', 'yes', 'y', 'on', '1')),
368 const=MergeRequestsSkipped.DEFAULT,
369 help='Set project merge requests check for skipped pipelines'
370 ' (true, false, default: %(const)s)',
371 )
372 group.add_argument(
373 '--set-merge-resolved',
374 dest='set_merge_resolved',
375 metavar='CHECK',
376 nargs='?',
377 type=lambda x: (str(x).lower() in ('true', 't', 'yes', 'y', 'on', '1')),
378 const=MergeRequestsResolved.DEFAULT,
379 help='Set project merge requests check for resolved threads'
380 ' (true, false, default: %(const)s)',
381 )
383 # Arguments CI/CD settings definitions
384 group = parser.add_argument_group('ci/cd settings arguments')
385 group.add_argument(
386 '--add-jobs-token-allowlist',
387 dest='add_jobs_token_allowlist',
388 action='store',
389 metavar='PATH',
390 help='Add a group or project to CI/CD job token allowlist',
391 )
392 group.add_argument(
393 '--remove-jobs-token-allowlist',
394 dest='remove_jobs_token_allowlist',
395 action='store',
396 metavar='PATH',
397 help='Remove a group or project from CI/CD job token allowlist',
398 )
399 subgroup = group.add_mutually_exclusive_group()
400 subgroup.add_argument(
401 '--erase-jobs-artifacts',
402 dest='erase_jobs_artifacts',
403 action='store_true',
404 help='Erase all CI/CD jobs artifacts',
405 )
406 subgroup.add_argument(
407 '--erase-jobs-contents',
408 dest='erase_jobs_contents',
409 action='store_true',
410 help='Erase all CI/CD jobs artifacts and traces',
411 )
413 # Arguments issues definitions
414 group = parser.add_argument_group('issues arguments')
415 group.add_argument(
416 '--get-project-issues-boards',
417 dest='get_project_issues_boards',
418 action='store_true',
419 help='Get the GitLab project issues boards in JSON format',
420 )
421 group.add_argument(
422 '--set-project-issues-boards',
423 dest='set_project_issues_boards',
424 metavar='JSON',
425 help='Set the GitLab project issues boards from JSON format',
426 )
428 # Arguments labels definitions
429 group = parser.add_argument_group('labels arguments')
430 subgroup = group.add_mutually_exclusive_group()
431 subgroup.add_argument(
432 '--get-group-labels',
433 dest='get_group_labels',
434 action='store_true',
435 help='Get the GitLab group labels in JSON format',
436 )
437 subgroup.add_argument(
438 '--set-group-labels',
439 dest='set_group_labels',
440 metavar='JSON',
441 help='Set the GitLab group labels from JSON format',
442 )
443 subgroup = group.add_mutually_exclusive_group()
444 subgroup.add_argument(
445 '--get-project-labels',
446 dest='get_project_labels',
447 action='store_true',
448 help='Get the GitLab project labels in JSON format',
449 )
450 subgroup.add_argument(
451 '--set-project-labels',
452 dest='set_project_labels',
453 metavar='JSON',
454 help='Set the GitLab project labels from JSON format',
455 )
457 # Arguments positional definitions
458 group = parser.add_argument_group('positional arguments')
459 group.add_argument(
460 '--',
461 dest='double_dash',
462 action='store_true',
463 help='Positional arguments separator (recommended)',
464 )
465 group.add_argument(
466 dest='url_path',
467 action='store',
468 nargs='?',
469 help='GitLab group or project path URL',
470 )
472 # Arguments parser
473 options: Namespace = parser.parse_args()
475 # Help informations
476 if options.help:
477 print(' ')
478 parser.print_help()
479 print(' ')
480 Platform.flush()
481 sys_exit(0)
483 # Instantiate settings
484 settings: Settings = Settings(name=Bundle.NAME)
486 # Prepare no_color
487 if not options.no_color:
488 if settings.has('themes', 'no_color'):
489 options.no_color = settings.get_bool('themes', 'no_color')
490 else:
491 options.no_color = False
492 settings.set_bool('themes', 'no_color', options.no_color)
494 # Configure no_color
495 if options.no_color:
496 environ[Bundle.ENV_FORCE_COLOR] = '0'
497 environ[Bundle.ENV_NO_COLOR] = '1'
499 # Prepare colors
500 Colors.prepare()
502 # Settings setter
503 if options.set:
504 settings.set(options.set[0], options.set[1], options.set[2])
505 settings.show()
506 sys_exit(0)
508 # Settings informations
509 if options.settings:
510 settings.show()
511 sys_exit(0)
513 # Instantiate updates
514 updates: Updates = Updates(
515 name=Bundle.PACKAGE,
516 settings=settings,
517 )
519 # Version informations
520 if options.version:
521 print(
522 f'{Bundle.NAME} {Version.get()} from {Version.path()} (python {Version.python()})'
523 )
524 Platform.flush()
525 sys_exit(0)
527 # Check for current updates
528 if options.update_check:
529 if not updates.check():
530 updates.check(older=True)
531 sys_exit(0)
533 # Arguments validation
534 if not options.url_path:
535 result = Entrypoint.Result.CRITICAL
537 # Header
538 print(' ')
539 Platform.flush()
541 # Tool identifier
542 if result != Entrypoint.Result.CRITICAL:
543 print(f'{Colors.BOLD} {Bundle.NAME}'
544 f'{Colors.YELLOW_LIGHT} ({Version.get()})'
545 f'{Colors.RESET}')
546 Platform.flush()
548 # CLI entrypoint
549 if result != Entrypoint.Result.CRITICAL:
550 result = Entrypoint.cli(
551 options,
552 environments,
553 )
555 # CLI helper
556 else:
557 parser.print_help()
559 # Footer
560 print(' ')
561 Platform.flush()
563 # Check for daily updates
564 if updates.enabled and updates.daily:
565 updates.check()
567 # Result
568 if result in [
569 Entrypoint.Result.SUCCESS,
570 Entrypoint.Result.FINALIZE,
571 ]:
572 sys_exit(0)
573 else:
574 sys_exit(1)
576# Entrypoint
577if __name__ == '__main__': # pragma: no cover
578 main()