From f7464aec2cb448b31ef62d7b2162cded43739819 Mon Sep 17 00:00:00 2001
From: Jana Cupova <root@localhost>
Date: Jul 22 2025 14:19:09 +0000
Subject: [PATCH 1/10] Add remove-group-pkg CLI command


Fixes: https://pagure.io/koji/issue/3199

---

diff --git a/cli/koji_cli/commands.py b/cli/koji_cli/commands.py
index efe07df..198b6da 100644
--- a/cli/koji_cli/commands.py
+++ b/cli/koji_cli/commands.py
@@ -3100,6 +3100,26 @@ def handle_add_group_pkg(goptions, session, args):
         session.groupPackageListAdd(tag, group, pkg)
 
 
+def handle_remove_group_pkg(goptions, session, args):
+    "[admin] Remove a package from a group's package listing"
+    usage = "usage: %prog remove-group-pkg [options] <tag> <group> <pkg> [<pkg> ...]"
+    parser = OptionParser(usage=get_usage_str(usage))
+    parser.add_option("--force", action='store_true', help="Override blocks if necessary")
+    (options, args) = parser.parse_args(args)
+    if len(args) < 3:
+        parser.error("You must specify a tag name, group name, and one or more package names")
+    tag = args[0]
+    group = args[1]
+    opts = {}
+    opts['force'] = options.force
+    activate_session(session, goptions)
+    dsttag = session.getTag(tag)
+    if dsttag is None:
+        error("No such tag: %s" % tag)
+    with session.multicall() as m:
+        [m.groupPackageListRemove(tag, group, pkg, **opts) for pkg in args[2:]]
+
+
 def handle_block_group_pkg(goptions, session, args):
     "[admin] Block a package from a group's package listing"
     usage = "usage: %prog block-group-pkg [options] <tag> <group> <pkg> [<pkg> ...]"
diff --git a/tests/test_cli/data/list-commands-admin.txt b/tests/test_cli/data/list-commands-admin.txt
index 6677619..1928c1b 100644
--- a/tests/test_cli/data/list-commands-admin.txt
+++ b/tests/test_cli/data/list-commands-admin.txt
@@ -49,6 +49,7 @@ admin commands:
         regen-repo                Generate a current repo if there is not one
         remove-external-repo      Remove an external repo from a tag or tags, or remove entirely
         remove-group              Remove group from tag
+        remove-group-pkg          Remove a package from a group's package listing
         remove-host-from-channel  Remove a host from a channel
         remove-pkg                Remove a package from the listing for tag
         remove-sig                Remove signed RPMs from db and disk
diff --git a/tests/test_cli/data/list-commands.txt b/tests/test_cli/data/list-commands.txt
index a205772..809229d 100644
--- a/tests/test_cli/data/list-commands.txt
+++ b/tests/test_cli/data/list-commands.txt
@@ -49,6 +49,7 @@ admin commands:
         regen-repo                Generate a current repo if there is not one
         remove-external-repo      Remove an external repo from a tag or tags, or remove entirely
         remove-group              Remove group from tag
+        remove-group-pkg          Remove a package from a group's package listing
         remove-host-from-channel  Remove a host from a channel
         remove-pkg                Remove a package from the listing for tag
         remove-sig                Remove signed RPMs from db and disk
diff --git a/tests/test_cli/test_remove_group_pkg.py b/tests/test_cli/test_remove_group_pkg.py
new file mode 100644
index 0000000..62277a6
--- /dev/null
+++ b/tests/test_cli/test_remove_group_pkg.py
@@ -0,0 +1,109 @@
+from __future__ import absolute_import
+
+import mock
+import six
+
+from koji_cli.commands import handle_remove_group_pkg
+
+import koji
+from . import utils
+
+
+class TestRemoveGroupPkg(utils.CliTestCase):
+
+    def setUp(self):
+        # Show long diffs in error output...
+        self.maxDiff = None
+        self.options = mock.MagicMock()
+        self.options.debug = False
+        self.session = mock.MagicMock()
+        self.session.getAPIVersion.return_value = koji.API_VERSION
+        self.activate_session_mock = mock.patch('koji_cli.commands.activate_session').start()
+        self.error_format = """Usage: %s remove-group-pkg [options] <tag> <group> <pkg> [<pkg> ...]
+(Specify the --help global option for a list of other help options)
+
+%s: error: {message}
+""" % (self.progname, self.progname)
+
+    def test_handle_remove_pkg_not_existing_tag(self):
+        tag = 'tag'
+        package = 'package'
+        group = 'group'
+        args = [tag, group, package]
+
+        self.session.getTag.return_value = None
+        self.assert_system_exit(
+            handle_remove_group_pkg,
+            self.options, self.session, args,
+            stderr='No such tag: %s\n' % tag,
+            stdout='',
+            activate_session=None,
+            exit_code=1)
+        # Finally, assert that things were called as we expected.
+        self.activate_session_mock.assert_called_once_with(self.session, self.options)
+        self.session.getTag.assert_called_once_with(tag)
+        self.session.groupPackageListRemove.assert_not_called()
+        self.session.multiCall.assert_not_called()
+
+    def test_handle_remove_pkg_wrong_count_args(self):
+        tag = 'tag'
+        group = 'group'
+        args = [tag, group]
+        expected_error = self.format_error_message(
+            'You must specify a tag name, group name, and one or more package names')
+        self.assert_system_exit(
+            handle_remove_group_pkg,
+            self.options, self.session, args,
+            stderr=expected_error,
+            stdout='',
+            activate_session=None,
+            exit_code=2)
+        # Finally, assert that things were called as we expected.
+        self.activate_session_mock.assert_not_called()
+        self.session.getTag.assert_not_called()
+        self.session.groupPackageListRemove.assert_not_called()
+        self.session.multiCall.assert_not_called()
+
+    @mock.patch('sys.stdout', new_callable=six.StringIO)
+    @mock.patch('sys.stderr', new_callable=six.StringIO)
+    def test_handle_remove_pkg(self, stderr, stdout):
+        tag = 'tag'
+        dsttag = {'name': tag, 'id': 1}
+        package = 'package'
+        group = 'group'
+        args = [tag, group, package]
+
+        self.session.getTag.return_value = dsttag
+        self.session.groupPackageListRemove.return_value = None
+        handle_remove_group_pkg(self.options, self.session, args)
+        actual = stderr.getvalue()
+        expected = ''
+        self.assertMultiLineEqual(actual, expected)
+        actual = stdout.getvalue()
+        expected = ''
+        self.assertMultiLineEqual(actual, expected)
+        # Finally, assert that things were called as we expected.
+        self.activate_session_mock.assert_called_once_with(self.session, self.options)
+        self.session.getTag.assert_called_once_with(tag)
+
+    @mock.patch('sys.stdout', new_callable=six.StringIO)
+    @mock.patch('sys.stderr', new_callable=six.StringIO)
+    def test_handle_remove_pkg_with_force(self, stderr, stdout):
+        tag = 'tag'
+        dsttag = {'name': tag, 'id': 1}
+        package = 'package'
+        group = 'group'
+        args = [tag, group, package, '--force']
+
+        self.session.getTag.return_value = dsttag
+        self.session.groupPackageListRemove.return_value = None
+        handle_remove_group_pkg(self.options, self.session, args)
+        actual = stderr.getvalue()
+        expected = ''
+        self.assertMultiLineEqual(actual, expected)
+        actual = stdout.getvalue()
+        expected = ''
+        self.assertMultiLineEqual(actual, expected)
+        # Finally, assert that things were called as we expected.
+        self.activate_session_mock.assert_called_once_with(self.session, self.options)
+        self.session.getTag.assert_called_once_with(tag)

From c5828a4b8e0e6454bc80ad7ad83dab73dc923fab Mon Sep 17 00:00:00 2001
From: Mike McLean <mikem@redhat.com>
Date: Jul 22 2025 14:19:09 +0000
Subject: [PATCH 2/10] update remove-group-pkg with sanity checks


---

diff --git a/cli/koji_cli/commands.py b/cli/koji_cli/commands.py
index 198b6da..be496e4 100644
--- a/cli/koji_cli/commands.py
+++ b/cli/koji_cli/commands.py
@@ -3101,23 +3101,50 @@ def handle_add_group_pkg(goptions, session, args):
 
 
 def handle_remove_group_pkg(goptions, session, args):
-    "[admin] Remove a package from a group's package listing"
+    "[admin] Remove packages from a group's package listing"
     usage = "usage: %prog remove-group-pkg [options] <tag> <group> <pkg> [<pkg> ...]"
     parser = OptionParser(usage=get_usage_str(usage))
-    parser.add_option("--force", action='store_true', help="Override blocks if necessary")
     (options, args) = parser.parse_args(args)
     if len(args) < 3:
         parser.error("You must specify a tag name, group name, and one or more package names")
     tag = args[0]
     group = args[1]
-    opts = {}
-    opts['force'] = options.force
+    packages = args[2:]
     activate_session(session, goptions)
-    dsttag = session.getTag(tag)
-    if dsttag is None:
+    taginfo = session.getTag(tag)
+    if taginfo is None:
         error("No such tag: %s" % tag)
+
+    # sanity checks
+    groups = session.getTagGroups(taginfo['id'], incl_reqs=False, incl_blocked=True)
+    for ginfo in groups:
+        if ginfo['name'] == group:
+            break
+    else:
+        error("Group %s is not present in tag %s" % (group, tag))
+    pkg_idx = {p['package']: p for p in ginfo['packagelist']}
+    sane = True
+    for pkg in packages:
+        if pkg not in pkg_idx:
+            print("Package %s is not included in this group" % pkg)
+            sane = False
+            continue
+        pinfo = pkg_idx[pkg]
+        if pinfo['blocked']:
+            print("Package %s is blocked in this group. You could use unblock-group-pkg to unblock it" % pkg)
+            sane = False
+            continue
+        if pinfo['tag_id'] != taginfo['id']:
+            # listing is inherited
+            srctag = session.getTag(pinfo['tag_id'])
+            print("The entry for package %s is inherited from %s. You could use block-group-pkg to prevent this" % (pkg, srctag['name']))
+            sane = False
+            continue
+    if not sane:
+        error('Invalid parameters')
+
     with session.multicall() as m:
-        [m.groupPackageListRemove(tag, group, pkg, **opts) for pkg in args[2:]]
+        [m.groupPackageListRemove(taginfo['id'], group, pkg) for pkg in packages]
 
 
 def handle_block_group_pkg(goptions, session, args):

From f4435d4a9c69885cbd48b338b733a5540b6a1f73 Mon Sep 17 00:00:00 2001
From: Mike McLean <mikem@redhat.com>
Date: Jul 22 2025 14:19:09 +0000
Subject: [PATCH 3/10] remove-group-req too


---

diff --git a/cli/koji_cli/commands.py b/cli/koji_cli/commands.py
index be496e4..8c37945 100644
--- a/cli/koji_cli/commands.py
+++ b/cli/koji_cli/commands.py
@@ -3218,6 +3218,52 @@ def handle_unblock_group_req(goptions, session, args):
     activate_session(session, goptions)
     session.groupReqListUnblock(tag, group, req)
 
+def handle_remove_group_req(goptions, session, args):
+    "[admin] Remove entries from a group's requirement listing"
+    usage = "usage: %prog remove-group-req [options] <tag> <group> <req> [<req> ...]"
+    parser = OptionParser(usage=get_usage_str(usage))
+    (options, args) = parser.parse_args(args)
+    if len(args) < 3:
+        parser.error("You must specify a tag name, group name, and one or more requirement names")
+    tag = args[0]
+    group = args[1]
+    requires = args[2:]
+    activate_session(session, goptions)
+    taginfo = session.getTag(tag)
+    if taginfo is None:
+        error("No such tag: %s" % tag)
+
+    # sanity checks
+    groups = session.getTagGroups(taginfo['id'], incl_pkgs=False, incl_blocked=True)
+    for ginfo in groups:
+        if ginfo['name'] == group:
+            break
+    else:
+        error("Group %s is not present in tag %s" % (group, tag))
+    req_idx = {r['name']: r for r in ginfo['grouplist']}
+    sane = True
+    for req in requires:
+        if req not in req_idx:
+            print("Req %s is not included in this group" % req)
+            sane = False
+            continue
+        rinfo = req_idx[req]
+        if rinfo['blocked']:
+            print("Req %s is blocked in this group. You could use unblock-group-req to unblock it" % req)
+            sane = False
+            continue
+        if rinfo['tag_id'] != taginfo['id']:
+            # listing is inherited
+            srctag = session.getTag(rinfo['tag_id'])
+            print("The entry for req %s is inherited from %s. You could use block-group-req to prevent this" % (req, srctag['name']))
+            sane = False
+            continue
+    if not sane:
+        error('Invalid parameters')
+
+    with session.multicall() as m:
+        [m.groupReqListRemove(taginfo['id'], group, req) for req in requires]
+
 
 def anon_handle_list_channels(goptions, session, args):
     "[info] Print channels listing"

From fb292aeca1e6909949ea439caa72a5ca6a79c6d1 Mon Sep 17 00:00:00 2001
From: Mike McLean <mikem@redhat.com>
Date: Jul 22 2025 14:19:10 +0000
Subject: [PATCH 4/10] update sanity check for remove-group


---

diff --git a/cli/koji_cli/commands.py b/cli/koji_cli/commands.py
index 8c37945..d4eb480 100644
--- a/cli/koji_cli/commands.py
+++ b/cli/koji_cli/commands.py
@@ -137,14 +137,23 @@ def handle_remove_group(goptions, session, args):
     if not (session.hasPerm('admin') or session.hasPerm('tag')):
         parser.error("This action requires tag or admin privileges")
 
-    dsttag = session.getTag(tag)
-    if not dsttag:
+    taginfo = session.getTag(tag)
+    if not taginfo:
         error("No such tag: %s" % tag)
 
-    groups = dict([(p['name'], p['group_id']) for p in session.getTagGroups(tag, inherit=False)])
-    group_id = groups.get(group, None)
-    if group_id is None:
+    # sanity checks
+    groups = session.getTagGroups(taginfo['id'], incl_pkgs=False, incl_reqs=False, incl_blocked=True)
+    for ginfo in groups:
+        if ginfo['name'] == group:
+            break
+    else:
         error("Group %s doesn't exist within tag %s" % (group, tag))
+    if ginfo['blocked']:
+        error("Group %s is blocked in this tag. You could use unblock-group to unblock it" % group)
+    if ginfo['tag_id'] != taginfo['id']:
+        # listing is inherited
+        srctag = session.getTag(ginfo['tag_id'])
+        error("The entry for group %s is inherited from %s. You could use block-group to prevent this" % (group, srctag['name']))
 
     session.groupListRemove(tag, group)
 

From 96fc238737ddb704751a8b22a38faada1c96ef78 Mon Sep 17 00:00:00 2001
From: Mike McLean <mikem@redhat.com>
Date: Jul 22 2025 14:19:10 +0000
Subject: [PATCH 5/10] clean up block-group; add unblock-group


---

diff --git a/cli/koji_cli/commands.py b/cli/koji_cli/commands.py
index d4eb480..c59a10e 100644
--- a/cli/koji_cli/commands.py
+++ b/cli/koji_cli/commands.py
@@ -111,18 +111,37 @@ def handle_block_group(goptions, session, args):
     if not (session.hasPerm('admin') or session.hasPerm('tag')):
         parser.error("This action requires tag or admin privileges")
 
-    dsttag = session.getTag(tag)
-    if not dsttag:
+    taginfo = session.getTag(tag)
+    if not taginfo:
         error("No such tag: %s" % tag)
 
-    groups = dict([(p['name'], p['group_id']) for p in session.getTagGroups(tag, inherit=False)])
-    group_id = groups.get(group, None)
-    if group_id is None:
+    # sanity check
+    groups = session.getTagGroups(taginfo['id'], incl_pkgs=False, incl_reqs=False, incl_blocked=True)
+    for ginfo in groups:
+        if ginfo['name'] == group:
+            break
+    else:
         error("Group %s doesn't exist within tag %s" % (group, tag))
+    if ginfo['blocked']:
+        error("Group %s is already blocked in this tag" % group)
+    # (we don't care if the entry is inherited for this)
 
     session.groupListBlock(tag, group)
 
 
+def handle_unblock_group(goptions, session, args):
+    "[admin] Unblock a group from tag"
+    usage = "usage: %prog unblock-group [options] <tag> <group>"
+    parser = OptionParser(usage=get_usage_str(usage))
+    (options, args) = parser.parse_args(args)
+    if len(args) < 2:
+        parser.error("You must specify a tag name and group name")
+    tag = args[0]
+    group = args[1]
+    activate_session(session, goptions)
+    session.groupListUnblock(tag, group)
+
+
 def handle_remove_group(goptions, session, args):
     "[admin] Remove group from tag"
     usage = "usage: %prog remove-group <tag> <group>"

From da3938fbc17ff531afe25cb7a3b2a431ba3c86be Mon Sep 17 00:00:00 2001
From: Mike McLean <mikem@redhat.com>
Date: Jul 22 2025 14:19:10 +0000
Subject: [PATCH 6/10] ...


---

diff --git a/cli/koji_cli/commands.py b/cli/koji_cli/commands.py
index c59a10e..38bb903 100644
--- a/cli/koji_cli/commands.py
+++ b/cli/koji_cli/commands.py
@@ -134,7 +134,7 @@ def handle_unblock_group(goptions, session, args):
     usage = "usage: %prog unblock-group [options] <tag> <group>"
     parser = OptionParser(usage=get_usage_str(usage))
     (options, args) = parser.parse_args(args)
-    if len(args) < 2:
+    if len(args) != 2:
         parser.error("You must specify a tag name and group name")
     tag = args[0]
     group = args[1]

From 0f06d749ef9062cc9f910f937ae803d6d5024590 Mon Sep 17 00:00:00 2001
From: Mike McLean <mikem@redhat.com>
Date: Jul 22 2025 14:19:10 +0000
Subject: [PATCH 7/10] unit tests


---

diff --git a/tests/test_cli/data/list-commands-admin.txt b/tests/test_cli/data/list-commands-admin.txt
index 1928c1b..84f3180 100644
--- a/tests/test_cli/data/list-commands-admin.txt
+++ b/tests/test_cli/data/list-commands-admin.txt
@@ -49,7 +49,8 @@ admin commands:
         regen-repo                Generate a current repo if there is not one
         remove-external-repo      Remove an external repo from a tag or tags, or remove entirely
         remove-group              Remove group from tag
-        remove-group-pkg          Remove a package from a group's package listing
+        remove-group-pkg          Remove packages from a group's package listing
+        remove-group-req          Remove entries from a group's requirement listing
         remove-host-from-channel  Remove a host from a channel
         remove-pkg                Remove a package from the listing for tag
         remove-sig                Remove signed RPMs from db and disk
@@ -65,6 +66,7 @@ admin commands:
         set-pkg-owner             Set the owner for a package
         set-pkg-owner-global      Set the owner for a package globally
         set-task-priority         Set task priority
+        unblock-group             Unblock a group from tag
         unblock-group-pkg         Unblock a package from a group's package listing
         unblock-group-req         Unblock a group's requirement listing
         unblock-pkg               Unblock a package in the listing for tag
diff --git a/tests/test_cli/data/list-commands.txt b/tests/test_cli/data/list-commands.txt
index 809229d..9671bba 100644
--- a/tests/test_cli/data/list-commands.txt
+++ b/tests/test_cli/data/list-commands.txt
@@ -49,7 +49,8 @@ admin commands:
         regen-repo                Generate a current repo if there is not one
         remove-external-repo      Remove an external repo from a tag or tags, or remove entirely
         remove-group              Remove group from tag
-        remove-group-pkg          Remove a package from a group's package listing
+        remove-group-pkg          Remove packages from a group's package listing
+        remove-group-req          Remove entries from a group's requirement listing
         remove-host-from-channel  Remove a host from a channel
         remove-pkg                Remove a package from the listing for tag
         remove-sig                Remove signed RPMs from db and disk
@@ -65,6 +66,7 @@ admin commands:
         set-pkg-owner             Set the owner for a package
         set-pkg-owner-global      Set the owner for a package globally
         set-task-priority         Set task priority
+        unblock-group             Unblock a group from tag
         unblock-group-pkg         Unblock a package from a group's package listing
         unblock-group-req         Unblock a group's requirement listing
         unblock-pkg               Unblock a package in the listing for tag
diff --git a/tests/test_cli/test_block_group.py b/tests/test_cli/test_block_group.py
index 2b5f499..41ebade 100644
--- a/tests/test_cli/test_block_group.py
+++ b/tests/test_cli/test_block_group.py
@@ -56,10 +56,11 @@ class TestBlockGroup(utils.CliTestCase):
 
     def test_handle_block_group_nonexistent_group(self):
         tag = 'tag'
+        tagID = 100
         group = 'group'
         arguments = [tag, group]
         self.session.hasPerm.return_value = True
-        self.session.getTag.return_value = tag
+        self.session.getTag.return_value = {'name': tag, 'id': tagID}
         self.session.getTagGroups.return_value = []
 
         # Run it and check immediate output
@@ -75,18 +76,19 @@ class TestBlockGroup(utils.CliTestCase):
         self.activate_session_mock.assert_called_once_with(self.session, self.options)
         self.session.hasPerm.assert_called_once_with('admin')
         self.session.getTag.assert_called_once_with(tag)
-        self.session.getTagGroups.assert_called_once_with(tag, inherit=False)
+        self.session.getTagGroups.assert_called_once_with(tagID, incl_pkgs=False, incl_reqs=False, incl_blocked=True)
         self.session.groupListBlock.assert_not_called()
 
     @mock.patch('sys.stdout', new_callable=six.StringIO)
     def test_handle_block_group(self, stdout):
         tag = 'tag'
+        tagID = 100
         group = 'group'
         arguments = [tag, group]
         self.session.hasPerm.return_value = True
-        self.session.getTag.return_value = tag
+        self.session.getTag.return_value = {'name': tag, 'id': tagID}
         self.session.getTagGroups.return_value = [
-            {'name': 'group', 'group_id': 'groupId'}]
+                {'name': 'group', 'group_id': 'groupId', 'blocked': False}]
 
         # Run it and check immediate output
         rv = handle_block_group(self.options, self.session, arguments)
@@ -98,10 +100,34 @@ class TestBlockGroup(utils.CliTestCase):
         self.activate_session_mock.assert_called_once_with(self.session, self.options)
         self.session.hasPerm.assert_called_once_with('admin')
         self.session.getTag.assert_called_once_with(tag)
-        self.session.getTagGroups.assert_called_once_with(tag, inherit=False)
+        self.session.getTagGroups.assert_called_once_with(tagID, incl_pkgs=False, incl_reqs=False, incl_blocked=True)
         self.session.groupListBlock.assert_called_once_with(tag, group)
         self.assertEqual(rv, None)
 
+    def test_handle_group_already_blocked(self):
+        tag = 'tag'
+        tagID = 100
+        group = 'group'
+        arguments = [tag, group]
+        self.session.hasPerm.return_value = True
+        self.session.getTag.return_value = {'name': tag, 'id': tagID}
+        self.session.getTagGroups.return_value = [
+                {'name': 'group', 'group_id': 'groupId', 'blocked': True}]
+
+        # Run it and check immediate output
+        self.assert_system_exit(
+            handle_block_group,
+            self.options, self.session, arguments,
+            stderr='Group group is already blocked in this tag\n',
+            stdout='',
+            exit_code=1)
+
+        # Finally, assert that things were called as we expected.
+        self.session.hasPerm.assert_called_once_with('admin')
+        self.session.getTag.assert_called_once_with(tag)
+        self.session.getTagGroups.assert_called_once_with(tagID, incl_pkgs=False, incl_reqs=False, incl_blocked=True)
+        self.session.groupListBlock.assert_not_called()
+
     def test_handle_block_group_error_handling(self):
         expected = self.format_error_message(
             "Please specify a tag name and a group name")
diff --git a/tests/test_cli/test_remove_group.py b/tests/test_cli/test_remove_group.py
index a6b058f..a4e1638 100644
--- a/tests/test_cli/test_remove_group.py
+++ b/tests/test_cli/test_remove_group.py
@@ -54,12 +54,13 @@ class TestRemoveGroup(utils.CliTestCase):
     @mock.patch('koji_cli.commands.activate_session')
     def test_handle_remove_group_nonexistent_group(self, activate_session_mock, stdout, stderr):
         tag = 'tag'
+        tagID = 100
         group = 'group'
         arguments = [tag, group]
 
         # Mock out the xmlrpc server
         self.session.hasPerm.return_value = True
-        self.session.getTag.return_value = tag
+        self.session.getTag.return_value = {'name': tag, 'id': tagID}
         self.session.getTagGroups.return_value = []
 
         with self.assertRaises(SystemExit):
@@ -69,7 +70,59 @@ class TestRemoveGroup(utils.CliTestCase):
         activate_session_mock.assert_called_once_with(self.session, self.options)
         self.session.hasPerm.assert_called_once_with('admin')
         self.session.getTag.assert_called_once_with(tag)
-        self.session.getTagGroups.assert_called_once_with(tag, inherit=False)
+        self.session.getTagGroups.assert_called_once_with(tagID, incl_pkgs=False, incl_reqs=False, incl_blocked=True)
+        self.session.groupListRemove.assert_not_called()
+
+    @mock.patch('sys.stdout', new_callable=six.StringIO)
+    @mock.patch('sys.stderr', new_callable=six.StringIO)
+    @mock.patch('koji_cli.commands.activate_session')
+    def test_handle_remove_group_blocked_group(self, activate_session_mock, stdout, stderr):
+        tag = 'tag'
+        tagID = 100
+        group = 'group'
+        arguments = [tag, group]
+
+        # Mock out the xmlrpc server
+        self.session.hasPerm.return_value = True
+        self.session.getTag.return_value = {'name': tag, 'id': tagID}
+        self.session.getTagGroups.return_value = [{'name': 'group', 'tag_id': tagID,
+                                                   'blocked': True}]
+
+        with self.assertRaises(SystemExit):
+            handle_remove_group(self.options, self.session, arguments)
+
+        # assert that things were called as we expected.
+        activate_session_mock.assert_called_once_with(self.session, self.options)
+        self.session.hasPerm.assert_called_once_with('admin')
+        self.session.getTag.assert_called_once_with(tag)
+        self.session.getTagGroups.assert_called_once_with(tagID, incl_pkgs=False, incl_reqs=False, incl_blocked=True)
+        self.session.groupListRemove.assert_not_called()
+
+    @mock.patch('sys.stdout', new_callable=six.StringIO)
+    @mock.patch('sys.stderr', new_callable=six.StringIO)
+    @mock.patch('koji_cli.commands.activate_session')
+    def test_handle_remove_group_inherited_group(self, activate_session_mock, stdout, stderr):
+        tag = 'tag'
+        tagID = 100
+        parentID = 99
+        group = 'group'
+        arguments = [tag, group]
+
+        # Mock out the xmlrpc server
+        self.session.hasPerm.return_value = True
+        self.session.getTag.return_value = {'name': tag, 'id': tagID}
+        self.session.getTagGroups.return_value = [{'name': 'group', 'tag_id': parentID,
+                                                   'blocked': False}]
+
+        with self.assertRaises(SystemExit):
+            handle_remove_group(self.options, self.session, arguments)
+
+        # assert that things were called as we expected.
+        activate_session_mock.assert_called_once_with(self.session, self.options)
+        self.session.hasPerm.assert_called_once_with('admin')
+        self.session.getTagGroups.assert_called_once_with(tagID, incl_pkgs=False, incl_reqs=False, incl_blocked=True)
+        expected_calls = [mock.call(tag), mock.call(parentID)]
+        self.assertEqual(self.session.getTag.call_args_list, expected_calls)
         self.session.groupListRemove.assert_not_called()
 
     @mock.patch('sys.stdout', new_callable=six.StringIO)
@@ -77,14 +130,16 @@ class TestRemoveGroup(utils.CliTestCase):
     @mock.patch('koji_cli.commands.activate_session')
     def test_handle_remove_group(self, activate_session_mock, stdout, stderr):
         tag = 'tag'
+        tagID = 100
         group = 'group'
         arguments = [tag, group]
 
         # Mock out the xmlrpc server
         self.session.hasPerm.return_value = True
-        self.session.getTag.return_value = tag
+        self.session.getTag.return_value = {'name': tag, 'id': tagID}
         self.session.getTagGroups.return_value = [
-            {'name': 'group', 'group_id': 'groupId'}]
+                {'name': 'group', 'group_id': 'groupId', 'blocked': False, 'tag_id': tagID}]
+        # so case where group is present and directly in the tag
 
         rv = handle_remove_group(self.options, self.session, arguments)
 
@@ -92,7 +147,7 @@ class TestRemoveGroup(utils.CliTestCase):
         activate_session_mock.assert_called_once_with(self.session, self.options)
         self.session.hasPerm.assert_called_once_with('admin')
         self.session.getTag.assert_called_once_with(tag)
-        self.session.getTagGroups.assert_called_once_with(tag, inherit=False)
+        self.session.getTagGroups.assert_called_once_with(tagID, incl_pkgs=False, incl_reqs=False, incl_blocked=True)
         self.session.groupListRemove.assert_called_once_with(tag, group)
         self.assertEqual(rv, None)
 
diff --git a/tests/test_cli/test_remove_group_pkg.py b/tests/test_cli/test_remove_group_pkg.py
index 62277a6..df167b0 100644
--- a/tests/test_cli/test_remove_group_pkg.py
+++ b/tests/test_cli/test_remove_group_pkg.py
@@ -25,6 +25,9 @@ class TestRemoveGroupPkg(utils.CliTestCase):
 %s: error: {message}
 """ % (self.progname, self.progname)
 
+    def tearDown(self):
+        mock.patch.stopall()
+
     def test_handle_remove_pkg_not_existing_tag(self):
         tag = 'tag'
         package = 'package'
@@ -68,12 +71,17 @@ class TestRemoveGroupPkg(utils.CliTestCase):
     @mock.patch('sys.stderr', new_callable=six.StringIO)
     def test_handle_remove_pkg(self, stderr, stdout):
         tag = 'tag'
-        dsttag = {'name': tag, 'id': 1}
+        tagID = 1
+        dsttag = {'name': tag, 'id': tagID}
         package = 'package'
         group = 'group'
         args = [tag, group, package]
 
         self.session.getTag.return_value = dsttag
+        self.session.getTagGroups.return_value = [
+            {'name': group, 'group_id': 'groupId', 'blocked': False, 'tag_id': tagID,
+             'packagelist': [{'package': package, 'blocked': False, 'tag_id': tagID}]
+            }]
         self.session.groupPackageListRemove.return_value = None
         handle_remove_group_pkg(self.options, self.session, args)
         actual = stderr.getvalue()
@@ -86,24 +94,99 @@ class TestRemoveGroupPkg(utils.CliTestCase):
         self.activate_session_mock.assert_called_once_with(self.session, self.options)
         self.session.getTag.assert_called_once_with(tag)
 
-    @mock.patch('sys.stdout', new_callable=six.StringIO)
-    @mock.patch('sys.stderr', new_callable=six.StringIO)
-    def test_handle_remove_pkg_with_force(self, stderr, stdout):
+    def test_handle_group_not_present(self):
         tag = 'tag'
-        dsttag = {'name': tag, 'id': 1}
+        tagID = 1
+        dsttag = {'name': tag, 'id': tagID}
         package = 'package'
         group = 'group'
-        args = [tag, group, package, '--force']
+        args = [tag, group, package]
 
         self.session.getTag.return_value = dsttag
+        self.session.getTagGroups.return_value = []
         self.session.groupPackageListRemove.return_value = None
-        handle_remove_group_pkg(self.options, self.session, args)
-        actual = stderr.getvalue()
-        expected = ''
-        self.assertMultiLineEqual(actual, expected)
-        actual = stdout.getvalue()
-        expected = ''
-        self.assertMultiLineEqual(actual, expected)
-        # Finally, assert that things were called as we expected.
-        self.activate_session_mock.assert_called_once_with(self.session, self.options)
+
+        self.assert_system_exit(
+            handle_remove_group_pkg,
+            self.options, self.session, args,
+            stderr='Group group is not present in tag tag\n',
+            stdout='',
+            exit_code=1)
+
+        self.session.getTag.assert_called_once_with(tag)
+
+    def test_handle_package_not_included(self):
+        tag = 'tag'
+        tagID = 1
+        dsttag = {'name': tag, 'id': tagID}
+        package = 'package'
+        group = 'group'
+        args = [tag, group, package]
+
+        self.session.getTag.return_value = dsttag
+        self.session.getTagGroups.return_value = [
+            {'name': group, 'group_id': 'groupId', 'blocked': False, 'tag_id': tagID,
+             'packagelist': []
+            }]
+        self.session.groupPackageListRemove.return_value = None
+
+        self.assert_system_exit(
+            handle_remove_group_pkg,
+            self.options, self.session, args,
+            stdout='Package package is not included in this group\n',
+            stderr='Invalid parameters\n',
+            exit_code=1)
+
+        self.session.getTag.assert_called_once_with(tag)
+
+    def test_handle_package_blocked(self):
+        tag = 'tag'
+        tagID = 1
+        dsttag = {'name': tag, 'id': tagID}
+        package = 'package'
+        group = 'group'
+        args = [tag, group, package]
+
+        self.session.getTag.return_value = dsttag
+        self.session.getTagGroups.return_value = [
+            {'name': group, 'group_id': 'groupId', 'blocked': False, 'tag_id': tagID,
+             'packagelist': [{'package': package, 'blocked': True, 'tag_id': tagID}]
+            }]
+        self.session.groupPackageListRemove.return_value = None
+
+        self.assert_system_exit(
+            handle_remove_group_pkg,
+            self.options, self.session, args,
+            stdout='Package package is blocked in this group. '
+                   'You could use unblock-group-pkg to unblock it\n',
+            stderr='Invalid parameters\n',
+            exit_code=1)
+
         self.session.getTag.assert_called_once_with(tag)
+
+    def test_handle_package_inherited(self):
+        tag = 'tag'
+        tagID = 1
+        dsttag = {'name': tag, 'id': tagID}
+        parentID = 20
+        ptag = {'name': 'parent', 'id': parentID}
+        package = 'package'
+        group = 'group'
+        args = [tag, group, package]
+
+        self.session.getTag.side_effect = [dsttag, ptag]
+        self.session.getTagGroups.return_value = [
+            {'name': group, 'group_id': 'groupId', 'blocked': False, 'tag_id': tagID,
+             'packagelist': [{'package': package, 'blocked': False, 'tag_id': parentID}]
+            }]
+        self.session.groupPackageListRemove.return_value = None
+
+        self.assert_system_exit(
+            handle_remove_group_pkg,
+            self.options, self.session, args,
+            stdout='The entry for package package is inherited from parent. You could use block-group-pkg to prevent this\n',
+            stderr='Invalid parameters\n',
+            exit_code=1)
+
+
+# the end
diff --git a/tests/test_cli/test_remove_group_req.py b/tests/test_cli/test_remove_group_req.py
new file mode 100644
index 0000000..7970639
--- /dev/null
+++ b/tests/test_cli/test_remove_group_req.py
@@ -0,0 +1,198 @@
+from __future__ import absolute_import
+
+import mock
+import six
+
+from koji_cli.commands import handle_remove_group_req
+
+import koji
+from . import utils
+
+
+class TestRemoveGroupReq(utils.CliTestCase):
+
+    def setUp(self):
+        # Show long diffs in error output...
+        self.maxDiff = None
+        self.options = mock.MagicMock()
+        self.options.debug = False
+        self.session = mock.MagicMock()
+        self.session.getAPIVersion.return_value = koji.API_VERSION
+        self.activate_session_mock = mock.patch('koji_cli.commands.activate_session').start()
+        self.error_format = """Usage: %s remove-group-req [options] <tag> <group> <req> [<req> ...]
+(Specify the --help global option for a list of other help options)
+
+%s: error: {message}
+""" % (self.progname, self.progname)
+
+    def tearDown(self):
+        mock.patch.stopall()
+
+    def test_wrong_count_args(self):
+        tag = 'tag'
+        group = 'group'
+        args = [tag, group]
+        expected_error = self.format_error_message(
+            'You must specify a tag name, group name, and one or more requirement names')
+        self.assert_system_exit(
+            handle_remove_group_req,
+            self.options, self.session, args,
+            stderr=expected_error,
+            stdout='',
+            activate_session=None,
+            exit_code=2)
+        # Finally, assert that things were called as we expected.
+        self.activate_session_mock.assert_not_called()
+        self.session.getTag.assert_not_called()
+        self.session.groupReqListRemove.assert_not_called()
+        self.session.multicall.assert_not_called()
+
+    def test_no_such_tag(self):
+        tag = 'tag'
+        group = 'group'
+        req = 'other_group'
+        args = [tag, group, req]
+        self.session.getTag.return_value = None
+
+        self.assert_system_exit(
+            handle_remove_group_req,
+            self.options, self.session, args,
+            stderr='No such tag: tag\n',
+            stdout='',
+            activate_session=None,
+            exit_code=1)
+
+        # Finally, assert that things were called as we expected.
+        self.session.getTag.assert_called_once()
+        self.session.groupReqListRemove.assert_not_called()
+        self.session.multicall.assert_not_called()
+
+    @mock.patch('sys.stdout', new_callable=six.StringIO)
+    @mock.patch('sys.stderr', new_callable=six.StringIO)
+    def test_success(self, stderr, stdout):
+        tag = 'tag'
+        tagID = 1
+        dsttag = {'name': tag, 'id': tagID}
+        group = 'group'
+        req = 'other_group'
+        args = [tag, group, req]
+
+        self.session.getTag.return_value = dsttag
+        self.session.getTagGroups.return_value = [
+            {'name': group, 'group_id': 'groupId', 'blocked': False, 'tag_id': tagID,
+             'grouplist': [{'name': req, 'blocked': False, 'tag_id': tagID}]
+            }]
+
+        handle_remove_group_req(self.options, self.session, args)
+
+        actual = stderr.getvalue()
+        expected = ''
+        self.assertMultiLineEqual(actual, expected)
+        actual = stdout.getvalue()
+        expected = ''
+        self.assertMultiLineEqual(actual, expected)
+        # Finally, assert that things were called as we expected.
+        self.activate_session_mock.assert_called_once_with(self.session, self.options)
+        self.session.getTag.assert_called_once_with(tag)
+        self.session.multicall.assert_called_once()
+
+    def test_handle_group_not_present(self):
+        tag = 'tag'
+        tagID = 1
+        dsttag = {'name': tag, 'id': tagID}
+        group = 'group'
+        req = 'other_group'
+        args = [tag, group, req]
+
+        self.session.getTag.return_value = dsttag
+        self.session.getTagGroups.return_value = []
+
+        self.assert_system_exit(
+            handle_remove_group_req,
+            self.options, self.session, args,
+            stderr='Group group is not present in tag tag\n',
+            stdout='',
+            exit_code=1)
+
+        self.session.getTag.assert_called_once_with(tag)
+        self.session.groupReqListRemove.assert_not_called()
+        self.session.multicall.assert_not_called()
+
+    def test_handle_req_not_included(self):
+        tag = 'tag'
+        tagID = 1
+        dsttag = {'name': tag, 'id': tagID}
+        group = 'group'
+        req = 'other_group'
+        args = [tag, group, req]
+
+        self.session.getTag.return_value = dsttag
+        self.session.getTagGroups.return_value = [
+            {'name': group, 'group_id': 'groupId', 'blocked': False, 'tag_id': tagID,
+             'grouplist': []
+            }]
+
+        self.assert_system_exit(
+            handle_remove_group_req,
+            self.options, self.session, args,
+            stdout='Req other_group is not included in this group\n',
+            stderr='Invalid parameters\n',
+            exit_code=1)
+
+        self.session.getTag.assert_called_once_with(tag)
+        self.session.groupReqListRemove.assert_not_called()
+        self.session.multicall.assert_not_called()
+
+    def test_handle_req_blocked(self):
+        tag = 'tag'
+        tagID = 1
+        dsttag = {'name': tag, 'id': tagID}
+        group = 'group'
+        req = 'other_group'
+        args = [tag, group, req]
+
+        self.session.getTag.return_value = dsttag
+        self.session.getTagGroups.return_value = [
+            {'name': group, 'group_id': 'groupId', 'blocked': False, 'tag_id': tagID,
+             'grouplist': [{'name': req, 'blocked': True, 'tag_id': tagID}]
+            }]
+
+        self.assert_system_exit(
+            handle_remove_group_req,
+            self.options, self.session, args,
+            stdout='Req other_group is blocked in this group. '
+                   'You could use unblock-group-req to unblock it\n',
+            stderr='Invalid parameters\n',
+            exit_code=1)
+
+        self.session.getTag.assert_called_once_with(tag)
+        self.session.groupReqListRemove.assert_not_called()
+        self.session.multicall.assert_not_called()
+
+    def test_handle_req_inherited(self):
+        tag = 'tag'
+        tagID = 1
+        dsttag = {'name': tag, 'id': tagID}
+        parentID = 20
+        ptag = {'name': 'parent', 'id': parentID}
+        group = 'group'
+        req = 'other_group'
+        args = [tag, group, req]
+
+        self.session.getTag.side_effect = [dsttag, ptag]
+        self.session.getTagGroups.return_value = [
+            {'name': group, 'group_id': 'groupId', 'blocked': False, 'tag_id': tagID,
+             'grouplist': [{'name': req, 'blocked': False, 'tag_id': parentID}]
+            }]
+
+        self.assert_system_exit(
+            handle_remove_group_req,
+            self.options, self.session, args,
+            stdout='The entry for req other_group is inherited from parent. You could use block-group-req to prevent this\n',
+            stderr='Invalid parameters\n',
+            exit_code=1)
+        self.session.groupReqListRemove.assert_not_called()
+        self.session.multicall.assert_not_called()
+
+
+# the end
diff --git a/tests/test_cli/test_unblock_group.py b/tests/test_cli/test_unblock_group.py
new file mode 100644
index 0000000..797527a
--- /dev/null
+++ b/tests/test_cli/test_unblock_group.py
@@ -0,0 +1,74 @@
+from __future__ import absolute_import
+
+try:
+    from unittest import mock
+except ImportError:
+    import mock
+import six
+import koji
+
+from koji_cli.commands import handle_unblock_group
+from . import utils
+
+
+class TestBlockGroup(utils.CliTestCase):
+    # Show long diffs in error output...
+    maxDiff = None
+
+    def setUp(self):
+        self.maxDiff = None
+        self.options = mock.MagicMock()
+        self.options.debug = False
+        self.session = mock.MagicMock()
+        self.session.getAPIVersion.return_value = koji.API_VERSION
+        self.activate_session_mock = mock.patch('koji_cli.commands.activate_session').start()
+        self.error_format = """Usage: %s unblock-group [options] <tag> <group>
+(Specify the --help global option for a list of other help options)
+
+%s: error: {message}
+""" % (self.progname, self.progname)
+
+    def tearDown(self):
+        mock.patch.stopall()
+
+    def test_handle_unblock_group_badargs(self):
+        tag = 'tag'
+        group = 'group'
+        arguments = [tag, group, 'extra_arg']
+        self.session.hasPerm.return_value = True
+        self.session.getTag.return_value = None
+
+        expected = self.format_error_message(
+            "You must specify a tag name and group name")
+        # Run it and check immediate output
+        self.assert_system_exit(
+            handle_unblock_group,
+            self.options, self.session, arguments,
+            stderr=expected,
+            stdout='',
+            activate_session=None,
+            exit_code=2)
+
+        # Finally, assert that things were called as we expected.
+        self.activate_session_mock.assert_not_called()
+        self.session.groupListUnblock.assert_not_called()
+
+    @mock.patch('sys.stdout', new_callable=six.StringIO)
+    def test_handle_unblock_group(self, stdout):
+        tag = 'tag'
+        group = 'group'
+        arguments = [tag, group]
+
+        # Run it and check immediate output
+        rv = handle_unblock_group(self.options, self.session, arguments)
+        actual = stdout.getvalue()
+        expected = ''
+        self.assertMultiLineEqual(actual, expected)
+
+        # Finally, assert that things were called as we expected.
+        self.activate_session_mock.assert_called_once_with(self.session, self.options)
+        self.session.groupListUnblock.assert_called_once_with(tag, group)
+        self.assertEqual(rv, None)
+
+
+# the end

From 6b5eb875af25d254fe5dc0d0eb6a4bda527e284c Mon Sep 17 00:00:00 2001
From: Mike McLean <mikem@redhat.com>
Date: Jul 22 2025 14:19:10 +0000
Subject: [PATCH 8/10] docs updates


---

diff --git a/docs/source/index.rst b/docs/source/index.rst
index b7233f5..b052644 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -33,6 +33,7 @@ Contents
     repo_generation
     exporting_repositories
     tag_inheritance
+    managing_comps_data
     misc
     release_notes/release_notes
     migrations/migrations
diff --git a/docs/source/managing_comps_data.rst b/docs/source/managing_comps_data.rst
new file mode 100644
index 0000000..6cd4af4
--- /dev/null
+++ b/docs/source/managing_comps_data.rst
@@ -0,0 +1,157 @@
+Managing comps data for tags
+============================
+
+Koji can record comps data for each tag and uses that data to write a comps
+file whenever it generates a yum repo for that tag. This document describes
+how this data is stored, how to read it, and how to change it.
+
+For background on comps files, see
+
+* https://dnf5.readthedocs.io/en/latest/misc/comps.7.html
+* https://fedoraproject.org/wiki/How_to_use_and_edit_comps.xml_for_package_groups
+
+The comps data *affects how rpm builds are performed*.
+Various buildroots generated by Koji use these groups for their base install.
+E.g.
+
+* rpm builds use the ``build`` group
+* source rpm builds use the ``srpm-build`` group
+* maven builds use the ``maven-build`` group
+* wrapper rpm builds use the ``wrapper-rpm-build`` group
+* appliance builds use the ``appliance-build`` group
+* livecd builds use the ``livecd-build`` group
+* livemedia builds use the ``livemedia-build`` group
+
+
+Reading the data
+----------------
+
+The ``koji list-groups`` cli command will output the comps data for a given tag
+
+.. code-block:: text
+
+    Usage: koji list-groups [options] <tag> [<group>]
+    (Specify the --help global option for a list of other help options)
+
+    Options:
+      -h, --help      show this help message and exit
+      --event=EVENT#  query at event
+      --ts=TIMESTAMP  query at last event before timestamp
+      --repo=REPO#    query at event for a repo
+      --show-blocked  Show blocked packages and groups
+
+The command will optionally accept a group name to limit the output to a single group.
+
+Like many other types of data in Koji, the comps data is versioned and Koji remembers its history.
+The ``--event/--ts/--repo`` options can be used to query this data from an earlier point in time.
+
+The ``show-blocked`` option will include blocked entries in the output.
+
+Alternately ``show-groups`` command can be used to present the groups data in different formats.
+This command is a historical oddity, but still useful to export the data in comps format.
+
+Example
+-------
+
+The following command prints the srpm-build group data for the f41-build tag.
+
+.. code-block:: text
+
+    $ koji list-groups f41-build srpm-build
+    srpm-build  [f41]
+      bash: None, mandatory  [f41]
+      fedora-release: None, mandatory  [f41]
+      fedpkg-minimal: None, mandatory  [f41]
+      glibc-minimal-langpack: None, mandatory  [f41]
+      gnupg2: None, mandatory  [f41]
+      redhat-rpm-config: None, mandatory  [f41]
+      rpm-build: None, mandatory  [f41]
+      shadow-utils: None, mandatory  [f41]
+
+Because our command included a specific group name, the command only outputs that one group.
+Under this group, we see the list of packages in the group and their parameters.
+The bracketed value at the end shows the tag that the entry is inherited from.
+
+
+Groups data and inheritance
+---------------------------
+
+Like most other data associated with tags, groups data is inheritable.
+Because the groups data is complex, so is the inheritance.
+The groups data is inherited piecemeal, with individual groups, group package
+entries, and group dependencies separately inheritable.
+
+You can prevent an entry from being inherited by blocking it.
+This can be done via the ``block-group``, ``block-group-pkg``, and ``block-group-req``
+commands respectively (and undone with the corresponding unblock command).
+
+As noted above, you can see which tags an entry comes from via the ``list-groups`` command.
+
+So, a given tag will have a list of groups that includes inherited entries.
+Each of those groups will have a list of packages and a list of group dependencies
+which include inherited entries.
+
+For admin sanity, we recommend defining a comps in a base tag in your inheritance
+and keeping adjustments in child tags to a minimum.
+A common case is to add additional packages to the build group for a specialized
+build tag.
+
+
+Changing comps data
+-------------------
+
+You can add, remove, block, and unblock individual entries with the relevant command
+
+.. code-block:: text
+
+    add-group                 Add a group to a tag
+    remove-group              Remove group from tag
+    block-group               Block group in tag
+    unblock-group             Unblock a group from tag
+    add-group-pkg             Add a package to a group's package listing
+    remove-group-pkg          Remove packages from a group's package listing
+    block-group-pkg           Block a package from a group's package listing
+    unblock-group-pkg         Unblock a package from a group's package listing
+    add-group-req             Add a group to a group's required list
+    block-group-req           Block a group's requirement listing
+    remove-group-req          Remove entries from a group's requirement listing
+    unblock-group-req         Unblock a group's requirement listing
+
+Additionally, you can also import groups data directly from a comps file with the ``import-comps`` command.
+This is useful when setting up a new tag from scratch.
+
+.. code-block:: text
+
+    Usage: koji import-comps [options] <file> <tag>
+    (Specify the --help global option for a list of other help options)
+
+    Options:
+      -h, --help  show this help message and exit
+      --force     force import
+
+
+Blocking vs removing
+--------------------
+
+The various remove commands remove a specific entry from a specific tag.
+They will fail if the entry is not actually in the specified tag.
+This commonly happens when an entry is inherited.
+
+When your tag has an inherited comps entry that you want to remove, you have two
+options:
+
+* block the entry in the tag
+* remove the entry from the ancestor tag it is inherited from
+
+It is generally safest to block, as this only affects the tag of concern.
+Removing the entry in an ancestor could affect other tags negatively.
+However, there are certainly cases where it makes sense to remove the entry
+from the ancestor.
+
+
+Group dependencies
+------------------
+
+For historical reasons, Koji's comps data allows specifying group dependecies/requirements.
+This is antiquated feature of comps that is longer used by dnf,
+so in practice there is no need to specify such entries (i.e. the various ``*-req`` commands).

From 3d42de2a99bd47a6181a059bba46d3d6ba78640b Mon Sep 17 00:00:00 2001
From: Mike McLean <mikem@redhat.com>
Date: Jul 22 2025 14:19:10 +0000
Subject: [PATCH 9/10] flake8


---

diff --git a/cli/koji_cli/commands.py b/cli/koji_cli/commands.py
index 38bb903..2e15b96 100644
--- a/cli/koji_cli/commands.py
+++ b/cli/koji_cli/commands.py
@@ -116,7 +116,8 @@ def handle_block_group(goptions, session, args):
         error("No such tag: %s" % tag)
 
     # sanity check
-    groups = session.getTagGroups(taginfo['id'], incl_pkgs=False, incl_reqs=False, incl_blocked=True)
+    groups = session.getTagGroups(taginfo['id'], incl_pkgs=False, incl_reqs=False,
+                                  incl_blocked=True)
     for ginfo in groups:
         if ginfo['name'] == group:
             break
@@ -161,7 +162,8 @@ def handle_remove_group(goptions, session, args):
         error("No such tag: %s" % tag)
 
     # sanity checks
-    groups = session.getTagGroups(taginfo['id'], incl_pkgs=False, incl_reqs=False, incl_blocked=True)
+    groups = session.getTagGroups(taginfo['id'], incl_pkgs=False, incl_reqs=False,
+                                  incl_blocked=True)
     for ginfo in groups:
         if ginfo['name'] == group:
             break
@@ -172,7 +174,8 @@ def handle_remove_group(goptions, session, args):
     if ginfo['tag_id'] != taginfo['id']:
         # listing is inherited
         srctag = session.getTag(ginfo['tag_id'])
-        error("The entry for group %s is inherited from %s. You could use block-group to prevent this" % (group, srctag['name']))
+        error("The entry for group %s is inherited from %s. "
+              "You could use block-group to prevent this" % (group, srctag['name']))
 
     session.groupListRemove(tag, group)
 
@@ -3159,13 +3162,15 @@ def handle_remove_group_pkg(goptions, session, args):
             continue
         pinfo = pkg_idx[pkg]
         if pinfo['blocked']:
-            print("Package %s is blocked in this group. You could use unblock-group-pkg to unblock it" % pkg)
+            print("Package %s is blocked in this group. "
+                  "You could use unblock-group-pkg to unblock it" % pkg)
             sane = False
             continue
         if pinfo['tag_id'] != taginfo['id']:
             # listing is inherited
             srctag = session.getTag(pinfo['tag_id'])
-            print("The entry for package %s is inherited from %s. You could use block-group-pkg to prevent this" % (pkg, srctag['name']))
+            print("The entry for package %s is inherited from %s. "
+                  "You could use block-group-pkg to prevent this" % (pkg, srctag['name']))
             sane = False
             continue
     if not sane:
@@ -3246,6 +3251,7 @@ def handle_unblock_group_req(goptions, session, args):
     activate_session(session, goptions)
     session.groupReqListUnblock(tag, group, req)
 
+
 def handle_remove_group_req(goptions, session, args):
     "[admin] Remove entries from a group's requirement listing"
     usage = "usage: %prog remove-group-req [options] <tag> <group> <req> [<req> ...]"
@@ -3277,13 +3283,15 @@ def handle_remove_group_req(goptions, session, args):
             continue
         rinfo = req_idx[req]
         if rinfo['blocked']:
-            print("Req %s is blocked in this group. You could use unblock-group-req to unblock it" % req)
+            print("Req %s is blocked in this group. "
+                  "You could use unblock-group-req to unblock it" % req)
             sane = False
             continue
         if rinfo['tag_id'] != taginfo['id']:
             # listing is inherited
             srctag = session.getTag(rinfo['tag_id'])
-            print("The entry for req %s is inherited from %s. You could use block-group-req to prevent this" % (req, srctag['name']))
+            print("The entry for req %s is inherited from %s. "
+                  "You could use block-group-req to prevent this" % (req, srctag['name']))
             sane = False
             continue
     if not sane:

From c45a3921468f542c10dc98a8ffc9e8779b234fb9 Mon Sep 17 00:00:00 2001
From: Mike McLean <mikem@redhat.com>
Date: Jul 22 2025 14:19:10 +0000
Subject: [PATCH 10/10] update mock imports


---

diff --git a/tests/test_cli/test_remove_group_pkg.py b/tests/test_cli/test_remove_group_pkg.py
index df167b0..42d6398 100644
--- a/tests/test_cli/test_remove_group_pkg.py
+++ b/tests/test_cli/test_remove_group_pkg.py
@@ -1,6 +1,9 @@
 from __future__ import absolute_import
 
-import mock
+try:
+    from unittest import mock
+except ImportError:
+    import mock
 import six
 
 from koji_cli.commands import handle_remove_group_pkg
diff --git a/tests/test_cli/test_remove_group_req.py b/tests/test_cli/test_remove_group_req.py
index 7970639..8bfea48 100644
--- a/tests/test_cli/test_remove_group_req.py
+++ b/tests/test_cli/test_remove_group_req.py
@@ -1,6 +1,9 @@
 from __future__ import absolute_import
 
-import mock
+try:
+    from unittest import mock
+except ImportError:
+    import mock
 import six
 
 from koji_cli.commands import handle_remove_group_req

