import base64 import os import time import random import string import requests import json import dateutil.parser import datetime import re import gitlab # pip install python-gitlab import gitlab.v4.objects import pygitea # pip install pygitea (https://github.com/h44z/pygitea) SCRIPT_VERSION = "1.0" GLOBAL_ERROR_COUNT = 0 ####################### # CONFIG SECTION START ####################### GITLAB_URL = 'https://gitlab.source.com' GITLAB_TOKEN = 'gitlab token' # needed to clone the repositories, keep empty to try publickey (untested) GITLAB_ADMIN_USER = 'admin username' GITLAB_ADMIN_PASS = 'admin password' GITEA_URL = 'https://gitea.dest.com' GITEA_TOKEN = 'gitea token' ####################### # CONFIG SECTION END ####################### def main(): print_color(bcolors.HEADER, "---=== Gitlab to Gitea migration ===---") print("Version: " + SCRIPT_VERSION) print() # private token or personal token authentication gl = gitlab.Gitlab(GITLAB_URL, private_token=GITLAB_TOKEN) gl.auth() assert(isinstance(gl.user, gitlab.v4.objects.CurrentUser)) print_info("Connected to Gitlab, version: " + str(gl.version())) gt = pygitea.API(GITEA_URL, token=GITEA_TOKEN) gt_version = gt.get('/version').json() print_info("Connected to Gitea, version: " + str(gt_version['version'])) # IMPORT USERS AND GROUPS import_users_groups(gl, gt) # IMPORT PROJECTS import_projects(gl, gt) print() if GLOBAL_ERROR_COUNT == 0: print_success("Migration finished with no errors!") else: print_error("Migration finished with " + str(GLOBAL_ERROR_COUNT) + " errors!") # # Data loading helpers for Gitea # def get_labels(gitea_api: pygitea, owner: string, repo: string) -> []: existing_labels = [] label_response: requests.Response = gitea_api.get("/repos/" + owner + "/" + repo + "/labels") if label_response.ok: existing_labels = label_response.json() else: print_error("Failed to load existing milestones for project " + repo + "! " + label_response.text) return existing_labels def get_milestones(gitea_api: pygitea, owner: string, repo: string) -> []: existing_milestones = [] milestone_response: requests.Response = gitea_api.get("/repos/" + owner + "/" + repo + "/milestones") if milestone_response.ok: existing_milestones = milestone_response.json() else: print_error("Failed to load existing milestones for project " + repo + "! " + milestone_response.text) return existing_milestones def get_issues(gitea_api: pygitea, owner: string, repo: string) -> []: existing_issues = [] issue_response: requests.Response = gitea_api.get("/repos/" + owner + "/" + repo + "/issues", params={ "state": "all", "page": -1 }) if issue_response.ok: existing_issues = issue_response.json() else: print_error("Failed to load existing issues for project " + repo + "! " + issue_response.text) return existing_issues def get_teams(gitea_api: pygitea, orgname: string) -> []: existing_teams = [] team_response: requests.Response = gitea_api.get("/orgs/" + orgname + "/teams") if team_response.ok: existing_teams = team_response.json() else: print_error("Failed to load existing teams for organization " + orgname + "! " + team_response.text) return existing_teams def get_team_members(gitea_api: pygitea, teamid: int) -> []: existing_members = [] member_response: requests.Response = gitea_api.get("/teams/" + str(teamid) + "/members") if member_response.ok: existing_members = member_response.json() else: print_error("Failed to load existing members for team " + str(teamid) + "! " + member_response.text) return existing_members def get_collaborators(gitea_api: pygitea, owner: string, repo: string) -> []: existing_collaborators = [] collaborator_response: requests.Response = gitea_api.get("/repos/" + owner+ "/" + repo + "/collaborators") if collaborator_response.ok: existing_collaborators = collaborator_response.json() else: print_error("Failed to load existing collaborators for project " + repo + "! " + collaborator_response.text) return existing_collaborators def get_user_or_group(gitea_api: pygitea, project: gitlab.v4.objects.Project) -> {}: result = None response: requests.Response = gitea_api.get("/users/" + project.namespace['path']) if response.ok: result = response.json() else: response: requests.Response = gitea_api.get("/orgs/" + name_clean(project.namespace["name"])) if response.ok: result = response.json() else: print_error("Failed to load user or group " + project.namespace["name"] + "! " + response.text) return result def get_user_keys(gitea_api: pygitea, username: string) -> {}: result = [] key_response: requests.Response = gitea_api.get("/users/" + username + "/keys") if key_response.ok: result = key_response.json() else: print_error("Failed to load user keys for user " + username + "! " + key_response.text) return result def user_exists(gitea_api: pygitea, username: string) -> bool: user_response: requests.Response = gitea_api.get("/users/" + username) if user_response.ok: print_warning("User " + username + " does already exist in Gitea, skipping!") else: print("User " + username + " not found in Gitea, importing!") return user_response.ok def user_key_exists(gitea_api: pygitea, username: string, keyname: string) -> bool: existing_keys = get_user_keys(gitea_api, username) if existing_keys: existing_key = next((item for item in existing_keys if item["title"] == keyname), None) if existing_key is not None: print_warning("Public key " + keyname + " already exists for user " + username + ", skipping!") return True else: print("Public key " + keyname + " does not exists for user " + username + ", importing!") return False else: print("No public keys for user " + username + ", importing!") return False def organization_exists(gitea_api: pygitea, orgname: string) -> bool: group_response: requests.Response = gitea_api.get("/orgs/" + orgname) if group_response.ok: print_warning("Group " + orgname + " does already exist in Gitea, skipping!") else: print("Group " + orgname + " not found in Gitea, importing!") return group_response.ok def member_exists(gitea_api: pygitea, username: string, teamid: int) -> bool: existing_members = get_team_members(gitea_api, teamid) if existing_members: existing_member = next((item for item in existing_members if item["username"] == username), None) if existing_member: print_warning("Member " + username + " is already in team " + str(teamid) + ", skipping!") return True else: print("Member " + username + " is not in team " + str(teamid) + ", importing!") return False else: print("No members in team " + str(teamid) + ", importing!") return False def collaborator_exists(gitea_api: pygitea, owner: string, repo: string, username: string) -> bool: collaborator_response: requests.Response = gitea_api.get("/repos/" + owner + "/" + repo + "/collaborators/" + username) if collaborator_response.ok: print_warning("Collaborator " + username + " does already exist in Gitea, skipping!") else: print("Collaborator " + username + " not found in Gitea, importing!") return collaborator_response.ok def repo_exists(gitea_api: pygitea, owner: string, repo: string) -> bool: repo_response: requests.Response = gitea_api.get("/repos/" + owner + "/" + repo) if repo_response.ok: print_warning("Project " + repo + " does already exist in Gitea, skipping!") else: print("Project " + repo + " not found in Gitea, importing!") return repo_response.ok def label_exists(gitea_api: pygitea, owner: string, repo: string, labelname: string) -> bool: existing_labels = get_labels(gitea_api, owner, repo) if existing_labels: existing_label = next((item for item in existing_labels if item["name"] == labelname), None) if existing_label is not None: print_warning("Label " + labelname + " already exists in project " + repo + ", skipping!") return True else: print("Label " + labelname + " does not exists in project " + repo + ", importing!") return False else: print("No labels in project " + repo + ", importing!") return False def milestone_exists(gitea_api: pygitea, owner: string, repo: string, milestone: string) -> bool: existing_milestones = get_milestones(gitea_api, owner, repo) if existing_milestones: existing_milestone = next((item for item in existing_milestones if item["title"] == milestone), None) if existing_milestone is not None: print_warning("Milestone " + milestone + " already exists in project " + repo + ", skipping!") return True else: print("Milestone " + milestone + " does not exists in project " + repo + ", importing!") return False else: print("No milestones in project " + repo + ", importing!") return False def issue_exists(gitea_api: pygitea, owner: string, repo: string, issue: string) -> bool: existing_issues = get_issues(gitea_api, owner, repo) if existing_issues: existing_issue = next((item for item in existing_issues if item["title"] == issue), None) if existing_issue is not None: print_warning("Issue " + issue + " already exists in project " + repo + ", skipping!") return True else: print("Issue " + issue + " does not exists in project " + repo + ", importing!") return False else: print("No issues in project " + repo + ", importing!") return False # # Import helper functions # def _import_project_labels(gitea_api: pygitea, labels: [gitlab.v4.objects.ProjectLabel], owner: string, repo: string): for label in labels: if not label_exists(gitea_api, owner, repo, label.name): import_response: requests.Response = gitea_api.post("/repos/" + owner + "/" + repo + "/labels", json={ "name": label.name, "color": label.color, "description": label.description # currently not supported }) if import_response.ok: print_info("Label " + label.name + " imported!") else: print_error("Label " + label.name + " import failed: " + import_response.text) def _import_project_milestones(gitea_api: pygitea, milestones: [gitlab.v4.objects.ProjectMilestone], owner: string, repo: string): for milestone in milestones: if not milestone_exists(gitea_api, owner, repo, milestone.title): due_date = None if milestone.due_date is not None and milestone.due_date != '': due_date = dateutil.parser.parse(milestone.due_date).strftime('%Y-%m-%dT%H:%M:%SZ') import_response: requests.Response = gitea_api.post("/repos/" + owner + "/" + repo + "/milestones", json={ "description": milestone.description, "due_on": due_date, "title": milestone.title, }) if import_response.ok: print_info("Milestone " + milestone.title + " imported!") existing_milestone = import_response.json() if existing_milestone: # update milestone state, this cannot be done in the initial import :( # TODO: gitea api ignores the closed state... update_response: requests.Response = gitea_api.patch("/repos/" + owner + "/" + repo + "/milestones/" + str(existing_milestone['id']), json={ "description": milestone.description, "due_on": due_date, "title": milestone.title, "state": milestone.state }) if update_response.ok: print_info("Milestone " + milestone.title + " updated!") else: print_error("Milestone " + milestone.title + " update failed: " + update_response.text) else: print_error("Milestone " + milestone.title + " import failed: " + import_response.text) def _import_project_issues(gitea_api: pygitea, issues: [gitlab.v4.objects.ProjectIssue], owner: string, repo: string): # reload all existing milestones and labels, needed for assignment in issues existing_milestones = get_milestones(gitea_api, owner, repo) existing_labels = get_labels(gitea_api, owner, repo) for issue in issues: if not issue_exists(gitea_api, owner, repo, issue.title): due_date = '' if issue.due_date is not None: due_date = dateutil.parser.parse(issue.due_date).strftime('%Y-%m-%dT%H:%M:%SZ') assignee = None if issue.assignee is not None: assignee = issue.assignee['username'] assignees = [] for tmp_assignee in issue.assignees: assignees.append(tmp_assignee['username']) milestone = None if issue.milestone is not None: existing_milestone = next((item for item in existing_milestones if item["title"] == issue.milestone['title']), None) if existing_milestone: milestone = existing_milestone['id'] labels = [] for label in issue.labels: existing_label = next((item for item in existing_labels if item["name"] == label), None) if existing_label: labels.append(existing_label['id']) import_response: requests.Response = gitea_api.post("/repos/" + owner + "/" + repo + "/issues", json={ "assignee": assignee, "assignees": assignees, "body": issue.description, "closed": issue.state == 'closed', "due_on": due_date, "labels": labels, "milestone": milestone, "title": issue.title, }) if import_response.ok: print_info("Issue " + issue.title + " imported!") else: print_error("Issue " + issue.title + " import failed: " + import_response.text) def _import_project_repo(gitea_api: pygitea, project: gitlab.v4.objects.Project): if not repo_exists(gitea_api, project.namespace['name'], name_clean(project.name)): clone_url = project.http_url_to_repo if GITLAB_ADMIN_PASS == '' and GITLAB_ADMIN_USER == '': clone_url = project.ssh_url_to_repo private = project.visibility == 'private' or project.visibility == 'internal' # Load the owner (users and groups can both be fetched using the /users/ endpoint) owner = get_user_or_group(gitea_api, project) if owner: import_response: requests.Response = gitea_api.post("/repos/migrate", json={ "auth_password": GITLAB_ADMIN_PASS, "auth_username": GITLAB_ADMIN_USER, "clone_addr": clone_url, "description": project.description, "mirror": False, "private": private, "repo_name": name_clean(project.name), "uid": owner['id'] }) if import_response.ok: print_info("Project " + name_clean(project.name) + " imported!") else: print_error("Project " + name_clean(project.name) + " import failed: " + import_response.text) else: print_error("Failed to load project owner for project " + name_clean(project.name)) def _import_project_repo_collaborators(gitea_api: pygitea, collaborators: [gitlab.v4.objects.ProjectMember], project: gitlab.v4.objects.Project): for collaborator in collaborators: if not collaborator_exists(gitea_api, project.namespace['name'], name_clean(project.name), collaborator.username): permission = "read" if collaborator.access_level == 10: # guest access permission = "read" elif collaborator.access_level == 20: # reporter access permission = "read" elif collaborator.access_level == 30: # developer access permission = "write" elif collaborator.access_level == 40: # maintainer access permission = "admin" elif collaborator.access_level == 50: # owner access (only for groups) print_error("Groupmembers are currently not supported!") continue # groups are not supported else: print_warning("Unsupported access level " + str(collaborator.access_level) + ", setting permissions to 'read'!") import_response: requests.Response = gitea_api.put("/repos/" + project.namespace['name'] +"/" + name_clean(project.name) + "/collaborators/" + collaborator.username, json={ "permission": permission }) if import_response.ok: print_info("Collaborator " + collaborator.username + " imported!") else: print_error("Collaborator " + collaborator.username + " import failed: " + import_response.text) def _import_users(gitea_api: pygitea, users: [gitlab.v4.objects.User], notify: bool = False): for user in users: keys: [gitlab.v4.objects.UserKey] = user.keys.list(all=True) print("Importing user " + user.username + "...") print("Found " + str(len(keys)) + " public keys for user " + user.username) if not user_exists(gitea_api, user.username): tmp_password = 'Tmp1!' + ''.join(random.choices(string.ascii_uppercase + string.digits, k=10)) tmp_email = user.username + '@noemail-git.local' # Some gitlab instances do not publish user emails try: tmp_email = user.email except AttributeError: pass import_response: requests.Response = gitea_api.post("/admin/users", json={ "email": tmp_email, "full_name": user.name, "login_name": user.username, "password": tmp_password, "send_notify": notify, "source_id": 0, # local user "username": user.username }) if import_response.ok: print_info("User " + user.username + " imported, temporary password: " + tmp_password) else: print_error("User " + user.username + " import failed: " + import_response.text) # import public keys _import_user_keys(gitea_api, keys, user) def _import_user_keys(gitea_api: pygitea, keys: [gitlab.v4.objects.UserKey], user: gitlab.v4.objects.User): for key in keys: if not user_key_exists(gitea_api, user.username, key.title): import_response: requests.Response = gitea_api.post("/admin/users/" + user.username + "/keys", json={ "key": key.key, "read_only": True, "title": key.title, }) if import_response.ok: print_info("Public key " + key.title + " imported!") else: print_error("Public key " + key.title + " import failed: " + import_response.text) def _import_groups(gitea_api: pygitea, groups: [gitlab.v4.objects.Group]): for group in groups: members: [gitlab.v4.objects.GroupMember] = group.members.list(all=True) print("Importing group " + name_clean(group.name) + "...") print("Found " + str(len(members)) + " gitlab members for group " + name_clean(group.name)) if not organization_exists(gitea_api, name_clean(group.name)): import_response: requests.Response = gitea_api.post("/orgs", json={ "description": group.description, "full_name": group.full_name, "location": "", "username": name_clean(group.name), "website": "" }) if import_response.ok: print_info("Group " + name_clean(group.name) + " imported!") else: print_error("Group " + name_clean(group.name) + " import failed: " + import_response.text) # import group members _import_group_members(gitea_api, members, group) def _import_group_members(gitea_api: pygitea, members: [gitlab.v4.objects.GroupMember], group: gitlab.v4.objects.Group): # TODO: create teams based on gitlab permissions (access_level of group member) existing_teams = get_teams(gitea_api, name_clean(group.name)) if existing_teams: first_team = existing_teams[0] print("Organization teams fetched, importing users to first team: " + first_team['name']) # add members to teams for member in members: if not member_exists(gitea_api, member.username, first_team['id']): import_response: requests.Response = gitea_api.put("/teams/" + str(first_team['id']) + "/members/" + member.username) if import_response.ok: print_info("Member " + member.username + " added to group " + name_clean(group.name) + "!") else: print_error("Failed to add member " + member.username + " to group " + name_clean(group.name) + "!") else: print_error("Failed to import members to group " + name_clean(group.name) + ": no teams found!") # # Import functions # def import_users_groups(gitlab_api: gitlab.Gitlab, gitea_api: pygitea, notify=False): # read all users users: [gitlab.v4.objects.User] = gitlab_api.users.list(all=True) groups: [gitlab.v4.objects.Group] = gitlab_api.groups.list(all=True) print("Found " + str(len(users)) + " gitlab users as user " + gitlab_api.user.username) print("Found " + str(len(groups)) + " gitlab groups as user " + gitlab_api.user.username) # import all non existing users _import_users(gitea_api, users, notify) # import all non existing groups _import_groups(gitea_api, groups) def import_projects(gitlab_api: gitlab.Gitlab, gitea_api: pygitea): # read all projects and their issues projects: gitlab.v4.objects.Project = gitlab_api.projects.list(all=True) print("Found " + str(len(projects)) + " gitlab projects as user " + gitlab_api.user.username) for project in projects: collaborators: [gitlab.v4.objects.ProjectMember] = project.members.list(all=True) labels: [gitlab.v4.objects.ProjectLabel] = project.labels.list(all=True) milestones: [gitlab.v4.objects.ProjectMilestone] = project.milestones.list(all=True) issues: [gitlab.v4.objects.ProjectIssue] = project.issues.list(all=True) print("Importing project " + name_clean(project.name) + " from owner " + project.namespace['name']) print("Found " + str(len(collaborators)) + " collaborators for project " + name_clean(project.name)) print("Found " + str(len(labels)) + " labels for project " + name_clean(project.name)) print("Found " + str(len(milestones)) + " milestones for project " + name_clean(project.name)) print("Found " + str(len(issues)) + " issues for project " + name_clean(project.name)) # import project repo _import_project_repo(gitea_api, project) # import collaborators _import_project_repo_collaborators(gitea_api, collaborators, project) # import labels _import_project_labels(gitea_api, labels, project.namespace['name'], name_clean(project.name)) # import milestones _import_project_milestones(gitea_api, milestones, project.namespace['name'], name_clean(project.name)) # import issues _import_project_issues(gitea_api, issues, project.namespace['name'], name_clean(project.name)) # # Helper functions # class bcolors: HEADER = '\033[95m' OKBLUE = '\033[94m' OKGREEN = '\033[92m' WARNING = '\033[93m' FAIL = '\033[91m' ENDC = '\033[0m' BOLD = '\033[1m' UNDERLINE = '\033[4m' def color_message(color, message, colorend=bcolors.ENDC, bold=False): if bold: return bcolors.BOLD + color_message(color, message, colorend, False) return color + message + colorend def print_color(color, message, colorend=bcolors.ENDC, bold=False): print(color_message(color, message, colorend)) def print_info(message): print_color(bcolors.OKBLUE, message) def print_success(message): print_color(bcolors.OKGREEN, message) def print_warning(message): print_color(bcolors.WARNING, message) def print_error(message): global GLOBAL_ERROR_COUNT GLOBAL_ERROR_COUNT += 1 print_color(bcolors.FAIL, message) def name_clean(name): newName = name.replace(" ", "_") newName = re.sub(r"[^a-zA-Z0-9_\.-]", "-", newName) if (newName.lower() == "plugins"): return newName + "-user" return newName if __name__ == "__main__": main()