#10 Create fork of the active repository by subcommand
Merged 3 years ago by bstinson. Opened 3 years ago by onosek.
centos/ onosek/centpkg fork_initial  into  develop

file modified
+4
@@ -26,3 +26,7 @@ 

    /.build-*.log

    results_*/

    clog

+ 

+ [centpkg.distgit]

+ apibaseurl = https://gitlab.com

+ token = 

file modified
+89 -3
@@ -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,90 @@ 

          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 = <api_key_here>

+ 

+             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}/centos_{2}_{3}'.format(

+             distgit_api_base_url.rstrip('/'),

+             self.cmd.user,

+             self.cmd.ns,

+             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):

file added
+152
@@ -0,0 +1,152 @@ 

+ # -*- coding: utf-8 -*-

+ # utils.py - a module with support methods for centpkg

+ #

+ # Copyright (C) 2021 Red Hat Inc.

+ # Author(s): Ondrej Nosek <onosek@redhat.com>

+ #

+ # 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'

+     }

+     # define a new repository name/path to avoid collision with other projects

+     safe_name = "centos_{0}_{1}".format(namespace, repo_name)

+     payload = json.dumps({

+         'name': safe_name,  # name of the project after forking

+         'path': safe_name,

+     })

+     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}/centos_{3}_{4}.git'.format(

+         parsed_url.scheme,

+         parsed_url.netloc,

+         username,

+         namespace,

+         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

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 onosek@redhat.com

Instead of calling this CENTOS_ID could we possibly say "GITLAB_ID" ? I don't want folks to confuse this with their CentOS FAS Account

Nice!

centos-stream/rpms/pesign$ centpkg fork 
Fork of the repository has been created: 'https://gitlab.com/fork/bstinson/rpms/pesign'
Adding as remote 'bstinson'.

Looks like we might need to adjust the URL that gets printed in the message, but the fork gets created:
https://gitlab.com/bstinson/pesign

rebased onto 7b870db09feb9242d52aeb4cc2d9ef343f86eddf

3 years ago

Sure, I changed CENTOS_ID --> GITLAB_ID (I wasn't sure about that anyway).
Fixed the URL of the fork - removed "fork" and namespace. Is there any intention to create forks with namespaces (for example 'rpms') in future?
Error handling (repeated fork request; request with a wrong or missing token) was fixed.

rebased onto 09bd9b0a04d9844195409746adc63c531a00ac25

3 years ago

rebased onto 08994c5c1eed739597f6e0f0df5733a48c8aa10f

3 years ago

rebased onto fd6c57b9f5f08a483afaea02b6b5e6c2c0ef1a64

3 years ago

Actually, yes. We probably do need to consider the namespace because we will have to deal with things like rpms/nodejs and modules/nodejs which would collide if we tried to fork them both.

Actually, yes. We probably do need to consider the namespace because we will have to deal with things like rpms/nodejs and modules/nodejs which would collide if we tried to fork them both.

I am not sure if gitlab allows creating groups (namespaces) or not in gitlab's user account automatically.

I think if gitlab allows it, maybe we should add a Y/n question asking for if its okay to create the group in their account.

Hmm, you're right. Gitlab doesn't appear to support namespaces in personal accounts. I just tried forking https://gitlab.com/redhat/centos-stream/rpms/sscg (while I already have https://gitlab.com/sgallagher/sscg which is a mirror of the upstream project). It rejects it because the name is already taken. Note: this is with the built-in fork button in the web GUI. We could work around this with centpkg fork by prefixing the name with something like rpms_<project> or <cs_rpms_project>, but that would mean that centpkg would be effectively the ONLY way to do this.

There's a relevant Gitlab-org bug about this: https://gitlab.com/gitlab-org/gitlab/-/issues/15013

Looks like they've got that slated for release in 13.11 (end of April), which might be acceptable for us. Once that's in place, anyone could use the web UI for forking it to an alternative name of their choice.

@carlwgeorge @mohanboddu @bstinson and I just discussed this ticket elsewhere.

Our recommendation is that we have centpkg fork rename the repository when forking it to use centos_<namespace>_<project> as the new name to avoid the possibility of collisions. We will need to document this approach for anyone using a tool other than centpkg (once https://gitlab.com/gitlab-org/gitlab/-/issues/15013 is resolved). In the meantime, we will need to document that centpkg fork is the only safe method.

rebased onto 80f38de

3 years ago

@sgallagh (... and others) I saw, that you have decided about namespaces. I rebased the code and added another commit, that does rename you described. After the expected feature in Gitlab is available, this additional commit could be reverted. Is it OK this way?

Pull-Request has been merged by bstinson

3 years ago