From e5cf96341983888f2b899372b34ce80436b8d2d2 Mon Sep 17 00:00:00 2001 From: Anton Medvedev Date: Jun 27 2025 07:53:12 +0000 Subject: Adding request-unretirement command Signed-off-by: Anton Medvedev --- diff --git a/fedpkg/cli.py b/fedpkg/cli.py index e5ed34b..3d7adb5 100644 --- a/fedpkg/cli.py +++ b/fedpkg/cli.py @@ -21,6 +21,9 @@ import re import shutil import textwrap from datetime import datetime + +import arrow + # Use deprecated pkg_resources if importlib isn't available (python 3.6) try: from importlib.metadata import distribution @@ -44,9 +47,11 @@ from fedpkg.utils import (assert_new_tests_repo, assert_valid_epel_package, do_fork, expand_release, get_dist_git_url, get_fedora_release_state, get_pagure_branches, get_release_branches, get_stream_branches, is_epel, - new_pagure_issue, sl_list_to_dict, verify_sls) + new_pagure_issue, sl_list_to_dict, verify_sls, + get_last_commit_date) RELEASE_BRANCH_REGEX = r'^(f\d+|el\d+|eln|epel\d+|epel\d+\.\d+)$' +BUGZILLA_URL_REGEX = r"https:\/\/bugzilla\.redhat\.com\/show_bug\.cgi\?id=(\d{7})" LOCAL_PACKAGE_CONFIG = 'package.cfg' BODHI_TEMPLATE = """\ @@ -137,6 +142,7 @@ class fedpkgClient(cliClient): self.register_set_distgit_token() self.register_set_pagure_token() self.register_do_disable_monitoring() + self.register_request_unretirement() def setup_completers(self): """ @@ -499,6 +505,79 @@ class fedpkgClient(cliClient): ) request_branch_parser.set_defaults(command=self.request_branch) + def register_request_unretirement(self): + help_msg = "Request an unretirement of package branch" + description = textwrap.dedent(""" + Request an unretirement of package branch + + Branch name could be one of current active Fedora and EPEL releases. Use + command ``{0} releases-info`` to get release names that can be used to request + a branch. + + Below are various examples of requesting an unretirement of package branch. + + Request an unretirement of `rawhide` branch inside cloned repository. + It will use discovered username use `--user USER` to override it. + Also if branch was retired more + than 8 weeks ago it will ask for opened bugzilla ticket. + + {0} request-unretirement + + Request an unretirement of few branches: + + {0} request-unretirement -b rawhide f40 + + Request an unretirement of branch that was retired more than 8 weeks ago + and requires bugzilla review to proceed: + + {0} request-unretirement --bz_bug_id BUG_ID + + Request an unretirement of package with different namespace than `rpms`: + + {0} request-unretirement --namespace test + + Request an unretirement outside package repository: + + {0} request-unretirement --repo name_of_package + """.format(self.name)) + request_unretirement_parser = self.subparsers.add_parser( + "request-unretirement", + formatter_class=argparse.RawTextHelpFormatter, + help=help_msg, + description=description, + ) + request_unretirement_parser.add_argument( + '--repo', + required=False, + dest='repo_name', + metavar='NAME', + help='Repository name to unretire some branches in.' + ) + request_unretirement_parser.add_argument( + '--namespace', + required=False, + dest='repo_ns_name', + metavar='NS', + default='rpms', + help='Repository namespace name, as a default use `rpm`.', + ) + request_unretirement_parser.add_argument( + "--bz_bug_id", + required=False, + dest="bz_bug_id", + metavar="BUG_ID", + default=None, + help="Bugzilla bug ID with re-review." + ) + request_unretirement_parser.add_argument( + "-b", "--branches", + required=False, + nargs='+', + dest="branches", + help="Comma-separated list of branches for unretirement." + ) + request_unretirement_parser.set_defaults(command=self.request_unretirement) + def register_do_fork(self): help_msg = 'Create a new fork of the current repository' distgit_section = '{0}.distgit'.format(self.name) @@ -1334,6 +1413,140 @@ class fedpkgClient(cliClient): anongiturl=anongiturl, ) + def request_unretirement(self): + if self.args.repo_name: + self.cmd.repo_name = self.args.repo_name + self.cmd.ns = self.args.repo_ns_name + + try: + active_branch = self.cmd.repo.active_branch.name + except rpkgError: + active_branch = None + + if not self.args.branches: + if not active_branch: + branches = ["rawhide"] + else: + branches = [active_branch] + else: + branches = self.args.branches + + self._request_unretirement( + logger=self.log, + repo_name=self.cmd.repo_name, + ns=self.cmd.ns, + branches=branches, + bugzila_url=self.args.bz_bug_id, + fas_name=self.cmd.user, + name=self.name, + config=self.config, + ) + + @staticmethod + def _request_unretirement( + logger, repo_name, ns, branches, bugzila_url, fas_name, name, config + ): + """ Implementation of `request_unretirement`. + + Submits a request for an unretretirement of package branch. + + :param logger: A logger object. + :param repo_name: The string of the repo name. + :param ns: The string of pacakge namespace. + :param branches: The list of branches that need to be unretired. + :param bugzila_url: The URL of the bugzilla review. + Typically, the value of `self.args.bz_url`, None if not needed. + :param fas_name: The string of fas name of user. + Typically, value is `self.cmd.user`. + :param name: A string representing which section of the config should be + used. Typically, the value of `self.name`. + :param config: A dict containing the configuration, loaded from file. + Typically, the value of `self.config`. + """ + distgit_section = '{0}.distgit'.format(name) + distgit_url = config_get_safely(config, distgit_section, 'apibaseurl') + bodhi_url = config.get('{0}.bodhi'.format(name), 'url') + current_time = arrow.now() + bugzilla_need = False + + release_branches = list(itertools.chain( + *list(get_release_branches(bodhi_url).values()))) + release_branches.append('rawhide') + + requested_branches = branches.copy() + for branch in requested_branches: + if branch not in release_branches: + branches.remove(branch) + print( + f"Branch {0} can't be unretired, because it is not an release branch." + .format(branch) + ) + + if not branches: + raise rpkgError( + "All requested branches {0} are not release branches." + " So there is nothing to unretire.".format(requested_branches) + ) + + for branch in branches: + commit_date = get_last_commit_date(distgit_url, ns, repo_name, branch) + commited_time = arrow.get(commit_date) + difference_in_days = (current_time - commited_time).days + if difference_in_days > 56: # 56 days is 8 weeks + bugzilla_need = True + if bugzila_url is None: + raise rpkgError( + "Bugzilla url should be provided, " + "because last commit was made more than 8 weeks ago." + ) + + # is bug has fedora-review+ flag + def get_bug_id(bz_info): + if not bz_info: + return None + match = re.search(BUGZILLA_URL_REGEX, bz_info) + if match: + bz_bug_id = match.group(1) + return bz_bug_id + else: + return bz_info + + bug_id = get_bug_id(bugzila_url) + + if bugzilla_need: + bz_url = config.get('{0}.bugzilla'.format(name), 'url') + bz_client = BugzillaClient(bz_url) + # get_review_bug checks if the bug has fedora_review+ flag + bz_client.get_review_bug(bug_id, ns, repo_name) + + # Ticket creation + pagure_section = '{0}.pagure'.format(name) + pagure_url = config_get_safely(config, pagure_section, 'url') + pagure_token = config_get_safely(config, pagure_section, 'token') + + ticket_body = { + 'action': 'unretirement', + 'name': repo_name, + 'type': ns, + 'branches': branches, + 'review_bugzilla': bug_id, + 'maintainer': fas_name, + } + ticket_body = json.dumps(ticket_body, indent=True) + ticket_body = '```\n{0}\n```'.format(ticket_body) + ticket_title = 'Unretire {0}'.format(repo_name) + + print( + new_pagure_issue( + logger=logger, + url=pagure_url, + token=pagure_token, + title=ticket_title, + body=ticket_body, + cli_name=name, + ) + ) + def do_distgit_fork(self): """create fork of the distgit repository That includes creating fork itself using API call and then adding diff --git a/fedpkg/utils.py b/fedpkg/utils.py index 1ce9177..652a28e 100644 --- a/fedpkg/utils.py +++ b/fedpkg/utils.py @@ -13,6 +13,7 @@ import json import re from datetime import datetime, timezone +import tempfile import git import requests @@ -653,7 +654,32 @@ def disable_monitoring(logger, base_url, token, repo_name, namespace, cli_name): if re.search(r"Invalid or expired token", rv_error, re.IGNORECASE): base_error_msg += '\nFor invalid or expired tokens please ' \ 'set a new token in your user configuration with:' \ - '\n\n\t{0} set-distgit-token\n'.format(cli_name) + '\n\n\t{0} set-distgit-token \n'.format(cli_name) raise rpkgError(base_error_msg.format(rv_error)) logger.info("Monitoring of the project was sucessfully disabled.") + + +def get_last_commit_date(base_url, namespace, repo_name, branch): + """ + Gets the last commit date of a repository. + :param base_url: a string of the URL repository + :param namespace: a string of the namespace + :param repo_name: a string of the repository name + :param branch: a string of the branch + :return: a string of the last commit date + """ + git_url = "{0}/{1}/{2}.git".format(base_url, namespace, repo_name) + with tempfile.TemporaryDirectory() as temp_dir: + try: + git_repo = git.Repo.clone_from(git_url, temp_dir) + + git_repo.git.checkout(branch) + last_commit = git_repo.head.commit + last_commit_date = last_commit.committed_date + + return last_commit_date + except Exception: + error_msg = ("Unable to get last commit date. Try to check repo name and namespace " + "if it exists.") + raise rpkgError(error_msg) diff --git a/test/fedpkg-stage.conf b/test/fedpkg-stage.conf index 60fde62..541ddad 100644 --- a/test/fedpkg-stage.conf +++ b/test/fedpkg-stage.conf @@ -19,6 +19,7 @@ url = https://bugzilla.stg.example.com [fedpkg-stage.pagure] url = https://pagure.stg.example.com/ +token = TOKEN [fedpkg-stage.distgit] apibaseurl = https://src.example.com diff --git a/test/test_cli.py b/test/test_cli.py index a89f044..c2f0883 100644 --- a/test/test_cli.py +++ b/test/test_cli.py @@ -24,6 +24,8 @@ from tempfile import mkdtemp, mkstemp from unittest.mock import Mock, PropertyMock, call, patch import git +import arrow + # Use deprecated pkg_resources if packaging library isn't available (python 3.6) try: from packaging.version import parse as parse_version @@ -2526,3 +2528,274 @@ class TestSetToken(CliTestCase): 'rpkg', '{0}.conf'.format(cli.name)) self.assertEqual(os.stat(PATH).st_mode & 0o077, 0) + + +class TestRequestUnretirement(CliTestCase): + """Test the request-unretirement cli command.""" + + def setUp(self): + super(TestRequestUnretirement, self).setUp() + + def tearDown(self): + super(TestRequestUnretirement, self).tearDown() + + def get_cli(self, cli_cmd, name='fedpkg-stage', cfg="fedpkg-stage.conf", + user_cfg="fedpkg-user.conf"): + with patch('sys.argv', new=cli_cmd): + return self.new_cli(name=name, cfg=cfg, user_cfg=user_cfg) + + @patch("fedpkg.cli.get_last_commit_date") + @patch("fedpkg.cli.get_release_branches") + @patch("requests.post") + @patch('sys.stdout', new=io.StringIO()) + def test_request_unretirement(self, mock_request_post, mock_grb, mock_gcd): + """Tests request-unretirement command.""" + mock_grb.return_value = { + 'fedora': ['f39', 'f40', 'f41'], + 'epel': ['epel9', 'epel10'], + } + + mock_rv = Mock() + mock_rv.ok = True + mock_rv.json.return_value = {'issue': {'id': 41}} + mock_request_post.return_value = mock_rv + + commit_time = arrow.now().shift(days=-1).timestamp() + mock_gcd.return_value = commit_time + + self.run_cmd(['git', 'checkout', '-b', 'f40'], cwd=self.cloned_repo_path) + + cli_cmd = ['fedpkg-stage', '--path', self.cloned_repo_path, 'request-unretirement'] + + cli = self.get_cli(cli_cmd) + cli.request_unretirement() + + expected_issue_content = { + 'action': 'unretirement', + 'name': 'testpkg', + 'type': 'rpms', + 'branches': ['f40'], + 'review_bugzilla': None, + 'maintainer': 'root', + } + + post_data = mock_request_post.call_args_list[0][1]['data'] + actual_issue_content = json.loads(json.loads( + post_data)['issue_content'].strip('```')) + self.assertEqual(expected_issue_content, actual_issue_content) + output = sys.stdout.getvalue().strip() + expected_output = ('https://pagure.stg.example.com/releng/' + 'fedora-scm-requests/issue/41') + self.assertEqual(output, expected_output) + + @patch("fedpkg.cli.get_release_branches") + @patch("requests.post") + @patch('sys.stdout', new=io.StringIO()) + def test_request_unretirement_not_release_branch(self, mock_request_post, mock_grb): + """Tests request-unretirement command on branch that is not a release branch.""" + mock_grb.return_value = { + 'fedora': ['f39', 'f40', 'f41'], + 'epel': ['epel9', 'epel10'], + } + + mock_rv = Mock() + mock_rv.ok = True + mock_rv.json.return_value = {'issue': {'id': 41}} + mock_request_post.return_value = mock_rv + + expected_error = ( + "All requested branches {0} are not release branches. So there is nothing to unretire." + .format(['f15'])) + + self.run_cmd(['git', 'checkout', '-b', 'f15'], cwd=self.cloned_repo_path) + + cli_cmd = ['fedpkg-stage', '--path', self.cloned_repo_path, 'request-unretirement'] + + cli = self.get_cli(cli_cmd) + try: + cli.request_unretirement() + assert False, 'rpkgError not raised' + except rpkgError as error: + self.assertEqual(str(error), expected_error) + + @patch("fedpkg.cli.get_last_commit_date") + @patch("fedpkg.cli.get_release_branches") + @patch("requests.post") + @patch('sys.stdout', new=io.StringIO()) + def test_request_unretirement_bugzilla_needed_but_not_provided( + self, mock_request_post, mock_grb, mock_gcd + ): + """Tests request-unretirement that require to check bugzilla, but bug id wasn't provided""" + mock_grb.return_value = { + 'fedora': ['f39', 'f40', 'f41'], + 'epel': ['epel9', 'epel10'], + } + + mock_rv = Mock() + mock_rv.ok = True + mock_rv.json.return_value = {'issue': {'id': 41}} + mock_request_post.return_value = mock_rv + + commit_time = arrow.now().shift(days=-57).timestamp() + mock_gcd.return_value = commit_time + + expected_error = ( + "Bugzilla url should be provided, " + "because last commit was made more than 8 weeks ago." + ) + + self.run_cmd(['git', 'checkout', '-b', 'f40'], cwd=self.cloned_repo_path) + + cli_cmd = ['fedpkg-stage', '--path', self.cloned_repo_path, 'request-unretirement'] + cli = self.get_cli(cli_cmd) + + try: + cli.request_unretirement() + assert False, 'rpkgError not raised' + except rpkgError as error: + self.assertEqual(str(error), expected_error) + + @patch("fedpkg.bugzilla.BugzillaClient.get_review_bug") + @patch("fedpkg.cli.get_last_commit_date") + @patch("fedpkg.cli.get_release_branches") + @patch("requests.post") + @patch('sys.stdout', new=io.StringIO()) + def test_request_unretirement_bz_id_provided( + self, mock_request_post, mock_grb, mock_gcd, mock_bz_grb + ): + """Tests request-unretirement command when bz_id provided""" + mock_grb.return_value = { + 'fedora': ['f39', 'f40', 'f41'], + 'epel': ['epel9', 'epel10'], + } + + mock_rv = Mock() + mock_rv.ok = True + mock_rv.json.return_value = {'issue': {'id': 41}} + mock_request_post.return_value = mock_rv + + commit_time = arrow.now().shift(days=-57).timestamp() + mock_gcd.return_value = commit_time + + self.run_cmd(['git', 'checkout', '-b', 'f40'], cwd=self.cloned_repo_path) + + cli_cmd = [ + 'fedpkg-stage', '--path', self.cloned_repo_path, 'request-unretirement', + '--bz_bug_id', '1234567'] + + cli = self.get_cli(cli_cmd) + cli.request_unretirement() + + expected_issue_content = { + 'action': 'unretirement', + 'name': 'testpkg', + 'type': 'rpms', + 'branches': ['f40'], + 'review_bugzilla': '1234567', + 'maintainer': 'root', + } + + post_data = mock_request_post.call_args_list[0][1]['data'] + actual_issue_content = json.loads(json.loads( + post_data)['issue_content'].strip('```')) + self.assertEqual(expected_issue_content, actual_issue_content) + output = sys.stdout.getvalue().strip() + expected_output = ('https://pagure.stg.example.com/releng/' + 'fedora-scm-requests/issue/41') + self.assertEqual(output, expected_output) + + @patch("fedpkg.bugzilla.BugzillaClient.get_review_bug") + @patch("fedpkg.cli.get_last_commit_date") + @patch("fedpkg.cli.get_release_branches") + @patch("requests.post") + @patch('sys.stdout', new=io.StringIO()) + def test_request_unretirement_bz_url_provided( + self, mock_request_post, mock_grb, mock_gcd, mock_bz_grb + ): + """Tests request-unretirement command when bz url provided""" + mock_grb.return_value = { + 'fedora': ['f39', 'f40', 'f41'], + 'epel': ['epel9', 'epel10'], + } + + mock_rv = Mock() + mock_rv.ok = True + mock_rv.json.return_value = {'issue': {'id': 41}} + mock_request_post.return_value = mock_rv + + commit_time = arrow.now().shift(days=-57).timestamp() + mock_gcd.return_value = commit_time + + self.run_cmd(['git', 'checkout', '-b', 'f40'], cwd=self.cloned_repo_path) + + cli_cmd = [ + 'fedpkg-stage', '--path', self.cloned_repo_path, 'request-unretirement', + '--bz_bug_id', 'https://bugzilla.redhat.com/show_bug.cgi?id=1234567'] + + cli = self.get_cli(cli_cmd) + cli.request_unretirement() + + expected_issue_content = { + 'action': 'unretirement', + 'name': 'testpkg', + 'type': 'rpms', + 'branches': ['f40'], + 'review_bugzilla': '1234567', + 'maintainer': 'root', + } + + post_data = mock_request_post.call_args_list[0][1]['data'] + actual_issue_content = json.loads(json.loads( + post_data)['issue_content'].strip('```')) + self.assertEqual(expected_issue_content, actual_issue_content) + output = sys.stdout.getvalue().strip() + expected_output = ('https://pagure.stg.example.com/releng/' + 'fedora-scm-requests/issue/41') + self.assertEqual(output, expected_output) + + @patch("fedpkg.cli.get_last_commit_date") + @patch("fedpkg.cli.get_release_branches") + @patch("requests.post") + @patch('sys.stdout', new=io.StringIO()) + def test_request_unretirement_repo_name_provided( + self, mock_request_post, mock_grb, mock_gcd + ): + """Tests request-unretirement command.""" + mock_grb.return_value = { + 'fedora': ['f39', 'f40', 'f41'], + 'epel': ['epel9', 'epel10'], + } + + mock_rv = Mock() + mock_rv.ok = True + mock_rv.json.return_value = {'issue': {'id': 41}} + mock_request_post.return_value = mock_rv + + commit_time = arrow.now().shift(days=-1).timestamp() + mock_gcd.return_value = commit_time + + self.run_cmd(['git', 'checkout', '-b', 'f40'], cwd=self.cloned_repo_path) + cli_cmd = [ + 'fedpkg-stage', '--path', self.cloned_repo_path, 'request-unretirement', + '--repo', 'belusha' + ] + cli = self.get_cli(cli_cmd) + cli.request_unretirement() + + expected_issue_content = { + 'action': 'unretirement', + 'name': 'belusha', + 'type': 'rpms', + 'branches': ['f40'], + 'review_bugzilla': None, + 'maintainer': 'root', + } + + post_data = mock_request_post.call_args_list[0][1]['data'] + actual_issue_content = json.loads(json.loads( + post_data)['issue_content'].strip('```')) + self.assertEqual(expected_issue_content, actual_issue_content) + output = sys.stdout.getvalue().strip() + expected_output = ('https://pagure.stg.example.com/releng/' + 'fedora-scm-requests/issue/41') + self.assertEqual(output, expected_output) diff --git a/test/test_utils.py b/test/test_utils.py index 051e813..316fd44 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -14,7 +14,10 @@ from configparser import NoOptionError, NoSectionError import json import unittest from unittest.mock import Mock, patch + +import git from requests.exceptions import ConnectionError + from fedpkg import utils from freezegun import freeze_time from pyrpkg.errors import rpkgError @@ -644,6 +647,32 @@ class TestGetFedoraReleaseState(unittest.TestCase): utils.get_fedora_release_state, config, 'fedpkg', 'F30M') +class TestGetLastCommitDate(unittest.TestCase): + """Test get_last_commit_date""" + + @patch("git.Repo") + def test_get_last_commit_date_success(self, mock_git_repo): + expected_date = 1712761897 + mock_repo = mock_git_repo.clone_from.return_value + mock_commit = mock_repo.head.commit + mock_commit.committed_date = expected_date + + result = utils.get_last_commit_date('base_url', 'namespace', + 'repo_name', 'rawhide') + + self.assertEqual(result, expected_date) + + @patch("git.Repo") + def test_get_last_commit_date_exception(self, mock_git_repo): + error_msg = ("Unable to get last commit date. Try to check repo name and namespace " + "if it exists.") + mock_git_repo.clone_from.side_effect = git.exc.GitCommandError("some error") + + with self.assertRaises(rpkgError, msg=error_msg): + utils.get_last_commit_date( + "url", "ns", "name", "branch") + + class TestGetReleaseBranches(unittest.TestCase): """Test utils.get_release_branches""" diff --git a/tests-requirements.txt b/tests-requirements.txt index 178e74e..71785f5 100644 --- a/tests-requirements.txt +++ b/tests-requirements.txt @@ -3,6 +3,11 @@ coverage<5.0.0 cccolutils gitpython freezegun +jsonschema[format]>=2.5.1,<4.0.0 +bravado-core==5.16.1 +bravado<12,>=10.6.0 +fasjson-client rpm < 0.0.3 pytest pytest-cov +arrow