diff --git a/Documents/bin/git-manage b/Documents/bin/git-manage index 37e092a61dad40577c4a5c8d5240c88cf0589601..c7b5b38ddfb2e8fcb90cbf57e16f6d304dde166c 100755 --- a/Documents/bin/git-manage +++ b/Documents/bin/git-manage @@ -6,31 +6,227 @@ import argparse import os import os.path import sys +import github3.exceptions # pylint: disable=import-error +import gitlab.exceptions # pylint: disable=import-error +import passhole.passhole # pylint: disable=import-error sys.path.append(os.path.expanduser("~/Documents/bin")) -# import rcfiles.git # noqa: E402 pylint: disable=wrong-import-position -# import rcfiles.github # noqa: E402 pylint: disable=wrong-import-position -# import rcfiles.gitlab # noqa: E402 pylint: disable=wrong-import-position +import rcfiles.git # noqa: E402 pylint: disable=wrong-import-position +import rcfiles.github # noqa: E402 pylint: disable=wrong-import-position +import rcfiles.gitlab # noqa: E402 pylint: disable=wrong-import-position + + +GH_MIRROR_PREFIX = "https://*****@github.com" + + +def github_mirrors(project, gh_conn): + """Return a list of GitHub repositories that are mirrored from GitLab.""" + repos = [] + for mirror in project.remote_mirrors.list(all=True): + if mirror.url.startswith(GH_MIRROR_PREFIX): + name, owner = ( + mirror.url.removeprefix(GH_MIRROR_PREFIX) + .removesuffix("/") + .removesuffix(".git") + .split("/") + ) + try: + repos.append(gh_conn.repository(name, owner)) + except github3.exceptions.NotFoundError: + pass + return repos + + +def mirror_project(project, gh_conn, token): + """Mirror a project to GitHub.""" + gh_me = rcfiles.github.me(gh_conn) + mirror_url = f"https://{token}@github.com/{gh_me}/{project.name}.git" + + try: + gh_repo = gh_conn.repository(project.name, gh_me) + except github3.exceptions.NotFoundError: + gh_repo = gh_conn.create_repository(project.name) + + gh_repo.edit( + project.name, + homepage=project.web_url, + description=f"Mirror of {project.web_url}", + has_issues=False, + has_wiki=False, + default_branch=project.default_branch, + has_projects=False, + ) + + for mirror in project.remote_mirrors.list(all=True): + if mirror.url.startswith(f"{GH_MIRROR_PREFIX}/{gh_me}/{project.name}"): + mirror.url = mirror_url + break + else: + mirror = project.remote_mirrors.create({"url": mirror_url}) + + mirror.enabled = True + mirror.only_protected_branches = False + mirror.keep_divergent_refs = False + mirror.save() + + return gh_repo + + +def get_mirror_token(): + """Get the GitHub token for mirroring a GitLab repository. + + Reads it from a Keepass password database using Passhole. + """ + ENTRY_PATH = "Web Sites/GitHub".split("/") # noqa + TOKEN_FIELD = "GitLab mirroring token" # noqa nosec + + # The following line requires an interactive session for getting the + # password. + db = passhole.passhole.open_database() + entry = db.find_entries(path=ENTRY_PATH, first=True) + return passhole.passhole.get_field(entry, TOKEN_FIELD) + + +def guess_remote(remote_type="gitlab", gl_conn=None): + # pylint: disable=too-many-return-statements + """Try to guess the right remote of the Git repo we're in. + + return None if failed, or the repo dictionary from get_all_remotes(). + """ + if remote_type not in ["gitlab", "github"]: + return None + + if remote_type == "gitlab" and gl_conn is None: + return None + + remotes = rcfiles.git.get_all_remotes() + if remotes is None: + return None + + if "origin" in remotes: + remote = remotes["origin"] + if remote_type == "gitlab" and rcfiles.gitlab.is_gitlab_url( + gl_conn, remote["url"] + ): + return remote + if remote_type == "github" and rcfiles.github.is_github_url( + remote["url"] + ): + return remote + + for remote in remotes.values(): + if remote_type == "gitlab" and rcfiles.gitlab.is_gitlab_url( + gl_conn, remote["url"] + ): + return remote + if remote_type == "github" and rcfiles.github.is_github_url( + remote["url"] + ): + return remote + + return None + + +def guess_name(args, gh_conn=None, gl_conn=None): + """Try to guess the name from the arguments and the repository remotes.""" + if args.name is None: + if not rcfiles.git.in_repo(): + parser.error("Name not provided and not in a Git repo.") + + remote = guess_remote( + remote_type="github" + if "github" in args and args.github + else "gitlab", + gl_conn=gl_conn, + ) + if remote is None: + if "github" in args and args.github: + parser.error( + "Name not provided and could not find a GitHub remote." + ) + else: + parser.error( + "Name not provided and could not find a GitLab remote." + ) + else: + if "github" in args and args.github: + name = rcfiles.github.url_to_name(remote["url"]) + else: + name = rcfiles.gitlab.url_to_name(gl_conn, remote["url"]) + else: + if "github" in args and args.github: + if "/" in args.name: + name = args.name + else: + name = f"{rcfiles.github.me(gh_conn)}/{args.name}" + else: + if "/" in args.name: + name = args.name + else: + name = f"{rcfiles.gitlab.me(gl_conn)}/{args.name}" + return name def create_repo(args): """Create a new repository.""" - print(args) + if args.mirror and args.github: + parser.error("Can't mirror from GitHub to GitLab.") def mirror_repo(args): """Mirror a GitLab repository to GitHub.""" - print(args) + try: + gh_conn = rcfiles.github.connect() + except Exception as e: # pylint: disable=broad-except + parser.error(f"Failed to connect to GitHub: {e}") + try: + gl_conn = rcfiles.gitlab.connect() + except Exception as e: # pylint: disable=broad-except + parser.error(f"Failed to connect to GitLab: {e}") + + name = guess_name(args, gh_conn, gl_conn) + + try: + project = gl_conn.projects.get(name) + except gitlab.exceptions.GitlabGetError: + parser.error(f"Could not find GitLab project {name}.") + + gh_repo = mirror_project(project, gh_conn, get_mirror_token()) + if args.name is None and rcfiles.git.in_repo(): + rcfiles.git.add_remote(".", "github", gh_repo.ssh_url) def archive_repo(args): """Archive a repository.""" - print(args) + try: + gh_conn = rcfiles.github.connect() + except Exception as e: # pylint: disable=broad-except + parser.error(f"Failed to connect to GitHub: {e}") + if not args.github: + try: + gl_conn = rcfiles.gitlab.connect() + except Exception as e: # pylint: disable=broad-except + parser.error(f"Failed to connect to GitLab: {e}") + + if args.github: + owner, name = guess_name(args, gh_conn=gh_conn, gl_conn=None).split( + "/" + ) + else: + name = guess_name(args, gh_conn=None, gl_conn=gl_conn) + + if args.github: + repo = gh_conn.repository(owner, name) + repo.edit(name, archived=True) + else: + project = gl_conn.projects.get(name) + project.archive() + for mirror in github_mirrors(project, gh_conn): + mirror.edit(mirror.name, archived=True) -def main(): - """The main function.""" +if __name__ == "__main__": parser = argparse.ArgumentParser(description=__doc__) subparsers = parser.add_subparsers( title="Commands", required=True, dest="command" @@ -71,9 +267,5 @@ def main(): "--github", help="The repository is in GitHub.", action="store_true" ) - args = parser.parse_args() - args.func(args) - - -if __name__ == "__main__": - main() + _args = parser.parse_args() + _args.func(_args) diff --git a/Documents/bin/git-namespace-backup b/Documents/bin/git-namespace-backup index 4a3186f58679fce8a1f1d2cef87d801514ed3b4a..d8a790bf66014604959382e20afe5bb99535e88b 100755 --- a/Documents/bin/git-namespace-backup +++ b/Documents/bin/git-namespace-backup @@ -14,6 +14,7 @@ import pathlib import sys import os import os.path +import gitlab.exception # pylint: disable=import-error import sh # pylint: disable=import-error from sh.contrib import git # pylint: disable=import-error @@ -50,39 +51,45 @@ if __name__ == "__main__": args = parser.parse_args() with rcfiles.gitlab.connect() as conn: - group = rcfiles.gitlab.get_group(conn, args.namespace.name) - if group is None: - group = rcfiles.gitlab.create_group( - conn, args.namespace.name, visibility="internal" - ) + try: + group = conn.groups.get(args.namespace.name) print( - "Created new group id: {}, name: {}, path: {}.".format( + "Using existing group id: {}, name: {}, path: {}.".format( group.id, group.name, group.path ), file=sys.stderr, ) - else: + except gitlab.exceptions.GitlabGetError: + group = conn.groups.create( + { + "name": args.namespace.name, + "path": rcfiles.gitlab.name_to_path(args.namespace.name), + "visibility": "internal", + } + ) print( - "Using existing group id: {}, name: {}, path: {}.".format( + "Created new group id: {}, name: {}, path: {}.".format( group.id, group.name, group.path ), file=sys.stderr, ) for repo in list_repositories(args.namespace): - project = rcfiles.gitlab.get_project(conn, group.name, repo.name) - if project is None: - project = rcfiles.gitlab.create_project( - conn, repo.name, group.name + try: + project = conn.projects.get_project( + f"{group.path}/{repo.name}" ) print( - "Created new project id: {}, name: {}, path: {}.".format( + "Using existing project id: {}, name: {}, path: {}.".format( # noqa: E501 project.id, project.name, project.path ), file=sys.stderr, ) - else: + except gitlab.exceptions.GitlabGetError: + project = conn.projects.create( + {"name": repo.name, "namespace_id": group.id} + ) print( - "Using existing project id: {}, name: {}, path: {}.".format( # noqa: E501 + "Created new project id: {}, name: {}, path: {}.".format( project.id, project.name, project.path ), file=sys.stderr, diff --git a/Documents/bin/rcfiles/git.py b/Documents/bin/rcfiles/git.py index ed4110b6404861bf031dd4925f2e4c6fdc29ff00..f7ac33b614a38f1a2406a749eea303eea4b486b3 100644 --- a/Documents/bin/rcfiles/git.py +++ b/Documents/bin/rcfiles/git.py @@ -3,8 +3,8 @@ import configparser import os.path import pathlib +import sh # pylint: disable=import-error from sh.contrib import git # pylint: disable=import-error -from . import gitlab def is_repo(path): @@ -23,23 +23,14 @@ def in_repo(): return is_repo(".") -def get_remotes(): +def get_all_remotes(): """Return a dictionary of remotes and their URL. - Also, deduce the remote type ("gitlab", "github" or None if couldn't figure - it out) and get the namespace and repository name. If not in a Git repository, return None. """ if not in_repo: return None - gitlab_http_url = gitlab.get_url() - gitlab_ssh_url = ( - f'git@{gitlab_http_url.removeprefix("https://").removesuffix("/")}:' - ) - github_http_url = "https://github.com/" - github_ssh_url = "git@github.com:" - config = configparser.ConfigParser() config.read(".git/config") @@ -52,40 +43,24 @@ def get_remotes(): if x.startswith("remote ") } - for name, remote in remotes.items(): - if remote["url"].startswith(gitlab_http_url) or remote[ - "url" - ].startswith(gitlab_ssh_url): - remotes[name]["type"] = "gitlab" - parts = ( - remote["url"] - .removeprefix(gitlab_http_url) - .removeprefix(gitlab_ssh_url) - .removesuffix(".git") - .split("/") - ) - if len(parts) == 2: - remotes[name]["namespace"] = parts[0] - remotes[name]["name"] = parts[1] - elif remote["url"].startswith(github_http_url) or remote[ - "url" - ].startswith(github_ssh_url): - remotes[name]["type"] = "github" - parts = ( - remote["url"] - .removeprefix(github_http_url) - .removeprefix(github_ssh_url) - .removesuffix(".git") - .split("/") - ) - if len(parts) == 2: - remotes[name]["name"] = parts[1] - else: - remotes[remote]["type"] = None - return remotes -def add_remote(name, url): +def add_remote(repo, name, url): """Add a remote to the Git repository.""" - git.remote("add", name, url) + with sh.pushd(repo): + git.remote("add", name, url) + + +def author_name(): + """Get the author name.""" + if "GIT_AUTHOR_NAME" in os.environ: + return os.environ["GIT_AUTHOR_NAME"] + return git.config("--get", "user.name") + + +def author_email(): + """Get the author email.""" + if "GIT_AUTHOR_EMAil" in os.environ: + return os.environ["GIT_AUTHOR_EMAIL"] + return git.config("--get", "user.email") diff --git a/Documents/bin/rcfiles/github.py b/Documents/bin/rcfiles/github.py index f6bf094210bc38e9d0ca301d68376e581debd965..72d329430ad034f8c8e15788bfe3964238734c21 100644 --- a/Documents/bin/rcfiles/github.py +++ b/Documents/bin/rcfiles/github.py @@ -3,40 +3,43 @@ import os import github3 # pylint: disable=import-error +# from . import git + +HTTP_URL = "https://github.com/" +SSH_URL = "git@github.com:" + + +def url_to_name(url): + """Get the full name from the GitLab URL.""" + return ( + url.removeprefix(HTTP_URL) + .removeprefix(SSH_URL) + .removesuffix("/") + .removesuffix(".git") + ) + + +def is_github_url(url): + """Return is the URL for a GitHub repository.""" + return url.startswith(HTTP_URL) or url.startswith(SSH_URL) + def connect(): """Return a GitHub session.""" try: token = os.environ["GITHUB_TOKEN"] except KeyError: + # pylint: disable-next=raise-missing-from raise Exception("GITHUB_TOKEN environment variable not set.") return github3.login(token=token) -def github_me(conn): +# pylint: disable=invalid-name +def me(conn): """Return my GitHub account name.""" return conn.me().login -def create_repo( # pylint: disable=too-many-arguments - conn, name, description="", homepage="", has_issues=False, has_wiki=False -): - """Create a new GitHub repository under the login namespace.""" - return conn.create_repository( - name, description, homepage, has_issues=has_issues, has_wiki=has_wiki - ) - - -def get_repo(conn, name): - """Return the GitHub repo object with the given name.""" - me = github_me(conn) # pylint: disable=invalid-name - for i in conn.repositries_by(me, type="owner"): - if i.name == name: - return i - raise Exception(f"Could not find the {name} GitHub repository.") - - -def read_only_github(conn, name): - """Make a GitHub repository read-only.""" - repo = get_repo(conn, name) - repo.edit(name, archived=True) +def empty_commit(repository): + """Commit an empty commit.""" + raise NotImplementedError diff --git a/Documents/bin/rcfiles/gitlab.py b/Documents/bin/rcfiles/gitlab.py index 3ea4ada54c21343001bcbac84a2dc4d45f996965..3e28713b39a4b381404d1a8dc158c47f515b52d5 100644 --- a/Documents/bin/rcfiles/gitlab.py +++ b/Documents/bin/rcfiles/gitlab.py @@ -2,7 +2,34 @@ import os import re -import gitlab # pylint: disable=import-error +import gitlab # pylint: disable=import-error,useless-suppression +import gitlab.exceptions # pylint: disable=import-error +from . import git + + +def http_url(conn): + """Return the HTTP url to the GitLab instance.""" + return conn.get_url() + + +def ssh_url(conn): + """Return the SSH url to the GitLab instance.""" + return f'git@{http_url(conn).removeprefix("https://").removesuffix("/")}:' + + +def url_to_name(conn, url): + """Get the full name from the GitLab URL.""" + return ( + url.removeprefix(http_url(conn)) + .removeprefix(ssh_url(conn.git)) + .removesuffix("/") + .removesuffix(".git") + ) + + +def is_gitlab_url(conn, url): + """Return is the URL for a GitLab repository.""" + return url.startswith(http_url(conn)) or url.startswith(ssh_url(conn)) def name_to_path(name): @@ -17,11 +44,38 @@ def get_url(): ).removesuffix("api/v4") +def get_remotes(conn): + """Returns a list of all the GitLab remotes. + + Very similar to the get_all_remotes function from the general git module, + but just the GitLab remotes and a bit more information. + """ + remotes = git.get_all_remotes() + if remotes is None: + return None + + gl_remotes = { + name: remote + for name, remote in remotes.items() + if is_gitlab_url(conn, remote["url"]) + } + for name, remote in gl_remotes.items(): + try: + gl_remotes[name]["project"] = conn.projects.get( + url_to_name(conn, remote["url"]) + ) + except gitlab.exceptions.GitlabGetError: + pass + + return gl_remotes + + def connect(): """Return the GitLab object.""" try: token = os.environ["GITLAB_TOKEN"] except KeyError: + # pylint: disable-next=raise-missing-from raise Exception("GITLAB_TOKEN environment variable not set.") url = get_url() conn = gitlab.Gitlab(url=url, private_token=token) @@ -29,57 +83,21 @@ def connect(): return conn -def get_group(conn, name): - """Return the GitLab group object with the given name.""" - for group in conn.groups.list(all=True): - if group.name == name: - return group - return None - - -def create_group(conn, name, visibility=None, description=None): - """Create a new GitLab group and return that object.""" - data = { - "name": name, - "path": name_to_path(name), - "visibility": "public" if visibility is None else visibility, - } - if description is not None: - data["description"] = description - return conn.groups.create(data) - - -def get_project(conn, group, name): - """Returns a GitLab project.""" - # pylint: disable=invalid-name - g = get_group(conn, group) - if g is None: - return None - for p in g.projects.list(all=True): - if p.name == name: - return p - return None - - -def create_project(conn, name, group=None, description=None, visibility=None): - """Create a new GitLab project and return that object.""" - # pylint: disable=invalid-name - data = { - "name": name, - } - if group is not None: - g = get_group(conn, group) - if g is None: - return None - data["namespace_id"] = g.id - if description is not None: - data["description"] = description - if visibility is not None: - data["visibility"] = visibility - return conn.projects.create(data) - - -def read_only_project(conn, group, name): - """Make a GitLab project read-only.""" - project = get_project(conn, group, name) - project.archive() +# pylint: disable=invalid-name +def me(conn): + """Return my GitLab account name.""" + return conn.user.username + + +def empty_commit(project): + """Commit an empty commit.""" + return project.commit.create( + { + "id": project.id, + "branch": project.default_branch, + "commit_message": "Initial empty commit.", + "actions": [], + "author_email": git.author_email(), + "author_name": git.author_name(), + } + )