From d2e00736bc28e6a78a3fc3c06fd474ecada50894 Mon Sep 17 00:00:00 2001
From: Mike McLean <mikem@redhat.com>
Date: Mar 07 2019 17:32:15 +0000
Subject: [PATCH 1/20] partial


---

diff --git a/sidetag_hub.py b/sidetag_hub.py
index b9ca99a..13c3f9b 100644
--- a/sidetag_hub.py
+++ b/sidetag_hub.py
@@ -22,13 +22,8 @@ import koji
 import sys
 
 sys.path.insert(0, '/usr/share/koji-hub/')
-from kojihub import assert_policy, _singleValue, get_tag, get_tag_id, get_build_target_id, InsertProcessor
+from kojihub import assert_policy, _singleValue, get_tag, get_tag_id, get_build_target_id, get_event
 
-def insert(table, **kwargs):
-    ip = InsertProcessor(table)
-    ip.set(**kwargs)
-    ip.make_create()
-    ip.execute()
 
 @export
 def createSideTag(base_tag):
@@ -49,12 +44,16 @@ def createSideTag(base_tag):
     assert_policy('sidetag', {'tag' : base_tag['id']})
 
     # event_id is just used a unique value that makes sidetags names unique
-    event_id = _singleValue("SELECT get_event()")
+    event_id = get_event()
     sidetag_name = '{}-side-{}'.format(base_tag['name'], event_id)
-    sidetag_id = get_tag_id(sidetag_name, create=True)
+    sidetag_id = _create_tag(sidetag_name,
+                    parent=base_tag['id'],
+                    arches=base_tag['arches'],
+                    extra={'side-tag': True})
     target_name = sidetag_name
     target_id = get_build_target_id(target_name, create=True)
 
+    # TODO: koji needs a lower level call for target creation
     insert('tag_config', tag_id=sidetag_id)
     insert('build_target_config', build_target_id=target_id, build_tag=sidetag_id, dest_tag=sidetag_id)
     insert('tag_inheritance', tag_id=sidetag_id, parent_id=base_tag['id'], priority=0)

From ff73bc4dac425b0d8d59838fd54fd1cac5352435 Mon Sep 17 00:00:00 2001
From: Mike McLean <mikem@redhat.com>
Date: Mar 07 2019 21:02:29 +0000
Subject: [PATCH 2/20] avoid direct queries and provide remove call


relies on koji change: https://pagure.io/koji/pull-request/1331

---

diff --git a/sidetag_hub.py b/sidetag_hub.py
index 13c3f9b..4fcbf62 100644
--- a/sidetag_hub.py
+++ b/sidetag_hub.py
@@ -22,7 +22,9 @@ import koji
 import sys
 
 sys.path.insert(0, '/usr/share/koji-hub/')
-from kojihub import assert_policy, _singleValue, get_tag, get_tag_id, get_build_target_id, get_event
+from kojihub import assert_policy, get_tag, get_user, get_event, \
+        get_build_target, _create_tag, _create_build_target, _delete_tag, \
+        _delete_build_target
 
 
 @export
@@ -36,26 +38,49 @@ def createSideTag(base_tag):
     # Any logged-in user is able to request creation of side tags,
     # as long the request meets the policy.
     context.session.assertLogin()
+    user = get_user(context.session.user_id, strict=True)
 
     base_tag = get_tag(base_tag, strict=True)
 
     # Policy is a very flexible mechanism, that can restrict for which
     # tags sidetags can be created, or which users can create sidetags etc.
-    assert_policy('sidetag', {'tag' : base_tag['id']})
+    assert_policy('sidetag', {'tag': base_tag['id']})
 
     # event_id is just used a unique value that makes sidetags names unique
     event_id = get_event()
-    sidetag_name = '{}-side-{}'.format(base_tag['name'], event_id)
+    sidetag_name = '%s-side-%s' % (base_tag['name'], event_id)
     sidetag_id = _create_tag(sidetag_name,
                     parent=base_tag['id'],
                     arches=base_tag['arches'],
-                    extra={'side-tag': True})
-    target_name = sidetag_name
-    target_id = get_build_target_id(target_name, create=True)
-
-    # TODO: koji needs a lower level call for target creation
-    insert('tag_config', tag_id=sidetag_id)
-    insert('build_target_config', build_target_id=target_id, build_tag=sidetag_id, dest_tag=sidetag_id)
-    insert('tag_inheritance', tag_id=sidetag_id, parent_id=base_tag['id'], priority=0)
+                    extra={'sidetag': True,
+                        'sidetag_user': user['name'],
+                        'sidetag_user_id': user['id']})
+    _create_build_target(sidetag_name, sidetag_id, sidetag_id)
 
     return {'name': sidetag_name, 'id': sidetag_id}
+
+
+@export
+def removeSideTag(sidetag):
+    """Remove a side tag"""
+    context.session.assertLogin()
+    user = get_user(context.session.user_id, strict=True)
+    sidetag = get_tag(sidetag, strict=True)
+
+    # sanity/access
+    if not sidetag['extra'].get('sidetag'):
+        raise koji.GenericError('Not a sidetag: %(name)s' % sidetag)
+    if sidetag['extra'].get('sidetag_user_id') != user['id']:
+        if not context.session.hasPerm('admin'):
+            raise koji.ActionNotAllowed('This is not your sidetag')
+
+    # check target
+    target = get_build_target(sidetag['name'])
+    if not target:
+        raise koji.GenericError('Target is missing for sidetag')
+    if (target['build_tag'] != sidetag['id']
+            or target['dest_tag'] != sidetag['id']):
+        raise koji.GenericError('Target does not match sidetag')
+
+    _delete_build_target(target['id'])
+    _delete_tag(sidetag['id'])

From dd3ced672c0af14864e364b2132a757be9a6f561 Mon Sep 17 00:00:00 2001
From: Mike McLean <mikem@redhat.com>
Date: Mar 07 2019 21:31:32 +0000
Subject: [PATCH 3/20] callback to remove sidetags when they become empty


---

diff --git a/sidetag_hub.py b/sidetag_hub.py
index 4fcbf62..7b716cc 100644
--- a/sidetag_hub.py
+++ b/sidetag_hub.py
@@ -73,7 +73,10 @@ def removeSideTag(sidetag):
     if sidetag['extra'].get('sidetag_user_id') != user['id']:
         if not context.session.hasPerm('admin'):
             raise koji.ActionNotAllowed('This is not your sidetag')
+    _remove_sidetag(sidetag)
 
+
+def _remove_sidetag(sidetag):
     # check target
     target = get_build_target(sidetag['name'])
     if not target:
@@ -84,3 +87,28 @@ def removeSideTag(sidetag):
 
     _delete_build_target(target['id'])
     _delete_tag(sidetag['id'])
+
+
+@callback('postUntag')
+def handle_sidetag_untag(cbtype, *args, **kws)
+    """Remove a side tag when its last build is untagged"""
+    if 'tag' not in kws:
+        # shouldn't happen, but...
+        return
+    tag = get_tag(kws['tag'], strict=False)
+    if not tag:
+        # also shouldn't happen, but just in case
+        return
+    if not tag['extra'].get('sidetag'):
+        # not a side tag
+        return
+    # is the tag now empty?
+    builds = readTaggedBuilds(tag['id'], inherit=False)
+    if builds:
+        return
+    # looks like we've just untagged the last build from a side tag
+    try:
+        # XXX: are we double updating tag_listing?
+        _remove_sidetag(tag)
+    except koji.GenericError:
+        pass

From 524a5bb952ffde095779179bec69cab50e64f670 Mon Sep 17 00:00:00 2001
From: Tomas Kopecek <tkopecek@redhat.com>
Date: Mar 25 2019 10:31:40 +0000
Subject: [PATCH 4/20] missing imports


---

diff --git a/sidetag_hub.py b/sidetag_hub.py
index 7b716cc..ed1b7f5 100644
--- a/sidetag_hub.py
+++ b/sidetag_hub.py
@@ -17,14 +17,14 @@
 # Author: Mikolaj Izdebski <mizdebsk@redhat.com>
 
 from koji.context import context
-from koji.plugin import export
+from koji.plugin import export, callback
 import koji
 import sys
 
 sys.path.insert(0, '/usr/share/koji-hub/')
 from kojihub import assert_policy, get_tag, get_user, get_event, \
         get_build_target, _create_tag, _create_build_target, _delete_tag, \
-        _delete_build_target
+        _delete_build_target, readTaggedBuilds
 
 
 @export
@@ -90,7 +90,7 @@ def _remove_sidetag(sidetag):
 
 
 @callback('postUntag')
-def handle_sidetag_untag(cbtype, *args, **kws)
+def handle_sidetag_untag(cbtype, *args, **kws):
     """Remove a side tag when its last build is untagged"""
     if 'tag' not in kws:
         # shouldn't happen, but...

From 78bcafc5f0182ff3633ce5881a379065bf13b1e2 Mon Sep 17 00:00:00 2001
From: Tomas Kopecek <tkopecek@redhat.com>
Date: Apr 01 2019 14:53:52 +0000
Subject: [PATCH 5/20] add policy data


---

diff --git a/sidetag_hub.py b/sidetag_hub.py
index ed1b7f5..e69fa94 100644
--- a/sidetag_hub.py
+++ b/sidetag_hub.py
@@ -24,7 +24,7 @@ import sys
 sys.path.insert(0, '/usr/share/koji-hub/')
 from kojihub import assert_policy, get_tag, get_user, get_event, \
         get_build_target, _create_tag, _create_build_target, _delete_tag, \
-        _delete_build_target, readTaggedBuilds
+        _delete_build_target, readTaggedBuilds, QueryProcessor
 
 
 @export
@@ -42,19 +42,42 @@ def createSideTag(base_tag):
 
     base_tag = get_tag(base_tag, strict=True)
 
+    query = QueryProcessor(
+        tables=["tag_extra"],
+        clauses=[
+            "key='sidetag_user_id'",
+            "value=%(user_id)s",
+            "active IS TRUE",
+        ],
+        columns=["COUNT(*)", "MAX(tag_id) + 1"],
+        aliases=["user_tags", "tag_id_candidate"],
+        values={'user_id': str(user['id'])},
+    )
+    data = query.executeOne()
+    if not data:
+        # should not ever happen
+        raise koji.GenericError('Unknown db error')
+
     # Policy is a very flexible mechanism, that can restrict for which
     # tags sidetags can be created, or which users can create sidetags etc.
-    assert_policy('sidetag', {'tag': base_tag['id']})
-
-    # event_id is just used a unique value that makes sidetags names unique
-    event_id = get_event()
-    sidetag_name = '%s-side-%s' % (base_tag['name'], event_id)
+    assert_policy('sidetag', {
+        'tag': base_tag['id'],
+        'number_of_tags': data['user_tags'],
+    })
+
+    # tag_id_candidate is just used a unique value that makes sidetags names
+    # unique, it is the first unused number in user/tag sequence, not global
+    # top tag_id. Nothing should be infered from this number.
+    sidetag_name = '%s-side-%s' % (base_tag['name'], data['tag_id_candidate'])
     sidetag_id = _create_tag(sidetag_name,
-                    parent=base_tag['id'],
-                    arches=base_tag['arches'],
-                    extra={'sidetag': True,
-                        'sidetag_user': user['name'],
-                        'sidetag_user_id': user['id']})
+                             parent=base_tag['id'],
+                             arches=base_tag['arches'],
+                             extra={
+                                 'sidetag': True,
+                                 'sidetag_user': user['name'],
+                                 'sidetag_user_id': user['id']
+                                 }
+                             )
     _create_build_target(sidetag_name, sidetag_id, sidetag_id)
 
     return {'name': sidetag_name, 'id': sidetag_id}
@@ -91,11 +114,16 @@ def _remove_sidetag(sidetag):
 
 @callback('postUntag')
 def handle_sidetag_untag(cbtype, *args, **kws):
-    """Remove a side tag when its last build is untagged"""
+    """Remove a side tag when its last build is untagged
+
+    Note, that this is triggered only in case, that some build exists. For
+    never used tags, some other policy must be applied. Same holds for users
+    which don't untag their builds.
+    """
     if 'tag' not in kws:
         # shouldn't happen, but...
         return
-    tag = get_tag(kws['tag'], strict=False)
+    tag = get_tag(kws['tag']['id'], strict=False)
     if not tag:
         # also shouldn't happen, but just in case
         return

From 8f287a39c7748eb67b8764f0a6616f98a37f3f74 Mon Sep 17 00:00:00 2001
From: Tomas Kopecek <tkopecek@redhat.com>
Date: Apr 01 2019 15:19:26 +0000
Subject: [PATCH 6/20] CLI plugin


---

diff --git a/README.md b/README.md
index f01fc3d..7487c99 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@ Installation
 First install plugin file in Koji hub file system:
 
     mkdir -p /usr/lib/koji-hub-plugins/
-    cp koji/plugin.py /usr/lib/koji-hub-plugins/sidetag.py
+    cp sidetag_hub.py /usr/lib/koji-hub-plugins/sidetag.py
 
 And then enable it in hub config.  Minimal example
 `/etc/koji-hub/hub.conf`:
@@ -38,6 +38,29 @@ Example plugin usage from Python:
     ks.gssapi_login()
     ks.createSideTag('f30-build')
 
+
+CLI plugin
+==========
+
+Installation
+------------
+
+Plugin can be installed in python site-packages.
+
+    mkdir -p /user/lib/python3.7/site-packages/koji_cli_plugins
+    cp sidetag_cli.py /usr/lib/koji-hub-plugins/sidetag.py
+
+CLI plugin is automatically pulled by koji, so you can use it immediately.
+
+Usage
+-----
+
+   $ koji add-sidetag f30-build --wait
+   f30-build-side-123456
+   Successfully waited 1:36 for a new f30-build-side-123456 repo
+
+   $ koji remove-sidetag f30-build-side-123456
+
 Copying
 -------
 
diff --git a/sidetag_cli.py b/sidetag_cli.py
new file mode 100644
index 0000000..de6b7a9
--- /dev/null
+++ b/sidetag_cli.py
@@ -0,0 +1,54 @@
+from __future__ import absolute_import
+
+from optparse import OptionParser
+
+from koji.plugin import export_cli
+from koji_cli.lib import _, activate_session, watch_tasks
+from koji_cli.commands import anon_handle_wait_repo
+
+@export_cli
+def handle_add_sidetag(options, session, args):
+    "[admin] Create sidetag"
+    usage = _("usage: %prog add-sidetag [options] <basetag>")
+    usage += _("\n(Specify the --help global option for a list of other help options)")
+    parser = OptionParser(usage=usage)
+    parser.add_option("-q", "--quiet", action="store_true",
+                      help=_("Do not print tag name"), default=options.quiet)
+    parser.add_option("-w", "--wait", action="store_true",
+                      help=_("Wait until repo is ready."))
+    (opts, args) = parser.parse_args(args)
+
+    if len(args) != 1:
+        parser.error(_("Specify basetag"))
+    basetag = args[0]
+
+    activate_session(session, options)
+
+    tag = session.createSideTag(basetag)
+
+    if not opts.quiet:
+        print(tag['name'])
+
+    if opts.wait:
+        args = ["--target", tag["name"]]
+        if opts.quiet:
+            args.append('--quiet')
+        anon_handle_wait_repo(options, session, args)
+
+@export_cli
+def handle_remove_sidetag(options, session, args):
+    "[admin] Remove sidetag"
+    usage = _("usage: %prog remove-sidetag [options] <basetag>")
+    usage += _("\n(Specify the --help global option for a list of other help options)")
+    parser = OptionParser(usage=usage)
+    (opts, args) = parser.parse_args(args)
+
+    if not args:
+        parser.error(_("Specify one or more sidetags"))
+
+    activate_session(session, options)
+
+    session.multicall = True
+    for sidetag in args:
+        session.removeSideTag(sidetag)
+    session.multiCall()

From 5e4348e0d617b59fbf7db86e7fa53b0d4b6fc5f9 Mon Sep 17 00:00:00 2001
From: Tomas Kopecek <tkopecek@redhat.com>
Date: Apr 01 2019 16:41:30 +0000
Subject: [PATCH 7/20] list sidetags


---

diff --git a/sidetag_cli.py b/sidetag_cli.py
index de6b7a9..6101cde 100644
--- a/sidetag_cli.py
+++ b/sidetag_cli.py
@@ -52,3 +52,30 @@ def handle_remove_sidetag(options, session, args):
     for sidetag in args:
         session.removeSideTag(sidetag)
     session.multiCall()
+
+@export_cli
+def handle_list_sidetags(options, session, args):
+    "[admin] List sidetags"
+    usage = _("usage: %prog remove-sidetag [options]")
+    usage += _("\n(Specify the --help global option for a list of other help options)")
+    parser = OptionParser(usage=usage)
+    parser.add_option("--basetag", action="store", help=_("Filter on basetag"))
+    parser.add_option("--user", action="store", help=_("Filter on user"))
+    parser.add_option("--mine", action="store_true", help=_("Filter on user"))
+
+    (opts, args) = parser.parse_args(args)
+
+    if args:
+        parser.error(_("This command takes no arguments."))
+
+    if opts.mine and opts.user:
+        parser.error(_("Specify only one from --user --mine"))
+
+    if opts.mine:
+        activate_session(session, options)
+        user = session.getLoggedInUser()['id']
+    else:
+        user = opts.user
+
+    for tag in session.listSideTags(basetag=opts.basetag, user=user):
+        print(tag['name'])
diff --git a/sidetag_hub.py b/sidetag_hub.py
index e69fa94..6523837 100644
--- a/sidetag_hub.py
+++ b/sidetag_hub.py
@@ -22,9 +22,9 @@ import koji
 import sys
 
 sys.path.insert(0, '/usr/share/koji-hub/')
-from kojihub import assert_policy, get_tag, get_user, get_event, \
+from kojihub import assert_policy, get_tag, get_user, \
         get_build_target, _create_tag, _create_build_target, _delete_tag, \
-        _delete_build_target, readTaggedBuilds, QueryProcessor
+        _delete_build_target, readTaggedBuilds, QueryProcessor, nextval
 
 
 @export
@@ -49,12 +49,12 @@ def createSideTag(base_tag):
             "value=%(user_id)s",
             "active IS TRUE",
         ],
-        columns=["COUNT(*)", "MAX(tag_id) + 1"],
-        aliases=["user_tags", "tag_id_candidate"],
+        columns=["COUNT(*)"],
+        aliases=["user_tags"],
         values={'user_id': str(user['id'])},
     )
-    data = query.executeOne()
-    if not data:
+    user_tags = query.executeOne()
+    if user_tags is None:
         # should not ever happen
         raise koji.GenericError('Unknown db error')
 
@@ -62,13 +62,13 @@ def createSideTag(base_tag):
     # tags sidetags can be created, or which users can create sidetags etc.
     assert_policy('sidetag', {
         'tag': base_tag['id'],
-        'number_of_tags': data['user_tags'],
+        'number_of_tags': user_tags,
     })
 
-    # tag_id_candidate is just used a unique value that makes sidetags names
-    # unique, it is the first unused number in user/tag sequence, not global
-    # top tag_id. Nothing should be infered from this number.
-    sidetag_name = '%s-side-%s' % (base_tag['name'], data['tag_id_candidate'])
+    # ugly, it will waste one number in tag_id_seq, but result will match with
+    # id assigned by _create_tag
+    tag_id = nextval("tag_id_seq") + 1
+    sidetag_name = '%s-side-%s' % (base_tag['name'], tag_id)
     sidetag_id = _create_tag(sidetag_name,
                              parent=base_tag['id'],
                              arches=base_tag['arches'],
@@ -111,6 +111,54 @@ def _remove_sidetag(sidetag):
     _delete_build_target(target['id'])
     _delete_tag(sidetag['id'])
 
+@export
+def listSideTags(basetag=None, user=None):
+    """List all sidetags possible filtered by basetag or user"""
+    # te1.sidetag
+    # te2.user_id
+    # te3.basetag
+    if user is not None:
+        user_id = get_user(user, strict=True)['id']
+    else:
+        user_id = None
+    if basetag is not None:
+        basetag_id = get_tag(basetag, strict=True)['id']
+    else:
+        basetag_id = None
+
+    joins = ['LEFT JOIN tag_extra AS te1 ON tag.id = te1.tag_id']
+    clauses = [
+        "te1.active IS TRUE",
+        "te1.key = 'sidetag'",
+        "te1.value = 'true'",
+    ]
+    if user_id:
+        joins.append('LEFT JOIN tag_extra AS te2 ON tag.id = te2.tag_id')
+        clauses.extend([
+            "te2.active IS TRUE",
+            "te2.key = 'sidetag_user_id'",
+            "te2.value = %(user_id)s",
+        ])
+    if basetag_id:
+        joins.append('LEFT JOIN tag_inheritance ON tag.id = tag_inheritance.tag_id')
+        clauses.extend([
+            "tag_inheritance.active IS TRUE",
+            "tag_inheritance.parent_id = %(basetag_id)s",
+        ])
+
+    query = QueryProcessor(
+        tables=["tag"],
+        clauses=clauses,
+        columns=["tag.id", "tag.name"],
+        aliases=["id", "name"],
+        joins=joins,
+        values={
+            'basetag_id': basetag_id,
+            'user_id': user_id,
+        },
+    )
+    return query.execute()
+
 
 @callback('postUntag')
 def handle_sidetag_untag(cbtype, *args, **kws):

From 31e40da609fa0f16ca3d2c02fee7f9d13cda8d80 Mon Sep 17 00:00:00 2001
From: Tomas Kopecek <tkopecek@redhat.com>
Date: Apr 16 2019 07:58:47 +0000
Subject: [PATCH 8/20] configurable auto-delete


---

diff --git a/sidetag.conf b/sidetag.conf
new file mode 100644
index 0000000..2438efb
--- /dev/null
+++ b/sidetag.conf
@@ -0,0 +1,5 @@
+[sidetag]
+# automatically remove sidetag on untagging last package
+# needs at least koji 1.18 to not trigger bug
+# https://pagure.io/koji/issue/1379
+remove_empty = off
diff --git a/sidetag_hub.py b/sidetag_hub.py
index 6523837..6d5143e 100644
--- a/sidetag_hub.py
+++ b/sidetag_hub.py
@@ -21,6 +21,9 @@ from koji.plugin import export, callback
 import koji
 import sys
 
+CONFIG_FILE = '/etc/koji-hub/plugins/protonmsg.conf'
+CONFIG = None
+
 sys.path.insert(0, '/usr/share/koji-hub/')
 from kojihub import assert_policy, get_tag, get_user, \
         get_build_target, _create_tag, _create_build_target, _delete_tag, \
@@ -160,7 +163,6 @@ def listSideTags(basetag=None, user=None):
     return query.execute()
 
 
-@callback('postUntag')
 def handle_sidetag_untag(cbtype, *args, **kws):
     """Remove a side tag when its last build is untagged
 
@@ -188,3 +190,10 @@ def handle_sidetag_untag(cbtype, *args, **kws):
         _remove_sidetag(tag)
     except koji.GenericError:
         pass
+
+# read config and register
+if not CONFIG:
+    CONFIG = koji.read_config_files(CONFIG_FILE)
+    if CONFIG.has_option('sidetag', 'remove_empty') and \
+       CONFIG.getboolean('sidetag', 'remove_empty'):
+       handle_sidetag_untag = callback('postUntag')(handle_sidetag_untag)

From d87bbff656e4f676e95c4d76848795becc019dd4 Mon Sep 17 00:00:00 2001
From: Tomas Kopecek <tkopecek@redhat.com>
Date: Apr 29 2019 06:43:21 +0000
Subject: [PATCH 9/20] fix default config name


---

diff --git a/sidetag_hub.py b/sidetag_hub.py
index 6d5143e..d824b55 100644
--- a/sidetag_hub.py
+++ b/sidetag_hub.py
@@ -21,7 +21,7 @@ from koji.plugin import export, callback
 import koji
 import sys
 
-CONFIG_FILE = '/etc/koji-hub/plugins/protonmsg.conf'
+CONFIG_FILE = '/etc/koji-hub/plugins/sidetag.conf'
 CONFIG = None
 
 sys.path.insert(0, '/usr/share/koji-hub/')

From a293ae2f10a77fcce9208afd1b2a9626518f6313 Mon Sep 17 00:00:00 2001
From: Tomas Kopecek <tkopecek@redhat.com>
Date: May 16 2019 07:34:29 +0000
Subject: [PATCH 10/20] fix typos


---

diff --git a/sidetag_cli.py b/sidetag_cli.py
index 6101cde..5f2910a 100644
--- a/sidetag_cli.py
+++ b/sidetag_cli.py
@@ -38,7 +38,7 @@ def handle_add_sidetag(options, session, args):
 @export_cli
 def handle_remove_sidetag(options, session, args):
     "[admin] Remove sidetag"
-    usage = _("usage: %prog remove-sidetag [options] <basetag>")
+    usage = _("usage: %prog remove-sidetag [options] <sidetag>")
     usage += _("\n(Specify the --help global option for a list of other help options)")
     parser = OptionParser(usage=usage)
     (opts, args) = parser.parse_args(args)
@@ -73,7 +73,7 @@ def handle_list_sidetags(options, session, args):
 
     if opts.mine:
         activate_session(session, options)
-        user = session.getLoggedInUser()['id']
+        user = session.getLoggedInUser()['name']
     else:
         user = opts.user
 
diff --git a/sidetag_hub.py b/sidetag_hub.py
index d824b55..e393e3a 100644
--- a/sidetag_hub.py
+++ b/sidetag_hub.py
@@ -121,7 +121,7 @@ def listSideTags(basetag=None, user=None):
     # te2.user_id
     # te3.basetag
     if user is not None:
-        user_id = get_user(user, strict=True)['id']
+        user_id = str(get_user(user, strict=True)['id'])
     else:
         user_id = None
     if basetag is not None:

From f5ed34dd5b740348d3b836bf2e72ff7480b459cb Mon Sep 17 00:00:00 2001
From: Tomas Kopecek <tkopecek@redhat.com>
Date: May 17 2019 11:12:02 +0000
Subject: [PATCH 11/20] fix typo


---

diff --git a/sidetag_hub.py b/sidetag_hub.py
index e393e3a..8680d9c 100644
--- a/sidetag_hub.py
+++ b/sidetag_hub.py
@@ -65,7 +65,7 @@ def createSideTag(base_tag):
     # tags sidetags can be created, or which users can create sidetags etc.
     assert_policy('sidetag', {
         'tag': base_tag['id'],
-        'number_of_tags': user_tags,
+        'number_of_tags': user_tags['user_tags'],
     })
 
     # ugly, it will waste one number in tag_id_seq, but result will match with

From 19e50a7a3ddb0e726c5a9e19621d6385e037322d Mon Sep 17 00:00:00 2001
From: Tomas Kopecek <tkopecek@redhat.com>
Date: May 17 2019 11:12:24 +0000
Subject: [PATCH 12/20] simple message for policy violation


Otherwise, traceback would be printed, which is on one hand englighting
(prints policy rule applied), on the other hand confusing as it is one
page of traceback.

---

diff --git a/sidetag_cli.py b/sidetag_cli.py
index 5f2910a..87d7ba2 100644
--- a/sidetag_cli.py
+++ b/sidetag_cli.py
@@ -2,6 +2,7 @@ from __future__ import absolute_import
 
 from optparse import OptionParser
 
+import koji
 from koji.plugin import export_cli
 from koji_cli.lib import _, activate_session, watch_tasks
 from koji_cli.commands import anon_handle_wait_repo
@@ -24,7 +25,10 @@ def handle_add_sidetag(options, session, args):
 
     activate_session(session, options)
 
-    tag = session.createSideTag(basetag)
+    try:
+        tag = session.createSideTag(basetag)
+    except koji.ActionNotAllowed:
+        parser.error(_("Policy violation"))
 
     if not opts.quiet:
         print(tag['name'])

From c7a65951bd29c41ee451686457ee27a8e0bdffe7 Mon Sep 17 00:00:00 2001
From: Tomas Kopecek <tkopecek@redhat.com>
Date: May 21 2019 07:43:57 +0000
Subject: [PATCH 13/20] remove admin prefix in CLI


---

diff --git a/sidetag_cli.py b/sidetag_cli.py
index 87d7ba2..90baa62 100644
--- a/sidetag_cli.py
+++ b/sidetag_cli.py
@@ -9,7 +9,7 @@ from koji_cli.commands import anon_handle_wait_repo
 
 @export_cli
 def handle_add_sidetag(options, session, args):
-    "[admin] Create sidetag"
+    "Create sidetag"
     usage = _("usage: %prog add-sidetag [options] <basetag>")
     usage += _("\n(Specify the --help global option for a list of other help options)")
     parser = OptionParser(usage=usage)
@@ -41,7 +41,7 @@ def handle_add_sidetag(options, session, args):
 
 @export_cli
 def handle_remove_sidetag(options, session, args):
-    "[admin] Remove sidetag"
+    "Remove sidetag"
     usage = _("usage: %prog remove-sidetag [options] <sidetag>")
     usage += _("\n(Specify the --help global option for a list of other help options)")
     parser = OptionParser(usage=usage)
@@ -59,7 +59,7 @@ def handle_remove_sidetag(options, session, args):
 
 @export_cli
 def handle_list_sidetags(options, session, args):
-    "[admin] List sidetags"
+    "List sidetags"
     usage = _("usage: %prog remove-sidetag [options]")
     usage += _("\n(Specify the --help global option for a list of other help options)")
     parser = OptionParser(usage=usage)

From edb4d1d3fa7f7cba12a6a753f68cc3badfc8cc6a Mon Sep 17 00:00:00 2001
From: Tomas Kopecek <tkopecek@redhat.com>
Date: May 21 2019 07:44:51 +0000
Subject: [PATCH 14/20] add strict to tag removal


---

diff --git a/sidetag_cli.py b/sidetag_cli.py
index 90baa62..cf18ea4 100644
--- a/sidetag_cli.py
+++ b/sidetag_cli.py
@@ -55,7 +55,7 @@ def handle_remove_sidetag(options, session, args):
     session.multicall = True
     for sidetag in args:
         session.removeSideTag(sidetag)
-    session.multiCall()
+    session.multiCall(strict=True)
 
 @export_cli
 def handle_list_sidetags(options, session, args):

From ecd22eb23e6b58b858558ad80072755919cf7912 Mon Sep 17 00:00:00 2001
From: Tomas Kopecek <tkopecek@redhat.com>
Date: May 21 2019 07:45:43 +0000
Subject: [PATCH 15/20] faster sql query


---

diff --git a/sidetag_hub.py b/sidetag_hub.py
index 8680d9c..8300c61 100644
--- a/sidetag_hub.py
+++ b/sidetag_hub.py
@@ -181,8 +181,13 @@ def handle_sidetag_untag(cbtype, *args, **kws):
         # not a side tag
         return
     # is the tag now empty?
-    builds = readTaggedBuilds(tag['id'], inherit=False)
-    if builds:
+    query = QueryProcessor(
+        tables=["tag_listing"],
+        clauses=["tag_id = %(tag_id)s", "active IS TRUE"],
+        values={'tag_id': tag['id']},
+        opts={'countOnly': True}
+    )
+    if query.execute():
         return
     # looks like we've just untagged the last build from a side tag
     try:

From fc9b8322d3896b6be82326c8625e1eb44fcafe5c Mon Sep 17 00:00:00 2001
From: Tomas Kopecek <tkopecek@redhat.com>
Date: May 21 2019 07:47:23 +0000
Subject: [PATCH 16/20] refactor basetag names + docs


---

diff --git a/sidetag_hub.py b/sidetag_hub.py
index 8300c61..4a1eec8 100644
--- a/sidetag_hub.py
+++ b/sidetag_hub.py
@@ -31,11 +31,11 @@ from kojihub import assert_policy, get_tag, get_user, \
 
 
 @export
-def createSideTag(base_tag):
-    """
-    Create a side tag.
-    Options:
-    - base_tag: name or ID of base tag
+def createSideTag(basetag):
+    """Create a side tag.
+
+    :param basetag: name or ID of base tag
+    :type basetag: str or int
     """
 
     # Any logged-in user is able to request creation of side tags,
@@ -43,7 +43,7 @@ def createSideTag(base_tag):
     context.session.assertLogin()
     user = get_user(context.session.user_id, strict=True)
 
-    base_tag = get_tag(base_tag, strict=True)
+    basetag = get_tag(basetag, strict=True)
 
     query = QueryProcessor(
         tables=["tag_extra"],
@@ -64,17 +64,16 @@ def createSideTag(base_tag):
     # Policy is a very flexible mechanism, that can restrict for which
     # tags sidetags can be created, or which users can create sidetags etc.
     assert_policy('sidetag', {
-        'tag': base_tag['id'],
+        'tag': basetag['id'],
         'number_of_tags': user_tags['user_tags'],
     })
 
     # ugly, it will waste one number in tag_id_seq, but result will match with
     # id assigned by _create_tag
     tag_id = nextval("tag_id_seq") + 1
-    sidetag_name = '%s-side-%s' % (base_tag['name'], tag_id)
+    sidetag_name = '%s-side-%s' % (basetag['name'], tag_id)
     sidetag_id = _create_tag(sidetag_name,
-                             parent=base_tag['id'],
-                             arches=base_tag['arches'],
+                             parent=basetag['id'],
                              extra={
                                  'sidetag': True,
                                  'sidetag_user': user['name'],
@@ -88,7 +87,11 @@ def createSideTag(base_tag):
 
 @export
 def removeSideTag(sidetag):
-    """Remove a side tag"""
+    """Remove a side tag
+
+    :param sidetag: id or name of sidetag
+    :type sidetag: int or str
+    """
     context.session.assertLogin()
     user = get_user(context.session.user_id, strict=True)
     sidetag = get_tag(sidetag, strict=True)
@@ -115,8 +118,17 @@ def _remove_sidetag(sidetag):
     _delete_tag(sidetag['id'])
 
 @export
-def listSideTags(basetag=None, user=None):
-    """List all sidetags possible filtered by basetag or user"""
+def listSideTags(basetag=None, user=None, queryOpts=None):
+    """List all sidetags with additional filters
+
+    :param basetag: filter by basteag id or name
+    :type basetag: int or str
+    :param user: filter by userid or username
+    :type user: int or str
+    :param queryOpts: additional query options
+                      {countOnly, order, offset, limit}
+    :type queryOpts: dict
+    """
     # te1.sidetag
     # te2.user_id
     # te3.basetag
@@ -159,6 +171,7 @@ def listSideTags(basetag=None, user=None):
             'basetag_id': basetag_id,
             'user_id': user_id,
         },
+        opts=queryOpts
     )
     return query.execute()
 

From c728393ace78349d260100a61e6ab41c799b85a7 Mon Sep 17 00:00:00 2001
From: Tomas Kopecek <tkopecek@redhat.com>
Date: Aug 13 2019 08:00:21 +0000
Subject: [PATCH 17/20] copy arches from basetag


---

diff --git a/sidetag_hub.py b/sidetag_hub.py
index 4a1eec8..dead533 100644
--- a/sidetag_hub.py
+++ b/sidetag_hub.py
@@ -74,6 +74,7 @@ def createSideTag(basetag):
     sidetag_name = '%s-side-%s' % (basetag['name'], tag_id)
     sidetag_id = _create_tag(sidetag_name,
                              parent=basetag['id'],
+                             arches=basetag['arches'],
                              extra={
                                  'sidetag': True,
                                  'sidetag_user': user['name'],

From 4e4df6c052c9c47a9bb685705d640229405e6f4b Mon Sep 17 00:00:00 2001
From: Tomas Kopecek <tkopecek@redhat.com>
Date: Oct 30 2019 14:04:55 +0000
Subject: [PATCH 18/20] fix typo


---

diff --git a/README.md b/README.md
index 7487c99..756fcee 100644
--- a/README.md
+++ b/README.md
@@ -47,7 +47,7 @@ Installation
 
 Plugin can be installed in python site-packages.
 
-    mkdir -p /user/lib/python3.7/site-packages/koji_cli_plugins
+    mkdir -p /usr/lib/python3.7/site-packages/koji_cli_plugins
     cp sidetag_cli.py /usr/lib/koji-hub-plugins/sidetag.py
 
 CLI plugin is automatically pulled by koji, so you can use it immediately.

From 026ce61b79168159e0e9b9a13391fc68b641c535 Mon Sep 17 00:00:00 2001
From: Tomas Kopecek <tkopecek@redhat.com>
Date: Oct 30 2019 14:28:16 +0000
Subject: [PATCH 19/20] replace optarse with argparse


---

diff --git a/sidetag_cli.py b/sidetag_cli.py
index cf18ea4..417f087 100644
--- a/sidetag_cli.py
+++ b/sidetag_cli.py
@@ -1,6 +1,6 @@
 from __future__ import absolute_import
 
-from optparse import OptionParser
+from argparse import ArgumentParser
 
 import koji
 from koji.plugin import export_cli
@@ -10,23 +10,20 @@ from koji_cli.commands import anon_handle_wait_repo
 @export_cli
 def handle_add_sidetag(options, session, args):
     "Create sidetag"
-    usage = _("usage: %prog add-sidetag [options] <basetag>")
+    usage = _("usage: %(prog)s add-sidetag [options] <basetag>")
     usage += _("\n(Specify the --help global option for a list of other help options)")
-    parser = OptionParser(usage=usage)
-    parser.add_option("-q", "--quiet", action="store_true",
+    parser = ArgumentParser(usage=usage)
+    parser.add_argument("basetag", help="name of basetag")
+    parser.add_argument("-q", "--quiet", action="store_true",
                       help=_("Do not print tag name"), default=options.quiet)
-    parser.add_option("-w", "--wait", action="store_true",
+    parser.add_argument("-w", "--wait", action="store_true",
                       help=_("Wait until repo is ready."))
-    (opts, args) = parser.parse_args(args)
-
-    if len(args) != 1:
-        parser.error(_("Specify basetag"))
-    basetag = args[0]
+    opts = parser.parse_args(args)
 
     activate_session(session, options)
 
     try:
-        tag = session.createSideTag(basetag)
+        tag = session.createSideTag(opts.basetag)
     except koji.ActionNotAllowed:
         parser.error(_("Policy violation"))
 
@@ -42,35 +39,30 @@ def handle_add_sidetag(options, session, args):
 @export_cli
 def handle_remove_sidetag(options, session, args):
     "Remove sidetag"
-    usage = _("usage: %prog remove-sidetag [options] <sidetag>")
+    usage = _("usage: %(prog)s remove-sidetag [options] <sidetag> ...")
     usage += _("\n(Specify the --help global option for a list of other help options)")
-    parser = OptionParser(usage=usage)
-    (opts, args) = parser.parse_args(args)
-
-    if not args:
-        parser.error(_("Specify one or more sidetags"))
+    parser = ArgumentParser(usage=usage)
+    parser.add_argument("sidetags", help="name of sidetag", nargs="+")
+    opts = parser.parse_args(args)
 
     activate_session(session, options)
 
     session.multicall = True
-    for sidetag in args:
+    for sidetag in opts.sidetags:
         session.removeSideTag(sidetag)
     session.multiCall(strict=True)
 
 @export_cli
 def handle_list_sidetags(options, session, args):
     "List sidetags"
-    usage = _("usage: %prog remove-sidetag [options]")
+    usage = _("usage: %(prog)s remove-sidetag [options]")
     usage += _("\n(Specify the --help global option for a list of other help options)")
-    parser = OptionParser(usage=usage)
-    parser.add_option("--basetag", action="store", help=_("Filter on basetag"))
-    parser.add_option("--user", action="store", help=_("Filter on user"))
-    parser.add_option("--mine", action="store_true", help=_("Filter on user"))
-
-    (opts, args) = parser.parse_args(args)
+    parser = ArgumentParser(usage=usage)
+    parser.add_argument("--basetag", action="store", help=_("Filter on basetag"))
+    parser.add_argument("--user", action="store", help=_("Filter on user"))
+    parser.add_argument("--mine", action="store_true", help=_("Filter on user"))
 
-    if args:
-        parser.error(_("This command takes no arguments."))
+    opts = parser.parse_args(args)
 
     if opts.mine and opts.user:
         parser.error(_("Specify only one from --user --mine"))

From 0fd9f416fea23e41cc8d759d0d097a6787629e15 Mon Sep 17 00:00:00 2001
From: Tomas Kopecek <tkopecek@redhat.com>
Date: Oct 31 2019 11:19:34 +0000
Subject: [PATCH 20/20] update comment


---

diff --git a/sidetag.conf b/sidetag.conf
index 2438efb..03bd1ec 100644
--- a/sidetag.conf
+++ b/sidetag.conf
@@ -1,5 +1,4 @@
 [sidetag]
 # automatically remove sidetag on untagging last package
-# needs at least koji 1.18 to not trigger bug
-# https://pagure.io/koji/issue/1379
+# needs https://pagure.io/koji/issue/1379 merged
 remove_empty = off

