Coverage for src/cli/entrypoint.py: 6.04%

563 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-21 21:33 +0000

1#!/usr/bin/env python3 

2 

3# Standard libraries 

4from argparse import Namespace 

5from enum import Enum 

6import re 

7from tempfile import NamedTemporaryFile 

8from typing import List, NamedTuple, Optional 

9import urllib.parse 

10 

11# Modules libraries 

12from gitlab.config import ConfigError, GitlabConfigParser 

13from gitlab.exceptions import ( 

14 GitlabGetError, 

15 GitlabListError, 

16) 

17from gitlab.v4.objects import ( 

18 Group as GitLabGroup, 

19 GroupProject as GitLabGroupProject, 

20 Namespace as GitLabNamespace, 

21 Project as GitLabProject, 

22 ProjectImport as GitLabProjectImport, 

23 User as GitLabUser, 

24 UserProject as GitLabUserProject, 

25) 

26import questionary 

27 

28# Components 

29from ..features.gitlab import GitLabFeature 

30from ..package.bundle import Bundle 

31from ..prints.colors import Colors 

32from ..prints.themes import Themes 

33from ..system.platform import Platform 

34from ..types.environments import Environments 

35from ..types.humanize import Humanize 

36from ..types.gitlab import MigrationEntities 

37from ..types.namespaces import Namespaces 

38from ..types.paths import Paths 

39 

40# Entrypoint class, pylint: disable=too-many-lines,too-few-public-methods,too-many-nested-blocks,too-many-statements 

41class Entrypoint: 

42 

43 # Constants 

44 EXPORTS_PREFIX: str = f'{Bundle.NAME}-exports-' 

45 PACKAGES_PREFIX: str = f'{Bundle.NAME}-packages-' 

46 

47 # Enumerations 

48 Result = Enum('Result', [ 

49 'SUCCESS', 

50 'FINALIZE', 

51 'ERROR', 

52 'CRITICAL', 

53 ]) 

54 

55 # CLI, pylint: disable=too-many-branches,too-many-locals,too-many-return-statements 

56 @staticmethod 

57 def cli( 

58 options: Namespace, 

59 environments: Environments, 

60 ) -> Result: 

61 

62 # Variables 

63 changed: bool = False 

64 input_gitlab: GitLabFeature 

65 input_group: Optional[GitLabGroup] = None 

66 input_project: Optional[GitLabProject] = None 

67 input_user: Optional[GitLabUser] = None 

68 output_enabled: bool = bool(options.output_url_namespace) 

69 output_exists: bool = False 

70 output_gitlab: Optional[GitLabFeature] = None 

71 output_gitlab_id: str 

72 output_gitlab_namespace: str = '' 

73 output_gitlab_url: str 

74 output_namespace: Optional[GitLabNamespace] = None 

75 progress_count: int 

76 progress_index: int 

77 project: GitLabGroupProject | GitLabUserProject 

78 projects: List[GitLabGroupProject] | List[GitLabUserProject] 

79 result: Entrypoint.Result 

80 

81 # Header 

82 print(' ') 

83 

84 # Parse input URL variables 

85 input_gitlab_splits: urllib.parse.SplitResult = urllib.parse.urlsplit( 

86 options.input_url_path) 

87 input_gitlab_id: str = f'{input_gitlab_splits.netloc}' 

88 input_gitlab_url: str = f'{input_gitlab_splits.scheme}://{input_gitlab_splits.netloc}' 

89 input_gitlab_path: str = input_gitlab_splits.path.lstrip('/') 

90 

91 # Parse output URL variables 

92 if output_enabled: 

93 output_gitlab_splits: urllib.parse.SplitResult = urllib.parse.urlsplit( 

94 options.output_url_namespace) 

95 output_gitlab_id = f'{output_gitlab_splits.netloc}' 

96 output_gitlab_url = f'{output_gitlab_splits.scheme}://{output_gitlab_splits.netloc}' 

97 output_gitlab_namespace = output_gitlab_splits.path.lstrip('/') 

98 

99 # Prepare credentials 

100 input_private_token: str = environments.value('gitlab_input_token') 

101 output_private_token: str = environments.value('gitlab_output_token') 

102 job_token: str = environments.value('ci_job_token') 

103 input_ssl_verify: bool | str = True 

104 output_ssl_verify: bool | str = True 

105 

106 # Parse configuration files 

107 try: 

108 input_config: GitlabConfigParser 

109 if not input_private_token: 

110 input_config = GitlabConfigParser(input_gitlab_id, options.configs) 

111 input_private_token = str(input_config.private_token) 

112 if input_ssl_verify and (not input_config.ssl_verify 

113 or isinstance(input_config.ssl_verify, str)): 

114 input_ssl_verify = input_config.ssl_verify 

115 output_config: GitlabConfigParser 

116 if not output_private_token and output_enabled: 

117 output_config = GitlabConfigParser(output_gitlab_id, options.configs) 

118 output_private_token = str(output_config.private_token) 

119 if output_ssl_verify and (not output_config.ssl_verify 

120 or isinstance(output_config.ssl_verify, str)): 

121 output_ssl_verify = output_config.ssl_verify 

122 except ConfigError as e: 

123 print(str(e)) 

124 

125 # Input client 

126 input_gitlab = GitLabFeature( 

127 url=input_gitlab_url, 

128 private_token=input_private_token, 

129 job_token=job_token, 

130 ssl_verify=input_ssl_verify, 

131 dry_run=options.dry_run, 

132 ) 

133 print(f'{Colors.BOLD} - GitLab input: ' 

134 f'{Colors.GREEN}{input_gitlab.url}' 

135 f'{Colors.CYAN} ({input_gitlab.username})' 

136 f'{Colors.RESET}') 

137 Platform.flush() 

138 

139 # Output client 

140 if output_enabled: 

141 output_gitlab = GitLabFeature( 

142 url=output_gitlab_url, 

143 private_token=output_private_token, 

144 job_token=job_token, 

145 ssl_verify=output_ssl_verify, 

146 dry_run=options.dry_run, 

147 ) 

148 print(f'{Colors.BOLD} - GitLab output: ' 

149 f'{Colors.GREEN}{output_gitlab.url}' 

150 f'{Colors.CYAN} ({output_gitlab.username})' 

151 f'{Colors.RESET}') 

152 print(' ') 

153 Platform.flush() 

154 

155 # Input path 

156 try: 

157 input_group = input_gitlab.group(input_gitlab_path) 

158 print(f'{Colors.BOLD} - GitLab input group: ' 

159 f'{Colors.GREEN}{input_group.full_path}' 

160 f'{Colors.CYAN} # {Namespaces.text(input_group.description)}' 

161 f'{Colors.RESET}') 

162 Platform.flush() 

163 except GitlabGetError as exception: 

164 try: 

165 if '/' in input_gitlab_path: 

166 raise TypeError from exception 

167 input_user = input_gitlab.user(input_gitlab_path) 

168 input_namespace = input_gitlab.namespace(input_gitlab_path) 

169 print(f'{Colors.BOLD} - GitLab input user namespace: ' 

170 f'{Colors.GREEN}{input_namespace.full_path}' 

171 f'{Colors.CYAN} # {input_namespace.name}' 

172 f'{Colors.RESET}') 

173 print(' ') 

174 Platform.flush() 

175 except (GitlabGetError, TypeError): 

176 input_project = input_gitlab.project(input_gitlab_path) 

177 print(f'{Colors.BOLD} - GitLab input project: ' 

178 f'{Colors.GREEN}{input_project.path_with_namespace}' 

179 f'{Colors.CYAN} # {Namespaces.text(input_project.description)}' 

180 f'{Colors.RESET}') 

181 Platform.flush() 

182 

183 # Output group 

184 if output_enabled: 

185 assert output_gitlab 

186 try: 

187 output_exists = False 

188 output_group = output_gitlab.group(output_gitlab_namespace) 

189 output_namespace = output_gitlab.namespace(output_gitlab_namespace) 

190 output_exists = True 

191 print(f'{Colors.BOLD} - GitLab output group: ' 

192 f'{Colors.GREEN}{output_group.full_path}' 

193 f'{Colors.CYAN} # {Namespaces.text(output_group.description)}' 

194 f'{Colors.RESET}') 

195 print(' ') 

196 Platform.flush() 

197 

198 # Output namespace 

199 except GitlabGetError as exception: 

200 try: 

201 if '/' in output_gitlab_namespace: 

202 raise TypeError from exception 

203 _output_user = output_gitlab.user(output_gitlab_namespace) 

204 output_namespace = output_gitlab.namespace(output_gitlab_namespace) 

205 output_exists = True 

206 print(f'{Colors.BOLD} - GitLab output user namespace: ' 

207 f'{Colors.GREEN}{output_namespace.full_path}' 

208 f'{Colors.CYAN} # {output_namespace.name}' 

209 f'{Colors.RESET}') 

210 print(' ') 

211 Platform.flush() 

212 

213 # Output parent group 

214 except (GitlabGetError, TypeError): # pylint: disable=raise-missing-from 

215 

216 # Validate options 

217 if (not input_group and not input_user) or options.exclude_group: 

218 raise exception 

219 

220 # Missing output group 

221 print(f'{Colors.BOLD} - GitLab output group: ' 

222 f'{Colors.GREEN}{output_gitlab_namespace}' 

223 f'{Colors.RED} (Non-existent output group)' 

224 f'{Colors.RESET}') 

225 print(' ') 

226 Platform.flush() 

227 

228 # Validate options 

229 if options.rename_project and not input_project: 

230 raise RuntimeError( 

231 'Renaming project is only allowed with a single input project') 

232 

233 # Handle available features 

234 if options.available_entities: 

235 print(f'{Colors.BOLD} - GitLab project:' 

236 f'{Colors.RESET}') 

237 print(f'{Colors.BOLD} - Available entities: ' 

238 f'{Colors.CYAN}\'{", ".join(MigrationEntities.entities())}\'' 

239 f'{Colors.RESET}') 

240 print(f'{Colors.BOLD} - Available removers: ' 

241 f'{Colors.CYAN}\'{", ".join(MigrationEntities.removers())}\'' 

242 f'{Colors.RESET}') 

243 print(f'{Colors.BOLD} - Available templates: ' 

244 f'{Colors.CYAN}\'{", ".join(MigrationEntities.templates())}\'' 

245 f'{Colors.RESET}') 

246 Platform.flush() 

247 return Entrypoint.Result.FINALIZE 

248 

249 # Handle single project 

250 if input_project: 

251 

252 # Validate types 

253 if output_enabled: 

254 assert output_gitlab 

255 assert output_namespace 

256 

257 # Handle project 

258 Entrypoint.project( 

259 options, 

260 input_gitlab, 

261 output_gitlab, 

262 input_project.path_with_namespace, 

263 input_project.namespace['id'], 

264 output_namespace.full_path if output_namespace else '', 

265 output_gitlab_namespace, 

266 options.rename_project, 

267 1, 

268 1, 

269 ) 

270 

271 # Handle sources 

272 if options.archive_sources or options.delete_sources: 

273 print(f'{Colors.BOLD} - GitLab input project: ' 

274 f'{Colors.GREEN}{input_project.path_with_namespace}' 

275 f'{Colors.CYAN} # {Namespaces.text(input_project.description)}' 

276 f'{Colors.RESET}') 

277 Platform.flush() 

278 

279 # Archive input project 

280 if options.archive_sources: 

281 changed = input_gitlab.project_set_archive( 

282 input_project.path_with_namespace, 

283 enabled=True, 

284 ) 

285 print(f'{Colors.BOLD} - Archive sources project: ' 

286 f'{Colors.CYAN if changed else Colors.GREEN}' 

287 f'{"Success" if changed else "Already done"}' 

288 f'{Colors.RESET}') 

289 print(' ') 

290 Platform.flush() 

291 

292 # Delete input project 

293 elif options.delete_sources: 

294 

295 # Confirm project deletion 

296 if not Entrypoint.confirm( 

297 'Delete project', 

298 input_project.path_with_namespace, 

299 not options.confirm, 

300 'deletion', 

301 ): 

302 raise PermissionError() 

303 

304 # Delete input project 

305 input_gitlab.project_delete(input_project.path_with_namespace) 

306 print(f'{Colors.BOLD} - Delete sources project: ' 

307 f'{Colors.GREEN}Success' 

308 f'{Colors.RESET}') 

309 print(' ') 

310 Platform.flush() 

311 

312 # Handle group recursively 

313 elif input_group: 

314 

315 # Handle group if missing 

316 if not options.exclude_group: 

317 result = Entrypoint.group( 

318 options, 

319 input_gitlab, 

320 output_gitlab, 

321 input_group.full_path, 

322 output_gitlab_namespace, 

323 migration=not output_exists, 

324 ) 

325 if result in [ 

326 Entrypoint.Result.FINALIZE, 

327 Entrypoint.Result.ERROR, 

328 ]: 

329 return result 

330 

331 # Acquire output namespace 

332 if output_gitlab: 

333 output_namespace = output_gitlab.namespace( 

334 output_gitlab_namespace, 

335 optional=True, 

336 ) 

337 

338 # Validate types 

339 if output_enabled: 

340 assert output_gitlab 

341 assert output_namespace 

342 

343 # Iterate through subgroups 

344 if output_gitlab and not options.exclude_subgroups and not options.flatten_group \ 

345 or not output_exists: 

346 input_subgroups = sorted( 

347 input_group.descendant_groups.list( 

348 get_all=True, 

349 # include_subgroups=True, 

350 order_by='path', 

351 sort='asc', 

352 ), 

353 key=lambda item: item.full_path, 

354 ) 

355 progress_count = len(input_subgroups) 

356 progress_index = 0 

357 for input_subgroup in input_subgroups: 

358 progress_index += 1 

359 result = Entrypoint.subgroup( 

360 options, 

361 input_gitlab, 

362 output_gitlab, 

363 input_group.full_path, 

364 input_subgroup.full_path, 

365 output_namespace.full_path if output_namespace else '', 

366 migration=output_exists, 

367 progress_index=progress_index, 

368 progress_count=progress_count, 

369 ) 

370 if result in [ 

371 Entrypoint.Result.FINALIZE, 

372 Entrypoint.Result.ERROR, 

373 ]: 

374 return result 

375 

376 # Iterate through projects 

377 if not options.exclude_projects: 

378 projects = sorted( 

379 input_group.projects.list( 

380 get_all=True, 

381 with_shared=False, 

382 include_subgroups=not options.exclude_subgroups, 

383 order_by='path', 

384 sort='asc', 

385 ), 

386 key=lambda item: item.path_with_namespace, 

387 ) 

388 progress_count = len(projects) 

389 progress_index = 0 

390 for project in projects: 

391 progress_index += 1 

392 result = Entrypoint.project( 

393 options, 

394 input_gitlab, 

395 output_gitlab, 

396 project.path_with_namespace, 

397 input_group.full_path, 

398 output_namespace.full_path if output_namespace else '', 

399 output_gitlab_namespace, 

400 progress_index=progress_index, 

401 progress_count=progress_count, 

402 ) 

403 if result in [ 

404 Entrypoint.Result.FINALIZE, 

405 Entrypoint.Result.ERROR, 

406 ]: 

407 return result 

408 

409 # Archive input project 

410 if options.archive_sources: 

411 changed = input_gitlab.project_set_archive( 

412 project.path_with_namespace, 

413 enabled=True, 

414 ) 

415 print(f'{Colors.BOLD} - GitLab input project: ' 

416 f'{Colors.YELLOW_LIGHT}{project.path_with_namespace}' 

417 f'{Colors.CYAN} # {Namespaces.text(project.description)}' 

418 f'{Colors.RESET}') 

419 print(f'{Colors.BOLD} - Archive sources project: ' 

420 f'{Colors.CYAN if changed else Colors.GREEN}' 

421 f'{"Success" if changed else "Already done"}' 

422 f'{Colors.RESET}') 

423 print(' ') 

424 Platform.flush() 

425 

426 # Delete input group after validation 

427 if options.delete_sources: 

428 print(f'{Colors.BOLD} - GitLab input group: ' 

429 f'{Colors.GREEN}{input_group.full_path}' 

430 f'{Colors.CYAN} # {Namespaces.text(input_group.description)}' 

431 f'{Colors.RESET}') 

432 Platform.flush() 

433 if not Entrypoint.confirm( 

434 'Delete group', 

435 input_group.full_path, 

436 not options.confirm, 

437 'deletion', 

438 ): 

439 raise PermissionError() 

440 

441 # Delete input group 

442 input_gitlab.group_delete(input_group.full_path) 

443 print(f'{Colors.BOLD} - Delete sources group: ' 

444 f'{Colors.GREEN}Success' 

445 f'{Colors.RESET}') 

446 print(' ') 

447 Platform.flush() 

448 

449 # Handle user 

450 elif input_user: 

451 

452 # Validate types 

453 if output_enabled: 

454 assert output_gitlab 

455 assert output_namespace 

456 

457 # Iterate through projects 

458 if not options.exclude_projects: 

459 projects = sorted( 

460 input_user.projects.list( 

461 get_all=True, 

462 order_by='path', 

463 sort='asc', 

464 ), 

465 key=lambda item: item.path_with_namespace, 

466 ) 

467 progress_count = len(projects) 

468 progress_index = 0 

469 for project in projects: 

470 progress_index += 1 

471 result = Entrypoint.project( 

472 options, 

473 input_gitlab, 

474 output_gitlab, 

475 project.path_with_namespace, 

476 input_namespace.full_path, 

477 output_namespace.full_path if output_namespace else '', 

478 output_gitlab_namespace, 

479 progress_index=progress_index, 

480 progress_count=progress_count, 

481 ) 

482 if result in [ 

483 Entrypoint.Result.FINALIZE, 

484 Entrypoint.Result.ERROR, 

485 ]: 

486 return result 

487 

488 # Handle sources 

489 if options.archive_sources or options.delete_sources: 

490 print(f'{Colors.BOLD} - GitLab input project: ' 

491 f'{Colors.GREEN}{project.path_with_namespace}' 

492 f'{Colors.CYAN} # {Namespaces.text(project.description)}' 

493 f'{Colors.RESET}') 

494 Platform.flush() 

495 

496 # Archive input project 

497 if options.archive_sources: 

498 changed = input_gitlab.project_set_archive( 

499 project.path_with_namespace, 

500 enabled=True, 

501 ) 

502 print(f'{Colors.BOLD} - Archive sources project: ' 

503 f'{Colors.CYAN if changed else Colors.GREEN}' 

504 f'{"Success" if changed else "Already done"}' 

505 f'{Colors.RESET}') 

506 print(' ') 

507 Platform.flush() 

508 

509 # Delete input project 

510 elif options.delete_sources: 

511 

512 # Confirm project deletion 

513 if not Entrypoint.confirm( 

514 'Delete project', 

515 project.path_with_namespace, 

516 not options.confirm, 

517 'deletion', 

518 ): 

519 print(' ') 

520 Platform.flush() 

521 return Entrypoint.Result.SUCCESS 

522 

523 # Delete input project 

524 input_gitlab.project_delete(project.path_with_namespace) 

525 print(f'{Colors.BOLD} - Delete sources project: ' 

526 f'{Colors.GREEN}Success' 

527 f'{Colors.RESET}') 

528 print(' ') 

529 Platform.flush() 

530 

531 # Result 

532 return Entrypoint.Result.SUCCESS 

533 

534 # Confirm 

535 @staticmethod 

536 def confirm( 

537 description: str, 

538 text: str = '', 

539 interactive: bool = True, 

540 action: str = '', 

541 indent: str = ' ', 

542 ) -> bool: 

543 

544 # Header 

545 print( 

546 f'{Colors.BOLD}{indent}- {description}{": " if description else ""}Confirm \'' 

547 f'{Colors.RED}{text}' 

548 f'{Colors.BOLD}\' {action}:' 

549 f'{Colors.RESET}', end='') 

550 Platform.flush() 

551 

552 # Confirm without user interaction 

553 if not interactive: 

554 print(f'{Colors.RED} Confirmed by parameters' 

555 f'{Colors.RESET}') 

556 Platform.flush() 

557 return True 

558 

559 # Confirm without user input 

560 if not Platform.IS_TTY_STDIN: 

561 print(f'{Colors.RED} Confirmed without user input' 

562 f'{Colors.RESET}') 

563 Platform.flush() 

564 return True 

565 

566 # Get user configuration 

567 answer: bool = questionary.confirm( 

568 message='', 

569 default=False, 

570 qmark='', 

571 style=Themes.confirmation_style(), 

572 auto_enter=True, 

573 ).ask() 

574 

575 # Result 

576 return answer 

577 

578 # Group, pylint: disable=too-many-arguments,too-many-locals,too-many-positional-arguments 

579 @staticmethod 

580 def group( 

581 options: Namespace, 

582 input_gitlab: GitLabFeature, 

583 output_gitlab: Optional[GitLabFeature], 

584 criteria_input_group: str, 

585 criteria_output_group: str, 

586 migration: bool = True, 

587 ) -> Result: 

588 

589 # Variables 

590 changed: bool 

591 input_subgroup_allowed: str 

592 input_subgroup_parent: str 

593 output_group: Optional[GitLabGroup] = None 

594 output_namespace: Optional[GitLabNamespace] = None 

595 

596 # Acquire input group 

597 input_group = input_gitlab.group(criteria_input_group) 

598 input_group_namespace, _ = Namespaces.split_namespace( 

599 criteria_input_group, 

600 relative=False, 

601 ) 

602 

603 # Detect group or subgroup 

604 output_group_namespace, output_group_path = Namespaces.split_namespace( 

605 criteria_output_group, 

606 relative=False, 

607 ) 

608 

609 # Detect identical input and output 

610 same_namespace: bool = False 

611 if output_gitlab and input_gitlab.url == output_gitlab.url \ 

612 and input_group_namespace == output_group_namespace: 

613 same_namespace = True 

614 

615 # Prepare output group name 

616 output_group_name = input_group.name \ 

617 if input_group.name != input_group.path and not same_namespace \ 

618 else output_group_path 

619 

620 # Show group details 

621 print(f'{Colors.BOLD} - GitLab group: ' 

622 f'{Colors.YELLOW_LIGHT}{input_group.full_path} ' 

623 f'{Colors.CYAN}({Namespaces.text(input_group.description)})' 

624 f'{Colors.RESET}') 

625 Platform.flush() 

626 

627 # Validate subgroup paths 

628 for input_subgroup in input_group.descendant_groups.list( 

629 get_all=True, 

630 order_by='path', 

631 sort='asc', 

632 # include_subgroups=True, 

633 ): 

634 input_subgroup_allowed = input_subgroup.path.lstrip('-_').rstrip('._') 

635 input_subgroup_parent = input_subgroup.full_path[:-len(input_subgroup.path)] 

636 if input_subgroup.path != input_subgroup_allowed: 

637 print(f'{Colors.RED} - Incompatible GitLab subgroup path detected: ' 

638 f'{Colors.CYAN}{input_subgroup.full_path}' 

639 f'{Colors.RED} != ' 

640 f'{Colors.GREEN}{input_subgroup_parent}{input_subgroup_allowed}' 

641 f'{Colors.RESET}') 

642 print(' ') 

643 Platform.flush() 

644 return Entrypoint.Result.ERROR 

645 

646 # Migration mode 

647 if output_gitlab and not migration: 

648 

649 # Existing user 

650 try: 

651 if '/' in criteria_output_group: 

652 raise TypeError 

653 _output_user = output_gitlab.user(criteria_output_group) 

654 output_namespace = output_gitlab.namespace(criteria_output_group) 

655 print( 

656 f'{Colors.BOLD} - Already existing user namespace in GitLab output: ' 

657 f'{Colors.GREEN}{output_namespace.full_path}' 

658 f'{Colors.RESET}') 

659 print(' ') 

660 Platform.flush() 

661 return Entrypoint.Result.SUCCESS 

662 

663 # Existing group 

664 except (GitlabGetError, RuntimeError, TypeError): 

665 output_group = output_gitlab.group(criteria_output_group) 

666 print(f'{Colors.BOLD} - Already existing group in GitLab output: ' 

667 f'{Colors.GREEN}{output_group.full_path}' 

668 f'{Colors.CYAN} # {Namespaces.text(output_group.description)}' 

669 f'{Colors.RESET}') 

670 print(' ') 

671 Platform.flush() 

672 return Entrypoint.Result.SUCCESS 

673 

674 # Ignore existing names 

675 if output_gitlab: 

676 output_group_parent = output_gitlab.group(output_group_namespace) 

677 if output_group_name in [ 

678 output_group.name 

679 for output_group in output_group_parent.subgroups.list(get_all=True) 

680 ]: 

681 print(f'{Colors.RED} - Already existing group name in GitLab output: ' 

682 f'{Colors.CYAN}{output_group_name}' 

683 f'{Colors.RESET}') 

684 print(' ') 

685 Platform.flush() 

686 raise RuntimeError() 

687 

688 # Confirm group is exportable 

689 export_limitations = input_gitlab.group_export_limitations(input_group.full_path) 

690 if export_limitations: 

691 print(f'{Colors.BOLD} - Limited group export detected: ' 

692 f'{Colors.RESET}') 

693 for limitation, items in export_limitations.items(): 

694 (level, values) = items 

695 if level == GitLabFeature.LIMITATIONS_ERROR: 

696 print(f'{Colors.BOLD} - With data loss: ' 

697 f'{Colors.RED}{limitation}' 

698 + (f'{Colors.CYAN} ({", ".join(values)})' if values else '') + \ 

699 f'{Colors.RESET}') 

700 else: 

701 print(f'{Colors.BOLD} - With optional custom migration: ' 

702 f'{Colors.YELLOW_LIGHT}{limitation}' 

703 + (f'{Colors.CYAN} ({", ".join(values)})' if values else '') + \ 

704 f'{Colors.RESET}') 

705 if not options.dry_run and not Entrypoint.confirm( 

706 '', 

707 input_group.full_path, 

708 not options.confirm, 

709 'limited group export', 

710 indent=' ', 

711 ): 

712 raise PermissionError() 

713 

714 # Export group 

715 if output_gitlab or options.archive_exports: 

716 print(f'{Colors.BOLD} - Exporting from: ' 

717 f'{Colors.GREEN}{input_group.full_path}' 

718 f'{Colors.RESET}') 

719 Platform.flush() 

720 with NamedTemporaryFile( 

721 prefix=Paths.slugify(f'{Entrypoint.EXPORTS_PREFIX}' 

722 f'-{input_group.full_path}-'), 

723 suffix='.tar.gz', 

724 dir=options.archive_exports_dir, 

725 delete=not options.archive_exports_dir, 

726 ) as file_export: 

727 input_gitlab.group_export( 

728 file_export.name, 

729 input_group.full_path, 

730 options.reset_entities, 

731 ) 

732 

733 # Import group 

734 if output_gitlab: 

735 print(f'{Colors.BOLD} - Importing to: ' 

736 f'{Colors.GREEN}{criteria_output_group}' 

737 f'{Colors.RESET}') 

738 Platform.flush() 

739 output_gitlab.group_import( 

740 file_export.name, 

741 output_group_namespace, 

742 output_group_path, 

743 output_group_name, 

744 ) 

745 

746 # Abort group migration 

747 if not output_gitlab: 

748 print(f'{Colors.BOLD} - Exported group: ' 

749 f'{Colors.GREEN}Success' 

750 f'{Colors.RESET}') 

751 Platform.flush() 

752 

753 # Abort output group 

754 if not output_gitlab: 

755 print(' ') 

756 Platform.flush() 

757 return Entrypoint.Result.SUCCESS 

758 

759 # Acquire output group 

760 output_group_criteria: str = '' 

761 if not options.dry_run: 

762 output_group = output_gitlab.group(criteria_output_group) 

763 output_group_criteria = output_group.full_path 

764 

765 # Set group description 

766 description = Namespaces.describe( 

767 name=output_group_name, 

768 description=input_group.description, 

769 ) 

770 changed = output_gitlab.group_set_description( 

771 output_group_criteria, 

772 description, 

773 ) 

774 print(f'{Colors.BOLD} - Set description: ' 

775 f'{Colors.CYAN if changed else Colors.GREEN}{description}' 

776 f'{Colors.RESET}') 

777 Platform.flush() 

778 

779 # Set group avatar 

780 if options.set_avatar: 

781 output_gitlab.group_set_avatar( 

782 output_group_criteria, 

783 options.set_avatar, 

784 ) 

785 print(f'{Colors.BOLD} - Set avatar: ' 

786 f'{Colors.CYAN}{options.set_avatar}' 

787 f'{Colors.RESET}') 

788 Platform.flush() 

789 

790 # Show group result 

791 print(f'{Colors.BOLD} - Migrated group: ' 

792 f'{Colors.GREEN}Success' 

793 f'{Colors.RESET}') 

794 Platform.flush() 

795 

796 # Footer 

797 print(' ') 

798 Platform.flush() 

799 

800 # Result 

801 return Entrypoint.Result.SUCCESS 

802 

803 # Project, pylint: disable=too-many-arguments,too-many-branches,too-many-locals,too-many-positional-arguments 

804 @staticmethod 

805 def project( 

806 options: Namespace, 

807 input_gitlab: GitLabFeature, 

808 output_gitlab: Optional[GitLabFeature], 

809 criteria_project: str, 

810 criteria_input_namespace: str, 

811 criteria_output_namespace: str, 

812 output_gitlab_namespace: str, 

813 rename_project: str = '', 

814 progress_index: int = 1, 

815 progress_count: int = 1, 

816 ) -> Result: 

817 

818 # Variables 

819 archived: bool 

820 branch_default: str 

821 changed: bool = False 

822 input_namespace: GitLabNamespace 

823 input_project: GitLabProject 

824 output_exists: bool = False 

825 output_path: str 

826 output_project: GitLabProjectImport 

827 output_subnamespace: str 

828 output_subpath: str 

829 project: GitLabProject 

830 

831 # Acquire input project 

832 input_project = input_gitlab.project( 

833 criteria_project, 

834 statistics=True, 

835 ) 

836 

837 # Acquire input namespace 

838 input_namespace = input_gitlab.namespace(criteria_input_namespace) 

839 

840 # Parse input subpath 

841 input_subpath = Namespaces.subpath( 

842 input_namespace.full_path, 

843 input_project.path_with_namespace, 

844 ) 

845 

846 # Acquire output namespace 

847 if output_gitlab: 

848 output_namespace = output_gitlab.namespace( 

849 criteria_output_namespace, 

850 optional=True, 

851 ) 

852 

853 # Prepare project path 

854 output_project_path: str = rename_project if rename_project else input_project.path 

855 output_project_path = re.sub(r'\.+', '.', output_project_path) 

856 output_project_path = re.sub(r'-+', '-', output_project_path) 

857 output_project_path = re.sub(r'_+', '_', output_project_path) 

858 

859 # Normalize project path 

860 output_project_path = output_project_path.lstrip('-_. ') 

861 output_project_path = output_project_path.rstrip('-_. ') 

862 output_project_path = re.sub(r'\.git$', '', output_project_path) 

863 output_project_path = re.sub(r'\.atom$', '', output_project_path) 

864 if options.normalize_names: 

865 output_project_path = output_project_path.replace(' ', '-') 

866 output_project_path = output_project_path.replace('_', '-') 

867 output_project_path = output_project_path.lower() 

868 

869 # Parse output path 

870 if output_gitlab: 

871 output_subnamespace, output_path = Namespaces.split_namespace( 

872 input_subpath, 

873 relative=True, 

874 ) 

875 if output_path: 

876 output_path = re.sub( 

877 f'{input_project.path}$', 

878 output_project_path, 

879 output_path, 

880 ) 

881 

882 # Flatten output group 

883 if output_gitlab and options.flatten_group: 

884 output_subnamespace = '' 

885 

886 # Parse output subpath 

887 if output_gitlab: 

888 output_subpath = Namespaces.subpath( 

889 output_namespace.full_path, 

890 f'{output_gitlab_namespace}{output_subnamespace}/{output_path}', 

891 ) 

892 

893 # Show project details 

894 print(f'{Colors.BOLD} - GitLab input project (' 

895 f'{Colors.GREEN}{progress_index}' 

896 f'{Colors.RESET}/' 

897 f'{Colors.CYAN}{progress_count}' 

898 f'{Colors.BOLD}) : ' 

899 f'{Colors.YELLOW_LIGHT}{input_project.path_with_namespace} ' 

900 f'{Colors.CYAN}({Namespaces.text(input_project.description)})' 

901 f'{Colors.RESET}') 

902 Platform.flush() 

903 

904 # Ignore existing projects 

905 if output_gitlab and output_subpath in [ 

906 Namespaces.subpath( 

907 output_namespace.full_path, 

908 output_project.path_with_namespace, 

909 ) for output_project in output_gitlab.namespace_projects( 

910 criteria_output_namespace, 

911 include_subgroups=True, 

912 ) 

913 ]: 

914 output_exists = True 

915 project = output_gitlab.project( 

916 f'{output_namespace.full_path}/{output_subpath}') 

917 print(f'{Colors.BOLD} - Already existing project in GitLab output: ' 

918 f'{Colors.GREEN}{project.path_with_namespace}' 

919 f'{Colors.CYAN} # {Namespaces.text(project.description)}' 

920 f'{Colors.RESET}') 

921 Platform.flush() 

922 

923 # Ignore existing names 

924 if not output_exists and output_gitlab and output_project_path in [ 

925 output_project.name 

926 for output_project in output_gitlab.namespace_projects( 

927 f'{output_gitlab_namespace}{output_subnamespace}', 

928 include_subgroups=False, 

929 ) 

930 ]: 

931 print(f'{Colors.RED} - Already existing project name in GitLab output: ' 

932 f'{Colors.CYAN}{output_project_path}' 

933 f'{Colors.RESET}') 

934 print(' ') 

935 Platform.flush() 

936 raise RuntimeError() 

937 

938 # Migrate missing projects 

939 if not output_exists or options.overwrite: 

940 

941 # Detect project export limitions 

942 export_limitations = input_gitlab.project_export_limitations( 

943 input_project.path_with_namespace) 

944 

945 # Accept project packages migration 

946 if output_gitlab and options.migrate_packages and \ 

947 'Packages registry' in export_limitations: 

948 del export_limitations['Packages registry'] 

949 

950 # Confirm project is exportable 

951 if export_limitations: 

952 print(f'{Colors.BOLD} - Limited project export detected: ' 

953 f'{Colors.RESET}') 

954 for limitation, items in export_limitations.items(): 

955 (level, values) = items 

956 if level == GitLabFeature.LIMITATIONS_ERROR: 

957 print(f'{Colors.BOLD} - With data loss: ' 

958 f'{Colors.RED}{limitation}' 

959 + (f'{Colors.CYAN} ({", ".join(values)})' if values else '') + \ 

960 f'{Colors.RESET}') 

961 else: 

962 print(f'{Colors.BOLD} - With optional custom migration: ' 

963 f'{Colors.YELLOW_LIGHT}{limitation}' 

964 + (f'{Colors.CYAN} ({", ".join(values)})' if values else '') + \ 

965 f'{Colors.RESET}') 

966 if not options.dry_run and not Entrypoint.confirm( 

967 '', 

968 input_project.path_with_namespace, 

969 not options.confirm, 

970 'limited project export', 

971 indent=' ', 

972 ): 

973 raise PermissionError() 

974 

975 # Acquire project settings 

976 archived = input_project.archived 

977 branch_default = input_gitlab.project_get_branch_default( 

978 input_project.path_with_namespace) 

979 

980 # Export project 

981 if output_gitlab or options.archive_exports: 

982 print(f'{Colors.BOLD} - Exporting from: ' 

983 f'{Colors.GREEN}{input_namespace.full_path}' 

984 f'{Colors.CYAN} / {input_subpath}' 

985 f'{Colors.RESET}') 

986 Platform.flush() 

987 

988 # Show project sizes 

989 print( 

990 f'{Colors.BOLD} - Project sizes: ' 

991 f'{Colors.CYAN}{Humanize.size(input_project.statistics["storage_size"])} ' 

992 f'{Colors.YELLOW_LIGHT}(storage), ' 

993 f'{Colors.CYAN}{Humanize.size(input_project.statistics["repository_size"])} ' 

994 f'{Colors.YELLOW_LIGHT}(repository)' 

995 f'{Colors.RESET}') 

996 Platform.flush() 

997 

998 # Archive project during export 

999 changed = input_gitlab.project_set_archive( 

1000 input_project.path_with_namespace, 

1001 True, 

1002 ) 

1003 print(f'{Colors.BOLD} - Archiving during export: ' 

1004 f'{Colors.CYAN if changed else Colors.GREEN}Success' 

1005 f'{Colors.RESET}') 

1006 Platform.flush() 

1007 

1008 # Export project to file 

1009 with NamedTemporaryFile( 

1010 prefix=Paths.slugify(f'{Entrypoint.EXPORTS_PREFIX}' 

1011 f'-{input_project.path_with_namespace}-'), 

1012 suffix='.tar.gz', 

1013 dir=options.archive_exports_dir, 

1014 delete=not options.archive_exports_dir, 

1015 ) as file_export: 

1016 input_gitlab.project_export( 

1017 file_export.name, 

1018 input_project.path_with_namespace, 

1019 options.reset_entities, 

1020 ) 

1021 

1022 # Restore project archive 

1023 if not archived: 

1024 changed = input_gitlab.project_set_archive( 

1025 input_project.path_with_namespace, 

1026 False, 

1027 ) 

1028 print(f'{Colors.BOLD} - Unarchived after export: ' 

1029 f'{Colors.CYAN if changed else Colors.GREEN}Success' 

1030 f'{Colors.RESET}') 

1031 Platform.flush() 

1032 

1033 # Existing project removal 

1034 if output_gitlab and options.overwrite: 

1035 if not Entrypoint.confirm( 

1036 'Delete project', 

1037 f'{output_namespace.full_path}/{output_subpath}', 

1038 not options.confirm, 

1039 'deletion', 

1040 ): 

1041 raise PermissionError() 

1042 output_gitlab.project_delete( 

1043 f'{output_namespace.full_path}/{output_subpath}') 

1044 

1045 # Import project 

1046 if output_gitlab: 

1047 print(f'{Colors.BOLD} - Importing to: ' 

1048 f'{Colors.GREEN}{output_gitlab_namespace}' 

1049 f'{Colors.CYAN} / {output_subpath}' 

1050 f'{Colors.RESET}') 

1051 Platform.flush() 

1052 output_project = output_gitlab.project_import( 

1053 file_export.name, 

1054 f'{output_gitlab_namespace}{output_subnamespace}', 

1055 output_path, 

1056 output_project_path, 

1057 options.overwrite, 

1058 sudo=output_namespace.full_path 

1059 if output_namespace.kind == 'user' 

1060 and output_namespace != output_gitlab.username else None, 

1061 ) 

1062 

1063 # Successful project migration 

1064 if not output_gitlab: 

1065 print(f'{Colors.BOLD} - Exported project: ' 

1066 f'{Colors.GREEN}Success' 

1067 f'{Colors.RESET}') 

1068 Platform.flush() 

1069 

1070 # Abort output project 

1071 if not output_gitlab: 

1072 print(' ') 

1073 Platform.flush() 

1074 return Entrypoint.Result.SUCCESS 

1075 

1076 # Acquire subgroup description 

1077 output_subgroup_description: str 

1078 if options.dry_run: 

1079 output_subgroup_description = input_project.description 

1080 elif output_namespace.kind == 'user': 

1081 output_subgroup_description = output_namespace.name 

1082 else: 

1083 output_subgroup = output_gitlab.group( 

1084 f'{output_gitlab_namespace}{output_subnamespace}') 

1085 output_subgroup_description = output_subgroup.description 

1086 

1087 # Update project description 

1088 if options.update_description: 

1089 group_description = Namespaces.describe( 

1090 name=output_project_path, 

1091 description=output_subgroup_description, 

1092 ) 

1093 if not output_project.description or \ 

1094 not output_project.description.endswith(f' - {group_description}'): 

1095 description = f'{Namespaces.describe(name=output_project.name)}' \ 

1096 f' - {group_description}' 

1097 changed = output_gitlab.project_set_description( 

1098 output_project.path_with_namespace, 

1099 description, 

1100 ) 

1101 print(f'{Colors.BOLD} - Updated description: ' 

1102 f'{Colors.CYAN if changed else Colors.GREEN}{description}' 

1103 f'{Colors.RESET}') 

1104 Platform.flush() 

1105 else: 

1106 print(f'{Colors.BOLD} - Kept description: ' 

1107 f'{Colors.GREEN}{Namespaces.text(output_project.description)}' 

1108 f'{Colors.RESET}') 

1109 Platform.flush() 

1110 

1111 # Reset project members 

1112 if MigrationEntities.MEMBERS.name in options.reset_entities: 

1113 output_gitlab.project_reset_members(output_project.path_with_namespace) 

1114 print(f'{Colors.BOLD} - Reset members: ' 

1115 f'{Colors.GREEN}Success' 

1116 f'{Colors.RESET}') 

1117 Platform.flush() 

1118 

1119 # Set project avatar 

1120 if options.set_avatar: 

1121 output_gitlab.project_set_avatar( 

1122 output_project.path_with_namespace, 

1123 options.set_avatar, 

1124 ) 

1125 print(f'{Colors.BOLD} - Set avatar: ' 

1126 f'{Colors.CYAN}{options.set_avatar}' 

1127 f'{Colors.RESET}') 

1128 Platform.flush() 

1129 

1130 # Configure project repository 

1131 if branch_default: 

1132 changed = output_gitlab.project_set_branch_default( 

1133 output_project.path_with_namespace, 

1134 branch_default, 

1135 ) 

1136 print(f'{Colors.BOLD} - Set default branch: ' 

1137 f'{Colors.CYAN if changed else Colors.GREEN}{branch_default}' 

1138 f'{Colors.RESET}') 

1139 Platform.flush() 

1140 

1141 # Configure project archive 

1142 changed = output_gitlab.project_set_archive( 

1143 output_project.path_with_namespace, 

1144 archived, 

1145 ) 

1146 print(f'{Colors.BOLD} - ' 

1147 f'{"Archived" if archived else "Unarchived"}' 

1148 ' migrated project: ' 

1149 f'{Colors.CYAN if changed else Colors.GREEN}Success' 

1150 f'{Colors.RESET}') 

1151 Platform.flush() 

1152 

1153 # Show project result 

1154 print(f'{Colors.BOLD} - Migrated project: ' 

1155 f'{Colors.GREEN}Success' 

1156 f'{Colors.RESET}') 

1157 Platform.flush() 

1158 

1159 # Migrate project packages 

1160 if output_gitlab and options.migrate_packages and not options.dry_run: 

1161 

1162 # Prepare packages migration 

1163 project = output_gitlab.project( 

1164 f'{output_namespace.full_path}/{output_subpath}') 

1165 print(f'{Colors.BOLD} - Migrating packages from: ' 

1166 f'{Colors.GREEN}{input_namespace.full_path}' 

1167 f'{Colors.CYAN} / {input_subpath}' 

1168 f'{Colors.RESET}') 

1169 Platform.flush() 

1170 

1171 # Detect migrated packages 

1172 GitLabPackageFile = NamedTuple('GitLabPackageFile', ( 

1173 ('name', str), 

1174 ('version', str), 

1175 ('package_type', str), 

1176 ('file_name', str), 

1177 ('size', int), 

1178 ('file_md5', str), 

1179 ('file_sha1', str), 

1180 ('file_sha256', str), 

1181 )) 

1182 input_project_packages: List[GitLabPackageFile] = [] 

1183 output_project_packages: List[GitLabPackageFile] = [] 

1184 try: 

1185 for package in project.packages.list( 

1186 get_all=True, 

1187 include_versionless=True, 

1188 ): 

1189 for package_file in package.package_files.list(get_all=True): 

1190 output_project_packages += [ 

1191 GitLabPackageFile( 

1192 name=package.name, 

1193 version=package.version, 

1194 package_type=package.package_type, 

1195 file_name=package_file.file_name, 

1196 size=package_file.size, 

1197 file_md5=package_file.file_md5, 

1198 file_sha1=package_file.file_sha1, 

1199 file_sha256=package_file.file_sha256, 

1200 ) 

1201 ] 

1202 except GitlabListError: 

1203 pass 

1204 

1205 # Detect existing packages 

1206 try: 

1207 for package in input_project.packages.list( 

1208 get_all=True, 

1209 include_versionless=True, 

1210 ): 

1211 

1212 # Iterate through packages files 

1213 print(f'{Colors.BOLD} - Importing project package:' 

1214 f'{Colors.YELLOW_LIGHT} {package.name}' 

1215 f'{Colors.CYAN} / {package.version}' 

1216 f'{Colors.RESET}') 

1217 Platform.flush() 

1218 for package_file in package.package_files.list(get_all=True): 

1219 with NamedTemporaryFile( 

1220 prefix=Paths.slugify(f'{Entrypoint.PACKAGES_PREFIX}' 

1221 f'-{package.id}-{package_file.id}-'), 

1222 suffix='.tmp', 

1223 delete=True, 

1224 ) as package_tempfile: 

1225 

1226 # Detect already migrated package file 

1227 if GitLabPackageFile( 

1228 name=package.name, 

1229 version=package.version, 

1230 package_type=package.package_type, 

1231 file_name=package_file.file_name, 

1232 size=package_file.size, 

1233 file_md5=package_file.file_md5, 

1234 file_sha1=package_file.file_sha1, 

1235 file_sha256=package_file.file_sha256, 

1236 ) in output_project_packages: 

1237 print( 

1238 f'{Colors.BOLD} - Kept already migrated package file:' 

1239 f'{Colors.GREEN} {package_file.file_name}' 

1240 f'{Colors.GREY} / {package_file.size} bytes' 

1241 f'{Colors.RESET}') 

1242 Platform.flush() 

1243 

1244 # Handle input package file 

1245 else: 

1246 input_project_package = GitLabPackageFile( 

1247 name=package.name, 

1248 version=package.version, 

1249 package_type=package.package_type, 

1250 file_name=package_file.file_name, 

1251 size=0, 

1252 file_md5='', 

1253 file_sha1='', 

1254 file_sha256='', 

1255 ) 

1256 if input_project_package in input_project_packages and \ 

1257 not Entrypoint.confirm( 

1258 'Duplicated package file name detected', 

1259 package_file.file_name, 

1260 not options.confirm, 

1261 'unsafe migration', 

1262 ' ', 

1263 ): 

1264 continue 

1265 input_project_packages += [input_project_package] 

1266 

1267 # Upload generic package file 

1268 if package.package_type == 'generic': 

1269 package_tempfile.write( 

1270 input_project.generic_packages.download( 

1271 package_name=package.name, 

1272 package_version=package.version, 

1273 file_name=package_file.file_name, 

1274 )) 

1275 package_tempfile.flush() 

1276 project.generic_packages.upload( 

1277 package_name=package.name, 

1278 package_version=package.version, 

1279 file_name=package_file.file_name, 

1280 path=package_tempfile.name, 

1281 ) 

1282 print(f'{Colors.BOLD} - Uploaded package file:' 

1283 f'{Colors.GREEN} {package_file.file_name}' 

1284 f'{Colors.GREY} / {package_file.size} bytes' 

1285 f'{Colors.RESET}') 

1286 Platform.flush() 

1287 

1288 # Unsupported package file 

1289 else: 

1290 raise RuntimeError( 

1291 f'Unknown GitLab Packages type "{package.package_type}"' 

1292 f' for package {package.name}') 

1293 

1294 # Successful packages migration 

1295 print(f'{Colors.BOLD} - Migrated project packages: ' 

1296 f'{Colors.GREEN}Success' 

1297 f'{Colors.RESET}') 

1298 Platform.flush() 

1299 

1300 # Ignore missing packages 

1301 except GitlabListError: 

1302 print(f'{Colors.BOLD} - Ignored project packages: ' 

1303 f'{Colors.CYAN}No packages found' 

1304 f'{Colors.RESET}') 

1305 Platform.flush() 

1306 

1307 # Validate project migration 

1308 if output_gitlab and not options.dry_run: 

1309 print(f'{Colors.BOLD} - Validating project migration:' 

1310 f'{Colors.RESET}') 

1311 Platform.flush() 

1312 

1313 # Confirm project migration 

1314 project = output_gitlab.project( 

1315 f'{output_namespace.full_path}/{output_subpath}') 

1316 migration_differences = GitLabFeature.migration_projects_compare( 

1317 input_project, 

1318 project, 

1319 options.reset_entities, 

1320 ) 

1321 if migration_differences: 

1322 print(f'{Colors.BOLD} - ' 

1323 f'{Colors.RED}Differences detected after migration:' 

1324 f'{Colors.RESET}') 

1325 for migration_difference in migration_differences: 

1326 difference, details = migration_difference 

1327 print(f'{Colors.BOLD} - ' 

1328 f'{Colors.RED}{difference}:' 

1329 f'{Colors.CYAN} {details}' 

1330 f'{Colors.RESET}') 

1331 if not Entrypoint.confirm( 

1332 '', 

1333 project.path_with_namespace, 

1334 not options.confirm, 

1335 'incomplete project migration', 

1336 indent=' ', 

1337 ): 

1338 raise PermissionError() 

1339 

1340 # Show project validation 

1341 print(f'{Colors.BOLD} - Validated project migration: ' 

1342 f'{Colors.GREEN}Success' 

1343 f'{Colors.RESET}') 

1344 Platform.flush() 

1345 

1346 # Separator 

1347 print(' ') 

1348 Platform.flush() 

1349 

1350 # Result 

1351 return Entrypoint.Result.SUCCESS 

1352 

1353 # Subgroup, pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-statements 

1354 @staticmethod 

1355 def subgroup( 

1356 options: Namespace, 

1357 input_gitlab: GitLabFeature, 

1358 output_gitlab: Optional[GitLabFeature], 

1359 criteria_input_group: str, 

1360 criteria_input_subgroup: str, 

1361 criteria_output_group: str, 

1362 migration: bool = True, 

1363 progress_index: int = 1, 

1364 progress_count: int = 1, 

1365 ) -> Result: 

1366 

1367 # Variables 

1368 changed: bool 

1369 input_subpath: str 

1370 output_subgroup_fullpath: str 

1371 output_subgroup_namespace: str 

1372 output_subgroup_path: str 

1373 

1374 # Acquire input group 

1375 input_group = input_gitlab.group(criteria_input_group) 

1376 

1377 # Acquire input subgroup 

1378 input_subgroup = input_gitlab.group(criteria_input_subgroup) 

1379 

1380 # Acquire output group 

1381 if output_gitlab: 

1382 output_group = output_gitlab.group(criteria_output_group, optional=True) 

1383 

1384 # Show subgroup details 

1385 print(f'{Colors.BOLD} - GitLab subgroup (' 

1386 f'{Colors.GREEN}{progress_index}' 

1387 f'{Colors.RESET}/' 

1388 f'{Colors.CYAN}{progress_count}' 

1389 f'{Colors.BOLD}) : ' 

1390 f'{Colors.YELLOW_LIGHT}{input_subgroup.full_path} ' 

1391 f'{Colors.CYAN}({Namespaces.text(input_subgroup.description)})' 

1392 f'{Colors.RESET}') 

1393 Platform.flush() 

1394 

1395 # Parse subgroup paths 

1396 input_subpath = Namespaces.subpath( 

1397 input_group.full_path, 

1398 input_subgroup.full_path, 

1399 ) 

1400 output_subgroup_namespace, output_subgroup_path = Namespaces.split_namespace( 

1401 input_subpath, 

1402 relative=True, 

1403 ) 

1404 output_subgroup_fullpath = f'{output_subgroup_namespace}/{output_subgroup_path}' 

1405 

1406 # Migration mode 

1407 if migration: 

1408 

1409 # Ignore existing subgroup 

1410 if output_gitlab and f'{output_subgroup_fullpath.lstrip("/")}' in [ 

1411 Namespaces.subpath( 

1412 output_group.full_path, 

1413 output_subgroup.full_path, 

1414 ) for output_subgroup in output_group.descendant_groups.list( 

1415 get_all=True, 

1416 # include_subgroups=True, 

1417 ) 

1418 ]: 

1419 output_subgroup = output_gitlab.group( 

1420 f'{output_group.full_path}{output_subgroup_fullpath}') 

1421 print(f'{Colors.BOLD} - Already existing subgroup in GitLab output: ' 

1422 f'{Colors.GREEN}{output_subgroup.full_path}' 

1423 f'{Colors.CYAN} # {Namespaces.text(output_subgroup.description)}' 

1424 f'{Colors.RESET}') 

1425 print(' ') 

1426 Platform.flush() 

1427 return Entrypoint.Result.SUCCESS 

1428 

1429 # Confirm subgroup is exportable 

1430 export_limitations = input_gitlab.group_export_limitations( 

1431 input_subgroup.full_path) 

1432 if export_limitations: 

1433 print(f'{Colors.BOLD} - Limited subgroup export detected: ' 

1434 f'{Colors.RESET}') 

1435 for limitation, items in export_limitations.items(): 

1436 (level, values) = items 

1437 if level == GitLabFeature.LIMITATIONS_ERROR: 

1438 print(f'{Colors.BOLD} - With data loss: ' 

1439 f'{Colors.RED}{limitation}' 

1440 + (f'{Colors.CYAN} ({", ".join(values)})' if values else '') + \ 

1441 f'{Colors.RESET}') 

1442 else: 

1443 print(f'{Colors.BOLD} - With optional custom migration: ' 

1444 f'{Colors.YELLOW_LIGHT}{limitation}' 

1445 + (f'{Colors.CYAN} ({", ".join(values)})' if values else '') + \ 

1446 f'{Colors.RESET}') 

1447 if not options.dry_run and not Entrypoint.confirm( 

1448 '', 

1449 input_subgroup.full_path, 

1450 not options.confirm, 

1451 'limited subgroup export', 

1452 indent=' ', 

1453 ): 

1454 raise PermissionError() 

1455 

1456 # Export subgroup 

1457 if output_gitlab or options.archive_exports: 

1458 print(f'{Colors.BOLD} - Exporting from: ' 

1459 f'{Colors.GREEN}{input_subgroup.full_path}' 

1460 f'{Colors.RESET}') 

1461 Platform.flush() 

1462 with NamedTemporaryFile( 

1463 prefix=Paths.slugify(f'{Entrypoint.EXPORTS_PREFIX}' 

1464 f'-{input_subgroup.full_path}-'), 

1465 suffix='.tar.gz', 

1466 dir=options.archive_exports_dir, 

1467 delete=not options.archive_exports_dir, 

1468 ) as file_export: 

1469 input_gitlab.group_export( 

1470 file_export.name, 

1471 input_subgroup.full_path, 

1472 options.reset_entities, 

1473 ) 

1474 

1475 # Import subgroup 

1476 if output_gitlab: 

1477 print(f'{Colors.BOLD} - Importing to: ' 

1478 f'{Colors.GREEN}' 

1479 f'{output_group.full_path}{output_subgroup_fullpath}' 

1480 f'{Colors.RESET}') 

1481 Platform.flush() 

1482 output_gitlab.group_import( 

1483 file_export.name, 

1484 f'{output_group.full_path}{output_subgroup_namespace}', 

1485 output_subgroup_path, 

1486 input_subgroup.name, 

1487 ) 

1488 

1489 # Abort subgroup migration 

1490 if not output_gitlab: 

1491 print(f'{Colors.BOLD} - Exported subgroup: ' 

1492 f'{Colors.GREEN}Success' 

1493 f'{Colors.RESET}') 

1494 Platform.flush() 

1495 

1496 # Abort output subgroup 

1497 if not output_gitlab: 

1498 print(' ') 

1499 Platform.flush() 

1500 return Entrypoint.Result.SUCCESS 

1501 

1502 # Acquire subgroups 

1503 output_subgroup_child_description: str 

1504 output_subgroup_child_name: str 

1505 output_subgroup_parent_description: str 

1506 if not options.dry_run: 

1507 output_subgroup_parent = output_gitlab.group( 

1508 f'{output_group.full_path}{output_subgroup_namespace}') 

1509 output_subgroup_parent_description = output_subgroup_parent.description 

1510 output_subgroup_child = output_gitlab.group( 

1511 f'{output_group.full_path}{output_subgroup_fullpath}') 

1512 output_subgroup_child_description = output_subgroup_child.description 

1513 output_subgroup_child_name = output_subgroup_child.name 

1514 else: 

1515 output_subgroup_parent_description = input_subgroup.description 

1516 output_subgroup_child_description = input_subgroup.description 

1517 output_subgroup_child_name = input_subgroup.name 

1518 

1519 # Update subgroup header 

1520 print(f'{Colors.BOLD} - Updating subgroup: ' 

1521 f'{Colors.GREEN}{output_group.full_path}{output_subgroup_fullpath}' 

1522 f'{Colors.RESET}') 

1523 Platform.flush() 

1524 

1525 # Update subgroup description 

1526 if options.update_description: 

1527 parent_description = Namespaces.describe( 

1528 name=output_subgroup_child_name, 

1529 description=output_subgroup_parent_description, 

1530 ) 

1531 if not output_subgroup_child_description.endswith(f' - {parent_description}'): 

1532 description = f'{Namespaces.describe(name=output_subgroup_child_name)}' \ 

1533 f' - {parent_description}' 

1534 changed = output_gitlab.group_set_description( 

1535 f'{output_group.full_path}{output_subgroup_fullpath}', 

1536 description, 

1537 ) 

1538 print(f'{Colors.BOLD} - Updated description: ' 

1539 f'{Colors.CYAN if changed else Colors.GREEN}{description}' 

1540 f'{Colors.RESET}') 

1541 Platform.flush() 

1542 else: 

1543 print(f'{Colors.BOLD} - Kept description: ' 

1544 f'{Colors.GREEN}{output_subgroup_child_description}' 

1545 f'{Colors.RESET}') 

1546 Platform.flush() 

1547 

1548 # Set subgroup avatar 

1549 if options.set_avatar: 

1550 output_gitlab.group_set_avatar( 

1551 f'{output_group.full_path}{output_subgroup_fullpath}', 

1552 options.set_avatar, 

1553 ) 

1554 print(f'{Colors.BOLD} - Set avatar: ' 

1555 f'{Colors.CYAN}{options.set_avatar}' 

1556 f'{Colors.RESET}') 

1557 Platform.flush() 

1558 

1559 # Show subgroup result 

1560 print(f'{Colors.BOLD} - Migrated subgroup: ' 

1561 f'{Colors.GREEN}Success' 

1562 f'{Colors.RESET}') 

1563 Platform.flush() 

1564 

1565 # Footer 

1566 print(' ') 

1567 Platform.flush() 

1568 

1569 # Result 

1570 return Entrypoint.Result.SUCCESS