├── .dockerignore ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── docker-compose.yml ├── gitea_import_actions.py ├── migrate.py └── requirements.txt /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .env -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # ---> Python 2 | # Byte-compiled / optimized / DLL files 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | .hypothesis/ 50 | .pytest_cache/ 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | local_settings.py 59 | db.sqlite3 60 | 61 | # Flask stuff: 62 | instance/ 63 | .webassets-cache 64 | 65 | # Scrapy stuff: 66 | .scrapy 67 | 68 | # Sphinx documentation 69 | docs/_build/ 70 | 71 | # PyBuilder 72 | target/ 73 | 74 | # Jupyter Notebook 75 | .ipynb_checkpoints 76 | 77 | # IPython 78 | profile_default/ 79 | ipython_config.py 80 | 81 | # pyenv 82 | .python-version 83 | 84 | # celery beat schedule file 85 | celerybeat-schedule 86 | 87 | # SageMath parsed files 88 | *.sage.py 89 | 90 | # Environments 91 | .env 92 | .venv 93 | env/ 94 | venv/ 95 | ENV/ 96 | env.bak/ 97 | venv.bak/ 98 | 99 | # Spyder project settings 100 | .spyderproject 101 | .spyproject 102 | 103 | # Rope project settings 104 | .ropeproject 105 | 106 | # mkdocs documentation 107 | /site 108 | 109 | # mypy 110 | .mypy_cache/ 111 | .dmypy.json 112 | dmypy.json 113 | 114 | # Pyre type checker 115 | .pyre/ 116 | 117 | # ---> VisualStudioCode 118 | .vscode/* 119 | !.vscode/settings.json 120 | !.vscode/tasks.json 121 | !.vscode/launch.json 122 | !.vscode/extensions.json 123 | 124 | migration-env/* 125 | .env -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.6-alpine 2 | 3 | 4 | RUN apk --update add git 5 | 6 | WORKDIR /app 7 | ADD requirements.txt . 8 | RUN python3 -m pip install -r requirements.txt 9 | 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | Copyright (c) 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 5 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gitlab to Gitea migration script. 2 | 3 | This script uses the Gitlab and Gitea API's to migrate all data from 4 | Gitlab to Gitea. 5 | 6 | This script support migrating the following data: 7 | - Repositories & Wiki (fork status is lost) 8 | - Milestones 9 | - Labels 10 | - Issues (no comments) 11 | - Users (no profile pictures) 12 | - Groups 13 | - Public SSH keys 14 | 15 | Tested with Gitlab Version 13.0.6 and Gitea Version 1.11.6. 16 | 17 | ## Usage 18 | Change items in the config section of the script. 19 | 20 | Install all dependencies via `python -m pip install -r requirements.txt` and 21 | use python3 to execute the script. 22 | 23 | ### How to use with venv 24 | To keep your local system clean, it might be helpful to store all Python dependencies in one folder. 25 | Python provides a virtual environment package which can be used to accomplish this task. 26 | 27 | ```bash 28 | python3 -m venv migration-env 29 | source migration-env/bin/activate 30 | python3 -m pip install -r requirements.txt 31 | ``` 32 | 33 | Then start the migration script `python3 migrate.py`. -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | 3 | services: 4 | sync: 5 | build: 6 | context: . 7 | dockerfile: Dockerfile 8 | volumes: 9 | - ./:/app 10 | command: python3 migrate.py 11 | network_mode: host 12 | env_file: .env -------------------------------------------------------------------------------- /gitea_import_actions.py: -------------------------------------------------------------------------------- 1 | # Import commits to gitea action database. 2 | # use: 3 | # git log --pretty=format:'%H,%at,%s' --date=default > /tmp/commit.log 4 | # to get the commits logfile for a repository 5 | 6 | import mysql.connector as mariadb 7 | 8 | # set the following variables to fit your need... 9 | USERID = 1 10 | REPOID = 1 11 | BRANCH = "master" 12 | 13 | mydb = mariadb.connect( 14 | host="localhost", 15 | user="user", 16 | passwd="password", 17 | database="gitea" 18 | ) 19 | 20 | mycursor = mydb.cursor() 21 | 22 | sql = "INSERT INTO action (user_id, op_type, act_user_id, repo_id, comment_id, ref_name, is_private, created_unix) VALUES (%s, %s, %s, %s, %s, %s, %s, %s)" 23 | 24 | with open("/tmp/commit.log") as f: 25 | for line in f: 26 | line_clean = line.rstrip('\n') 27 | line_split = line_clean.split(',') 28 | val = (USERID, 5, USERID, REPOID, 0, BRANCH, 1, int(line_split[1])) # 5 means commit 29 | print(val) 30 | mycursor.execute(sql, val) 31 | 32 | mydb.commit() 33 | 34 | print("actions inserted.") -------------------------------------------------------------------------------- /migrate.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import os 3 | import time 4 | import random 5 | import string 6 | import requests 7 | import json 8 | import dateutil.parser 9 | import datetime 10 | import re 11 | from typing import List 12 | import json 13 | import pytz 14 | 15 | import gitlab # pip install python-gitlab 16 | import gitlab.v4.objects 17 | import pygitea # pip install pygitea (https://github.com/h44z/pygitea) 18 | 19 | SCRIPT_VERSION = "1.0" 20 | GLOBAL_ERROR_COUNT = 0 21 | 22 | ####################### 23 | # CONFIG SECTION START 24 | ####################### 25 | 26 | # Gitea user to use as a fallback for groups 27 | # for cases where the user's permissions are too limited to access group member details on GitLab. 28 | GITEA_FALLBACK_GROUP_MEMBER = os.getenv('GITEA_FALLBACK_GROUP_MEMBER', 'gitea_admin') 29 | 30 | REPOSITORY_MIRROR = (os.getenv('REPOSITORY_MIRROR', 'false')) == 'true' # if true, the repository will be mirrored 31 | GITLAB_URL = os.getenv('GITLAB_URL', 'https://gitlab.source.com') 32 | GITLAB_API_BASEURL = GITLAB_URL + '/api/v4' 33 | GITLAB_TOKEN = os.getenv('GITLAB_TOKEN', 'gitlab token') 34 | 35 | # needed to clone the repositories, keep empty to try publickey (untested) 36 | GITLAB_ADMIN_USER = os.getenv('GITLAB_ADMIN_USER', 'admin username') 37 | GITLAB_ADMIN_PASS = os.getenv('GITLAB_ADMIN_PASS', 'admin password') 38 | 39 | if GITLAB_URL == 'https://gitlab.com/' and GITLAB_ADMIN_USER == '' and GITLAB_ADMIN_PASS == '': 40 | # see https://forum.gitlab.com/t/how-to-git-clone-via-https-with-personal-access-token-in-private-project/43418/4 41 | GITLAB_ADMIN_USER = 'oauth2' 42 | GITLAB_ADMIN_PASS = GITLAB_TOKEN 43 | GITEA_URL = os.getenv('GITEA_URL','https://gitea.dest.com') 44 | GITEA_API_BASEURL = GITEA_URL + '/api/v1' 45 | GITEA_TOKEN = os.getenv('GITEA_TOKEN', 'gitea token') 46 | 47 | # For migrating from a self-hosted gitlab instance, use MIGRATE_BY_GROUPS=0 48 | # For migrating from the global gitlab.com, use MIGRATE_BY_GROUPS=1 which 49 | # migrates only projects and users which belong to groups accessible to the 50 | # user of the GITLAB_TOKEN. 51 | MIGRATE_BY_GROUPS = (os.getenv('MIGRATE_BY_GROUPS', '0')) == '1' 52 | TRUNCATE_GITEA = (os.getenv('TRUNCATE_GITEA', '0')) == '1' 53 | 54 | # Migrated projects can be automatically archived on gitlab to avoid users pushing 55 | # there commits after the migration to gitea 56 | GITLAB_ARCHIVE_MIGRATED_PROJECTS = (os.getenv('GITLAB_ARCHIVE_MIGRATED_PROJECTS', '0')) == '1' 57 | ####################### 58 | # CONFIG SECTION END 59 | ####################### 60 | 61 | 62 | def main(): 63 | print_color(bcolors.HEADER, "---=== Gitlab to Gitea migration ===---") 64 | print("Version: " + SCRIPT_VERSION) 65 | print() 66 | 67 | # private token or personal token authentication 68 | gl = gitlab.Gitlab(GITLAB_URL, private_token=GITLAB_TOKEN) 69 | gl.auth() 70 | assert(isinstance(gl.user, gitlab.v4.objects.CurrentUser)) 71 | print_info("Connected to Gitlab, version: " + str(gl.version())) 72 | 73 | gt = pygitea.API(GITEA_URL, token=GITEA_TOKEN) 74 | gt_version = gt.get('/version').json() 75 | print_info("Connected to Gitea, version: " + str(gt_version['version'])) 76 | 77 | if TRUNCATE_GITEA: 78 | print('Truncate...') 79 | truncate_all(gt) 80 | print('Truncate... done') 81 | 82 | 83 | # Create a directory in /tmp called gitlab_to_gitea 84 | tmp_dir = '/tmp/gitlab_to_gitea' 85 | if not os.path.exists(tmp_dir): 86 | os.makedirs(tmp_dir) 87 | print(f"Directory {tmp_dir} created.") 88 | else: 89 | print(f"Directory {tmp_dir} already exists.") 90 | 91 | print('Gathering projects and users...') 92 | users: List[gitlab.v4.objects.User] = [] 93 | groups: List[gitlab.v4.objects.Group] = gl.groups.list(all=True) 94 | projects: List[gitlab.v4.objects.Project] = [] 95 | 96 | if MIGRATE_BY_GROUPS: 97 | user_ids: Dict[int, int] = {} 98 | project_ids: Dict[int, int] = {} 99 | groups = gl.groups.list(all=True) 100 | for group in groups: 101 | print('group:', group.full_path) 102 | # ıf we do not have access memberlist do not run member creating 103 | try: 104 | for member in group.members.list(iterator=True): 105 | print(' member:', member.username) 106 | user_ids[member.id] = 1 107 | except Exception as e: 108 | print("Skipping group member import for group " + group.full_path + " due to error: " + str(e)) 109 | 110 | for group_project in group.projects.list(iterator=True): 111 | print(' group_project:', group_project.name_with_namespace) 112 | project_ids[group_project.id] = 1 113 | project = gl.projects.get(id=group_project.id) 114 | print(' project:', project.name_with_namespace) 115 | for member in project.members.list(iterator=True): 116 | print(' member:', member.username) 117 | user_ids[member.id] = 1 118 | for user in project.users.list(iterator=True): 119 | print(' user:', user.username) 120 | user_ids[user.id] = 1 121 | 122 | for user_id in user_ids: 123 | user = gl.users.get(id=user_id) 124 | print('user_id:',user_id,' user:',user.username) 125 | users.append(user) 126 | for project in user.projects.list(iterator=True): 127 | print(' project:',project.name_with_namespace) 128 | 129 | for project_id in project_ids: 130 | project = gl.projects.get(id=project_id) 131 | print('project_id:',project_id,' project:',project.name_with_namespace,' archived:',project.archived) 132 | projects.append(project) 133 | 134 | else: 135 | users = gl.users.list(all=True) 136 | projects = gl.projects.list(all=True) 137 | 138 | print('Gathering projects and users...done') 139 | 140 | # IMPORT USERS AND GROUPS 141 | import_users_groups(gl, gt, users, groups) 142 | 143 | # IMPORT PROJECTS 144 | import_projects(gl, gt, projects) 145 | 146 | print() 147 | if GLOBAL_ERROR_COUNT == 0: 148 | print_success("Migration finished with no errors!") 149 | else: 150 | print_error("Migration finished with " + str(GLOBAL_ERROR_COUNT) + " errors!") 151 | 152 | # 153 | # Data loading helpers for Gitea 154 | # 155 | 156 | def get_project_labels(gitea_api: pygitea, owner: string, repo: string) -> []: 157 | existing_labels = [] 158 | label_response: requests.Response = gitea_api.get("/repos/" + owner + "/" + repo + "/labels") 159 | if label_response.ok: 160 | existing_labels = label_response.json() 161 | else: 162 | print_error("Failed to load existing labels for project " + repo + "! " + label_response.text) 163 | 164 | return existing_labels 165 | 166 | def get_group_labels(gitea_api: pygitea, group: string) -> []: 167 | existing_labels = [] 168 | label_response: requests.Response = gitea_api.get("/orgs/" + group + "/labels") 169 | if label_response.ok: 170 | existing_labels = label_response.json() 171 | else: 172 | print_error("Failed to load existing labels for group " + group + "! " + label_response.text) 173 | 174 | return existing_labels 175 | 176 | def get_merged_labels(gitea_api: pygitea, owner: string, repo: string) -> []: 177 | project_labels = get_project_labels(gitea_api, owner, repo) 178 | group_labels = get_group_labels(gitea_api, owner) 179 | return project_labels + group_labels 180 | 181 | def get_milestones(gitea_api: pygitea, owner: string, repo: string) -> []: 182 | existing_milestones = [] 183 | milestone_response: requests.Response = gitea_api.get("/repos/" + owner + "/" + repo + "/milestones") 184 | if milestone_response.ok: 185 | existing_milestones = [milestone['title'] for milestone in milestone_response.json()] 186 | else: 187 | print_error("Failed to load existing milestones for project " + repo + "! " + milestone_response.text) 188 | 189 | return existing_milestones 190 | 191 | def get_issues(gitea_api: pygitea, owner: string, repo: string) -> []: 192 | existing_issues = [] 193 | issue_response: requests.Response = gitea_api.get("/repos/" + owner + "/" + repo + "/issues", params={ 194 | "state": "all", 195 | "page": -1 196 | }) 197 | if issue_response.ok: 198 | existing_issues = issue_response.json() 199 | else: 200 | print_error("Failed to load existing issues for project " + repo + "! " + issue_response.text) 201 | 202 | return existing_issues 203 | 204 | def get_issue_comments(gitea_api: pygitea, owner: string, repo: string) -> []: 205 | existing_issue_comments = [] 206 | issue_comments_response: requests.Response = gitea_api.get("/repos/" + owner + "/" + repo + "/issues/comments", params={ 207 | "state": "all", 208 | "page": -1 209 | }) 210 | if issue_comments_response.ok: 211 | existing_issue_comments = issue_comments_response.json() 212 | else: 213 | print_error("Failed to load existing issue comments for project " + repo + "! " + issue_comments_response.text) 214 | 215 | return existing_issue_comments 216 | 217 | def get_teams(gitea_api: pygitea, orgname: string) -> []: 218 | existing_teams = [] 219 | team_response: requests.Response = gitea_api.get("/orgs/" + orgname + "/teams") 220 | if team_response.ok: 221 | existing_teams = team_response.json() 222 | else: 223 | print_error("Failed to load existing teams for organization " + orgname + "! " + team_response.text) 224 | 225 | return existing_teams 226 | 227 | 228 | def get_team_members(gitea_api: pygitea, teamid: int) -> []: 229 | existing_members = [] 230 | member_response: requests.Response = gitea_api.get("/teams/" + str(teamid) + "/members") 231 | if member_response.ok: 232 | existing_members = [member['username'] for member in member_response.json()] 233 | else: 234 | print_error("Failed to load existing members for team " + str(teamid) + "! " + member_response.text) 235 | 236 | return existing_members 237 | 238 | 239 | def get_collaborators(gitea_api: pygitea, owner: string, repo: string) -> []: 240 | existing_collaborators = [] 241 | collaborator_response: requests.Response = gitea_api.get("/repos/" + owner+ "/" + repo + "/collaborators") 242 | if collaborator_response.ok: 243 | existing_collaborators = collaborator_response.json() 244 | else: 245 | print_error("Failed to load existing collaborators for project " + repo + "! " + collaborator_response.text) 246 | 247 | return existing_collaborators 248 | 249 | 250 | def get_user_or_group(gitea_api: pygitea, project: gitlab.v4.objects.Project) -> {}: 251 | result = None 252 | response: requests.Response = gitea_api.get("/users/" + name_clean(project.namespace['name'])) 253 | if response.ok: 254 | result = response.json() 255 | 256 | # The api may return a 200 response, even if it's not a user but an org, let's try again! 257 | if result is None or result["id"] == 0: 258 | response: requests.Response = gitea_api.get("/orgs/" + name_clean(project.namespace["name"])) 259 | if response.ok: 260 | result = response.json() 261 | else: 262 | print_error("Failed to load user or group " + name_clean(project.namespace["name"]) + "! " + response.text) 263 | 264 | return result 265 | 266 | 267 | def get_user_keys(gitea_api: pygitea, username: string) -> []: 268 | existing_keys = [] 269 | key_response: requests.Response = gitea_api.get("/users/" + username + "/keys") 270 | if key_response.ok: 271 | existing_keys = [key['title'] for key in key_response.json()] 272 | else: 273 | print_error("Failed to load user keys for user " + username + "! " + key_response.text) 274 | 275 | return existing_keys 276 | 277 | 278 | def user_exists(gitea_api: pygitea, username: string) -> bool: 279 | print("Looking for " + "/users/" + username + "/keys" + " in Gitea!") 280 | user_response: requests.Response = gitea_api.get("/users/" + username) 281 | if user_response.ok: 282 | print_warning("User " + username + " does already exist in Gitea, skipping!") 283 | else: 284 | print("User " + username + " not found in Gitea, importing!") 285 | 286 | return user_response.ok 287 | 288 | 289 | def user_key_exists(gitea_api: pygitea, username: string, keyname: string) -> bool: 290 | print("Looking for " + "/users/" + username + "/keys" + " in Gitea!") 291 | existing_keys = get_user_keys(gitea_api, username) 292 | if existing_keys: 293 | if keyname in existing_keys: 294 | print_warning("Public key " + keyname + " already exists for user " + username + ", skipping!") 295 | return True 296 | else: 297 | print("Public key " + keyname + " does not exists for user " + username + ", importing!") 298 | return False 299 | else: 300 | print("No public keys for user " + username + ", importing!") 301 | return False 302 | 303 | 304 | def organization_exists(gitea_api: pygitea, orgname: string) -> bool: 305 | print("Looking for " + "/orgs/" + orgname + " in Gitea!") 306 | group_response: requests.Response = gitea_api.get("/orgs/" + orgname) 307 | if group_response.ok: 308 | print_warning("Group " + orgname + " does already exist in Gitea, skipping!") 309 | else: 310 | print("Group " + orgname + " not found in Gitea, importing!") 311 | 312 | return group_response.ok 313 | 314 | 315 | def member_exists(gitea_api: pygitea, username: string, teamid: int) -> bool: 316 | print("Looking for " + "/teams/" + str(teamid) + "/members" + " in Gitea!") 317 | existing_members = get_team_members(gitea_api, teamid) 318 | if existing_members: 319 | if username in existing_members: 320 | print_warning("Member " + username + " is already in team " + str(teamid) + ", skipping!") 321 | return True 322 | else: 323 | print("Member " + username + " is not in team " + str(teamid) + ", importing!") 324 | return False 325 | else: 326 | print("No members in team " + str(teamid) + ", importing!") 327 | return False 328 | 329 | 330 | def collaborator_exists(gitea_api: pygitea, owner: string, repo: string, username: string) -> bool: 331 | print("Looking for " + "/repos/" + owner + "/" + repo + "/collaborators/" + username + " in Gitea!") 332 | collaborator_response: requests.Response = gitea_api.get("/repos/" + owner + "/" + repo + "/collaborators/" + username) 333 | if collaborator_response.ok: 334 | print_warning("Collaborator " + username + " does already exist in Gitea, skipping!") 335 | else: 336 | print("Collaborator " + username + " not found in Gitea, importing!") 337 | 338 | return collaborator_response.ok 339 | 340 | 341 | def repo_exists(gitea_api: pygitea, owner: string, repo: string) -> bool: 342 | print("Looking for " + "/repos/" + owner + "/" + repo + " in Gitea!") 343 | repo_response: requests.Response = gitea_api.get("/repos/" + owner + "/" + repo) 344 | if repo_response.ok: 345 | print_warning("Project " + repo + " does already exist in Gitea, skipping!") 346 | else: 347 | print("Project " + repo + " not found in Gitea, importing!") 348 | 349 | return repo_response.ok 350 | 351 | 352 | def project_label_exists(gitea_api: pygitea, owner: string, repo: string, labelname: string) -> bool: 353 | print("Looking for " + "/repos/" + owner + "/" + repo + "/labels in Gitea!") 354 | existing_labels = [label['name'] for label in get_project_labels(gitea_api, owner, repo)] 355 | if existing_labels: 356 | if labelname in existing_labels: 357 | print_warning("Label " + labelname + " already exists in project " + repo + " of owner " + owner) 358 | return True 359 | else: 360 | print("Label " + labelname + " does not exists in project " + repo + " of owner " + owner) 361 | return False 362 | else: 363 | print("No labels in project " + repo + " of owner " + owner) 364 | return False 365 | 366 | def group_label_exists(gitea_api: pygitea, group: string, labelname: string) -> bool: 367 | print("Looking for " + "/orgs/" + group + "/labels in Gitea!") 368 | existing_labels = [label['name'] for label in get_group_labels(gitea_api, group)] 369 | if existing_labels: 370 | if labelname in existing_labels: 371 | print_warning("Label " + labelname + " already exists in group " + group) 372 | return True 373 | else: 374 | print("Label " + labelname + " does not exists in group " + group) 375 | return False 376 | else: 377 | print("No labels in group " + group) 378 | return False 379 | 380 | def milestone_exists(gitea_api: pygitea, owner: string, repo: string, milestone: string) -> bool: 381 | print("Looking for " + "/repos/" + owner + "/" + repo + "/milestones" + " in Gitea!") 382 | existing_milestones = get_milestones(gitea_api, owner, repo) 383 | if existing_milestones: 384 | if milestone in existing_milestones: 385 | print_warning("Milestone " + milestone + " already exists in project " + repo + " of owner " + owner) 386 | return True 387 | else: 388 | print("Milestone " + milestone + " does not exists in project " + repo + " of owner " + owner) 389 | return False 390 | else: 391 | print("No milestones in project " + repo + " of owner " + owner) 392 | return False 393 | 394 | def get_issue(gitea_api: pygitea, owner: string, repo: string, issue_title: string = None, issue_id: int = None) -> {}: 395 | if issue_title is not None: 396 | print("Looking for " + "/repos/" + owner + "/" + repo + "/issues" + " in Gitea!") 397 | existing_issues = get_issues(gitea_api, owner, repo) 398 | if existing_issues: 399 | existing_issue = next((item for item in existing_issues if item['title'] == issue_title), None) 400 | if existing_issue is not None: 401 | print("Issue " + issue_title + " already exists in project " + repo) 402 | return existing_issue 403 | else: 404 | print("Issue " + issue_title + " does not exists in project " + repo) 405 | return None 406 | else: 407 | print("No issues in project " + repo) 408 | return None 409 | elif issue_id is not None: 410 | print("Looking for " + "/repos/" + owner + "/" + repo + "/issues/" + str(issue_id) + " in Gitea!") 411 | issue_response: requests.Response = gitea_api.get("/repos/" + owner + "/" + repo + "/issues/" + str(issue_id)) 412 | if issue_response.ok: 413 | print("Issue " + str(issue_id) + " already exists in project " + repo) 414 | return issue_response.json() 415 | else: 416 | print("Issue " + str(issue_id) + " does not exists in project " + repo) 417 | return None 418 | else: 419 | print_error("No issue title or id provided!") 420 | 421 | def get_issue_comment(gitea_api: pygitea, owner: string, repo: string, issue_url: string, comment_body: string): 422 | print("Looking for " + "/repos/" + owner + "/" + repo + "/issues/comments" + " in Gitea!") 423 | existing_issue_comments = get_issue_comments(gitea_api, owner, repo) 424 | if existing_issue_comments: 425 | existing_issue_comment = next((item for item in existing_issue_comments if (item["body"] == comment_body) and (issue_url == item['issue_url'])), None) 426 | 427 | short_comment_body = (comment_body[0:10] + "...") if len(comment_body) > 10 else comment_body 428 | if existing_issue_comment is not None: 429 | print("Issue comment " + short_comment_body + " already exists in project " + repo) 430 | return existing_issue_comment 431 | else: 432 | print("Issue comment " + short_comment_body + " does not exists in project " + repo) 433 | return None 434 | else: 435 | print("No issue comments in project " + repo) 436 | return None 437 | 438 | 439 | # 440 | # Import helper functions 441 | # 442 | 443 | def _import_project_labels(gitea_api: pygitea, labels: [gitlab.v4.objects.ProjectLabel], owner: string, repo: string): 444 | merged_labels = [label['name'] for label in get_merged_labels(gitea_api, owner, repo)] 445 | for label in labels: 446 | if not label.name in merged_labels: 447 | import_response: requests.Response = gitea_api.post("/repos/" + owner + "/" + repo + "/labels", json={ 448 | "name": label.name, 449 | "color": label.color, 450 | "description": label.description # currently not supported 451 | }) 452 | if import_response.ok: 453 | print_info("Label " + label.name + " imported!") 454 | else: 455 | print_error("Label " + label.name + " import failed: " + import_response.text) 456 | 457 | 458 | def _import_project_milestones(gitea_api: pygitea, milestones: [gitlab.v4.objects.ProjectMilestone], owner: string, repo: string): 459 | for milestone in milestones: 460 | print("_import_project_milestones, " + milestone.title + " with owner: " + owner + ", repo: "+ repo) 461 | if not milestone_exists(gitea_api, owner, repo, milestone.title): 462 | due_date = None 463 | if milestone.due_date is not None and milestone.due_date != '': 464 | due_date = dateutil.parser.parse(milestone.due_date).strftime('%Y-%m-%dT%H:%M:%SZ') 465 | 466 | import_response: requests.Response = gitea_api.post("/repos/" + owner + "/" + repo + "/milestones", json={ 467 | "description": milestone.description, 468 | "due_on": due_date, 469 | "title": milestone.title, 470 | }) 471 | if import_response.ok: 472 | print_info("Milestone " + milestone.title + " imported!") 473 | existing_milestone = import_response.json() 474 | 475 | if existing_milestone: 476 | # update milestone state, this cannot be done in the initial import :( 477 | # TODO: gitea api ignores the closed state... 478 | update_response: requests.Response = gitea_api.patch("/repos/" + owner + "/" + repo + "/milestones/" + str(existing_milestone['id']), json={ 479 | "description": milestone.description, 480 | "due_on": due_date, 481 | "title": milestone.title, 482 | "state": milestone.state 483 | }) 484 | if update_response.ok: 485 | print_info("Milestone " + milestone.title + " updated!") 486 | else: 487 | print_error("Milestone " + milestone.title + " update failed: " + update_response.text) 488 | else: 489 | print_error("Milestone " + milestone.title + " import failed: " + import_response.text) 490 | 491 | 492 | def _import_project_issues(gitea_api: pygitea, project_id, issues: [gitlab.v4.objects.ProjectIssue], owner: string, repo: string): 493 | # reload all existing milestones and labels, needed for assignment in issues 494 | existing_milestones = get_milestones(gitea_api, owner, repo) 495 | existing_labels = get_merged_labels(gitea_api, owner, repo) 496 | 497 | org_members = [member['login'] for member in json.loads(gitea_api.get(f'/orgs/{owner}/members').text)] 498 | 499 | for issue in issues: 500 | print("_import_project_issues" + issue.title + " with owner: " + owner + ", repo: "+ repo) 501 | notes: List[gitlab.v4.objects.ProjectIssueNote] = sorted(issue.notes.list(all=True), key=lambda x: x.created_at) 502 | 503 | gitea_issue = get_issue(gitea_api, owner, repo, issue.title) 504 | if not gitea_issue: 505 | due_date = '' 506 | if issue.due_date is not None: 507 | due_date = dateutil.parser.parse(issue.due_date).strftime('%Y-%m-%dT%H:%M:%SZ') 508 | 509 | assignee = None 510 | if issue.assignee is not None: 511 | assignee = issue.assignee['username'] 512 | 513 | assignees = [] 514 | for tmp_assignee in issue.assignees: 515 | assignees.append(tmp_assignee['username']) 516 | 517 | milestone = None 518 | if issue.milestone is not None and issue.milestone['title'] in existing_milestones: 519 | milestone = issue.milestone['id'] 520 | 521 | labels = [label['id'] for label in existing_labels if label['name'] in issue.labels] 522 | 523 | created_at_utc = dateutil.parser.parse(issue.created_at) 524 | created_at_local = created_at_utc.astimezone(pytz.timezone('Europe/Berlin')).strftime('%d.%m.%Y %H:%M') 525 | body = f"Created at: {created_at_local}\n\n{issue.description}" 526 | body = replace_issue_links(body, GITLAB_URL, GITEA_URL) 527 | 528 | params = {} 529 | if issue.author['username'] in org_members: 530 | params['sudo'] = issue.author['username'] 531 | else: 532 | body = f"Autor: {issue.author['name']}\n\n{body}" 533 | 534 | 535 | import_response: requests.Response = gitea_api.post("/repos/" + owner + "/" + repo + "/issues", json={ 536 | "assignee": assignee, 537 | "assignees": assignees, 538 | "body": body, 539 | "closed": issue.state == 'closed', 540 | "due_on": due_date, 541 | "labels": labels, 542 | "milestone": milestone, 543 | "title": issue.title 544 | }, params=params) 545 | if import_response.ok: 546 | print_info("Issue " + issue.title + " imported!") 547 | gitea_issue = json.loads(import_response.text) 548 | else: 549 | print_error("Issue " + issue.title + " import failed: " + import_response.text) 550 | continue 551 | 552 | # Find and handle markdown image links in the issue description 553 | description = body 554 | description_old = description 555 | description = replace_issue_links(description, GITLAB_URL, GITEA_URL) 556 | 557 | image_links = re.findall(r'\[.*?\]\((/uploads/.*?)\)', issue.description or '') 558 | for image_link in image_links: 559 | attachment_url = GITLAB_API_BASEURL + '/projects/' + str(project_id) + image_link 560 | attachment_response = requests.get(attachment_url, headers={'PRIVATE-TOKEN': GITLAB_TOKEN}) 561 | if attachment_response.ok: 562 | tmp_path = f'/tmp/gitlab_to_gitea/{os.path.basename(image_link)}' 563 | with open(tmp_path, 'wb') as file: 564 | file.write(attachment_response.content) 565 | print("Image downloaded successfully!") 566 | url = f'{GITEA_API_BASEURL}/repos/{owner}/{repo}/issues/{str(gitea_issue["number"])}/assets' 567 | headers = { 568 | 'Authorization': f'token {GITEA_TOKEN}' 569 | } 570 | files = { 571 | 'attachment': open(tmp_path, 'rb') 572 | } 573 | upload_response = requests.post(url, headers=headers, files=files) 574 | os.remove(tmp_path) 575 | if upload_response.ok: 576 | print_info("Attachment " + os.path.basename(image_link) + " uploaded!") 577 | # Replace the image link in the description with the new link 578 | new_image_link = upload_response.json()['browser_download_url'] 579 | description = description.replace(image_link, new_image_link) 580 | else: 581 | print_error("Attachment " + os.path.basename(image_link) + " upload failed: " + upload_response.text) 582 | else: 583 | print_error("Failed to download attachment " + attachment_url + " for issue " + issue.title + "!") 584 | 585 | if description != description_old: 586 | update_response: requests.Response = gitea_api.patch("/repos/" + owner + "/" + repo + "/issues/" + str(gitea_issue['number']), json={ 587 | "body": description 588 | }, params=params) 589 | if update_response.ok: 590 | print_info("Issue " + issue.title + " updated!") 591 | else: 592 | print_error("Issue " + issue.title + " update failed: " + update_response.text) 593 | 594 | # import the comments for the issue 595 | _import_issue_comments(gitea_api, project_id, gitea_issue, owner, repo, notes, org_members) 596 | 597 | 598 | def _import_issue_comments(gitea_api: pygitea, project_id, issue, owner: string, repo: string, notes: List[gitlab.v4.objects.ProjectIssueNote], org_members: List[str]): 599 | for note in notes: 600 | short_comment_body = (note.body[0:10] + "...") if len(note.body) > 10 else note.body 601 | 602 | existing_comment = get_issue_comment(gitea_api, owner, repo, issue['url'], note.body) 603 | comment_id = existing_comment['id'] if existing_comment else None 604 | body = note.body 605 | 606 | if not existing_comment: 607 | created_at_utc = dateutil.parser.parse(note.created_at) 608 | created_at_local = created_at_utc.astimezone(pytz.timezone('Europe/Berlin')).strftime('%d.%m.%Y %H:%M') 609 | body = f"{note.body}\n\n{created_at_local}" 610 | body = replace_issue_links(body, GITLAB_URL, GITEA_URL) 611 | 612 | params = {} 613 | if note.author['username'] in org_members: 614 | params['sudo'] = note.author['username'] 615 | else: 616 | body = f"Autor: {note.author['name']}\n\n{body}" 617 | 618 | import_response: requests.Response = gitea_api.post("/repos/" + owner + "/" + repo + "/issues/" + str(issue['number']) + "/comments", json={ 619 | "body": body, 620 | }, 621 | params=params) 622 | if import_response.ok: 623 | comment_id = json.loads(import_response.text)['id'] 624 | print_info("Issue comment " + short_comment_body + " imported!") 625 | else: 626 | print_error("Issue comment " + short_comment_body + " import failed: " + import_response.text) 627 | 628 | if not comment_id: 629 | print_warning("Failed to load comment id for comment " + short_comment_body + "!") 630 | continue 631 | 632 | # Find and handle markdown image links in the comment body 633 | comment_body = body 634 | comment_body_old = comment_body 635 | comment_body = replace_issue_links(comment_body, GITLAB_URL, GITEA_URL) 636 | 637 | image_links = re.findall(r'\[.*?\]\((/uploads/.*?)\)', note.body or '') 638 | for image_link in image_links: 639 | attachment_url = GITLAB_API_BASEURL + '/projects/' + str(project_id) + image_link 640 | attachment_response = requests.get(attachment_url, headers={'PRIVATE-TOKEN': GITLAB_TOKEN}) 641 | if attachment_response.ok: 642 | tmp_path = f'/tmp/gitlab_to_gitea/{os.path.basename(image_link)}' 643 | with open(tmp_path, 'wb') as file: 644 | file.write(attachment_response.content) 645 | print("Image downloaded successfully!") 646 | url = f'{GITEA_API_BASEURL}/repos/{owner}/{repo}/issues/comments/{comment_id}/assets' 647 | headers = { 648 | 'Authorization': f'token {GITEA_TOKEN}' 649 | } 650 | files = { 651 | 'attachment': open(tmp_path, 'rb') 652 | } 653 | upload_response = requests.post(url, headers=headers, files=files) 654 | os.remove(tmp_path) 655 | if upload_response.ok: 656 | print_info("Attachment " + os.path.basename(image_link) + " uploaded!") 657 | # Replace the image link in the comment body with the new link 658 | new_image_link = upload_response.json()['browser_download_url'] 659 | comment_body = comment_body.replace(image_link, new_image_link) 660 | else: 661 | print_error("Attachment " + os.path.basename(image_link) + " upload failed: " + upload_response.text) 662 | else: 663 | print_error("Failed to download attachment " + attachment_url + " for comment " + note.body + "!") 664 | 665 | if comment_body != comment_body_old: 666 | update_response: requests.Response = gitea_api.patch("/repos/" + owner + "/" + repo + "/issues/comments/" + str(comment_id), json={ 667 | "body": comment_body 668 | }, params=params) 669 | if update_response.ok: 670 | print_info("Comment " + short_comment_body + " updated!") 671 | else: 672 | print_error("Comment " + short_comment_body + " update failed: " + update_response.text) 673 | 674 | 675 | def _import_project_repo(gitea_api: pygitea, project: gitlab.v4.objects.Project): 676 | if not repo_exists(gitea_api, name_clean(project.namespace['name']), name_clean(project.name)): 677 | clone_url = project.http_url_to_repo 678 | if GITLAB_ADMIN_PASS == '' and GITLAB_ADMIN_USER == '': 679 | clone_url = project.ssh_url_to_repo 680 | private = project.visibility == 'private' or project.visibility == 'internal' 681 | 682 | # Load the owner (users and groups can both be fetched using the /users/ endpoint) 683 | owner = get_user_or_group(gitea_api, project) 684 | if owner: 685 | description = project.description 686 | 687 | if description is not None and len(description) > 255: 688 | description = description[:255] 689 | print_warning(f"Description of {name_clean(project.name)} had to be truncated to 255 characters!") 690 | 691 | import_response: requests.Response = gitea_api.post("/repos/migrate", json={ 692 | "auth_password": GITLAB_ADMIN_PASS, 693 | "auth_token": GITLAB_TOKEN, 694 | "auth_username": GITLAB_ADMIN_USER, 695 | "clone_addr": clone_url, 696 | "description": description, 697 | "mirror": REPOSITORY_MIRROR, 698 | "private": private, 699 | "repo_name": name_clean(project.name), 700 | "uid": owner['id'] 701 | }) 702 | if import_response.ok: 703 | print_info("Project " + name_clean(project.name) + " imported!") 704 | else: 705 | print_error("Project " + name_clean(project.name) + " import failed: " + import_response.text) 706 | else: 707 | print_error("Failed to load project owner for project " + name_clean(project.name)) 708 | 709 | 710 | def _import_project_repo_collaborators(gitea_api: pygitea, collaborators: [gitlab.v4.objects.ProjectMember], project: gitlab.v4.objects.Project): 711 | for collaborator in collaborators: 712 | 713 | if not collaborator_exists(gitea_api, name_clean(project.namespace['name']), name_clean(project.name), collaborator.username): 714 | permission = "read" 715 | 716 | if collaborator.access_level == 10: # guest access 717 | permission = "read" 718 | elif collaborator.access_level == 20: # reporter access 719 | permission = "read" 720 | elif collaborator.access_level == 30: # developer access 721 | permission = "write" 722 | elif collaborator.access_level == 40: # maintainer access 723 | permission = "admin" 724 | elif collaborator.access_level == 50: # owner access (only for groups) 725 | print_error("Groupmembers are currently not supported!") 726 | continue # groups are not supported 727 | else: 728 | print_warning("Unsupported access level " + str(collaborator.access_level) + ", setting permissions to 'read'!") 729 | 730 | import_response: requests.Response = gitea_api.put("/repos/" + name_clean(project.namespace['name']) +"/" + name_clean(project.name) + "/collaborators/" + collaborator.username, json={ 731 | "permission": permission 732 | }) 733 | if import_response.ok: 734 | print_info("Collaborator " + collaborator.username + " imported!") 735 | else: 736 | print_error("Collaborator " + collaborator.username + " import failed: " + import_response.text) 737 | 738 | 739 | def _import_users(gitea_api: pygitea, users: [gitlab.v4.objects.User], notify: bool = False): 740 | with open('created_users.txt', 'a') as f: 741 | for user in users: 742 | keys: [gitlab.v4.objects.UserKey] = user.keys.list(all=True) 743 | 744 | print("Importing user " + user.username + "...") 745 | print("Found " + str(len(keys)) + " public keys for user " + user.username) 746 | 747 | if not user_exists(gitea_api, user.username): 748 | tmp_password = 'Tmp1!' + ''.join(random.choices(string.ascii_uppercase + string.digits, k=10)) 749 | 750 | tmp_email = user.username + '@noemail-git.local' # Some gitlab instances do not publish user emails 751 | try: 752 | tmp_email = user.email 753 | except AttributeError: 754 | pass 755 | import_response: requests.Response = gitea_api.post("/admin/users", json={ 756 | "email": tmp_email, 757 | "full_name": user.name, 758 | "login_name": user.username, 759 | "password": tmp_password, 760 | "send_notify": notify, 761 | "source_id": 0, # local user 762 | "username": user.username, 763 | "visibility": "internal" 764 | }) 765 | if import_response.ok: 766 | print_info("User " + user.username + " imported, temporary password: " + tmp_password) 767 | f.write(f"{user.username},{tmp_password}\n") 768 | else: 769 | print_error("User " + user.username + " import failed: " + import_response.text) 770 | 771 | # Download and upload user avatar 772 | if user.avatar_url: 773 | avatar_response = requests.get(user.avatar_url) 774 | if avatar_response.ok: 775 | avatar_base64 = base64.b64encode(avatar_response.content).decode('utf-8') 776 | import_response: requests.Response = gitea_api.post("/user/avatar", json={ 777 | "image": avatar_base64 778 | }, params={'sudo': user.username}) 779 | if import_response.ok: 780 | print_info("Avatar for user " + user.username + " uploaded!") 781 | else: 782 | print_error("Avatar for user " + user.username + " upload failed: " + import_response.text) 783 | else: 784 | print_error("Failed to download avatar for user " + user.username + "!") 785 | 786 | # import public keys 787 | _import_user_keys(gitea_api, keys, user) 788 | 789 | 790 | def _import_user_keys(gitea_api: pygitea, keys: [gitlab.v4.objects.UserKey], user: gitlab.v4.objects.User): 791 | for key in keys: 792 | if not user_key_exists(gitea_api, user.username, key.title): 793 | import_response: requests.Response = gitea_api.post("/admin/users/" + user.username + "/keys", json={ 794 | "key": key.key, 795 | "read_only": True, 796 | "title": key.title, 797 | }) 798 | if import_response.ok: 799 | print_info("Public key " + key.title + " imported!") 800 | else: 801 | print_error("Public key " + key.title + " import failed: " + import_response.text) 802 | 803 | 804 | def _import_groups(gitea_api: pygitea, groups: [gitlab.v4.objects.Group]): 805 | for group in groups: 806 | try: 807 | members: [gitlab.v4.objects.GroupMember] = group.members_all.list(all=True) 808 | labels: [gitlab.v4.objects.GroupLabel] = group.labels.list(all=True) 809 | except Exception as e: 810 | print("Skipping group member import for group " + group.full_path + " due to error: " + str(e)) 811 | continue 812 | print("Importing group " + name_clean(group.name) + "...") 813 | print("Found " + str(len(members)) + " gitlab members for group " + name_clean(group.name)) 814 | 815 | if not organization_exists(gitea_api, name_clean(group.name)): 816 | import_response: requests.Response = gitea_api.post("/orgs", json={ 817 | "description": group.description, 818 | "full_name": group.full_name, 819 | "location": "", 820 | "username": name_clean(group.name), 821 | "website": "", 822 | "visibility": "internal" 823 | }) 824 | if import_response.ok: 825 | print_info("Group " + name_clean(group.name) + " imported!") 826 | else: 827 | print_error("Group " + name_clean(group.name) + " import failed: " + import_response.text) 828 | 829 | # import group members 830 | _import_group_members(gitea_api, members, group) 831 | 832 | _import_group_labels(gitea_api, labels, group) 833 | 834 | 835 | def _import_group_members(gitea_api: pygitea, members: [gitlab.v4.objects.GroupMember], group: gitlab.v4.objects.Group): 836 | # TODO: create teams based on gitlab permissions (access_level of group member) 837 | existing_teams = get_teams(gitea_api, name_clean(group.name)) 838 | if existing_teams: 839 | first_team = existing_teams[0] 840 | print("Organization teams fetched, importing users to first team: " + first_team['name']) 841 | 842 | # if members empty just add the fallback user 843 | if len(members) == 0: 844 | members = [{"username": GITEA_FALLBACK_GROUP_MEMBER}] 845 | # add members to teams 846 | for member in members: 847 | if not member_exists(gitea_api, member.username, first_team['id']): 848 | import_response: requests.Response = gitea_api.put("/teams/" + str(first_team['id']) + "/members/" + member.username) 849 | if import_response.ok: 850 | print_info("Member " + member.username + " added to group " + name_clean(group.name) + "!") 851 | else: 852 | print_error("Failed to add member " + member.username + " to group " + name_clean(group.name) + "!") 853 | else: 854 | print_error("Failed to import members to group " + name_clean(group.name) + ": no teams found!") 855 | 856 | 857 | def _import_group_labels(gitea_api: pygitea, labels: [gitlab.v4.objects.GroupLabel], group: gitlab.v4.objects.Group): 858 | group_labels = get_group_labels(gitea_api, name_clean(group.name)) 859 | for label in labels: 860 | if label.name not in group_labels: 861 | import_response: requests.Response = gitea_api.post("/orgs/" + name_clean(group.name) + "/labels", json={ 862 | "color": label.color, 863 | "description": label.description, 864 | "name": label.name 865 | }) 866 | if import_response.ok: 867 | print_info("Label " + label.name + " imported!") 868 | else: 869 | print_error("Label " + label.name + " import failed: " + import_response.text) 870 | 871 | # 872 | # Import functions 873 | # 874 | 875 | def import_users_groups(gitlab_api: gitlab.Gitlab, gitea_api: pygitea, users: List[gitlab.v4.objects.User], groups: List[gitlab.v4.objects.Group], notify=False): 876 | print("Found " + str(len(users)) + " gitlab users as user " + gitlab_api.user.username) 877 | print("Found " + str(len(groups)) + " gitlab groups as user " + gitlab_api.user.username) 878 | 879 | # import all non existing users 880 | _import_users(gitea_api, users, notify) 881 | 882 | # import all non existing groups 883 | _import_groups(gitea_api, groups) 884 | 885 | 886 | def import_projects(gitlab_api: gitlab.Gitlab, gitea_api: pygitea, projects: List[gitlab.v4.objects.Project]): 887 | print("Found " + str(len(projects)) + " gitlab projects as user " + gitlab_api.user.username) 888 | 889 | for project in projects: 890 | if GITLAB_ARCHIVE_MIGRATED_PROJECTS: 891 | try: 892 | project.archive() 893 | except Exception as e: 894 | print("WARNING: Failed to archive project '{}', reason: {}".format(project.name, e)) 895 | 896 | try: 897 | collaborators: [gitlab.v4.objects.ProjectMember] = project.members.list(all=True) 898 | labels: [gitlab.v4.objects.ProjectLabel] = project.labels.list(all=True) 899 | milestones: [gitlab.v4.objects.ProjectMilestone] = project.milestones.list(all=True) 900 | issues: [gitlab.v4.objects.ProjectIssue] = sorted(project.issues.list(all=True), key=lambda x: x.iid) 901 | 902 | print("Importing project " + name_clean(project.name) + " from owner " + name_clean(project.namespace['name'])) 903 | print("Found " + str(len(collaborators)) + " collaborators for project " + name_clean(project.name)) 904 | print("Found " + str(len(labels)) + " labels for project " + name_clean(project.name)) 905 | print("Found " + str(len(milestones)) + " milestones for project " + name_clean(project.name)) 906 | print("Found " + str(len(issues)) + " issues for project " + name_clean(project.name)) 907 | 908 | except Exception as e: 909 | print("This project failed: \n {}, \n reason {}: ".format(project.name, e)) 910 | 911 | else: 912 | projectOwner = name_clean(project.namespace['name']) 913 | projectName = name_clean(project.name) 914 | 915 | # import project repo 916 | _import_project_repo(gitea_api, project) 917 | 918 | # import collaborators 919 | _import_project_repo_collaborators(gitea_api, collaborators, project) 920 | 921 | # import labels 922 | _import_project_labels(gitea_api, labels, projectOwner, projectName) 923 | 924 | # import milestones 925 | _import_project_milestones(gitea_api, milestones, projectOwner, projectName) 926 | 927 | # import issues 928 | _import_project_issues(gitea_api, project.id, issues, projectOwner, projectName) 929 | 930 | 931 | def truncate_all(gitea_api: pygitea): 932 | print("Truncate all projects, organizations, and users!") 933 | 934 | # Get all users 935 | users_response = gitea_api.get('/admin/users') 936 | users = json.loads(users_response.text) 937 | for user in users: 938 | # Delete user repositories 939 | user_repos_response = gitea_api.get(f'/users/{user["login"]}/repos') 940 | user_repos = json.loads(user_repos_response.text) 941 | for repo in user_repos: 942 | repo_delete_response = gitea_api.delete(f'/repos/{repo["owner"]["login"]}/{repo["name"]}') 943 | if repo_delete_response.ok: 944 | print_info("Repository " + repo["owner"]["login"] + "/" + repo["name"] + " deleted!") 945 | else: 946 | print_error("Repository " + repo["owner"]["login"] + "/" + repo["name"] + " deletion failed: " + repo_delete_response.text) 947 | 948 | # Get all organizations 949 | organizations_response = gitea_api.get('/orgs') 950 | organizations = json.loads(organizations_response.text) 951 | for org in organizations: 952 | # Delete organization repositories 953 | org_repos_response = gitea_api.get(f'/orgs/{org["username"]}/repos') 954 | org_repos = json.loads(org_repos_response.text) 955 | for repo in org_repos: 956 | repo_delete_response = gitea_api.delete(f'/repos/{repo["owner"]["login"]}/{repo["name"]}') 957 | if repo_delete_response.ok: 958 | print_info("Repository " + repo["owner"]["login"] + "/" + repo["name"] + " deleted!") 959 | else: 960 | print_error("Repository " + repo["owner"]["login"] + "/" + repo["name"] + " deletion failed: " + repo_delete_response.text) 961 | # Delete organization 962 | orga_delete_response = gitea_api.delete(f'/orgs/{org["username"]}') 963 | if orga_delete_response.ok: 964 | print_info("Organization " + org["username"] + " deleted!") 965 | else: 966 | print_error("Organization " + org["username"] + " deletion failed: " + orga_delete_response.text) 967 | 968 | for user in users: 969 | # Delete user 970 | user_delete_response = gitea_api.delete(f'/admin/users/{user["login"]}') 971 | if user_delete_response.ok: 972 | print_info("User " + user["login"] + " deleted!") 973 | else: 974 | print_error("User " + user["login"] + " deletion failed: " + user_delete_response.text) 975 | 976 | 977 | # 978 | # Helper functions 979 | # 980 | 981 | class bcolors: 982 | HEADER = '\033[95m' 983 | OKBLUE = '\033[94m' 984 | OKGREEN = '\033[92m' 985 | WARNING = '\033[93m' 986 | FAIL = '\033[91m' 987 | ENDC = '\033[0m' 988 | BOLD = '\033[1m' 989 | UNDERLINE = '\033[4m' 990 | 991 | 992 | def color_message(color, message, colorend=bcolors.ENDC, bold=False): 993 | if bold: 994 | return bcolors.BOLD + color_message(color, message, colorend, False) 995 | 996 | return color + message + colorend 997 | 998 | def print_color(color, message, colorend=bcolors.ENDC, bold=False): 999 | print(color_message(color, message, colorend)) 1000 | 1001 | 1002 | def print_info(message): 1003 | print_color(bcolors.OKBLUE, message) 1004 | 1005 | 1006 | def print_success(message): 1007 | print_color(bcolors.OKGREEN, message) 1008 | 1009 | 1010 | def print_warning(message): 1011 | print_color(bcolors.WARNING, message) 1012 | 1013 | 1014 | def print_error(message): 1015 | global GLOBAL_ERROR_COUNT 1016 | GLOBAL_ERROR_COUNT += 1 1017 | print_color(bcolors.FAIL, message) 1018 | 1019 | 1020 | def name_clean(name): 1021 | newName = name.replace(" ", "") 1022 | newName = newName.replace("ä", "ae") 1023 | newName = newName.replace("ö", "oe") 1024 | newName = newName.replace("ü", "ue") 1025 | newName = newName.replace("Ä", "Ae") 1026 | newName = newName.replace("Ö", "Oe") 1027 | newName = newName.replace("Ü", "Ue") 1028 | newName = re.sub(r"[^a-zA-Z0-9_\.-]", "-", newName) 1029 | 1030 | if (newName.lower() == "plugins"): 1031 | return newName + "-user" 1032 | 1033 | return newName 1034 | 1035 | 1036 | def replace_issue_links(text: str, gitlab_url: str, gitea_url: str) -> str: 1037 | pattern = re.escape(gitlab_url) + r'/([^/]+)/([^/]+)/([^/]+)/-/issues/(\d+)' 1038 | replacement = gitea_url + r'/\2/\3/issues/\4' 1039 | text = re.sub(pattern, replacement, text or '') 1040 | pattern = re.escape(gitlab_url) + r'/([^/]+)/([^/]+)/-/issues/(\d+)' 1041 | replacement = gitea_url + r'/\1/\2/issues/\3' 1042 | text = re.sub(pattern, replacement, text or '') 1043 | return text 1044 | 1045 | 1046 | if __name__ == "__main__": 1047 | main() 1048 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | python-gitlab 2 | requests 3 | python-dateutil 4 | mysql-connector 5 | git+https://github.com/h44z/pygitea 6 | pytz --------------------------------------------------------------------------------