mozreview: add reviewer delegation (bug 1161230) r=smacleod
authorbyron jones <glob@mozilla.com>
Fri, 04 Mar 2016 14:35:54 +0800
changeset 8709 e16074ff71c882ff448e4efb6519df68cc158485
parent 8708 c28006880d979f56e94dea3d0b4a2eb2985c1e82
child 8711 a1c0fe9f5d87c43467ce30ff7986c9ed6c02c929
push id969
push userbjones@mozilla.com
push dateThu, 30 Jun 2016 05:50:38 +0000
reviewerssmacleod
bugs1161230
mozreview: add reviewer delegation (bug 1161230) r=smacleod Adds the ability for anyone with the required permissions in BMO to update the target reviewers. Due to limitations Review Board's limited permissions model (only the submitter can make changes), this requires a localstorage hosted draft, updating Review Board as the submitter, and providing a sudo-like ability for updating Bugzilla. MozReview-Commit-ID: H51WMF4SCTx
hgext/reviewboard/tests/test-review-request-delegation.t
pylib/mozreview/mozreview/bugzilla/client.py
pylib/mozreview/mozreview/errors.py
pylib/mozreview/mozreview/extension.py
pylib/mozreview/mozreview/extra_data.py
pylib/mozreview/mozreview/resources/ensure_drafts.py
pylib/mozreview/mozreview/resources/modify_reviewer.py
pylib/mozreview/mozreview/resources/verify_reviewer.py
pylib/mozreview/mozreview/signal_handlers.py
pylib/mozreview/mozreview/static/mozreview/css/commits.less
pylib/mozreview/mozreview/static/mozreview/css/review.less
pylib/mozreview/mozreview/static/mozreview/js/commits.js
pylib/mozreview/mozreview/static/mozreview/js/init_rr.js
pylib/mozreview/mozreview/templates/mozreview/scm_level.html
pylib/mozreview/mozreview/templates/mozreview/user-data.html
pylib/mozreview/mozreview/templatetags/mozreview.py
testing/vcttesting/reviewboard/mach_commands.py
new file mode 100644
--- /dev/null
+++ b/hgext/reviewboard/tests/test-review-request-delegation.t
@@ -0,0 +1,333 @@
+#require mozreviewdocker
+
+  $ . $TESTDIR/hgext/reviewboard/tests/helpers.sh
+  $ commonenv
+
+  $ cd client
+  $ echo foo0 > foo
+  $ hg -q commit -A -m 'root commit'
+  $ hg phase --public -r .
+
+Set up users
+
+  $ mozreview create-user l3author@example.com password 'L3 Contributor [:l3author]' --scm-level 3 --uid 2004 --key-file "$MOZREVIEW_HOME/keys/l3author@example.com"
+  Created user 6
+  $ mozreview create-user reviewer1@example.com password 'Reviewer 1 [:reviewer1]' --scm-level 3  --bugzilla-group editbugs --uid 2005 --key-file "$MOZREVIEW_HOME/keys/reviewer1@example.com"
+  Created user 7
+  $ mozreview create-user reviewer2@example.com password 'Reviewer 2 [:reviewer2]' --scm-level 3 --uid 2006 --key-file "$MOZREVIEW_HOME/keys/reviewer2@example.com"
+  Created user 8
+
+Create bug and review
+
+  $ l3key=`mozreview create-api-key l3author@example.com`
+  $ exportbzauth l3author@example.com password
+  $ bugzilla create-bug TestProduct TestComponent 'First Bug'
+  $ echo fruit > foo
+  $ hg commit -m 'Bug 1 - Initial commit to review r?reviewer1'
+  $ echo water >> foo
+  $ hg commit -m 'Bug 1 - Forgot water r?reviewer1'
+  $ hg --config bugzilla.username=l3author@example.com --config bugzilla.apikey=$l3key --config reviewboard.autopublish=true push
+  pushing to ssh://$DOCKER_HOSTNAME:$HGPORT6/test-repo
+  (adding commit id to 2 changesets)
+  saved backup bundle to $TESTTMP/client/.hg/strip-backup/*-addcommitid.hg (glob)
+  searching for changes
+  remote: adding changesets
+  remote: adding manifests
+  remote: adding file changes
+  remote: added 3 changesets with 3 changes to 1 files
+  remote: recorded push in pushlog
+  submitting 2 changesets for review
+  
+  changeset:  1:80ffd9136b8b
+  summary:    Bug 1 - Initial commit to review r?reviewer1
+  review:     http://$DOCKER_HOSTNAME:$HGPORT1/r/2 (draft)
+  
+  changeset:  2:493559840037
+  summary:    Bug 1 - Forgot water r?reviewer1
+  review:     http://$DOCKER_HOSTNAME:$HGPORT1/r/3 (draft)
+  
+  review id:  bz://1/mynick
+  review url: http://$DOCKER_HOSTNAME:$HGPORT1/r/1 (draft)
+  (published review request 1)
+
+Change the reviewer while logged in as reviewer1
+
+  $ exportbzauth reviewer1@example.com password
+  $ rbmanage modify-reviewers 1 2 'reviewer2'
+  $ rbmanage dump-summary 1
+  parent:
+    summary: bz://1/mynick
+    id: 1
+    submitter: l3author
+    issue_open_count: 0
+    status: pending
+    has_draft: false
+    reviewers:
+    - reviewer1
+    - reviewer2
+  children:
+  - summary: Bug 1 - Initial commit to review r?reviewer1
+    id: 2
+    commit: 80ffd9136b8b9d9541de1780e1a3e027665017fb
+    submitter: l3author
+    issue_open_count: 0
+    status: pending
+    has_draft: false
+    reviewers:
+    - reviewer2
+    reviewers_status:
+      reviewer2:
+        review_flag: r?
+        ship_it: false
+  - summary: Bug 1 - Forgot water r?reviewer1
+    id: 3
+    commit: 4935598400374354824ffde84a8b6767823100d1
+    submitter: l3author
+    issue_open_count: 0
+    status: pending
+    has_draft: false
+    reviewers:
+    - reviewer1
+    reviewers_status:
+      reviewer1:
+        review_flag: r?
+        ship_it: false
+
+Test multiple reviewers
+
+  $ rbmanage modify-reviewers 1 2 'reviewer1,reviewer2'
+  $ rbmanage dump-summary 1
+  parent:
+    summary: bz://1/mynick
+    id: 1
+    submitter: l3author
+    issue_open_count: 0
+    status: pending
+    has_draft: false
+    reviewers:
+    - reviewer1
+    - reviewer2
+  children:
+  - summary: Bug 1 - Initial commit to review r?reviewer1
+    id: 2
+    commit: 80ffd9136b8b9d9541de1780e1a3e027665017fb
+    submitter: l3author
+    issue_open_count: 0
+    status: pending
+    has_draft: false
+    reviewers:
+    - reviewer1
+    - reviewer2
+    reviewers_status:
+      reviewer1:
+        review_flag: r?
+        ship_it: false
+      reviewer2:
+        review_flag: r?
+        ship_it: false
+  - summary: Bug 1 - Forgot water r?reviewer1
+    id: 3
+    commit: 4935598400374354824ffde84a8b6767823100d1
+    submitter: l3author
+    issue_open_count: 0
+    status: pending
+    has_draft: false
+    reviewers:
+    - reviewer1
+    reviewers_status:
+      reviewer1:
+        review_flag: r?
+        ship_it: false
+
+Test invalid reviewer
+
+  $ rbmanage modify-reviewers 1 2 'invalid'
+  API Error: 400: 105: The reviewer 'invalid' was not found
+  [1]
+
+Change the reviewer while logged in as the submitter
+
+  $ exportbzauth l3author@example.com password
+  $ rbmanage modify-reviewers 1 2 'reviewer2'
+  $ rbmanage dump-summary 1
+  parent:
+    summary: bz://1/mynick
+    id: 1
+    submitter: l3author
+    issue_open_count: 0
+    status: pending
+    has_draft: false
+    reviewers:
+    - reviewer1
+    - reviewer2
+  children:
+  - summary: Bug 1 - Initial commit to review r?reviewer1
+    id: 2
+    commit: 80ffd9136b8b9d9541de1780e1a3e027665017fb
+    submitter: l3author
+    issue_open_count: 0
+    status: pending
+    has_draft: false
+    reviewers:
+    - reviewer2
+    reviewers_status:
+      reviewer2:
+        review_flag: r?
+        ship_it: false
+  - summary: Bug 1 - Forgot water r?reviewer1
+    id: 3
+    commit: 4935598400374354824ffde84a8b6767823100d1
+    submitter: l3author
+    issue_open_count: 0
+    status: pending
+    has_draft: false
+    reviewers:
+    - reviewer1
+    reviewers_status:
+      reviewer1:
+        review_flag: r?
+        ship_it: false
+
+Test user without editbugs
+
+  $ exportbzauth reviewer2@example.com password
+  $ rbmanage modify-reviewers 1 2 'reviewer1'
+  API Error: 500: 225: Error publishing: Bugzilla error: You are not authorized to edit attachment 1.
+  [1]
+
+Test verify-reviewers
+
+  $ rbmanage verify-reviewers 'reviewer1'
+  $ rbmanage verify-reviewers 'reviewer1,reviewer2'
+  $ rbmanage verify-reviewers 'invalid'
+  API Error: 400: 105: The reviewer 'invalid' was not found
+  [1]
+  $ rbmanage verify-reviewers 'reviewer1,invalid'
+  API Error: 400: 105: The reviewer 'invalid' was not found
+  [1]
+
+Test ensure-drafts
+
+  $ exportbzauth l3author@example.com password
+  $ rbmanage add-reviewer 2 --user reviewer1
+  2 people listed on review request
+  $ rbmanage dump-summary 1
+  parent:
+    summary: bz://1/mynick
+    id: 1
+    submitter: l3author
+    issue_open_count: 0
+    status: pending
+    has_draft: true
+    reviewers:
+    - reviewer1
+    - reviewer2
+  children:
+  - summary: Bug 1 - Initial commit to review r?reviewer1
+    id: 2
+    commit: 80ffd9136b8b9d9541de1780e1a3e027665017fb
+    submitter: l3author
+    issue_open_count: 0
+    status: pending
+    has_draft: true
+    reviewers:
+    - reviewer2
+    reviewers_status:
+      reviewer2:
+        review_flag: r?
+        ship_it: false
+  - summary: Bug 1 - Forgot water r?reviewer1
+    id: 3
+    commit: 4935598400374354824ffde84a8b6767823100d1
+    submitter: l3author
+    issue_open_count: 0
+    status: pending
+    has_draft: false
+    reviewers:
+    - reviewer1
+    reviewers_status:
+      reviewer1:
+        review_flag: r?
+        ship_it: false
+  $ rbmanage ensure-drafts 1
+  $ rbmanage dump-summary 1
+  parent:
+    summary: bz://1/mynick
+    id: 1
+    submitter: l3author
+    issue_open_count: 0
+    status: pending
+    has_draft: true
+    reviewers:
+    - reviewer1
+    - reviewer2
+  children:
+  - summary: Bug 1 - Initial commit to review r?reviewer1
+    id: 2
+    commit: 80ffd9136b8b9d9541de1780e1a3e027665017fb
+    submitter: l3author
+    issue_open_count: 0
+    status: pending
+    has_draft: true
+    reviewers:
+    - reviewer2
+    reviewers_status:
+      reviewer2:
+        review_flag: r?
+        ship_it: false
+  - summary: Bug 1 - Forgot water r?reviewer1
+    id: 3
+    commit: 4935598400374354824ffde84a8b6767823100d1
+    submitter: l3author
+    issue_open_count: 0
+    status: pending
+    has_draft: true
+    reviewers:
+    - reviewer1
+    reviewers_status:
+      reviewer1:
+        review_flag: r?
+        ship_it: false
+  $ rbmanage publish 1
+  $ rbmanage dump-summary 1
+  parent:
+    summary: bz://1/mynick
+    id: 1
+    submitter: l3author
+    issue_open_count: 0
+    status: pending
+    has_draft: false
+    reviewers:
+    - reviewer1
+    - reviewer2
+  children:
+  - summary: Bug 1 - Initial commit to review r?reviewer1
+    id: 2
+    commit: 80ffd9136b8b9d9541de1780e1a3e027665017fb
+    submitter: l3author
+    issue_open_count: 0
+    status: pending
+    has_draft: false
+    reviewers:
+    - reviewer1
+    - reviewer2
+    reviewers_status:
+      reviewer1:
+        review_flag: r?
+        ship_it: false
+      reviewer2:
+        review_flag: r?
+        ship_it: false
+  - summary: Bug 1 - Forgot water r?reviewer1
+    id: 3
+    commit: 4935598400374354824ffde84a8b6767823100d1
+    submitter: l3author
+    issue_open_count: 0
+    status: pending
+    has_draft: false
+    reviewers:
+    - reviewer1
+    reviewers_status:
+      reviewer1:
+        review_flag: r?
+        ship_it: false
--- a/pylib/mozreview/mozreview/bugzilla/client.py
+++ b/pylib/mozreview/mozreview/bugzilla/client.py
@@ -3,20 +3,22 @@
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 import logging
 import posixpath
 import xmlrpclib
 
 from urlparse import urlparse, urlunparse
 
+from django.contrib.auth.models import User
 from djblets.siteconfig.models import SiteConfiguration
 from djblets.util.decorators import simple_decorator
 
 from mozreview.bugzilla.errors import BugzillaError, BugzillaUrlError
+from mozreview.bugzilla.models import get_or_create_bugzilla_users
 from mozreview.bugzilla.transports import bugzilla_transport
 
 from mozautomation.commitparser import replace_reviewers
 
 
 logger = logging.getLogger(__name__)
 
 
@@ -335,16 +337,25 @@ class Bugzilla(object):
         """Convert an integer user ID to string username."""
         params = self._auth_params({
             'ids': [userid],
             'include_fields': self.user_fields
         })
         return self.proxy.User.get(params)
 
     @xmlrpc_to_bugzilla_errors
+    def get_user_from_irc_nick(self, irc_nick):
+        irc_nick = irc_nick.lstrip(":").lower()
+        users = get_or_create_bugzilla_users(self.query_users(":" + irc_nick))
+        for user in users:
+            if user.username.lower() == irc_nick:
+                return user
+        raise User.DoesNotExist()
+
+    @xmlrpc_to_bugzilla_errors
     def post_comment(self, bug_id, comment):
         params = self._auth_params({
             'id': bug_id,
             'comment': comment
         })
         logger.info('Posting comment on bug %d.' % bug_id)
         return self.proxy.Bug.add_comment(params)
 
--- a/pylib/mozreview/mozreview/errors.py
+++ b/pylib/mozreview/mozreview/errors.py
@@ -77,8 +77,13 @@ AUTOLAND_REQUEST_IN_PROGRESS = WebAPIErr
     "An autoland request for this review request is already in progress. "
     "Please wait for that request to finish.",
     http_status=405)  # 405 Method Not Allowed
 
 AUTOLAND_REVIEW_NOT_APPROVED = WebAPIError(
     1008,
     "Unable to continue as the review has not been approved.",
     http_status=405)  # 405 Method Not Allowed
+
+REVIEW_REQUEST_UPDATE_NOT_ALLOWED = WebAPIError(
+    1009,
+    "Updating review request not allowed at this time.",
+    http_status=405)  # 405 Method not Allowed
--- a/pylib/mozreview/mozreview/extension.py
+++ b/pylib/mozreview/mozreview/extension.py
@@ -80,19 +80,28 @@ from mozreview.resources.batch_review_re
     batch_review_request_resource,
 )
 from mozreview.resources.commit_data import (
     commit_data_resource,
 )
 from mozreview.resources.commit_rewrite import (
     commit_rewrite_resource,
 )
+from mozreview.resources.ensure_drafts import (
+    ensure_drafts_resource,
+)
+from mozreview.resources.modify_reviewer import (
+    modify_reviewer_resource,
+)
 from mozreview.resources.review_request_summary import (
     review_request_summary_resource,
 )
+from mozreview.resources.verify_reviewer import (
+    verify_reviewer_resource,
+)
 from mozreview.signal_handlers import (
     initialize_signal_handlers,
 )
 
 
 SETTINGS_PATH = os.path.join('/', 'mozreview-settings.json')
 SETTINGS = None
 
@@ -172,20 +181,23 @@ class MozReviewExtension(Extension):
         autoland_enable_resource,
         autoland_request_update_resource,
         autoland_trigger_resource,
         batch_review_request_resource,
         batch_review_resource,
         bugzilla_api_key_login_resource,
         commit_data_resource,
         commit_rewrite_resource,
+        ensure_drafts_resource,
         file_diff_reviewer_resource,
         ldap_association_resource,
+        modify_reviewer_resource,
         review_request_summary_resource,
         try_autoland_trigger_resource,
+        verify_reviewer_resource,
     ]
 
     middleware = [
         MozReviewCacheDisableMiddleware,
         MozReviewUserProfileMiddleware,
     ]
 
     def initialize(self):
@@ -275,17 +287,17 @@ class MozReviewExtension(Extension):
         TemplateHook(self, 'base-extrahead',
                      'mozreview/base-extrahead-login-form.html',
                      apply_to=['login'])
         TemplateHook(self, 'before-login-form',
                      'mozreview/before-login-form.html', apply_to=['login'])
         TemplateHook(self, 'after-login-form',
                      'mozreview/after-login-form.html', apply_to=['login'])
         TemplateHook(self, 'base-after-content',
-                     'mozreview/scm_level.html')
+                     'mozreview/user-data.html')
         TemplateHook(self, 'base-after-content',
                      'mozreview/repository.html')
         TemplateHook(self, 'base-after-content',
                      'mozreview/user_review_flag.html',
                      apply_to=review_request_url_names)
 
         ReviewRequestFieldsHook(self, 'main', [CommitsListField])
         # This forces the Commits field to be the top item.
--- a/pylib/mozreview/mozreview/extra_data.py
+++ b/pylib/mozreview/mozreview/extra_data.py
@@ -27,16 +27,17 @@ AUTHOR_KEY = MOZREVIEW_KEY + '.author'
 BASE_COMMIT_KEY = MOZREVIEW_KEY + '.base_commit'
 COMMIT_ID_KEY = MOZREVIEW_KEY + '.commit_id'
 COMMITS_KEY = MOZREVIEW_KEY + '.commits'
 DISCARD_ON_PUBLISH_KEY = MOZREVIEW_KEY + '.discard_on_publish_rids'
 FIRST_PUBLIC_ANCESTOR_KEY = MOZREVIEW_KEY + '.first_public_ancestor'
 IDENTIFIER_KEY = MOZREVIEW_KEY + '.identifier'
 SQUASHED_KEY = MOZREVIEW_KEY + '.is_squashed'
 UNPUBLISHED_KEY = MOZREVIEW_KEY + '.unpublished_rids'
+PUBLISH_AS_KEY = MOZREVIEW_KEY + '.publish_as'
 
 # CommitData fields which should be automatically copied from
 # draft_extra_data to extra_data when a review request is published.
 DRAFTED_COMMIT_DATA_KEYS = (
     AUTHOR_KEY,
     FIRST_PUBLIC_ANCESTOR_KEY,
     IDENTIFIER_KEY,
     COMMIT_ID_KEY,
@@ -202,8 +203,29 @@ def update_parent_rr_reviewers(parent_rr
         parent_rr_draft.target_people = total_reviewers
         parent_rr_draft.extra_data[REVIEWER_MAP_KEY] = json.dumps(reviewers_map_after)
 
         parent_rr = parent_rr_draft.get_review_request()
         if parent_rr.public and parent_rr_draft.changedesc is None:
             parent_rr_draft.changedesc = ChangeDescription.objects.create()
 
         parent_rr_draft.save()
+
+
+def set_publish_as(parent_rr, user, commit_data=None):
+    """Set PUBLISH_AS to the specified user.
+
+    When the specified review request is published, updates to Bugzilla
+    will be performed as the specified user.
+    """
+    commit_data = fetch_commit_data(parent_rr, commit_data)
+    commit_data.draft_extra_data.update({
+        PUBLISH_AS_KEY: user.id
+    })
+    commit_data.save(update_fields=['draft_extra_data'])
+
+
+def clear_publish_as(parent_rr, commit_data=None):
+    """Clears a previously set PUBLISH_AS."""
+    commit_data = fetch_commit_data(parent_rr, commit_data)
+    if PUBLISH_AS_KEY in commit_data.draft_extra_data:
+        del commit_data.draft_extra_data[PUBLISH_AS_KEY]
+        commit_data.save(update_fields=['draft_extra_data'])
new file mode 100644
--- /dev/null
+++ b/pylib/mozreview/mozreview/resources/ensure_drafts.py
@@ -0,0 +1,88 @@
+from __future__ import unicode_literals
+
+from django.db import (
+    transaction,
+)
+from djblets.webapi.decorators import (
+    webapi_login_required,
+    webapi_request_fields,
+    webapi_response_errors,
+)
+from djblets.webapi.errors import (
+    DOES_NOT_EXIST, INVALID_FORM_DATA,
+    NOT_LOGGED_IN,
+    PERMISSION_DENIED,
+)
+from reviewboard.reviews.models import (
+    ReviewRequest,
+    ReviewRequestDraft,
+)
+from reviewboard.site.urlresolvers import (
+    local_site_reverse,
+)
+from reviewboard.webapi.errors import (
+    PUBLISH_ERROR,
+)
+from reviewboard.webapi.resources import (
+    WebAPIResource,
+)
+
+from mozreview.errors import (
+    NOT_PARENT,
+)
+from mozreview.extra_data import (
+    is_parent,
+    gen_child_rrs,
+)
+
+
+class EnsureDraftsResource(WebAPIResource):
+    """Ensure drafts exist for each child request.
+
+    This causes Review Board to show the draft banner on the parent and all
+    children when any child is updated.
+    """
+
+    name = 'ensure_draft'
+    allowed_methods = ('GET', 'POST',)
+
+    @webapi_login_required
+    @webapi_response_errors(DOES_NOT_EXIST, INVALID_FORM_DATA,
+                            PUBLISH_ERROR, NOT_PARENT,
+                            NOT_LOGGED_IN, PERMISSION_DENIED)
+    @webapi_request_fields(
+        required={
+            'parent_request_id': {
+                'type': int,
+                'description': 'The parent review request to update',
+            },
+        },
+    )
+    def create(self, request, parent_request_id, *args, **kwargs):
+        try:
+            parent_rr = ReviewRequest.objects.get(pk=parent_request_id)
+        except ReviewRequest.DoesNotExist:
+            return DOES_NOT_EXIST
+
+        if not (parent_rr.is_accessible_by(request.user)
+                or parent_rr.is_mutable_by(request.user)):
+            return PERMISSION_DENIED
+
+        if not is_parent(parent_rr):
+            return NOT_PARENT
+
+        with transaction.atomic():
+            for child_rr in gen_child_rrs(parent_rr):
+                if child_rr.get_draft() is None:
+                    ReviewRequestDraft.create(child_rr)
+            if parent_rr.get_draft() is None:
+                ReviewRequestDraft.create(parent_rr)
+
+        return 200, {}
+
+    def get_uri(self, request):
+        named_url = self._build_named_url(self.name_plural)
+        return request.build_absolute_uri(
+            local_site_reverse(named_url, request=request, kwargs={}))
+
+ensure_drafts_resource = EnsureDraftsResource()
new file mode 100644
--- /dev/null
+++ b/pylib/mozreview/mozreview/resources/modify_reviewer.py
@@ -0,0 +1,190 @@
+from __future__ import unicode_literals
+
+import itertools
+import json
+import logging
+
+from django.contrib.auth.models import (
+    User,
+)
+from django.db import (
+    transaction,
+)
+from django.utils import (
+    six,
+)
+from djblets.webapi.decorators import (
+    webapi_login_required,
+    webapi_request_fields,
+    webapi_response_errors,
+)
+from djblets.webapi.errors import (
+    DOES_NOT_EXIST,
+    INVALID_FORM_DATA,
+    NOT_LOGGED_IN,
+    PERMISSION_DENIED,
+)
+from reviewboard.reviews.errors import (
+    PublishError,
+)
+from reviewboard.reviews.models import (
+    ReviewRequest,
+    ReviewRequestDraft,
+)
+from reviewboard.site.urlresolvers import (
+    local_site_reverse,
+)
+from reviewboard.webapi.errors import (
+    PUBLISH_ERROR,
+)
+from reviewboard.webapi.resources import (
+    WebAPIResource,
+)
+
+from mozreview.bugzilla.client import (
+    Bugzilla,
+)
+from mozreview.errors import (
+    NOT_PARENT,
+    REVIEW_REQUEST_UPDATE_NOT_ALLOWED,
+)
+from mozreview.extra_data import (
+    is_parent,
+    gen_child_rrs,
+    set_publish_as,
+    clear_publish_as,
+    update_parent_rr_reviewers,
+)
+from mozreview.models import (
+    get_bugzilla_api_key,
+)
+
+
+class ModifyReviewerResource(WebAPIResource):
+    """Resource to modify the reviewers for a particular review request.
+
+    We require a separate resource to handle this so we can allow
+    anyone with permissions in bugzilla to modify the request.
+
+    The reviewers JSON is in the form of:
+        {
+            child_rrid: [ 'reviewer1', 'reviewer2', ... ],
+            ...
+        }
+    eg. {"5":["level1"]} updates rrid 5, clearing all existing reviewers then
+        setting the reviewers to the "level1" user.
+    """
+
+    name = 'modify_reviewer'
+    allowed_methods = ('GET', 'POST',)
+
+    @webapi_login_required
+    @webapi_response_errors(DOES_NOT_EXIST, INVALID_FORM_DATA,
+                            PUBLISH_ERROR, NOT_PARENT,
+                            NOT_LOGGED_IN, PERMISSION_DENIED)
+    @webapi_request_fields(
+        required={
+            'parent_request_id': {
+                'type': int,
+                'description': 'The parent review request to update',
+            },
+            'reviewers': {
+                'type': six.text_type,
+                'description': 'A JSON string contining the new reviewers'
+            },
+        },
+    )
+    def create(self, request, parent_request_id, reviewers, *args, **kwargs):
+        try:
+            parent_rr = ReviewRequest.objects.get(pk=parent_request_id)
+        except ReviewRequest.DoesNotExist:
+            return DOES_NOT_EXIST
+
+        if not (parent_rr.is_accessible_by(request.user)
+                or parent_rr.is_mutable_by(request.user)):
+            return PERMISSION_DENIED
+
+        if not is_parent(parent_rr):
+            return NOT_PARENT
+
+        # Validate and expand the new reviewer list.
+
+        bugzilla = Bugzilla(get_bugzilla_api_key(request.user))
+        child_reviewers = json.loads(reviewers)
+        invalid_reviewers = []
+        for child_rrid in child_reviewers:
+            users = []
+            for username in child_reviewers[child_rrid]:
+                try:
+                    users.append(bugzilla.get_user_from_irc_nick(username))
+                except User.DoesNotExist:
+                    invalid_reviewers.append(username)
+            child_reviewers[child_rrid] = users
+
+        if invalid_reviewers:
+            # Because this isn't called through Review Board's built-in
+            # backbone system, it's dramatically simpler to return just the
+            # intended error message instead of categorising the errors by
+            # field.
+            if len(invalid_reviewers) == 1:
+                return INVALID_FORM_DATA.with_message(
+                    "The reviewer '%s' was not found" % invalid_reviewers[0])
+            else:
+                return INVALID_FORM_DATA.with_message(
+                    "The reviewers '%s' were not found"
+                    % "', '".join(invalid_reviewers))
+
+        # Review Board only supports the submitter updating a review
+        # request.  In order for this to work, we publish these changes
+        # in Review Board under the review submitter's account, and
+        # set an extra_data field which instructs our bugzilla
+        # connector to use this request's user when adjusting flags.
+        #
+        # Updating the review request requires creating a draft and
+        # publishing it, so we have to be careful to not overwrite
+        # existing drafts.
+
+        try:
+            with transaction.atomic():
+                for rr in itertools.chain([parent_rr],
+                                          gen_child_rrs(parent_rr)):
+                    if rr.get_draft() is not None:
+                        return REVIEW_REQUEST_UPDATE_NOT_ALLOWED.with_message(
+                            "Unable to update reviewers as the review "
+                            "request has pending changes (the patch author "
+                            "has a draft)")
+
+                try:
+                    for child_rr in gen_child_rrs(parent_rr):
+                        if str(child_rr.id) in child_reviewers:
+                            if not child_rr.is_accessible_by(request.user):
+                                return PERMISSION_DENIED.with_message(
+                                    "You do not have permission to update "
+                                    "reviewers on review request %s"
+                                    % child_rr.id)
+
+                            draft = ReviewRequestDraft.create(child_rr)
+                            draft.target_people.clear()
+                            for user in child_reviewers[str(child_rr.id)]:
+                                draft.target_people.add(user)
+
+                    set_publish_as(parent_rr, request.user)
+                    parent_rr_draft = ReviewRequestDraft.create(parent_rr)
+                    update_parent_rr_reviewers(parent_rr_draft)
+                    parent_rr.publish(user=parent_rr.submitter)
+                finally:
+                    clear_publish_as(parent_rr)
+
+        except PublishError as e:
+                logging.error("failed to update reviewers on %s: %s"
+                              % (parent_rr.id, str(e)))
+                return PUBLISH_ERROR.with_message(str(e))
+
+        return 200, {}
+
+    def get_uri(self, request):
+        named_url = self._build_named_url(self.name_plural)
+        return request.build_absolute_uri(
+            local_site_reverse(named_url, request=request, kwargs={}))
+
+modify_reviewer_resource = ModifyReviewerResource()
new file mode 100644
--- /dev/null
+++ b/pylib/mozreview/mozreview/resources/verify_reviewer.py
@@ -0,0 +1,78 @@
+from __future__ import unicode_literals
+
+from django.contrib.auth.models import (
+    User,
+)
+from django.utils import (
+    six,
+)
+from djblets.webapi.decorators import (
+    webapi_login_required,
+    webapi_request_fields,
+    webapi_response_errors,
+)
+from djblets.webapi.errors import (
+    INVALID_FORM_DATA,
+    NOT_LOGGED_IN,
+)
+from mozreview.bugzilla.client import (
+    Bugzilla,
+)
+from mozreview.models import (
+    get_bugzilla_api_key,
+)
+from reviewboard.site.urlresolvers import (
+    local_site_reverse,
+)
+from reviewboard.webapi.resources import (
+    WebAPIResource,
+)
+
+
+class VerifyReviewerResource(WebAPIResource):
+    """Resource to check the validity of provided reviewer names."""
+
+    allowed_methods = ('GET', 'POST')
+    name = 'verify_reviewer'
+
+    @webapi_login_required
+    @webapi_response_errors(INVALID_FORM_DATA, NOT_LOGGED_IN)
+    @webapi_request_fields(
+        required={
+            'reviewers': {
+                'type': six.text_type,
+                'description': 'A comma separated list of reviewers'
+            },
+        },
+    )
+    def create(self, request, reviewers, *args, **kwargs):
+        bugzilla = Bugzilla(get_bugzilla_api_key(request.user))
+        new_reviewers = [u.strip() for u in reviewers.split(',') if u.strip()]
+        invalid_reviewers = []
+        for reviewer in new_reviewers:
+            try:
+                bugzilla.get_user_from_irc_nick(reviewer)
+            except User.DoesNotExist:
+                invalid_reviewers.append(reviewer)
+
+        if invalid_reviewers:
+            # Because this isn't called through Review Board's built-in
+            # backbone system, it's dramatically simpler to return just the
+            # intended error message instead of categorising the errors by
+            # field.
+            if len(invalid_reviewers) == 1:
+                return INVALID_FORM_DATA.with_message(
+                    "The reviewer '%s' was not found" % invalid_reviewers[0])
+            else:
+                return INVALID_FORM_DATA.with_message(
+                    "The reviewers '%s' were not found"
+                    % "', '".join(invalid_reviewers))
+
+        return 200, {}
+
+    def get_uri(self, request):
+        named_url = self._build_named_url(self.name_plural)
+        return request.build_absolute_uri(
+            local_site_reverse(named_url, request=request, kwargs={}))
+
+verify_reviewer_resource = VerifyReviewerResource()
--- a/pylib/mozreview/mozreview/signal_handlers.py
+++ b/pylib/mozreview/mozreview/signal_handlers.py
@@ -1,24 +1,28 @@
 from __future__ import unicode_literals
 
 import copy
 import json
 import logging
 
+from django.contrib.auth.models import (
+    User,
+)
 from django.db.models.signals import (
     post_save,
     pre_delete,
     pre_save,
 )
 
 from djblets.siteconfig.models import (
     SiteConfiguration,
 )
 from reviewboard.reviews.errors import (
+    NotModifiedError,
     PublishError,
 )
 from reviewboard.extensions.hooks import (
     SignalHook,
 )
 from reviewboard.reviews.models import (
     Review,
     ReviewRequest,
@@ -55,16 +59,17 @@ from mozreview.extra_data import (
     gen_rrs_by_rids,
     get_parent_rr,
     IDENTIFIER_KEY,
     is_parent,
     is_pushed,
     REVIEWID_RE,
     REVIEW_FLAG_KEY,
     UNPUBLISHED_KEY,
+    PUBLISH_AS_KEY,
     update_parent_rr_reviewers,
 )
 from mozreview.messages import (
     AUTO_CLOSE_DESCRIPTION,
     AUTO_SUBMITTED_DESCRIPTION,
     NEVER_USED_DESCRIPTION,
     OBSOLETE_DESCRIPTION,
 )
@@ -261,17 +266,23 @@ def on_review_request_publishing(user, r
     except (TypeError, ValueError):
         raise InvalidBugIdError(bug_id)
 
     siteconfig = SiteConfiguration.objects.get_current()
     using_bugzilla = (
         siteconfig.settings.get("auth_backend", "builtin") == "bugzilla")
 
     if using_bugzilla:
-        b = Bugzilla(get_bugzilla_api_key(user))
+        commit_data = fetch_commit_data(review_request_draft)
+        publish_as_id = commit_data.draft_extra_data.get(PUBLISH_AS_KEY)
+        if publish_as_id:
+            u = User.objects.get(id=publish_as_id)
+            b = Bugzilla(get_bugzilla_api_key(u))
+        else:
+            b = Bugzilla(get_bugzilla_api_key(user))
 
         try:
             if b.is_bug_confidential(bug_id):
                 raise ConfidentialBugError
         except BugzillaError as e:
             # Special cases:
             #   100: Invalid Bug Alias
             #   101: Bug does not exist
@@ -324,16 +335,21 @@ def on_review_request_publishing(user, r
 
                 # Setup the parent signal handler to approve the publish
                 # and then publish the child.
                 commit_request_publishing.connect(approve_publish,
                                                   sender=child,
                                                   weak=False)
                 try:
                     child.publish(user=user)
+                except NotModifiedError:
+                    # As we create empty drafts as part of allowing reviewer
+                    # delegation, delete these empty drafts instead of
+                    # throwing an error.
+                    child.get_draft(user=user).delete()
                 finally:
                     commit_request_publishing.disconnect(
                         receiver=approve_publish,
                         sender=child,
                         weak=False)
 
                 if child.id in unpublished_rids:
                     unpublished_rids.remove(child.id)
--- a/pylib/mozreview/mozreview/static/mozreview/css/commits.less
+++ b/pylib/mozreview/mozreview/static/mozreview/css/commits.less
@@ -283,8 +283,16 @@
     }
 
     .action-separator{
         background-color: #E4E3DD;
         height:1px;
         margin: 5px;
     }
 }
+
+#local-draft-banner {
+  margin-top: 10px;
+  margin-bottom: -10px;
+  border-bottom: none;
+  border-bottom-left-radius: 0;
+  border-bottom-right-radius: 0;
+}
--- a/pylib/mozreview/mozreview/static/mozreview/css/review.less
+++ b/pylib/mozreview/mozreview/static/mozreview/css/review.less
@@ -19,18 +19,16 @@
       padding: 10px 50px;
       color: #333;
       text-decoration: none;
     }
   }
 }
 
 #draft-banner {
-  cursor: pointer;
-
   #field_changedescription,
   p {
     display: none;
   }
 }
 
 #discard-banner {
   #btn-review-request-reopen,
@@ -119,16 +117,23 @@ label[for="mozreview-autoland-try-syntax
   }
 }
 
 #try-syntax-error {
   display: none;
   color: red;
 }
 
+.parent-request {
+  #id_shipit,
+  label[for="id_shipit"] {
+    display: none;
+  }
+}
+
 .parent-request #review-form .edit-field {
   // Without the Ship It checkbox, the edit field gets squished up
   // with the Markdown Reference link unless we clear the latter's float.
   clear: right;
 }
 
 // Hide the "Reviewers" section; unfortunately these don't have IDs
 // TODO: Update this to use fieldset IDs once they are available upstream
@@ -146,8 +151,24 @@ label[for="mozreview-autoland-try-syntax
 }
 
 // Hide the "Ship it" checkbox, since we use the r?, r+, r- flags provided
 // by MRReviewFlag.
 #id_shipit,
 label[for="id_shipit"] {
   display: none;
 }
+
+// Don't show gravatar for meta changes; RB is hardcoded to always use the
+// submitter, but we allow non-submitters to update the target people.
+#reviews {
+  .changedesc {
+    .box-status {
+      display: none;
+    }
+    .box-container {
+      padding-left: 0;
+    }
+    .box::before, .box::after {
+      display: none;
+    }
+  }
+}
--- a/pylib/mozreview/mozreview/static/mozreview/js/commits.js
+++ b/pylib/mozreview/mozreview/static/mozreview/js/commits.js
@@ -19,148 +19,377 @@
  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
  * SOFTWARE.
  */
 
 $(document).on("mozreview_ready", function() {
-  if (!MozReview.isParent) {
-    // At this time, there's no need to set up the editors for the reviewers if
-    // we're not looking at the parent review request.
+  if (!RB.UserSession.instance.get("username")) {
     return;
   }
 
-  console.assert(MozReview.parentEditor, "We should have a parent commit editor");
-  console.assert(MozReview.parentView, "We should have a parent commit editor view");
-
-  /**
-   * A not-amazing error reporting mechanism that depends on a DOM
-   * node with ID "error-container" existing on the page somewhere.
-   */
-  var $ErrorContainer = $("#error-container");
-  function reportError(aMsg) {
-    if (typeof(aMsg) == "object") {
-      if (aMsg.errorText) {
-        aMsg = aMsg.errorText;
-      } else {
-        aMsg = JSON.stringify(aMsg, null, "\t");
+  // Simple case-insensitive array of strings comparison
+  function arraysEqualsCI(a, b) {
+    var i = a.length;
+    if (i != b.length) {
+      return false;
+    }
+    while (i--) {
+      if (a[i].toLowerCase() != b[i].toLowerCase()) {
+        return false;
       }
     }
+    return true;
+   }
 
-    $("#error-info").text(aMsg);
-    $("#error-stack").text(new Error().stack);
-    $ErrorContainer.attr("haserror", "true");
-    RB.PageManager.getPage().reviewRequestEditorView._scheduleResizeLayout();
+  function showError(errorMessage, xhr) {
+    if (xhr && xhr.responseJSON && xhr.responseJSON.err) {
+      errorMessage = xhr.responseJSON.err.msg;
+    }
+    $("#review-request-warning")
+      .delay(6000)
+      .fadeOut(400, function() {
+        $(this).hide();
+      })
+      .show()
+      .text(errorMessage);
   }
 
   $("#error-close").click(function() {
-    $ErrorContainer.attr("haserror", "false");
+    $("#error-container").attr("haserror", "false");
   });
 
   $("#error-stack-toggle").click(function() {
     $("#error-stack").toggle();
   });
 
-  // Hook up the inline editor for each commit's reviewer list. This inline editor
-  // code is mostly copied from Review Board itself - please see the copyright
-  // notice in the header.
-  var editorOptions = {
-    editIconClass: "rb-icon rb-icon-edit",
-    useEditIconOnly: true,
-    enabled: true
-  };
+  /*
+   * Review Board only allows the review request submitter to change the
+   * target reviewers.  As MozReview wants to allow 'anyone' to change the
+   * reviewers, we expose the editor to all users (Bugzilla will perform the
+   * permissions check for us).
+   *
+   * In order for this to work correctly there are two separate paths depending
+   * on if the user is the submitter or not.
+   *
+   * Submitters use Review Board's normal draft mechanism, with a small tweak
+   * that creates a draft on all children when any reviewers are updated.  This
+   * causes Review Board to show the draft banner when viewing any review
+   * request in the series.
+   *
+   * Non-submitters can't use a normal draft, as a review request can only have
+   * one, and it's used by the submitter.  Instead we create a client-side
+   * fake draft in local storage, and display a fake draft banner above the
+   * commits table.
+   */
+
+  //
+  // Local Drafts
+  //
+
+  function getLocalDraft() {
+    var localDrafts = window.localStorage.localDrafts ?
+      JSON.parse(window.localStorage.localDrafts) : {};
+    var parent_rrid = $("#mozreview-parent-request").data("id");
+    return localDrafts[parent_rrid] ? localDrafts[parent_rrid] : {};
+  }
+
+  function setLocalDraft(draft) {
+    var localDrafts = window.localStorage.localDrafts ?
+      JSON.parse(window.localStorage.localDrafts) : {};
+    var parent_rrid = $("#mozreview-parent-request").data("id");
+    if (draft) {
+      localDrafts[parent_rrid] = draft;
+    }
+    else {
+      delete localDrafts[parent_rrid];
+    }
+    window.localStorage.localDrafts = JSON.stringify(localDrafts);
+  }
+
+  function hasLocalDraft() {
+    return Object.keys(getLocalDraft()).length !== 0;
+  }
+
+  function publishLocalDraft() {
+    if (!hasLocalDraft()) {
+      discardLocalDraft();
+      return;
+    }
+    var draft = getLocalDraft();
+    RB.setActivityIndicator(true, {});
+    $.ajax({
+      type: "POST",
+      data: {
+        parent_request_id: $("#mozreview-parent-request").data("id"),
+        reviewers: JSON.stringify(draft)
+      },
+      url: "/api/extensions/mozreview.extension.MozReviewExtension/modify-reviewers/",
+      success: function(rsp) {
+        discardLocalDraft(true);
+        window.location.reload(true);
+      },
+      error: function(xhr, textStatus, errorThrown) {
+        RB.setActivityIndicator(false, {});
+        showError(errorThrown, xhr);
+      }
+    });
+  }
+
+  function discardLocalDraft(silent) {
+    setLocalDraft(undefined);
+    if (!silent) {
+      $(".mozreview-child-reviewer-list").each(function() {
+        restoreOriginalReviewerState($(this));
+      });
+      hideLocalDraftBanner();
+    }
+  }
 
-  var reviewerList = $(".mozreview-child-reviewer-list");
+  function restoreLocalDraftState($reviewer_list) {
+    var draft = getLocalDraft();
+    if (!$reviewer_list) {
+      $reviewer_list = $(".mozreview-child-reviewer-list");
+    }
+    $reviewer_list.each(function() {
+      var $this = $(this);
+      var rrid = $this.data("id");
+      if (draft[rrid]) {
+        saveOriginalReviewerState($this);
+        $this.html(draft[rrid].join(", "));
+      }
+    });
+  }
+
+  function showLocalDraftBanner() {
+    if (!hasLocalDraft()) {
+      $("#local-draft-banner").remove();
+      return;
+    }
+    if ($("#local-draft-banner").length) {
+      return;
+    }
+    $("<div/>")
+      .attr("id", "local-draft-banner")
+      .addClass("banner")
+      .addClass("box-inner")
+      .append(
+        $("<p>")
+          .text("You have pending changes to this review.")
+      )
+      .append(
+        $("<span/>")
+          .addClass("banner-actions")
+          .append(
+            $("<input/>")
+              .attr("type", "button")
+              .addClass("publish-button")
+              .val("Publish")
+              .click(function(event) {
+                event.preventDefault();
+                publishLocalDraft();
+              })
+          )
+          .append(" ")
+          .append(
+            $("<input/>")
+              .attr("type", "button")
+              .addClass("discard-button")
+              .val("Discard")
+              .click(function(event) {
+                event.preventDefault();
+                discardLocalDraft();
+              })
+          )
+      )
+      .insertBefore("#mozreview-child-requests");
+      RB.PageManager.getPage().reviewRequestEditorView._scheduleResizeLayout();
+  }
+
+  function hideLocalDraftBanner() {
+    $("#local-draft-banner").remove();
+    RB.PageManager.getPage().reviewRequestEditorView._scheduleResizeLayout();
+  }
+
+  function saveOriginalReviewerState($reviewer_list) {
+    if (!$reviewer_list.data("orig-html")) {
+      $reviewer_list.data("orig-html", $reviewer_list.html().trim());
+      var reviewers = $reviewer_list.find(".reviewer-name")
+                                    .map(function() { return $(this).text(); })
+                                    .sort();
+      $reviewer_list.data("orig-reviewers", $.makeArray(reviewers));
+    }
+  }
+
+  function restoreOriginalReviewerState($reviewer_list) {
+    if ($reviewer_list.data("orig-html")) {
+      $reviewer_list.html($reviewer_list.data("orig-html"));
+    }
+  }
+
+  function ensureNativeDrafts() {
+    RB.setActivityIndicator(true, {});
+    $.ajax({
+      type: "POST",
+      data: {
+        parent_request_id: $("#mozreview-parent-request").data("id")
+      },
+      url: "/api/extensions/mozreview.extension.MozReviewExtension/ensure-drafts/",
+      success: function(rsp) {
+        RB.setActivityIndicator(false, {});
+      },
+      error: function(xhr, textStatus, errorThrown) {
+        RB.setActivityIndicator(false, {});
+        showError(errorThrown, xhr);
+      }
+    });
+  }
+
+  function augmentNativeBanner() {
+    if (!MozReview.isParent) {
+      // Unfortunately we cannot publish from children, so provide a link
+      // to the parent instead.
+      var parent_rrid = $("#mozreview-parent-request").data("id");
+      $("#draft-banner").append(
+          $('<a href="../' + parent_rrid + '/" title="You can only Publish or Discard when ' +
+            'viewing the \'Review Summary / Parent\'.">Publish or Discard my changes.</a>'));
+    }
+  }
+
   var editors = {};
 
-  if (MozReview.currentIsMutableByUser) {
-  var reviewerListEditors = reviewerList
-    .inlineEditor(editorOptions)
+  function updateReviewers($reviewer_list, value) {
+    var rrid = $reviewer_list.data("id");
+
+    // Parse updated reviewer list.
+    var reviewers = value.split(/[ ,]+/)
+      .map(function(name) {
+        name = name.trim();
+        if (name.substr(name, 0, 1) === ":") {
+          name = name.substring(1);
+        }
+        return name;
+      })
+      .filter(function(name) {
+        return name !== "";
+      })
+      .sort();
+
+
+    // No need to do anything if nothing is changed.
+    if (arraysEqualsCI($reviewer_list.data("orig-reviewers"), reviewers)) {
+      $reviewer_list.html($reviewer_list.data("orig-html"));
+      return;
+    }
+
+    // TODO retain reviewer background status colour after an edit
+    $reviewer_list.text(reviewers.join(" , "));
+
+    if (MozReview.isSubmitter) {
+      // When the submitter updates reviewers, use RB's native drafts.
+      var editor;
+      if (!editors[rrid]) {
+        var rr = new RB.ReviewRequest({ id: rrid });
+        editor = new RB.ReviewRequestEditor({ reviewRequest: rr });
+        editors[rrid] = editor;
+      } else {
+        editor = editors[rrid];
+      }
+
+      editor.setDraftField(
+        "targetPeople",
+        reviewers.join(","),
+        {
+          jsonFieldName: "target_people",
+          error: function(error) {
+            showError(error.errorText);
+            restoreOriginalReviewerState($reviewer_list);
+          },
+          success: function() {
+            MozReview.reviewEditor.set("public", false);
+            // Our draft relies on a field that isn't part of RB's front-end
+            // model, so changes aren't picked up by the model automatically.
+            // Manually record a draft exists so the banner will be displayed.
+            var view = RB.PageManager.getPage().reviewRequestEditorView;
+            view.model.set("hasDraft", true);
+            MozReview.reviewEditor.trigger("saved");
+            // Extend the draft to encompass the parent and all children, so
+            // the draft banner is visible on all review requests in the set.
+            ensureNativeDrafts();
+            augmentNativeBanner();
+          }
+        }, this);
+
+    } else {
+      // Otherwise use our local draft.
+
+      RB.setActivityIndicator(true, {});
+      $.ajax({
+        type: "POST",
+        data: { reviewers: reviewers.join(",") },
+        url: "/api/extensions/mozreview.extension.MozReviewExtension/verify-reviewers/",
+        success: function(rsp) {
+          RB.setActivityIndicator(false, {});
+          // All reviewrs ok - create fake draft in localStorage.
+          var localDraft = getLocalDraft();
+          localDraft[rrid] = reviewers;
+          setLocalDraft(localDraft);
+          showLocalDraftBanner();
+        },
+        error: function(xhr, textStatus, errorThrown) {
+          RB.setActivityIndicator(false, {});
+          restoreLocalDraftState($reviewer_list);
+          showError(errorThrown, xhr);
+        }
+      });
+    }
+  }
+
+  $(".mozreview-child-reviewer-list")
+    .inlineEditor({
+      editIconClass: "rb-icon rb-icon-edit",
+      useEditIconOnly: true,
+      enabled: true,
+      setFieldValue: function(editor, value) {
+        editor._field.val(value.trim());
+      }
+    })
     .on({
       beginEdit: function() {
-        // The editCount is used to determine if we should warn the user before
-        // unloading the page because they still have an editor open.
-        console.log("beginning edit " + $(this).data("id"));
-        MozReview.parentEditor.incr("editCount");
+        $reviewer_list = $(this);
+        // Store the original html and reviewer list so we can restore later.
+        saveOriginalReviewerState($reviewer_list);
+        // store the current edit to support cancelling
+        $reviewer_list.data("prior", $reviewer_list.html());
+        // Inc editCount to enable "leave this page" warning.
+        MozReview.reviewEditor.incr("editCount");
       },
       cancel: function() {
-        MozReview.parentEditor.decr("editCount");
+        $reviewer_list = $(this);
+        // restoreOriginalReviewerState($reviewer_list);
+        $reviewer_list.html($reviewer_list.data("prior"));
+        $reviewer_list.data("prior", "");
+        MozReview.reviewEditor.decr("editCount");
       },
       complete: function(e, value) {
-        // The ReviewRequestEditor is the interface that we use to modify
-        // a review request easily.
-        var editor, reviewRequest;
-        var id = $(this).data("id");
-        if (!editors[id]) {
-          reviewRequest = new RB.ReviewRequest({id: $(this).data("id")});
-          editor = new RB.ReviewRequestEditor({reviewRequest: reviewRequest});
-          editors[id] = editor;
-        } else {
-          editor = editors[id];
-        }
-
-        var originalContents = $(this).text();
-
-        var warning = $("#review-request-warning");
-        // For Mozilla, we sometimes use colons as a prefix for searching for
-        // IRC nicks - that's just a convention that has developed over time.
-        // Since IRC nicks are what MozReview recognizes, we need to be careful
-        // that the user hasn't actually included those colon prefixes, otherwise
-        // MozReview is going to complain that it doesn't recognize the user (since
-        // MozReview's notion of a username doesn't include the colon prefix).
-        var sanitized = value.split(" ").map(function(aName) {
-          var trimmed = aName.trim();
-          if (trimmed.indexOf(":") == 0) {
-            trimmed = trimmed.substring(1);
-          }
-          return trimmed;
-        });
-
-        // This sets the reviewers on the child review request.
-        editor.setDraftField(
-          "targetPeople",
-          sanitized.join(", "),
-          {
-            jsonFieldName: "target_people",
-            error: function(error) {
-              MozReview.parentEditor.decr("editCount");
-              console.error(error.errorText);
-
-              // This error display code is copied pretty much verbatim
-              // from Review Board core to match the behaviour of attempting
-              // to set a target reviewer to one or more users that does not
-              // exist.
-              warning
-                .delay(6000)
-                .fadeOut(400, function() {
-                  $(this).hide();
-                })
-                .show()
-                .html(error.errorText);
-
-              // Revert the list back to what we started with.
-              $(this).text(originalContents);
-            },
-            success: function() {
-              MozReview.parentEditor.decr("editCount");
-              MozReview.parentEditor.set('public', false);
-              // Our draft relies on a field that isn't part of RB's front-end
-              // model, so changes aren't picked up by the model automatically.
-              // Manually record a draft exists so the banner will be displayed.
-              var view = RB.PageManager.getPage().reviewRequestEditorView;
-              view.model.set("hasDraft", true);
-
-              MozReview.parentEditor.trigger('saved');
-
-            }
-          }, this);
+        $reviewer_list = $(this);
+        $reviewer_list.data("prior", "");
+        MozReview.reviewEditor.decr("editCount");
+        updateReviewers($reviewer_list, value);
       }
     });
+
+  // Update UI if there's an existing draft.
+  if (MozReview.isSubmitter) {
+    augmentNativeBanner();
+  } else if (hasLocalDraft()) {
+    showLocalDraftBanner();
+    restoreLocalDraftState();
   }
 
   // This next bit sets up the autocomplete popups for reviewers. This
   // code is mostly copied from Review Board itself - please see the
   // copyright notice in the header.
   var acOptions = {
     fieldName: "users",
     nameKey: "username",
@@ -195,20 +424,18 @@
   };
 
   // Again, this is copied almost verbatim from Review Board core to
   // mimic traditional behaviour for this kind of field.
   $("#mozreview-child-requests input[type=text]").mozreviewautocomplete({
     formatItem: function(data) {
       var s = data[acOptions.nameKey];
       if (acOptions.descKey && data[acOptions.descKey]) {
-        s += ' <span>(' + _.escape(data[acOptions.descKey]) +
-             ')</span>';
+        s += " <span>(" + _.escape(data[acOptions.descKey]) + ")</span>";
       }
-
       return s;
     },
     matchCase: false,
     multiple: true,
     searchPrefix: ":",
     parse: function(data) {
       var items = data[acOptions.fieldName],
           itemsLen = items.length,
@@ -224,43 +451,36 @@
           value: value[acOptions.nameKey],
           result: value[acOptions.nameKey]
         });
       }
 
       return parsed;
     },
     url: SITE_ROOT +
-         "api/" + (acOptions.resourceName || acOptions.fieldName) + '/',
+         "api/" + (acOptions.resourceName || acOptions.fieldName) + "/",
     extraParams: acOptions.extraParams,
     cmp: acOptions.cmp,
     width: 350,
     error: function(xhr) {
-      var text;
-      try {
-        text = $.parseJSON(xhr.responseText).err.msg;
-      } catch (e) {
-        text = 'HTTP ' + xhr.status + ' ' + xhr.statusText;
-      }
-      reportError(text);
+      showError(xhr.statusText, xhr);
     }
   }).on("autocompleteshow", function() {
     /*
      * Add the footer to the bottom of the results pane the
      * first time it's created.
      *
      * Note that we may have multiple .ui-autocomplete-results
      * elements, and we don't necessarily know which is tied to
      * this. So, we'll look for all instances that don't contain
      * a footer.
      */
 
-    var resultsPane = $('.ui-autocomplete-results:not(' +
-                        ':has(.ui-autocomplete-footer))');
+    var resultsPane = $(".ui-autocomplete-results:not(" +
+                        ":has(.ui-autocomplete-footer))");
     if (resultsPane.length > 0) {
-      $('<div/>')
-        .addClass('ui-autocomplete-footer')
-        .text(gettext('Press Tab to auto-complete.'))
+      $("<div/>")
+        .addClass("ui-autocomplete-footer")
+        .text(gettext("Press Tab to auto-complete."))
         .appendTo(resultsPane);
     }
   });
-
 });
--- a/pylib/mozreview/mozreview/static/mozreview/js/init_rr.js
+++ b/pylib/mozreview/mozreview/static/mozreview/js/init_rr.js
@@ -8,29 +8,29 @@ var MozReview = {};
   var parentID = $("#mozreview-parent-request").data("id");
 
   if (!parentID) {
     console.error("Could not find a valid id for the parent review " +
                   "request.");
     return;
   }
 
-  // The current user's scm level has been injected in an invisible div.
-  MozReview.scmLevel = $("#scm-level").data("scm-level");
+  // Load injected user data>
+  var $userData = $("#user_data");
+  MozReview.scmLevel = $userData.data("scm-level");
   MozReview.hasScmLevel1 = MozReview.scmLevel >= 1;
   MozReview.hasScmLevel3 = MozReview.scmLevel == 3;
+  MozReview.isSubmitter = !!$userData.data("is-submitter");
 
   // Whether or not the repository has associated try and landing repositories
   // is in an invisible div.
   MozReview.autolandingToTryEnabled = $("#repository").data("autolanding-to-try-enabled");
   MozReview.autolandingEnabled = $("#repository").data("autolanding-enabled");
   MozReview.landingRepository = $("#repository").data("landing-repository");
 
-  console.log("Found parent review request ID: " + parentID);
-
   var page = RB.PageManager.getPage();
 
   // Setup a CSS class so we can differentiate between parent
   // and commit review requests.
   var currentID = page.reviewRequest.id;
 
   if (currentID == parentID) {
       $("body").addClass("parent-request");
@@ -39,18 +39,17 @@ var MozReview = {};
   }
 
   var pageReviewRequest = page.reviewRequest;
   var pageEditor = page.reviewRequestEditor;
   var pageView = page.reviewRequestEditorView;
 
   MozReview.currentIsMutableByUser = pageEditor.get("mutableByUser");
   MozReview.isParent = (parentID == pageReviewRequest.id);
-  MozReview.parentEditor = MozReview.isParent ? pageEditor
-                                              : null;
+  MozReview.reviewEditor = pageEditor;
   MozReview.parentView = MozReview.isParent ? pageView
                                             : null;
 
   // Review Board doesn't currently expose approval status in the
   // review request model so we extend it and use our own model
   // for the parent so we can access it.
   var patchedRR = RB.ReviewRequest.extend({
     defaults: function() {
rename from pylib/mozreview/mozreview/templates/mozreview/scm_level.html
rename to pylib/mozreview/mozreview/templates/mozreview/user-data.html
--- a/pylib/mozreview/mozreview/templates/mozreview/scm_level.html
+++ b/pylib/mozreview/mozreview/templates/mozreview/user-data.html
@@ -1,2 +1,9 @@
 {% load mozreview %}
-<div id="scm-level" data-scm-level="{{ request.mozreview_profile|scm_level }}"></div>
+<div id="user_data"
+  data-scm-level="{{ request.mozreview_profile|scm_level }}"
+  {% if request.user and review_request %}
+    {% if review_request.submitter.id == request.user.id %}
+      data-is-submitter="true"
+    {% endif %}
+  {% endif %}
+></div>
--- a/pylib/mozreview/mozreview/templatetags/mozreview.py
+++ b/pylib/mozreview/mozreview/templatetags/mozreview.py
@@ -52,18 +52,22 @@ def extra_data(review_request, key):
 
 
 @register.filter()
 def scm_level(mozreview_profile):
     if mozreview_profile is None:
         return ''
     elif mozreview_profile.has_scm_ldap_group('scm_level_3'):
         return '3'
+    elif mozreview_profile.has_scm_ldap_group('scm_level_2'):
+        return '2'
     elif mozreview_profile.has_scm_ldap_group('scm_level_1'):
         return '1'
+    else:
+        return ''
 
 
 @register.filter()
 def required_ldap_group(repository):
     try:
         return repository.extra_data['required_ldap_group']
     except (AttributeError, KeyError):
         return ''
--- a/testing/vcttesting/reviewboard/mach_commands.py
+++ b/testing/vcttesting/reviewboard/mach_commands.py
@@ -785,8 +785,58 @@ class ReviewBoardCommands(object):
             d['commit'] = commit['commit']
             for k in ('id', 'commit'):
                 if k in commit:
                     d[k] = commit[k]
             d['reviewers'] = list(commit['reviewers'])
             result['commits'].append(d)
 
         print(yaml.safe_dump(result, default_flow_style=False).rstrip())
+
+    @Command('modify-reviewers', category='reviewboard',
+             description='Update reviewers on a child review request')
+    @CommandArgument('parent_rrid', help='Parent review request id')
+    @CommandArgument('child_rrid', help='Child review request id')
+    @CommandArgument('reviewers', help='Comma delimited list of reviewers')
+    def modify_reviewers(self, parent_rrid, child_rrid, reviewers):
+        from rbtools.api.errors import APIError
+        import json
+        c = self._get_client()
+        try:
+            r = c.get_path('/extensions/mozreview.extension.MozReviewExtension'
+                           '/modify-reviewers/')
+            reviewers = {child_rrid: reviewers.split(',')}
+            r.create(parent_request_id=parent_rrid,
+                     reviewers=json.dumps(reviewers))
+        except APIError as e:
+            print('API Error: %s: %s: %s' % (e.http_status, e.error_code,
+                                             e.rsp['err']['msg']))
+            return 1
+
+    @Command('verify-reviewers', category='reviewboard',
+             description='Update reviewers on a child review request')
+    @CommandArgument('reviewers', help='Comma delimited list of reviewers')
+    def verify_reviewers(self, reviewers):
+        from rbtools.api.errors import APIError
+        c = self._get_client()
+        try:
+            r = c.get_path('/extensions/mozreview.extension.MozReviewExtension'
+                           '/verify-reviewers/')
+            r.create(reviewers=reviewers)
+        except APIError as e:
+            print('API Error: %s: %s: %s' % (e.http_status, e.error_code,
+                                             e.rsp['err']['msg']))
+            return 1
+
+    @Command('ensure-drafts', category='reviewboard',
+             description='Create drafts on all review request children')
+    @CommandArgument('parent_rrid', help='Parent review request id')
+    def ensure_drafts(self, parent_rrid):
+        from rbtools.api.errors import APIError
+        c = self._get_client()
+        try:
+            r = c.get_path('/extensions/mozreview.extension.MozReviewExtension'
+                           '/ensure-drafts/')
+            r.create(parent_request_id=parent_rrid)
+        except APIError as e:
+            print('API Error: %s: %s: %s' % (e.http_status, e.error_code,
+                                             e.rsp['err']['msg']))
+            return 1