From 034a81925951fc03560982fae8caab00ebbdab3e Mon Sep 17 00:00:00 2001 From: Enrico Ludwig Date: Fri, 19 Jul 2024 10:31:42 +0200 Subject: [PATCH] Improved error handling; added stats; implemented remaining overrride flags --- README.md | 11 +- gitlab2gitea/gitlab2gitea.py | 424 +++++++++++++++++++++++++++++++---- 2 files changed, 383 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index ff8e09a..be00e14 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,9 @@ We're planning to release a growing amount of open source software, that is free ### What scripts can be found here? -| Name | Description | License | Current Version | Written in | Supported Distros | File | -| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | --------------- | ---------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------- | -| Borgmatic Setup Tool | If you plan to use borg as backup solution, you should also take a look at [borgmatic](https://torsion.org/borgmatic/). It's a Python wrapper for the award winning backup tool [borgbackup](https://borgbackup.readthedocs.io/en/stable/index.html) that simplifies creating secure and reliable backups even more. You can even store your configurations in files. This script will do the setup for you to. | [MIT]([LICENSE](https://git.zion-networks.de/ZionNetworks/linux-bash-scripts/src/branch/main/LICENSE)) | v1.2.1 | Bash | Debian and derivates | [bormatic_setup.sh](https://git.zion-networks.de/ZionNetworks/linux-bash-scripts/src/branch/main/borgmatic/borgmatic_setup.sh) | -| Git Rewrite Author | **USE WITH CAUTION!!!**

This script will rewrite the entire history of the remote end and set the author email to the provided one.

**This is NOT reversible!** | [MIT]([LICENSE](https://git.zion-networks.de/ZionNetworks/linux-bash-scripts/src/branch/main/LICENSE)) | v1.0.0 | Bash | Most Linux distros | [git_rewrite_author.sh](https://git.zion-networks.de/ZionNetworks/linux-bash-scripts/src/branch/main/git/git_rewrite_author.sh) | -| UFW Beautifier | Simple Python script to get a fancy formatted `ufw.log` | [MIT]([LICENSE](https://git.zion-networks.de/ZionNetworks/linux-bash-scripts/src/branch/main/LICENSE)) | v1.0.0 | Python | Most Linux distros | [ufw_beautifier.py](https://git.zion-networks.de/ZionNetworks/linux-bash-scripts/src/branch/main/ufw/ufw_beautifier.py) | +| Name | Description | License | Current Version | Written in | Supported Distros | Path | +| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | --------------- | ---------- | -------------------- | ------------------------------------------------------------------------------------------------------------ | +| Borgmatic Setup Tool | If you plan to use borg as backup solution, you should also take a look at [borgmatic](https://torsion.org/borgmatic/). It's a Python wrapper for the award winning backup tool [borgbackup](https://borgbackup.readthedocs.io/en/stable/index.html) that simplifies creating secure and reliable backups even more. You can even store your configurations in files. This script will do the setup for you to. | [MIT]([LICENSE](https://git.zion-networks.de/ZionNetworks/linux-bash-scripts/src/branch/main/LICENSE)) | v1.2.1 | Bash | Debian and derivates | [Borgmatic Setup](https://git.zion-networks.de/ZionNetworks/linux-bash-scripts/src/branch/main/borgmatic) | +| Git Rewrite Author | **USE WITH CAUTION!!!**

This script will rewrite the entire history of the remote end and set the author email to the provided one.

**This is NOT reversible!** | [MIT]([LICENSE](https://git.zion-networks.de/ZionNetworks/linux-bash-scripts/src/branch/main/LICENSE)) | v1.0.0 | Bash | Most Linux distros | [Git Scripts](https://git.zion-networks.de/ZionNetworks/linux-bash-scripts/src/branch/main/git) | +| UFW Beautifier | Simple Python script to get a fancy formatted `ufw.log` | [MIT]([LICENSE](https://git.zion-networks.de/ZionNetworks/linux-bash-scripts/src/branch/main/LICENSE)) | v1.0.0 | Python | Most Linux distros | [UFW Beautifier](https://git.zion-networks.de/ZionNetworks/linux-bash-scripts/src/branch/main/ufw) | +| Gitlab2Gitea Migration | Python script to perform a full migration from Gitlab to Gitea | [MIT]([LICENSE](https://git.zion-networks.de/ZionNetworks/linux-bash-scripts/src/branch/main/LICENSE)) | v1.0.0-pre | Python | Most Linux distros | [Gitlab to Gitea](https://git.zion-networks.de/ZionNetworks/linux-bash-scripts/src/branch/main/gitlab2gitea) | \ No newline at end of file diff --git a/gitlab2gitea/gitlab2gitea.py b/gitlab2gitea/gitlab2gitea.py index 18015fd..63e4035 100644 --- a/gitlab2gitea/gitlab2gitea.py +++ b/gitlab2gitea/gitlab2gitea.py @@ -29,12 +29,15 @@ # --include-issues Include issues repositories (default: False) # --include-merge-requests Include merge requests repositories (default: False) # -# --override-groups Override existing groups on Gitea (default: False) - not implemented yet -# --override-users Override existing users on Gitea (default: False) - not implemented yet +# --override-groups Override existing groups on Gitea (default: False) +# --override-users Override existing users on Gitea (default: False) # --override-projects Override existing projects on Gitea (default: False) # # --skip-empty-groups Skip empty groups (default: False) - not implemented yet # --skip-empty-projects Skip empty projects (default: False) - not implemented yet +# --skip-users [user1,user2,...] Skip specific users (default: None) - not implemented yet +# --skip-groups [group1,group2,...] Skip specific groups (default: None) - not implemented yet +# --skip-projects [project1,project2,...] Skip specific projects (default: None) - not implemented yet # # --only-groups Migrate only groups (default: False) # --only-users Migrate only users (default: False) @@ -85,6 +88,12 @@ OVERRIDE_EXISTING_GROUPS = False OVERRIDE_EXISTING_USERS = False OVERRIDE_EXISTING_PROJECTS = False +SKIP_EMPTY_GROUPS = False +SKIP_EMPTY_PROJECTS = False +SKIP_USERS = [] +SKIP_GROUPS = [] +SKIP_PROJECTS = [] + ONLY_GROUPS = False ONLY_USERS = False ONLY_PROJECTS = False @@ -154,6 +163,36 @@ GITEA_RESERVED_REPONAMES = [ "wiki", ] +# Runtime variables + +# fmt: off +STATS = { + "users": { + "deleted": [], + "created": [], + "skipped": [], + "updated": [], + "errors": [] + }, + + "groups": { + "deleted": [], + "created": [], + "skipped": [], + "updated": [], + "errors": [] + }, + + "projects": { + "deleted": [], + "created": [], + "skipped": [], + "updated": [], + "errors": [] + }, +} +# fmt: on + # Imports import os @@ -585,20 +624,27 @@ def _exception(exception, custom_message=None): lineno = exc_tb.tb_lineno formatted_traceback = traceback.format_exc() - # Prepare the exception message - exception_message = (f"{custom_message}\n" if custom_message else "") + ( - f"\033[1m\033[31m[EXC]\033[0m {exception} " + # Prepare formatted and clear exception messages + formatted_exception_message = ( + (f"{custom_message}\n" if custom_message else "") + + f"\033[1m\033[31m[EXC]\033[0m {exception} " f"(file: {filename}, line: {lineno})\n" f"{formatted_traceback}\n" ) - # Print the exception message to the console - print(exception_message) + clear_exception_message = ( + (f"{custom_message}\n" if custom_message else "") + + f"[EXC] {exception} (file: {filename}, line: {lineno})\n" + + formatted_traceback + ) - # Write the exception message to the log file if defined + # Print the formatted exception message to the console + print(formatted_exception_message) + + # Write the clear exception message to the log file if defined if LOG_FILE: with open(LOG_FILE, "a") as log_file: - log_file.write(f"[EXC] {exception_message}\n") + log_file.write(clear_exception_message + "\n") # PROGRAM @@ -638,16 +684,20 @@ def gitlab2gitea_visibility(visibility: str) -> str: def check_gitlab(): _debug(f"REQUEST: GET {GITLAB_URL}/api/{GITLAB_API_VERSION}/version") - response = requests.get( - f"{GITLAB_URL}/api/{GITLAB_API_VERSION}/version", - headers={ - "Content-Type": "application/json", - "Accept": "application/json", - "Authorization": f"Bearer {GITLAB_TOKEN}", - }, - ) + try: + response = requests.get( + f"{GITLAB_URL}/api/{GITLAB_API_VERSION}/version", + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + "Authorization": f"Bearer {GITLAB_TOKEN}", + }, + ) - _trace(f"RESPONSE: {response.json()}") + _trace(f"RESPONSE: {response.json()}") + except Exception as e: + _exception(e, f"Failed to get GitLab version: {e}") + EXIT_REQUESTED = True if response.status_code != 200: response_message = ( @@ -664,16 +714,20 @@ def check_gitlab(): def check_gitea(): _debug(f"REQUEST: GET {GITEA_URL}/api/{GITEA_API_VERSION}/version") - response = requests.get( - f"{GITEA_URL}/api/{GITEA_API_VERSION}/version", - headers={ - "Content-Type": "application/json", - "Accept": "application/json", - "Authorization": f"token {GITEA_TOKEN}", - }, - ) + try: + response = requests.get( + f"{GITEA_URL}/api/{GITEA_API_VERSION}/version", + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + "Authorization": f"token {GITEA_TOKEN}", + }, + ) - _trace(f"RESPONSE: {response.json()}") + _trace(f"RESPONSE: {response.json()}") + except Exception as e: + _exception(e, f"Failed to get Gitea version: {e}") + EXIT_REQUESTED = True if response.status_code != 200: response_message = ( @@ -867,10 +921,33 @@ def migrate_gitlab_project_to_gitea(gitlab_project: dict): if "message" in response.json() else "Unknown error" ) + + STATS["projects"]["errors"].append( + { + "group": ( + gitlab_project["namespace"]["path"] + if gitlab_project["namespace"]["kind"] == "group" + else gitlab_project["owner"]["username"] + ), + "name": gitlab_project["path"], + "error": response_message if response_message else "Unknown error", + } + ) + raise Exception(f"Failed to create Gitea project: {response_message}") else: project = response.json() + STATS["projects"]["created"].append( + { + "group": ( + gitlab_project["namespace"]["path"] + if gitlab_project["namespace"]["kind"] == "group" + else gitlab_project["owner"]["username"] + ), + "name": gitlab_project["path"], + } + ) return project @@ -911,6 +988,15 @@ def migrate_gitlab_user_to_gitea(user: dict): if "message" in response.json() else "Unknown error" ) + + STATS["users"]["errors"].append( + { + "username": user["username"], + "error": response_message if response_message else "Unknown error", + "admin": user["is_admin"], + } + ) + raise Exception(f"Failed to create Gitea user: {response_message}") else: user = response.json() @@ -920,6 +1006,14 @@ def migrate_gitlab_user_to_gitea(user: dict): else: _info(f'User "{user["username"]}" created on Gitea') + STATS["users"]["created"].append( + { + "username": user["username"], + "email": user["email"], + "admin": user["is_admin"], + } + ) + return user @@ -1049,10 +1143,22 @@ def migrate_gitlab_group_to_gitea(gitlab_group: dict): if "message" in response.json() else "Unknown error" ) + + STATS["groups"]["errors"].append( + { + "name": gitlab_group["path"], + "error": response_message if response_message else "Unknown error", + } + ) + raise Exception(f"Failed to create Gitea group: {response_message}") else: group = response.json() + STATS["groups"]["created"].append( + {"name": gitlab_group["path"], "full_name": gitlab_group["full_name"]} + ) + return group @@ -1497,6 +1603,9 @@ def create_missing_groups(gitlab_groups: list, gitea_groups: list): if is_gitea_reserved_organame(name): _warn(f'Skipping group "{name}": Group name is reserved on Gitea!') + + STATS["groups"]["skipped"].append({"name": name, "reason": "Reserved"}) + continue if not exists: @@ -1505,8 +1614,22 @@ def create_missing_groups(gitlab_groups: list, gitea_groups: list): try: _info(f'Migrating Gitlab group "{name}" to Gitea...') migrate_gitlab_group_to_gitea(gitlab_group) + _info(f'Group "{name}" created on Gitea') except Exception as e: _exception(f'Failed to create Gitea group "{name}": {e}', e) + else: + if OVERRIDE_EXISTING_GROUPS: + try: + _info( + f'Group "{name}" already exists on Gitea and will be overridden...' + ) + delete_gitea_group(name) + _info(f'Group "{name}" deleted on Gitea') + _info(f'Creating missing group "{name}" on Gitea...') + migrate_gitlab_group_to_gitea(gitlab_group) + _info(f'Group "{name}" created on Gitea') + except Exception as e: + _exception(f'Failed to override Gitea group "{name}": {e}', e) def create_missing_users(gitlab_users: list, gitea_users: list): @@ -1530,14 +1653,27 @@ def create_missing_users(gitlab_users: list, gitea_users: list): _warn( f'User "{name}" does not have an email address and will not be created!' ) + + STATS["users"]["skipped"].append( + {"username": name, "reason": "No email address"} + ) + continue if gitea_user["username"] == None or gitea_user["username"] == "": _warn(f'User "{name}" does not have a username and will not be created!') + + STATS["users"]["skipped"].append( + {"username": name, "reason": "No username"} + ) + continue if is_gitea_reserved_username(name): _warn(f'Skipping user "{name}": Username is reserved on Gitea!') + + STATS["users"]["skipped"].append({"username": name, "reason": "Reserved"}) + continue if not exists: @@ -1546,8 +1682,22 @@ def create_missing_users(gitlab_users: list, gitea_users: list): try: _info(f'Migrating Gitlab user "{name}" to Gitea...') migrate_gitlab_user_to_gitea(gitlab_user) + _info(f'User "{name}" created on Gitea') except Exception as e: _exception(f'Failed to create Gitea user "{name}": {e}', e) + else: + if OVERRIDE_EXISTING_USERS: + try: + _info( + f'User "{name}" already exists on Gitea and will be overridden...' + ) + delete_gitea_user(name) + _info(f'User "{name}" deleted on Gitea') + _info(f'Creating missing user "{name}" on Gitea...') + migrate_gitlab_user_to_gitea(gitlab_user) + _info(f'User "{name}" created on Gitea') + except Exception as e: + _exception(f'Failed to override Gitea user "{name}": {e}', e) def create_missing_projects(gitlab_projects: list, gitea_projects: list): @@ -1572,32 +1722,69 @@ def create_missing_projects(gitlab_projects: list, gitea_projects: list): break if is_gitea_reserved_reponame(name): - _warn(f'Skipping project "{name}": Project name is reserved on Gitea!') + _warn( + f'Skipping project "{group}/{name}": Project name is reserved on Gitea!' + ) + + STATS["projects"]["skipped"].append( + {"group": group, "name": name, "reason": "Reserved"} + ) + continue if is_gitlab_project_in_subgroup(gitlab_project): _warn( - f'Skipping project "{name}": Project is in a subgroup and not supported by Gitea!' + f'Skipping project "{group}/{name}": Project is in a subgroup and not supported by Gitea!' ) + + STATS["projects"]["skipped"].append( + {"group": group, "name": name, "reason": "Unsupported Subgroup"} + ) + continue if not exists: - _info(f'Creating missing project "{name}" on Gitea...') + _info(f'Creating missing project "{group}/{name}" on Gitea...') try: - _info(f'Migrating Gitlab project "{name}" to Gitea...') + _info(f'Migrating Gitlab project "{group}/{name}" to Gitea...') migrate_gitlab_project_to_gitea(gitlab_project) + _info(f'Project "{group}/{name}" created on Gitea') if ONLY_ONE_PROJECT: _warn("DEBUG MODE ENABLED - BREAKING AFTER FIRST PROJECT") break except Exception as e: - _exception(f'Failed to create Gitea project "{name}": {e}', e) + _exception(f'Failed to create Gitea project "{group}/{name}": {e}', e) if ONLY_ONE_PROJECT: _warn("DEBUG MODE ENABLED - BREAKING AFTER FIRST PROJECT") break + else: + if OVERRIDE_EXISTING_PROJECTS: + try: + _info( + f'Project "{group}/{name}" already exists on Gitea and will be overridden...' + ) + delete_gitea_project(gitea_project) + _info(f'Project "{group}/{name}" deleted on Gitea') + _info(f'Creating missing project "{group}/{name}" on Gitea...') + migrate_gitlab_project_to_gitea(gitlab_project) + _info(f'Project "{group}/{name}" created on Gitea') + + if ONLY_ONE_PROJECT: + _warn("DEBUG MODE ENABLED - BREAKING AFTER FIRST PROJECT") + break + + except Exception as e: + _exception( + f'Failed to override Gitea project "{group}/{name}": {e}', e + ) + + if ONLY_ONE_PROJECT: + _warn("DEBUG MODE ENABLED - BREAKING AFTER FIRST PROJECT") + break def update_existing_groups(gitlab_groups: list, gitea_groups: list): @@ -1634,6 +1821,7 @@ def update_existing_groups(gitlab_groups: list, gitea_groups: list): "full_name": gitlab_group["full_name"], }, ) + _info(f'Group "{name}" updated on Gitea') except Exception as e: _exception(f'Failed to update Gitea group "{name}": {e}', e) @@ -1673,6 +1861,7 @@ def update_existing_users(gitlab_users: list, gitea_users: list): try: _info(f'Updating existing user "{name}" on Gitea...') update_gitea_user(gitlab_user) + _info(f'User "{name}" updated on Gitea') except Exception as e: _exception(f'Failed to update Gitea user "{name}": {e}', e) else: @@ -1702,36 +1891,109 @@ def update_existing_projects(gitlab_projects: list, gitea_projects: list): break if is_gitea_reserved_reponame(name): - _warn(f'Skipping project "{name}": Project name is reserved on Gitea!') + _warn( + f'Skipping project "{group}/{name}": Project name is reserved on Gitea!' + ) continue if is_gitlab_project_in_subgroup(gitlab_project): _warn( - f'Skipping project "{name}": Project is in a subgroup and not supported by Gitea!' + f'Skipping project "{group}/{name}": Project is in a subgroup and not supported by Gitea!' ) continue if exists: try: - _info(f'Updating existing project "{name}" on Gitea...') - if OVERRIDE_EXISTING_PROJECTS: - delete_gitea_project(gitea_project) - migrate_gitlab_project_to_gitea(gitlab_project) - else: - update_gitea_project(gitlab_project) + _info(f'Updating existing project "{group}/{name}" on Gitea...') + update_gitea_project(gitlab_project) + _info(f'Project "{group}/{name}" updated on Gitea') if ONLY_ONE_PROJECT: _warn("DEBUG MODE ENABLED - BREAKING AFTER FIRST PROJECT") break except Exception as e: - _exception(f'Failed to update Gitea project "{name}": {e}', e) + _exception(f'Failed to update Gitea project "{group}/{name}": {e}', e) if ONLY_ONE_PROJECT: _warn("DEBUG MODE ENABLED - BREAKING AFTER FIRST PROJECT") break else: - _warn(f'Project "{name}" does not exist on Gitea!') + _warn(f'Project "{group}/{name}" does not exist on Gitea!') + + +# ENDPOINT: DELETE /api/{GITEA_API_VERSION}/orgs/{org} +def delete_gitea_group(name: str): + + _debug(f"REQUEST: DELETE {GITEA_URL}/api/{GITEA_API_VERSION}/orgs/{name}") + + response = requests.delete( + f"{GITEA_URL}/api/{GITEA_API_VERSION}/orgs/{name}", + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + "Authorization": f"token {GITEA_TOKEN}", + }, + ) + + if response.status_code != 204: + _trace(f"RESPONSE: {response.json()}") + response_message = ( + response.json()["message"] + if "message" in response.json() + else "Unknown error" + ) + + STATS["groups"]["errors"].append( + { + "name": name, + "error": response_message if response_message else "Unknown error", + } + ) + + raise Exception(f"Failed to delete Gitea group: {response_message}") + else: + _info(f'Group "{name}" deleted on Gitea') + + STATS["groups"]["deleted"].append({"name": name}) + + +# ENDPOINT: DELETE /api/{GITEA_API_VERSION}/admin/users/{username} +def delete_gitea_user(username: str): + + _debug( + f"REQUEST: DELETE {GITEA_URL}/api/{GITEA_API_VERSION}/admin/users/{username}" + ) + + response = requests.delete( + f"{GITEA_URL}/api/{GITEA_API_VERSION}/admin/users/{username}", + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + "Authorization": f"token {GITEA_TOKEN}", + }, + ) + + if response.status_code != 204: + _trace(f"RESPONSE: {response.json()}") + response_message = ( + response.json()["message"] + if "message" in response.json() + else "Unknown error" + ) + + STATS["users"]["errors"].append( + { + "username": username, + "error": response_message if response_message else "Unknown error", + } + ) + + raise Exception(f"Failed to delete Gitea user: {response_message}") + else: + _info(f'User "{username}" deleted on Gitea') + + STATS["users"]["deleted"].append({"username": username}) # ENDPOINT: DELETE /api/{GITEA_API_VERSION}/repos/{owner}/{repo} @@ -1751,21 +2013,34 @@ def delete_gitea_project(project: dict): }, ) - _trace(f"RESPONSE: {response.json()}") - if response.status_code != 204: + _trace(f"RESPONSE: {response.json()}") response_message = ( response.json()["message"] if "message" in response.json() else "Unknown error" ) + + STATS["projects"]["errors"].append( + { + "group": owner, + "name": repo, + "error": response_message if response_message else "Unknown error", + } + ) + raise Exception(f"Failed to delete Gitea project: {response_message}") else: _info(f'Project "{owner}/{repo}" deleted on Gitea') + STATS["projects"]["deleted"].append({"group": owner, "name": repo}) + def migrate_groups(): + if OVERRIDE_EXISTING_GROUPS: + _warn("EXISTING GROUPS WILL BE OVERRIDDEN!") + gitlab_groups = get_gitlab_groups() gitea_groups = get_gitea_groups() @@ -1785,7 +2060,9 @@ def migrate_groups(): if missing_matches > 0: _warn(f"{missing_matches} groups are missing on Gitea!") - if missing_matches > 0 and not NO_CREATE_MISSING_GROUPS: + if OVERRIDE_EXISTING_GROUPS or ( + missing_matches > 0 and not NO_CREATE_MISSING_GROUPS + ): _info("Creating missing groups on Gitea...") if not DRY_RUN: @@ -1821,6 +2098,10 @@ def migrate_groups(): def migrate_users(): + + if OVERRIDE_EXISTING_USERS: + _warn("EXISTING USERS WILL BE OVERRIDDEN!") + gitlab_users = get_gitlab_users() gitea_users = get_gitea_users() @@ -1840,7 +2121,7 @@ def migrate_users(): if missing_matches > 0: _warn(f"{missing_matches} users are missing on Gitea!") - if missing_matches > 0 and not NO_CREATE_MISSING_USERS: + if OVERRIDE_EXISTING_USERS or (missing_matches > 0 and not NO_CREATE_MISSING_USERS): _info("Creating missing users on Gitea...") if not DRY_RUN: @@ -1919,7 +2200,9 @@ def migrate_projects(): if missing_matches > 0: _warn(f"{missing_matches} projects are missing on Gitea!") - if missing_matches > 0 and not NO_CREATE_MISSING_PROJECTS: + if OVERRIDE_EXISTING_PROJECTS or ( + missing_matches > 0 and not NO_CREATE_MISSING_PROJECTS + ): _info("Creating missing projects on Gitea...") if not DRY_RUN: @@ -1998,6 +2281,48 @@ def run_migration(): _info("Project migration completed!") +def print_stats(): + + _info("Migration Statistics:") + _info("") + + _info("Groups:") + _info(f" - Created: {len(STATS['groups']['created'])}") + _info(f" - Updated: {len(STATS['groups']['updated'])}") + _info(f" - Deleted: {len(STATS['groups']['deleted'])}") + _info(f" - Skipped: {len(STATS['groups']['skipped'])}") + _info(f" - Errors: {len(STATS['groups']['errors'])}") + _info("") + + _info("Errors:") + for error in STATS["groups"]["errors"]: + _error(f' - Group "{error["name"]}": {error["error"]}') + + _info("Users:") + _info(f" - Created: {len(STATS['users']['created'])}") + _info(f" - Updated: {len(STATS['users']['updated'])}") + _info(f" - Deleted: {len(STATS['users']['deleted'])}") + _info(f" - Skipped: {len(STATS['users']['skipped'])}") + _info(f" - Errors: {len(STATS['users']['errors'])}") + _info("") + + _info("Errors:") + for error in STATS["users"]["errors"]: + _error(f' - User "{error["username"]}": {error["error"]}') + + _info("Projects:") + _info(f" - Created: {len(STATS['projects']['created'])}") + _info(f" - Updated: {len(STATS['projects']['updated'])}") + _info(f" - Deleted: {len(STATS['projects']['deleted'])}") + _info(f" - Skipped: {len(STATS['projects']['skipped'])}") + _info(f" - Errors: {len(STATS['projects']['errors'])}") + _info("") + + _info("Errors:") + for error in STATS["projects"]["errors"]: + _error(f' - Project "{error["group"]}/{error["name"]}": {error["error"]}') + + def main(): signal.signal(signal.SIGINT, signal_handler) @@ -2045,8 +2370,13 @@ def main(): try: run_migration() + + _info("Migration completed!") + _info("") except Exception as e: _exception(f"An error occurred: {e}", e) + finally: + print_stats() def signal_handler(sig, frame):