From 80f38de2d6484ab33411a53fcdfe7a31d2bfa7ed Mon Sep 17 00:00:00 2001 From: Ondrej Nosek Date: Mar 30 2021 19:12:58 +0000 Subject: Create fork of the active repository by subcommand Adds new command 'fork' that calls API method which forks active repository for the given (or active) user and creates a remote record (named after user) in git configuration. GitLab Personal Access Token have to be added to the config for proper functionality. Signed-off-by: Ondrej Nosek --- diff --git a/src/centpkg.conf b/src/centpkg.conf index 79a16ba..24092cf 100644 --- a/src/centpkg.conf +++ b/src/centpkg.conf @@ -26,3 +26,7 @@ git_excludes = /.build-*.log results_*/ clog + +[centpkg.distgit] +apibaseurl = https://gitlab.com +token = diff --git a/src/centpkg/cli.py b/src/centpkg/cli.py index 2bd69d7..798ba11 100755 --- a/src/centpkg/cli.py +++ b/src/centpkg/cli.py @@ -14,7 +14,13 @@ # the full text of the license. from __future__ import print_function + +import argparse +import textwrap + +from centpkg.utils import config_get_safely, do_add_remote, do_fork from pyrpkg.cli import cliClient +from six.moves.urllib_parse import urlparse class centpkgClient(cliClient): @@ -25,10 +31,89 @@ class centpkgClient(cliClient): self.setup_centos_subparsers() def setup_centos_subparsers(self): - self.register_parser() + self.register_do_fork() - def register_parser(self): - pass + def register_do_fork(self): + help_msg = 'Create a new fork of the current repository' + distgit_section = '{0}.distgit'.format(self.name) + distgit_api_base_url = config_get_safely(self.config, distgit_section, "apibaseurl") + description = textwrap.dedent(''' + Create a new fork of the current repository + + Before the operation, you need to generate an API token at + https://{1}/-/profile/personal_access_tokens, select the relevant + scope(s) and save it in your local user configuration located + at ~/.config/rpkg/{0}.conf. For example: + + [{0}.distgit] + token = + + Below is a basic example of the command to fork a current repository: + + {0} fork + + Operation requires username (GITLAB_ID). by default, current logged + username is taken. It could be overridden by reusing an argument: + + {0} --user GITLAB_ID fork + '''.format(self.name, urlparse(distgit_api_base_url).netloc)) + + fork_parser = self.subparsers.add_parser( + 'fork', + formatter_class=argparse.RawDescriptionHelpFormatter, + help=help_msg, + description=description) + fork_parser.set_defaults(command=self.do_distgit_fork) + + def do_distgit_fork(self): + """create fork of the distgit repository + That includes creating fork itself using API call and then adding + remote tracked repository + """ + distgit_section = '{0}.distgit'.format(self.name) + distgit_api_base_url = config_get_safely(self.config, distgit_section, "apibaseurl") + distgit_remote_base_url = self.config.get( + '{0}'.format(self.name), + "gitbaseurl", + vars={'user': self.cmd.user, 'repo': self.cmd.repo_name}, + ) + distgit_token = config_get_safely(self.config, distgit_section, 'token') + + ret = do_fork( + logger=self.log, + base_url=distgit_api_base_url, + token=distgit_token, + repo_name=self.cmd.repo_name, + namespace=self.cmd.ns, + cli_name=self.name, + ) + + # assemble url of the repo in web browser + fork_url = '{0}/{1}/{2}'.format( + distgit_api_base_url.rstrip('/'), + self.cmd.user, + self.cmd.repo_name, + ) + + if ret: + msg = "Fork of the repository has been created: '{0}'" + else: + msg = "Repo '{0}' already exists." + self.log.info(msg.format(fork_url)) + + ret = do_add_remote( + base_url=distgit_api_base_url, + remote_base_url=distgit_remote_base_url, + username=self.cmd.user, + repo=self.cmd.repo, + repo_name=self.cmd.repo_name, + namespace=self.cmd.ns, + ) + if ret: + msg = "Adding as remote '{0}'." + else: + msg = "Remote with name '{0}' already exists." + self.log.info(msg.format(self.cmd.user)) class centpkgClientSig(cliClient): diff --git a/src/centpkg/utils.py b/src/centpkg/utils.py new file mode 100644 index 0000000..634e8bb --- /dev/null +++ b/src/centpkg/utils.py @@ -0,0 +1,146 @@ +# -*- coding: utf-8 -*- +# utils.py - a module with support methods for centpkg +# +# Copyright (C) 2021 Red Hat Inc. +# Author(s): Ondrej Nosek +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation; either version 2 of the License, or (at your +# option) any later version. See http://www.gnu.org/copyleft/gpl.html for +# the full text of the license. + +import json + +import git +import requests +from pyrpkg import rpkgError +from requests.exceptions import ConnectionError +from six.moves.configparser import NoOptionError, NoSectionError +from six.moves.urllib.parse import quote_plus, urlparse + + +def do_fork(logger, base_url, token, repo_name, namespace, cli_name): + """ + Creates a fork of the project. + :param logger: A logger object + :param base_url: a string of the URL repository + :param token: a string of the API token that has rights to make a fork + :param repo_name: a string of the repository name + :param namespace: a string determines a type of the repository + :param cli_name: string of the CLI's name (e.g. centpkg) + :return: a bool; True when fork was created, False when already exists + """ + api_url = '{0}/api/v4'.format(base_url.rstrip('/')) + project_id = quote_plus("redhat/centos-stream/{0}/{1}".format(namespace, repo_name)) + fork_url = '{0}/projects/{1}/fork'.format(api_url, project_id) + + headers = { + 'PRIVATE-TOKEN': token, + 'Accept': 'application/json', + 'Content-Type': 'application/json' + } + payload = json.dumps({}) + try: + rv = requests.post( + fork_url, headers=headers, data=payload, timeout=60) + except ConnectionError as error: + error_msg = ('The connection to API failed while trying to ' + 'create a new fork. The error was: {0}'.format(str(error))) + raise rpkgError(error_msg) + + try: + # Extract response json for debugging + rv_json = rv.json() + logger.debug("Pagure API response: '{0}'".format(rv_json)) + except Exception: + pass + + base_error_msg = ('The following error occurred while creating a new fork: {0}') + if not rv.ok: + # fork was already created + if rv.status_code == 409 or rv.reason == "Conflict": + return False + # show hint for invalid, expired or revoked token + elif rv.status_code == 401 or rv.reason == "Unauthorized": + base_error_msg += '\nFor invalid or expired token refer to ' \ + '"{0} fork -h" to set a token in your user ' \ + 'configuration.'.format(cli_name) + raise rpkgError(base_error_msg.format(rv.text)) + + return True + + +def do_add_remote(base_url, remote_base_url, username, repo, repo_name, + namespace): + """ + Adds remote tracked repository + :param base_url: a string of the URL repository + :param remote_base_url: a string of the remote tracked repository + :param username: a string of the (FAS) user name + :param repo: object, current project git repository + :param repo_name: a string of the repository name + :param namespace: a string determines a type of the repository + :return: a bool; True if remote was created, False when already exists + """ + parsed_url = urlparse(remote_base_url) + remote_url = '{0}://{1}/{2}/{3}.git'.format( + parsed_url.scheme, + parsed_url.netloc, + username, + repo_name, + ) + + # check already existing remote + for remote in repo.remotes: + if remote.name == username: + return False + + try: + # create remote with username as its name + repo.create_remote(username, url=remote_url) + except git.exc.GitCommandError as e: + error_msg = "During create remote:\n {0}\n {1}".format( + " ".join(e.command), e.stderr) + raise rpkgError(error_msg) + return True + + +def config_get_safely(config, section, option): + """ + Returns option from the user's configuration file. In case of missing + section or option method throws an exception with a human-readable + warning and a possible hint. + The method should be used especially in situations when there are newly + added sections/options into the config. In this case, there is a risk that + the user's config wasn't properly upgraded. + + :param config: ConfigParser object + :param section: section name in the config + :param option: name of the option + :return: option value from the right section + :rtype: str + """ + + hint = ( + "First (if possible), refer to the help of the current command " + "(-h/--help).\n" + "There also might be a new version of the config after upgrade.\n" + "Hint: you can check if you have 'centpkg.conf.rpmnew' or " + "'centpkg.conf.rpmsave' in the config directory. If yes, try to merge " + "your changes to the config with the maintainer provided version " + "(or replace centpkg.conf file with 'centpkg.conf.rpmnew')." + ) + + try: + return config.get(section, option) + except NoSectionError: + msg = "Missing section '{0}' in the config file.".format(section) + raise rpkgError("{0}\n{1}".format(msg, hint)) + except NoOptionError: + msg = "Missing option '{0}' in the section '{1}' of the config file.".format( + option, section + ) + raise rpkgError("{0}\n{1}".format(msg, hint)) + except Exception: + raise