git-cl-upload: Set all reviewers and ccs in a single batch api call

The old system had two faults:
* It set reviewers and ccs via different mechanisms, which is confusing
* It set CCs with a single call for each, resulting in N separate emails,
  with each email going to the uploader and all reviewers and all previous
  CCs.

This new system just collects all reviewers and CCs, and sets them
in a single call. That call will fail if *any* of the individual
reviewers or ccs fail, so it also parses the response and retries with
only the ones which would have succeeded on their own. If that second
call fails, or the first fails in an unexpected way, it raises an
exception like normal

Bug: 710028
Change-Id: I1be508487a41f0b68f9c41908229b8f5342830a3
Reviewed-on: https://chromium-review.googlesource.com/479712
Commit-Queue: Aaron Gable <agable@chromium.org>
Reviewed-by: Andrii Shyshkalov <tandrii@chromium.org>
diff --git a/gerrit_util.py b/gerrit_util.py
index 4bdbb56..d1ef989 100755
--- a/gerrit_util.py
+++ b/gerrit_util.py
@@ -653,32 +653,44 @@
   return ReadHttpJsonResponse(CreateHttpConn(host, path))
 
 
-def AddReviewers(host, change, add=None, is_reviewer=True, notify=True):
+def AddReviewers(host, change, reviewers=None, ccs=None, notify=True,
+                 accept_statuses=frozenset([200, 400, 422])):
   """Add reviewers to a change."""
-  errors = None
-  if not add:
+  if not reviewers and not ccs:
     return None
-  if isinstance(add, basestring):
-    add = (add,)
-  path = 'changes/%s/reviewers' % change
-  for r in add:
-    state = 'REVIEWER' if is_reviewer else 'CC'
-    notify = 'ALL' if notify else 'NONE'
-    body = {
-      'reviewer': r,
-      'state': state,
-      'notify': notify,
-    }
-    try:
-      conn = CreateHttpConn(host, path, reqtype='POST', body=body)
-      _ = ReadHttpJsonResponse(conn)
-    except GerritError as e:
-      if e.http_status == 422:  # "Unprocessable Entity"
-        LOGGER.warn('Note: "%s" not added as a %s' % (r, state.lower()))
-        errors = True
-      else:
-        raise
-  return errors
+  reviewers = frozenset(reviewers or [])
+  ccs = frozenset(ccs or [])
+  path = 'changes/%s/revisions/current/review' % change
+
+  body = {
+    'reviewers': [],
+    'notify': 'ALL' if notify else 'NONE',
+  }
+  for r in sorted(reviewers | ccs):
+    state = 'REVIEWER' if r in reviewers else 'CC'
+    body['reviewers'].append({
+     'reviewer': r,
+     'state': state,
+     'notify': 'NONE',  # We handled `notify` argument above.
+   })
+
+  conn = CreateHttpConn(host, path, reqtype='POST', body=body)
+  # Gerrit will return 400 if one or more of the requested reviewers are
+  # unprocessable. We read the response object to see which were rejected,
+  # warn about them, and retry with the remainder.
+  resp = ReadHttpJsonResponse(conn, accept_statuses=accept_statuses)
+
+  errored = set()
+  for result in resp.get('reviewers', {}).itervalues():
+    r = result.get('input')
+    state = 'REVIEWER' if r in reviewers else 'CC'
+    if result.get('error'):
+      errored.add(r)
+      LOGGER.warn('Note: "%s" not added as a %s' % (r, state.lower()))
+  if errored:
+    # Try again, adding only those that didn't fail, and only accepting 200.
+    AddReviewers(host, change, reviewers=(reviewers-errored),
+                 ccs=(ccs-errored), notify=notify, accept_statuses=[200])
 
 
 def RemoveReviewers(host, change, remove=None):