gerrit: rewrite helpers to use classes

This adds more LOC, but it makes the parsing more robust, and allows
for better encapsulation of subcommand-specific options.  The current
code forces all options for all subcommands to be part of the single
global parser leading to a lot of options that only make sense for a
few subcommands.  It also makes it hard to build more complicated
subcommands with multiple optional settings.

BUG=None
TEST=ran various `gerrit` commands

Change-Id: Ice2bb390f6c542325886e8b2da73c4b426ea9803
Reviewed-on: https://chromium-review.googlesource.com/c/chromiumos/chromite/+/2048210
Tested-by: Mike Frysinger <vapier@chromium.org>
Reviewed-by: Alex Klein <saklein@chromium.org>
Commit-Queue: Mike Frysinger <vapier@chromium.org>
diff --git a/scripts/gerrit.py b/scripts/gerrit.py
index cfe8321..a025ddd 100644
--- a/scripts/gerrit.py
+++ b/scripts/gerrit.py
@@ -13,6 +13,7 @@
 from __future__ import print_function
 
 import collections
+import functools
 import inspect
 import json
 import re
@@ -34,9 +35,19 @@
 assert sys.version_info >= (3, 6), 'This module requires Python 3.6+'
 
 
-# Locate actions that are exposed to the user.  All functions that start
-# with "UserAct" are fair game.
-ACTION_PREFIX = 'UserAct'
+class UserAction(object):
+  """Base class for all custom user actions."""
+
+  # The name of the command the user types in.
+  COMMAND = None
+
+  @staticmethod
+  def init_subparser(parser):
+    """Add arguments to this action's subparser."""
+
+  @staticmethod
+  def __call__(opts):
+    """Implement the action."""
 
 
 # How many connections we'll use in parallel.  We don't want this to be too high
@@ -256,28 +267,77 @@
   return sorted(ret, key=key)
 
 
-def UserActTodo(opts):
+class _ActionSearchQuery(UserAction):
+  """Base class for actions that perform searches."""
+
+  @staticmethod
+  def init_subparser(parser):
+    """Add arguments to this action's subparser."""
+    parser.add_argument('--sort', default='number',
+                        help='Key to sort on (number, project); use "unsorted" '
+                             'to disable')
+    parser.add_argument('-b', '--branch',
+                        help='Limit output to the specific branch')
+    parser.add_argument('-p', '--project',
+                        help='Limit output to the specific project')
+    parser.add_argument('-t', '--topic',
+                        help='Limit output to the specific topic')
+
+
+class ActionTodo(_ActionSearchQuery):
   """List CLs needing your review"""
-  cls = FilteredQuery(opts, ('reviewer:self status:open NOT owner:self '
-                             'label:Code-Review=0,user=self '
-                             'NOT label:Verified<0'))
-  PrintCls(opts, cls)
+
+  COMMAND = 'todo'
+
+  @staticmethod
+  def __call__(opts):
+    """Implement the action."""
+    cls = FilteredQuery(opts, ('reviewer:self status:open NOT owner:self '
+                               'label:Code-Review=0,user=self '
+                               'NOT label:Verified<0'))
+    PrintCls(opts, cls)
 
 
-def UserActSearch(opts, query):
+class ActionSearch(_ActionSearchQuery):
   """List CLs matching the search query"""
-  cls = FilteredQuery(opts, query)
-  PrintCls(opts, cls)
-UserActSearch.usage = '<query>'
+
+  COMMAND = 'search'
+
+  @staticmethod
+  def init_subparser(parser):
+    """Add arguments to this action's subparser."""
+    _ActionSearchQuery.init_subparser(parser)
+    parser.add_argument('query',
+                        help='The search query')
+
+  @staticmethod
+  def __call__(opts):
+    """Implement the action."""
+    cls = FilteredQuery(opts, opts.query)
+    PrintCls(opts, cls)
 
 
-def UserActMine(opts):
+class ActionMine(_ActionSearchQuery):
   """List your CLs with review statuses"""
-  if opts.draft:
-    rule = 'is:draft'
-  else:
-    rule = 'status:new'
-  UserActSearch(opts, 'owner:self %s' % (rule,))
+
+  COMMAND = 'mine'
+
+  @staticmethod
+  def init_subparser(parser):
+    """Add arguments to this action's subparser."""
+    _ActionSearchQuery.init_subparser(parser)
+    parser.add_argument('--draft', default=False, action='store_true',
+                        help='Show draft changes')
+
+  @staticmethod
+  def __call__(opts):
+    """Implement the action."""
+    if opts.draft:
+      rule = 'is:draft'
+    else:
+      rule = 'status:new'
+    cls = FilteredQuery(opts, 'owner:self %s' % (rule,))
+    PrintCls(opts, cls)
 
 
 def _BreadthFirstSearch(to_visit, children, visited_key=lambda x: x):
@@ -304,15 +364,35 @@
   return to_visit
 
 
-def UserActDeps(opts, query):
+class ActionDeps(_ActionSearchQuery):
   """List CLs matching a query, and all transitive dependencies of those CLs"""
-  cls = _Query(opts, query, raw=False)
 
-  @memoize.Memoize
-  def _QueryChange(cl, helper=None):
-    return _Query(opts, cl, raw=False, helper=helper)
+  COMMAND = 'deps'
 
-  def _ProcessDeps(cl, deps, required):
+  @staticmethod
+  def init_subparser(parser):
+    """Add arguments to this action's subparser."""
+    _ActionSearchQuery.init_subparser(parser)
+    parser.add_argument('query',
+                        help='The search query')
+
+  def __call__(self, opts):
+    """Implement the action."""
+    cls = _Query(opts, opts.query, raw=False)
+
+    @memoize.Memoize
+    def _QueryChange(cl, helper=None):
+      return _Query(opts, cl, raw=False, helper=helper)
+
+    transitives = _BreadthFirstSearch(
+        cls, functools.partial(self._Children, opts, _QueryChange),
+        visited_key=lambda cl: cl.gerrit_number)
+
+    transitives_raw = [cl.patch_dict for cl in transitives]
+    PrintCls(opts, transitives_raw)
+
+  @staticmethod
+  def _ProcessDeps(opts, querier, cl, deps, required):
     """Yields matching dependencies for a patch"""
     # We need to query the change to guarantee that we have a .gerrit_number
     for dep in deps:
@@ -322,7 +402,7 @@
       helper = opts.gerrit[dep.remote]
 
       # TODO(phobbs) this should maybe catch network errors.
-      changes = _QueryChange(dep.ToGerritQueryText(), helper=helper)
+      changes = querier(dep.ToGerritQueryText(), helper=helper)
 
       # Handle empty results.  If we found a commit that was pushed directly
       # (e.g. a bot commit), then gerrit won't know about it.
@@ -344,241 +424,369 @@
         if change.status == 'NEW':
           yield change
 
-  def _Children(cl):
+  @classmethod
+  def _Children(cls, opts, querier, cl):
     """Yields the Gerrit and CQ-Depends dependencies of a patch"""
-    for change in _ProcessDeps(cl, cl.PaladinDependencies(None), True):
+    for change in cls._ProcessDeps(
+        opts, querier, cl, cl.PaladinDependencies(None), True):
       yield change
-    for change in _ProcessDeps(cl, cl.GerritDependencies(), False):
+    for change in cls._ProcessDeps(
+        opts, querier, cl, cl.GerritDependencies(), False):
       yield change
 
-  transitives = _BreadthFirstSearch(
-      cls, _Children,
-      visited_key=lambda cl: cl.gerrit_number)
 
-  transitives_raw = [cl.patch_dict for cl in transitives]
-  PrintCls(opts, transitives_raw)
-UserActDeps.usage = '<query>'
-
-
-def UserActInspect(opts, *args):
+class ActionInspect(_ActionSearchQuery):
   """Show the details of one or more CLs"""
-  cls = []
-  for arg in args:
-    helper, cl = GetGerrit(opts, arg)
-    change = FilteredQuery(opts, 'change:%s' % cl, helper=helper)
-    if change:
-      cls.extend(change)
-    else:
-      logging.warning('no results found for CL %s', arg)
-  PrintCls(opts, cls)
-UserActInspect.usage = '<CLs...>'
+
+  COMMAND = 'inspect'
+
+  @staticmethod
+  def init_subparser(parser):
+    """Add arguments to this action's subparser."""
+    _ActionSearchQuery.init_subparser(parser)
+    parser.add_argument('cls', nargs='+', metavar='CL',
+                        help='The CL(s) to update')
+
+  @staticmethod
+  def __call__(opts):
+    """Implement the action."""
+    cls = []
+    for arg in opts.cls:
+      helper, cl = GetGerrit(opts, arg)
+      change = FilteredQuery(opts, 'change:%s' % cl, helper=helper)
+      if change:
+        cls.extend(change)
+      else:
+        logging.warning('no results found for CL %s', arg)
+    PrintCls(opts, cls)
 
 
-def UserActLabel_as(opts, *args):
+class _ActionLabeler(UserAction):
+  """Base helper for setting labels."""
+
+  LABEL = None
+  VALUES = None
+
+  @classmethod
+  def init_subparser(cls, parser):
+    """Add arguments to this action's subparser."""
+    parser.add_argument('--ne', '--no-emails', dest='notify',
+                        default='ALL', action='store_const', const='NONE',
+                        help='Do not send e-mail notifications')
+    parser.add_argument('-m', '--msg', '--message', metavar='MESSAGE',
+                        help='Optional message to include')
+    parser.add_argument('cls', nargs='+', metavar='CL',
+                        help='The CL(s) to update')
+    parser.add_argument('value', nargs=1, metavar='value', choices=cls.VALUES,
+                        help='The label value; one of [%(choices)s]')
+
+  @classmethod
+  def __call__(cls, opts):
+    """Implement the action."""
+    # Convert user friendly command line option into a gerrit parameter.
+    def task(arg):
+      helper, cl = GetGerrit(opts, arg)
+      helper.SetReview(cl, labels={cls.LABEL: opts.value[0]}, msg=opts.msg,
+                       dryrun=opts.dryrun, notify=opts.notify)
+    _run_parallel_tasks(task, *opts.cls)
+
+
+class ActionLabelAutoSubmit(_ActionLabeler):
   """Change the Auto-Submit label"""
-  num = args[-1]
-  def task(arg):
-    helper, cl = GetGerrit(opts, arg)
-    helper.SetReview(cl, labels={'Auto-Submit': num},
-                     dryrun=opts.dryrun, notify=opts.notify)
-  _run_parallel_tasks(task, *args[:-1])
-UserActLabel_as.arg_min = 2
-UserActLabel_as.usage = '<CLs...> <0|1>'
+
+  COMMAND = 'label-as'
+  LABEL = 'Auto-Submit'
+  VALUES = ('0', '1')
 
 
-def UserActLabel_cr(opts, *args):
+class ActionLabelCodeReview(_ActionLabeler):
   """Change the Code-Review label (1=LGTM 2=LGTM+Approved)"""
-  num = args[-1]
-  def task(arg):
-    helper, cl = GetGerrit(opts, arg)
-    helper.SetReview(cl, labels={'Code-Review': num},
-                     dryrun=opts.dryrun, notify=opts.notify)
-  _run_parallel_tasks(task, *args[:-1])
-UserActLabel_cr.arg_min = 2
-UserActLabel_cr.usage = '<CLs...> <-2|-1|0|1|2>'
+
+  COMMAND = 'label-cr'
+  LABEL = 'Code-Review'
+  VALUES = ('-2', '-1', '0', '1', '2')
 
 
-def UserActLabel_v(opts, *args):
+class ActionLabelVerified(_ActionLabeler):
   """Change the Verified label"""
-  num = args[-1]
-  def task(arg):
-    helper, cl = GetGerrit(opts, arg)
-    helper.SetReview(cl, labels={'Verified': num},
-                     dryrun=opts.dryrun, notify=opts.notify)
-  _run_parallel_tasks(task, *args[:-1])
-UserActLabel_v.arg_min = 2
-UserActLabel_v.usage = '<CLs...> <-1|0|1>'
+
+  COMMAND = 'label-v'
+  LABEL = 'Verified'
+  VALUES = ('-1', '0', '1')
 
 
-def UserActLabel_cq(opts, *args):
+class ActionLabelCommitQueue(_ActionLabeler):
   """Change the Commit-Queue label (1=dry-run 2=commit)"""
-  num = args[-1]
-  def task(arg):
-    helper, cl = GetGerrit(opts, arg)
-    helper.SetReview(cl, labels={'Commit-Queue': num},
-                     dryrun=opts.dryrun, notify=opts.notify)
-  _run_parallel_tasks(task, *args[:-1])
-UserActLabel_cq.arg_min = 2
-UserActLabel_cq.usage = '<CLs...> <0|1|2>'
+
+  COMMAND = 'label-cq'
+  LABEL = 'Commit-Queue'
+  VALUES = ('0', '1', '2')
 
 
-def UserActSubmit(opts, *args):
+class _ActionSimpleParallelCLs(UserAction):
+  """Base helper for actions that only accept CLs."""
+
+  @staticmethod
+  def init_subparser(parser):
+    """Add arguments to this action's subparser."""
+    parser.add_argument('cls', nargs='+', metavar='CL',
+                        help='The CL(s) to update')
+
+  def __call__(self, opts):
+    """Implement the action."""
+    def task(arg):
+      helper, cl = GetGerrit(opts, arg)
+      self._process_one(helper, cl, opts)
+    _run_parallel_tasks(task, *opts.cls)
+
+
+class ActionSubmit(_ActionSimpleParallelCLs):
   """Submit CLs"""
-  def task(arg):
-    helper, cl = GetGerrit(opts, arg)
+
+  COMMAND = 'submit'
+
+  @staticmethod
+  def _process_one(helper, cl, opts):
+    """Use |helper| to process the single |cl|."""
     helper.SubmitChange(cl, dryrun=opts.dryrun)
-  _run_parallel_tasks(task, *args)
-UserActSubmit.usage = '<CLs...>'
 
 
-def UserActAbandon(opts, *args):
+class ActionAbandon(_ActionSimpleParallelCLs):
   """Abandon CLs"""
-  def task(arg):
-    helper, cl = GetGerrit(opts, arg)
+
+  COMMAND = 'abandon'
+
+  @staticmethod
+  def _process_one(helper, cl, opts):
+    """Use |helper| to process the single |cl|."""
     helper.AbandonChange(cl, dryrun=opts.dryrun)
-  _run_parallel_tasks(task, *args)
-UserActAbandon.usage = '<CLs...>'
 
 
-def UserActRestore(opts, *args):
+class ActionRestore(_ActionSimpleParallelCLs):
   """Restore CLs that were abandoned"""
-  def task(arg):
-    helper, cl = GetGerrit(opts, arg)
+
+  COMMAND = 'restore'
+
+  @staticmethod
+  def _process_one(helper, cl, opts):
+    """Use |helper| to process the single |cl|."""
     helper.RestoreChange(cl, dryrun=opts.dryrun)
-  _run_parallel_tasks(task, *args)
-UserActRestore.usage = '<CLs...>'
 
 
-def UserActReviewers(opts, cl, *args):
+class ActionReviewers(UserAction):
   """Add/remove reviewers' emails for a CL (prepend with '~' to remove)"""
-  emails = args
-  # Allow for optional leading '~'.
-  email_validator = re.compile(r'^[~]?%s$' % constants.EMAIL_REGEX)
-  add_list, remove_list, invalid_list = [], [], []
 
-  for x in emails:
-    if not email_validator.match(x):
-      invalid_list.append(x)
-    elif x[0] == '~':
-      remove_list.append(x[1:])
-    else:
-      add_list.append(x)
+  COMMAND = 'reviewers'
 
-  if invalid_list:
-    cros_build_lib.Die(
-        'Invalid email address(es): %s' % ', '.join(invalid_list))
+  @staticmethod
+  def init_subparser(parser):
+    """Add arguments to this action's subparser."""
+    parser.add_argument('--ne', '--no-emails', dest='notify',
+                        default='ALL', action='store_const', const='NONE',
+                        help='Do not send e-mail notifications')
+    parser.add_argument('cl', metavar='CL',
+                        help='The CL to update')
+    parser.add_argument('reviewers', nargs='+',
+                        help='The reviewers to add/remove')
 
-  if add_list or remove_list:
-    helper, cl = GetGerrit(opts, cl)
-    helper.SetReviewers(cl, add=add_list, remove=remove_list,
-                        dryrun=opts.dryrun, notify=opts.notify)
-UserActReviewers.usage = '<CL> <emails...>'
+  @staticmethod
+  def __call__(opts):
+    """Implement the action."""
+    # Allow for optional leading '~'.
+    email_validator = re.compile(r'^[~]?%s$' % constants.EMAIL_REGEX)
+    add_list, remove_list, invalid_list = [], [], []
+
+    for email in opts.reviewers:
+      if not email_validator.match(email):
+        invalid_list.append(email)
+      elif email[0] == '~':
+        remove_list.append(email[1:])
+      else:
+        add_list.append(email)
+
+    if invalid_list:
+      cros_build_lib.Die(
+          'Invalid email address(es): %s' % ', '.join(invalid_list))
+
+    if add_list or remove_list:
+      helper, cl = GetGerrit(opts, opts.cl)
+      helper.SetReviewers(cl, add=add_list, remove=remove_list,
+                          dryrun=opts.dryrun, notify=opts.notify)
 
 
-def UserActAssign(opts, cl, assignee):
-  """Set the assignee for a CL"""
-  helper, cl = GetGerrit(opts, cl)
-  helper.SetAssignee(cl, assignee, dryrun=opts.dryrun)
-UserActAssign.usage = '<CL> <assignee>'
+class ActionAssign(_ActionSimpleParallelCLs):
+  """Set the assignee for CLs"""
+
+  COMMAND = 'assign'
+
+  @staticmethod
+  def init_subparser(parser):
+    """Add arguments to this action's subparser."""
+    parser.add_argument('assignee',
+                        help='The new assignee')
+    _ActionSimpleParallelCLs.init_subparser(parser)
+
+  @staticmethod
+  def _process_one(helper, cl, opts):
+    """Use |helper| to process the single |cl|."""
+    helper.SetAssignee(cl, opts.assignee, dryrun=opts.dryrun)
 
 
-def UserActMessage(opts, cl, message):
+class ActionMessage(UserAction):
   """Add a message to a CL"""
-  helper, cl = GetGerrit(opts, cl)
-  helper.SetReview(cl, msg=message, dryrun=opts.dryrun)
-UserActMessage.usage = '<CL> <message>'
+
+  COMMAND = 'message'
+
+  @staticmethod
+  def init_subparser(parser):
+    """Add arguments to this action's subparser."""
+    parser.add_argument('message',
+                        help='The message to post')
+    _ActionSimpleParallelCLs.init_subparser(parser)
+
+  @staticmethod
+  def _process_one(helper, cl, opts):
+    """Use |helper| to process the single |cl|."""
+    helper.SetReview(cl, msg=opts.message, dryrun=opts.dryrun)
 
 
-def UserActTopic(opts, topic, *args):
+class ActionTopic(UserAction):
   """Set a topic for one or more CLs"""
-  def task(arg):
-    helper, arg = GetGerrit(opts, arg)
-    helper.SetTopic(arg, topic, dryrun=opts.dryrun)
-  _run_parallel_tasks(task, *args)
-UserActTopic.usage = '<topic> <CLs...>'
+
+  COMMAND = 'topic'
+
+  @staticmethod
+  def init_subparser(parser):
+    """Add arguments to this action's subparser."""
+    parser.add_argument('topic',
+                        help='The topic to set')
+    _ActionSimpleParallelCLs.init_subparser(parser)
+
+  @staticmethod
+  def _process_one(helper, cl, opts):
+    """Use |helper| to process the single |cl|."""
+    helper.SetTopic(cl, opts.topic, dryrun=opts.dryrun)
 
 
-def UserActPrivate(opts, cl, private_str):
-  """Set the private bit on a CL to private"""
-  try:
-    private = cros_build_lib.BooleanShellValue(private_str, False)
-  except ValueError:
-    raise RuntimeError('Unknown "boolean" value: %s' % private_str)
+class ActionPrivate(_ActionSimpleParallelCLs):
+  """Mark CLs private"""
 
-  helper, cl = GetGerrit(opts, cl)
-  helper.SetPrivate(cl, private, dryrun=opts.dryrun)
-UserActPrivate.usage = '<CL> <private str>'
+  COMMAND = 'private'
+
+  @staticmethod
+  def _process_one(helper, cl, opts):
+    """Use |helper| to process the single |cl|."""
+    helper.SetPrivate(cl, True, dryrun=opts.dryrun)
 
 
-def UserActSethashtags(opts, cl, *args):
+class ActionPublic(_ActionSimpleParallelCLs):
+  """Mark CLs public"""
+
+  COMMAND = 'public'
+
+  @staticmethod
+  def _process_one(helper, cl, opts):
+    """Use |helper| to process the single |cl|."""
+    helper.SetPrivate(cl, False, dryrun=opts.dryrun)
+
+
+class ActionSethashtags(UserAction):
   """Add/remove hashtags on a CL (prepend with '~' to remove)"""
-  hashtags = args
-  add = []
-  remove = []
-  for hashtag in hashtags:
-    if hashtag.startswith('~'):
-      remove.append(hashtag[1:])
-    else:
-      add.append(hashtag)
-  helper, cl = GetGerrit(opts, cl)
-  helper.SetHashtags(cl, add, remove, dryrun=opts.dryrun)
-UserActSethashtags.usage = '<CL> <hashtags...>'
+
+  COMMAND = 'hashtags'
+
+  @staticmethod
+  def init_subparser(parser):
+    """Add arguments to this action's subparser."""
+    parser.add_argument('cl', metavar='CL',
+                        help='The CL to update')
+    parser.add_argument('hashtags', nargs='+',
+                        help='The hashtags to add/remove')
+
+  @staticmethod
+  def __call__(opts):
+    """Implement the action."""
+    add = []
+    remove = []
+    for hashtag in opts.hashtags:
+      if hashtag.startswith('~'):
+        remove.append(hashtag[1:])
+      else:
+        add.append(hashtag)
+    helper, cl = GetGerrit(opts, opts.cl)
+    helper.SetHashtags(cl, add, remove, dryrun=opts.dryrun)
 
 
-def UserActDeletedraft(opts, *args):
+class ActionDeletedraft(_ActionSimpleParallelCLs):
   """Delete draft CLs"""
-  def task(arg):
-    helper, cl = GetGerrit(opts, arg)
+
+  COMMAND = 'deletedraft'
+
+  @staticmethod
+  def _process_one(helper, cl, opts):
+    """Use |helper| to process the single |cl|."""
     helper.DeleteDraft(cl, dryrun=opts.dryrun)
-  _run_parallel_tasks(task, *args)
-UserActDeletedraft.usage = '<CLs...>'
 
 
-def UserActReviewed(opts, *args):
+class ActionReviewed(_ActionSimpleParallelCLs):
   """Mark CLs as reviewed"""
-  def task(arg):
-    helper, cl = GetGerrit(opts, arg)
+
+  COMMAND = 'reviewed'
+
+  @staticmethod
+  def _process_one(helper, cl, opts):
+    """Use |helper| to process the single |cl|."""
     helper.ReviewedChange(cl, dryrun=opts.dryrun)
-  _run_parallel_tasks(task, *args)
-UserActReviewed.usage = '<CLs...>'
 
 
-def UserActUnreviewed(opts, *args):
+class ActionUnreviewed(_ActionSimpleParallelCLs):
   """Mark CLs as unreviewed"""
-  def task(arg):
-    helper, cl = GetGerrit(opts, arg)
+
+  COMMAND = 'unreviewed'
+
+  @staticmethod
+  def _process_one(helper, cl, opts):
+    """Use |helper| to process the single |cl|."""
     helper.UnreviewedChange(cl, dryrun=opts.dryrun)
-  _run_parallel_tasks(task, *args)
-UserActUnreviewed.usage = '<CLs...>'
 
 
-def UserActIgnore(opts, *args):
+class ActionIgnore(_ActionSimpleParallelCLs):
   """Ignore CLs (suppress notifications/dashboard/etc...)"""
-  def task(arg):
-    helper, cl = GetGerrit(opts, arg)
+
+  COMMAND = 'ignore'
+
+  @staticmethod
+  def _process_one(helper, cl, opts):
+    """Use |helper| to process the single |cl|."""
     helper.IgnoreChange(cl, dryrun=opts.dryrun)
-  _run_parallel_tasks(task, *args)
-UserActIgnore.usage = '<CLs...>'
 
 
-def UserActUnignore(opts, *args):
+class ActionUnignore(_ActionSimpleParallelCLs):
   """Unignore CLs (enable notifications/dashboard/etc...)"""
-  def task(arg):
-    helper, cl = GetGerrit(opts, arg)
+
+  COMMAND = 'unignore'
+
+  @staticmethod
+  def _process_one(helper, cl, opts):
+    """Use |helper| to process the single |cl|."""
     helper.UnignoreChange(cl, dryrun=opts.dryrun)
-  _run_parallel_tasks(task, *args)
-UserActUnignore.usage = '<CLs...>'
 
 
-def UserActAccount(opts):
+class ActionAccount(UserAction):
   """Get the current user account information"""
-  helper, _ = GetGerrit(opts)
-  acct = helper.GetAccount()
-  if opts.json:
-    json.dump(acct, sys.stdout)
-  else:
-    print('account_id:%i  %s <%s>' %
-          (acct['_account_id'], acct['name'], acct['email']))
+
+  COMMAND = 'account'
+
+  @staticmethod
+  def __call__(opts):
+    """Implement the action."""
+    helper, _ = GetGerrit(opts)
+    acct = helper.GetAccount()
+    if opts.json:
+      json.dump(acct, sys.stdout)
+    else:
+      print('account_id:%i  %s <%s>' %
+            (acct['_account_id'], acct['name'], acct['email']))
 
 
 @memoize.Memoize
@@ -589,26 +797,23 @@
     An ordered dictionary mapping the user subcommand (e.g. "foo") to the
     function that implements that command (e.g. UserActFoo).
   """
-  ret = collections.OrderedDict()
-  for funcname in sorted(globals()):
-    if not funcname.startswith(ACTION_PREFIX):
+  VALID_NAME = re.compile(r'^[a-z][a-z-]*[a-z]$')
+
+  actions = {}
+  for cls in globals().values():
+    if (not inspect.isclass(cls) or
+        not issubclass(cls, UserAction) or
+        not getattr(cls, 'COMMAND', None)):
       continue
 
-    # Turn "UserActFoo" into just "Foo" for further checking below.
-    funcname_chopped = funcname[len(ACTION_PREFIX):]
-
     # Sanity check names for devs adding new commands.  Should be quick.
-    expected_funcname = funcname_chopped.lower().capitalize()
-    if funcname_chopped != expected_funcname:
-      raise RuntimeError('callback "%s" is misnamed; should be "%s"' %
-                         (funcname_chopped, expected_funcname))
+    cmd = cls.COMMAND
+    assert VALID_NAME.match(cmd), '"%s" must match [a-z-]+' % (cmd,)
+    assert cmd not in actions, 'multiple "%s" commands found' % (cmd,)
 
-    # Turn "Foo_bar" into "foo-bar".
-    cmdname = funcname_chopped.lower().replace('_', '-')
-    func = globals()[funcname]
-    ret[cmdname] = func
+    actions[cmd] = cls
 
-  return ret
+  return collections.OrderedDict(sorted(actions.items()))
 
 
 def _GetActionUsages():
@@ -618,7 +823,7 @@
   cmds = list(actions.keys())
   functions = list(actions.values())
   usages = [getattr(x, 'usage', '') for x in functions]
-  docs = [x.__doc__ for x in functions]
+  docs = [x.__doc__.splitlines()[0] for x in functions]
 
   cmd_indent = len(max(cmds, key=len))
   usage_indent = len(max(usages, key=len))
@@ -630,8 +835,7 @@
 
 def GetParser():
   """Returns the parser to use for this module."""
-  usage = """%(prog)s [options] <action> [action args]
-
+  description = """\
 There is no support for doing line-by-line code review via the command line.
 This helps you manage various bits and CL status.
 
@@ -659,12 +863,12 @@
 
 Actions:
 """
-  usage += _GetActionUsages()
+  description += _GetActionUsages()
 
   actions = _GetActions()
 
   site_params = config_lib.GetSiteParams()
-  parser = commandline.ArgumentParser(usage=usage)
+  parser = commandline.ArgumentParser(description=description)
   parser.add_argument('-i', '--internal', dest='gob', action='store_const',
                       default=site_params.EXTERNAL_GOB_INSTANCE,
                       const=site_params.INTERNAL_GOB_INSTANCE,
@@ -673,9 +877,6 @@
                       default=site_params.EXTERNAL_GOB_INSTANCE,
                       help=('Gerrit (on borg) instance to query (default: %s)' %
                             (site_params.EXTERNAL_GOB_INSTANCE)))
-  parser.add_argument('--sort', default='number',
-                      help='Key to sort on (number, project); use "unsorted" '
-                           'to disable')
   parser.add_argument('--raw', default=False, action='store_true',
                       help='Return raw results (suitable for scripting)')
   parser.add_argument('--json', default=False, action='store_true',
@@ -683,23 +884,22 @@
   parser.add_argument('-n', '--dry-run', default=False, action='store_true',
                       dest='dryrun',
                       help='Show what would be done, but do not make changes')
-  parser.add_argument('--ne', '--no-emails', default=True, action='store_false',
-                      dest='send_email',
-                      help='Do not send email for some operations '
-                           '(e.g. ready/review/trybotready/verify)')
   parser.add_argument('-v', '--verbose', default=False, action='store_true',
                       help='Be more verbose in output')
-  parser.add_argument('-b', '--branch',
-                      help='Limit output to the specific branch')
-  parser.add_argument('--draft', default=False, action='store_true',
-                      help="Show draft changes (applicable to 'mine' only)")
-  parser.add_argument('-p', '--project',
-                      help='Limit output to the specific project')
-  parser.add_argument('-t', '--topic',
-                      help='Limit output to the specific topic')
-  parser.add_argument('action', choices=list(actions.keys()),
-                      help='The gerrit action to perform')
-  parser.add_argument('args', nargs='*', help='Action arguments')
+
+  # Subparsers are required by default under Python 2.  Python 3 changed to
+  # not required, but didn't include a required option until 3.7.  Setting
+  # the required member works in all versions (and setting dest name).
+  subparsers = parser.add_subparsers(dest='action')
+  subparsers.required = True
+  for cmd, cls in actions.items():
+    # Format the full docstring by removing the file level indentation.
+    description = re.sub(r'^  ', '', cls.__doc__, flags=re.M)
+    subparser = subparsers.add_parser(cmd, description=description)
+    subparser.add_argument('-n', '--dry-run', dest='dryrun',
+                           default=False, action='store_true',
+                           help='Show what would be done only')
+    cls.init_subparser(subparser)
 
   return parser
 
@@ -711,8 +911,6 @@
   # A cache of gerrit helpers we'll load on demand.
   opts.gerrit = {}
 
-  # Convert user friendly command line option into a gerrit parameter.
-  opts.notify = 'ALL' if opts.send_email else 'NONE'
   opts.Freeze()
 
   # pylint: disable=global-statement
@@ -721,18 +919,9 @@
 
   # Now look up the requested user action and run it.
   actions = _GetActions()
-  functor = actions[opts.action]
-  argspec = inspect.getargspec(functor)
-  if argspec.varargs:
-    arg_min = getattr(functor, 'arg_min', len(argspec.args))
-    if len(opts.args) < arg_min:
-      parser.error('incorrect number of args: %s expects at least %s' %
-                   (opts.action, arg_min))
-  elif len(argspec.args) - 1 != len(opts.args):
-    parser.error('incorrect number of args: %s expects %s' %
-                 (opts.action, len(argspec.args) - 1))
+  obj = actions[opts.action]()
   try:
-    functor(opts, *opts.args)
+    obj(opts)
   except (cros_build_lib.RunCommandError, gerrit.GerritException,
           gob_util.GOBError) as e:
     cros_build_lib.Die(e)