Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/tracker/__init__.py b/tracker/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/tracker/__init__.py
@@ -0,0 +1 @@
+
diff --git a/tracker/attachment_helpers.py b/tracker/attachment_helpers.py
new file mode 100644
index 0000000..9ed9a7c
--- /dev/null
+++ b/tracker/attachment_helpers.py
@@ -0,0 +1,112 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Functions to help display attachments and compute quotas."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import base64
+import hmac
+import logging
+
+from framework import urls
+from services import secrets_svc
+from tracker import tracker_helpers
+
+
+VIEWABLE_IMAGE_TYPES = [
+    'image/jpeg', 'image/gif', 'image/png', 'image/x-png', 'image/webp',
+    ]
+VIEWABLE_VIDEO_TYPES = [
+    'video/ogg', 'video/mp4', 'video/mpg', 'video/mpeg', 'video/webm',
+    'video/quicktime',
+    ]
+MAX_PREVIEW_FILESIZE = 15 * 1024 * 1024  # 15 MB
+
+
+def IsViewableImage(mimetype_charset, filesize):
+  """Return true if we can safely display such an image in the browser.
+
+  Args:
+    mimetype_charset: string with the mimetype string that we got back
+        from the 'file' command.  It may have just the mimetype, or it
+        may have 'foo/bar; charset=baz'.
+    filesize: int length of the file in bytes.
+
+  Returns:
+    True iff we should allow the user to view a thumbnail or safe version
+    of the image in the browser.  False if this might not be safe to view,
+    in which case we only offer a download link.
+  """
+  mimetype = mimetype_charset.split(';', 1)[0]
+  return (mimetype in VIEWABLE_IMAGE_TYPES and
+          filesize < MAX_PREVIEW_FILESIZE)
+
+
+def IsViewableVideo(mimetype_charset, filesize):
+  """Return true if we can safely display such a video in the browser.
+
+  Args:
+    mimetype_charset: string with the mimetype string that we got back
+        from the 'file' command.  It may have just the mimetype, or it
+        may have 'foo/bar; charset=baz'.
+    filesize: int length of the file in bytes.
+
+  Returns:
+    True iff we should allow the user to watch the video in the page.
+  """
+  mimetype = mimetype_charset.split(';', 1)[0]
+  return (mimetype in VIEWABLE_VIDEO_TYPES and
+          filesize < MAX_PREVIEW_FILESIZE)
+
+
+def IsViewableText(mimetype, filesize):
+  """Return true if we can safely display such a file as escaped text."""
+  return (mimetype.startswith('text/') and
+          filesize < MAX_PREVIEW_FILESIZE)
+
+
+def SignAttachmentID(aid):
+  """One-way hash of attachment ID to make it harder for people to scan."""
+  digester = hmac.new(secrets_svc.GetXSRFKey())
+  digester.update(str(aid))
+  return base64.urlsafe_b64encode(digester.digest())
+
+
+def GetDownloadURL(attachment_id):
+  """Return a relative URL to download an attachment to local disk."""
+  return 'attachment?aid=%s&signed_aid=%s' % (
+        attachment_id, SignAttachmentID(attachment_id))
+
+
+def GetViewURL(attach, download_url, project_name):
+  """Return a relative URL to view an attachment in the browser."""
+  if IsViewableImage(attach.mimetype, attach.filesize):
+    return download_url + '&inline=1'
+  elif IsViewableVideo(attach.mimetype, attach.filesize):
+    return download_url + '&inline=1'
+  elif IsViewableText(attach.mimetype, attach.filesize):
+    return tracker_helpers.FormatRelativeIssueURL(
+        project_name, urls.ISSUE_ATTACHMENT_TEXT,
+        aid=attach.attachment_id)
+  else:
+    return None
+
+
+def GetThumbnailURL(attach, download_url):
+  """Return a relative URL to view an attachment thumbnail."""
+  if IsViewableImage(attach.mimetype, attach.filesize):
+    return download_url + '&inline=1&thumb=1'
+  else:
+    return None
+
+
+def GetVideoURL(attach, download_url):
+  """Return a relative URL to view an attachment thumbnail."""
+  if IsViewableVideo(attach.mimetype, attach.filesize):
+    return download_url + '&inline=1'
+  else:
+    return None
diff --git a/tracker/component_helpers.py b/tracker/component_helpers.py
new file mode 100644
index 0000000..786ab96
--- /dev/null
+++ b/tracker/component_helpers.py
@@ -0,0 +1,93 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Helper functions for component-related servlets."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import collections
+import logging
+import re
+
+from proto import tracker_pb2
+from tracker import tracker_bizobj
+
+
+ParsedComponentDef = collections.namedtuple(
+    'ParsedComponentDef',
+    'leaf_name, docstring, deprecated, '
+    'admin_usernames, cc_usernames, admin_ids, cc_ids, '
+    'label_strs, label_ids')
+
+
+def ParseComponentRequest(mr, post_data, services):
+  """Parse the user's request to create or update a component definition.
+
+  If an error is encountered then this function populates mr.errors
+  """
+  leaf_name = post_data.get('leaf_name', '')
+  docstring = post_data.get('docstring', '')
+  deprecated = 'deprecated' in post_data
+
+  admin_usernames = [
+      uname.strip() for uname in re.split('[,;\s]+', post_data['admins'])
+      if uname.strip()]
+  cc_usernames = [
+      uname.strip() for uname in re.split('[,;\s]+', post_data['cc'])
+      if uname.strip()]
+  all_user_ids = services.user.LookupUserIDs(
+      mr.cnxn, admin_usernames + cc_usernames, autocreate=True)
+
+  admin_ids = []
+  for admin_name in admin_usernames:
+    if admin_name not in all_user_ids:
+      mr.errors.member_admins = '%s unrecognized' % admin_name
+      continue
+    admin_id = all_user_ids[admin_name]
+    if admin_id not in admin_ids:
+     admin_ids.append(admin_id)
+
+  cc_ids = []
+  for cc_name in cc_usernames:
+    if cc_name not in all_user_ids:
+      mr.errors.member_cc = '%s unrecognized' % cc_name
+      continue
+    cc_id = all_user_ids[cc_name]
+    if cc_id not in cc_ids:
+      cc_ids.append(cc_id)
+
+  label_strs = [
+    lab.strip() for lab in re.split('[,;\s]+', post_data['labels'])
+    if lab.strip()]
+
+  label_ids = services.config.LookupLabelIDs(
+      mr.cnxn, mr.project_id, label_strs, autocreate=True)
+
+  return ParsedComponentDef(
+      leaf_name, docstring, deprecated,
+      admin_usernames, cc_usernames, admin_ids, cc_ids,
+      label_strs, label_ids)
+
+
+def GetComponentCcIDs(issue, config):
+  """Return auto-cc'd users for any component or ancestor the issue is in."""
+  result = set()
+  for component_id in issue.component_ids:
+    cd = tracker_bizobj.FindComponentDefByID(component_id, config)
+    if cd:
+      result.update(GetCcIDsForComponentAndAncestors(config, cd))
+
+  return result
+
+
+def GetCcIDsForComponentAndAncestors(config, cd):
+  """Return auto-cc'd user IDs for the given component and ancestors."""
+  result = set(cd.cc_ids)
+  ancestors = tracker_bizobj.FindAncestorComponents(config, cd)
+  for anc_cd in ancestors:
+    result.update(anc_cd.cc_ids)
+
+  return result
diff --git a/tracker/componentcreate.py b/tracker/componentcreate.py
new file mode 100644
index 0000000..9cb713c
--- /dev/null
+++ b/tracker/componentcreate.py
@@ -0,0 +1,153 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""A servlet for project owners to create a new component def."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import time
+
+from framework import framework_helpers
+from framework import framework_views
+from framework import jsonfeed
+from framework import permissions
+from framework import servlet
+from framework import urls
+from tracker import component_helpers
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+from tracker import tracker_views
+
+import ezt
+
+
+class ComponentCreate(servlet.Servlet):
+  """Servlet allowing project owners to create a component."""
+
+  _MAIN_TAB_MODE = servlet.Servlet.MAIN_TAB_PROCESS
+  _PAGE_TEMPLATE = 'tracker/component-create-page.ezt'
+
+  def AssertBasePermission(self, mr):
+    """Check whether the user has any permission to visit this page.
+
+    Args:
+      mr: commonly used info parsed from the request.
+    """
+    super(ComponentCreate, self).AssertBasePermission(mr)
+    if not self.CheckPerm(mr, permissions.EDIT_PROJECT):
+      raise permissions.PermissionException(
+          'User is not allowed to administer this project')
+
+  def GatherPageData(self, mr):
+    """Build up a dictionary of data values to use when rendering the page.
+
+    Args:
+      mr: commonly used info parsed from the request.
+
+    Returns:
+      Dict of values used by EZT for rendering the page.
+    """
+    config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+    users_by_id = framework_views.MakeAllUserViews(
+        mr.cnxn, self.services.user,
+        *[list(cd.admin_ids) + list(cd.cc_ids)
+          for cd in config.component_defs])
+    component_def_views = [
+        tracker_views.ComponentDefView(mr.cnxn, self.services, cd, users_by_id)
+        # TODO(jrobbins): future component-level view restrictions.
+        for cd in config.component_defs]
+    for cdv in component_def_views:
+      setattr(cdv, 'selected', None)
+      path = (cdv.parent_path + '>' + cdv.leaf_name).lstrip('>')
+      if path == mr.component_path:
+        setattr(cdv, 'selected', True)
+
+    return {
+        'parent_path': mr.component_path,
+        'admin_tab_mode': servlet.Servlet.PROCESS_TAB_COMPONENTS,
+        'component_defs': component_def_views,
+        'initial_leaf_name': '',
+        'initial_docstring': '',
+        'initial_deprecated': ezt.boolean(False),
+        'initial_admins': [],
+        'initial_cc': [],
+        'initial_labels': [],
+        }
+
+  def ProcessFormData(self, mr, post_data):
+    """Validate and store the contents of the issues tracker admin page.
+
+    Args:
+      mr: commonly used info parsed from the request.
+      post_data: HTML form data from the request.
+
+    Returns:
+      String URL to redirect the user to, or None if response was already sent.
+    """
+    config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+    parent_path = post_data.get('parent_path', '')
+    parsed = component_helpers.ParseComponentRequest(
+        mr, post_data, self.services)
+
+    if parent_path:
+      parent_def = tracker_bizobj.FindComponentDef(parent_path, config)
+      if not parent_def:
+        self.abort(500, 'parent component not found')
+      allow_parent_edit = permissions.CanEditComponentDef(
+          mr.auth.effective_ids, mr.perms, mr.project, parent_def, config)
+      if not allow_parent_edit:
+        raise permissions.PermissionException(
+            'User is not allowed to add a subcomponent here')
+
+      path = '%s>%s' % (parent_path, parsed.leaf_name)
+    else:
+      path = parsed.leaf_name
+
+    leaf_name_error_msg = LeafNameErrorMessage(
+        parent_path, parsed.leaf_name, config)
+    if leaf_name_error_msg:
+      mr.errors.leaf_name = leaf_name_error_msg
+
+    if mr.errors.AnyErrors():
+      self.PleaseCorrect(
+          mr, parent_path=parent_path,
+          initial_leaf_name=parsed.leaf_name,
+          initial_docstring=parsed.docstring,
+          initial_deprecated=ezt.boolean(parsed.deprecated),
+          initial_admins=parsed.admin_usernames,
+          initial_cc=parsed.cc_usernames,
+          initial_labels=parsed.label_strs,
+      )
+      return
+
+    created = int(time.time())
+    creator_id = self.services.user.LookupUserID(
+        mr.cnxn, mr.auth.email, autocreate=False)
+
+    self.services.config.CreateComponentDef(
+        mr.cnxn, mr.project_id, path, parsed.docstring, parsed.deprecated,
+        parsed.admin_ids, parsed.cc_ids, created, creator_id,
+        label_ids=parsed.label_ids)
+
+    return framework_helpers.FormatAbsoluteURL(
+        mr, urls.ADMIN_COMPONENTS, saved=1, ts=int(time.time()))
+
+
+def LeafNameErrorMessage(parent_path, leaf_name, config):
+  """Return an error message for the given component name, or None."""
+  if not tracker_constants.COMPONENT_NAME_RE.match(leaf_name):
+    return 'Invalid component name'
+
+  if parent_path:
+    path = '%s>%s' % (parent_path, leaf_name)
+  else:
+    path = leaf_name
+
+  if tracker_bizobj.FindComponentDef(path, config):
+    return 'That name is already in use.'
+
+  return None
diff --git a/tracker/componentdetail.py b/tracker/componentdetail.py
new file mode 100644
index 0000000..01f2469
--- /dev/null
+++ b/tracker/componentdetail.py
@@ -0,0 +1,246 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""A servlet for project and component owners to view and edit components."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import time
+
+import ezt
+
+from features import filterrules_helpers
+from framework import framework_helpers
+from framework import framework_views
+from framework import permissions
+from framework import servlet
+from framework import timestr
+from framework import urls
+from tracker import component_helpers
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+from tracker import tracker_views
+
+
+class ComponentDetail(servlet.Servlet):
+  """Servlets allowing project owners to view and edit a component."""
+
+  _MAIN_TAB_MODE = servlet.Servlet.MAIN_TAB_PROCESS
+  _PAGE_TEMPLATE = 'tracker/component-detail-page.ezt'
+
+  def _GetComponentDef(self, mr):
+    """Get the config and component definition to be viewed or edited."""
+    if not mr.component_path:
+      self.abort(404, 'component not specified')
+    config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+    component_def = tracker_bizobj.FindComponentDef(mr.component_path, config)
+    if not component_def:
+      self.abort(404, 'component not found')
+    return config, component_def
+
+  def AssertBasePermission(self, mr):
+    """Check whether the user has any permission to visit this page.
+
+    Args:
+      mr: commonly used info parsed from the request.
+    """
+    super(ComponentDetail, self).AssertBasePermission(mr)
+    _config, component_def = self._GetComponentDef(mr)
+
+    # TODO(jrobbins): optional restrictions on viewing fields by component.
+
+    allow_view = permissions.CanViewComponentDef(
+        mr.auth.effective_ids, mr.perms, mr.project, component_def)
+    if not allow_view:
+      raise permissions.PermissionException(
+          'User is not allowed to view this component')
+
+  def GatherPageData(self, mr):
+    """Build up a dictionary of data values to use when rendering the page.
+
+    Args:
+      mr: commonly used info parsed from the request.
+
+    Returns:
+      Dict of values used by EZT for rendering the page.
+    """
+    config, component_def = self._GetComponentDef(mr)
+    users_by_id = framework_views.MakeAllUserViews(
+        mr.cnxn, self.services.user,
+        component_def.admin_ids, component_def.cc_ids)
+    component_def_view = tracker_views.ComponentDefView(
+        mr.cnxn, self.services, component_def, users_by_id)
+    initial_admins = [users_by_id[uid].email for uid in component_def.admin_ids]
+    initial_cc = [users_by_id[uid].email for uid in component_def.cc_ids]
+    initial_labels = [
+        self.services.config.LookupLabel(mr.cnxn, mr.project_id, label_id)
+        for label_id in component_def.label_ids]
+
+    creator, created = self._GetUserViewAndFormattedTime(
+        mr, component_def.creator_id, component_def.created)
+    modifier, modified = self._GetUserViewAndFormattedTime(
+        mr, component_def.modifier_id, component_def.modified)
+
+    allow_edit = permissions.CanEditComponentDef(
+        mr.auth.effective_ids, mr.perms, mr.project, component_def, config)
+
+    subcomponents = tracker_bizobj.FindDescendantComponents(
+        config, component_def)
+    templates = self.services.template.TemplatesWithComponent(
+        mr.cnxn, component_def.component_id)
+    allow_delete = allow_edit and not subcomponents and not templates
+
+    return {
+        'admin_tab_mode': servlet.Servlet.PROCESS_TAB_COMPONENTS,
+        'component_def': component_def_view,
+        'initial_leaf_name': component_def_view.leaf_name,
+        'initial_docstring': component_def.docstring,
+        'initial_deprecated': ezt.boolean(component_def.deprecated),
+        'initial_admins': initial_admins,
+        'initial_cc': initial_cc,
+        'initial_labels': initial_labels,
+        'allow_edit': ezt.boolean(allow_edit),
+        'allow_delete': ezt.boolean(allow_delete),
+        'subcomponents': subcomponents,
+        'templates': templates,
+        'creator': creator,
+        'created': created,
+        'modifier': modifier,
+        'modified': modified,
+        }
+
+  def ProcessFormData(self, mr, post_data):
+    """Validate and store the contents of the issues tracker admin page.
+
+    Args:
+      mr: commonly used info parsed from the request.
+      post_data: HTML form data from the request.
+
+    Returns:
+      String URL to redirect the user to, or None if response was already sent.
+    """
+    config, component_def = self._GetComponentDef(mr)
+    allow_edit = permissions.CanEditComponentDef(
+        mr.auth.effective_ids, mr.perms, mr.project, component_def, config)
+    if not allow_edit:
+      raise permissions.PermissionException(
+          'User is not allowed to edit or delete this component')
+
+    if 'deletecomponent' in post_data:
+      allow_delete = not tracker_bizobj.FindDescendantComponents(
+          config, component_def)
+      if not allow_delete:
+        raise permissions.PermissionException(
+            'User tried to delete component that had subcomponents')
+      return self._ProcessDeleteComponent(mr, component_def)
+
+    else:
+      return self._ProcessEditComponent(mr, post_data, config, component_def)
+
+
+  def _ProcessDeleteComponent(self, mr, component_def):
+    """The user wants to delete the specified custom field definition."""
+    self.services.issue.DeleteComponentReferences(
+        mr.cnxn, component_def.component_id)
+    self.services.config.DeleteComponentDef(
+        mr.cnxn, mr.project_id, component_def.component_id)
+    return framework_helpers.FormatAbsoluteURL(
+        mr, urls.ADMIN_COMPONENTS, deleted=1, ts=int(time.time()))
+
+  def _GetUserViewAndFormattedTime(self, mr, user_id, timestamp):
+    formatted_time = (timestr.FormatAbsoluteDate(timestamp)
+                      if timestamp else None)
+    user = self.services.user.GetUser(mr.cnxn, user_id) if user_id else None
+    user_view = None
+    if user:
+      user_view = framework_views.UserView(user)
+      viewing_self = mr.auth.user_id == user_id
+      # Do not obscure email if current user is a site admin. Do not obscure
+      # email if current user is the same as the creator. For all other
+      # cases do whatever obscure_email setting for the user is.
+      email_obscured = (not(mr.auth.user_pb.is_site_admin or viewing_self)
+                        and user_view.obscure_email)
+      if not email_obscured:
+        user_view.RevealEmail()
+
+    return user_view, formatted_time
+
+  def _ProcessEditComponent(self, mr, post_data, config, component_def):
+    """The user wants to edit this component definition."""
+    parsed = component_helpers.ParseComponentRequest(
+        mr, post_data, self.services)
+
+    if not tracker_constants.COMPONENT_NAME_RE.match(parsed.leaf_name):
+      mr.errors.leaf_name = 'Invalid component name'
+
+    original_path = component_def.path
+    if mr.component_path and '>' in mr.component_path:
+      parent_path = mr.component_path[:mr.component_path.rindex('>')]
+      new_path = '%s>%s' % (parent_path, parsed.leaf_name)
+    else:
+      new_path = parsed.leaf_name
+
+    conflict = tracker_bizobj.FindComponentDef(new_path, config)
+    if conflict and conflict.component_id != component_def.component_id:
+      mr.errors.leaf_name = 'That name is already in use.'
+
+    creator, created = self._GetUserViewAndFormattedTime(
+        mr, component_def.creator_id, component_def.created)
+    modifier, modified = self._GetUserViewAndFormattedTime(
+        mr, component_def.modifier_id, component_def.modified)
+
+    if mr.errors.AnyErrors():
+      self.PleaseCorrect(
+          mr, initial_leaf_name=parsed.leaf_name,
+          initial_docstring=parsed.docstring,
+          initial_deprecated=ezt.boolean(parsed.deprecated),
+          initial_admins=parsed.admin_usernames,
+          initial_cc=parsed.cc_usernames,
+          initial_labels=parsed.label_strs,
+          created=created,
+          creator=creator,
+          modified=modified,
+          modifier=modifier,
+      )
+      return None
+
+    new_modified = int(time.time())
+    new_modifier_id = self.services.user.LookupUserID(
+        mr.cnxn, mr.auth.email, autocreate=False)
+    self.services.config.UpdateComponentDef(
+        mr.cnxn, mr.project_id, component_def.component_id,
+        path=new_path, docstring=parsed.docstring, deprecated=parsed.deprecated,
+        admin_ids=parsed.admin_ids, cc_ids=parsed.cc_ids, modified=new_modified,
+        modifier_id=new_modifier_id, label_ids=parsed.label_ids)
+
+    update_rule = False
+    if new_path != original_path:
+      update_rule = True
+      # If the name changed then update all of its subcomponents as well.
+      subcomponent_ids = tracker_bizobj.FindMatchingComponentIDs(
+          original_path, config, exact=False)
+      for subcomponent_id in subcomponent_ids:
+        if subcomponent_id == component_def.component_id:
+          continue
+        subcomponent_def = tracker_bizobj.FindComponentDefByID(
+            subcomponent_id, config)
+        subcomponent_new_path = subcomponent_def.path.replace(
+            original_path, new_path, 1)
+        self.services.config.UpdateComponentDef(
+            mr.cnxn, mr.project_id, subcomponent_def.component_id,
+            path=subcomponent_new_path)
+
+    if (set(parsed.cc_ids) != set(component_def.cc_ids) or
+        set(parsed.label_ids) != set(component_def.label_ids)):
+      update_rule = True
+    if update_rule:
+      filterrules_helpers.RecomputeAllDerivedFields(
+          mr.cnxn, self.services, mr.project, config)
+
+    return framework_helpers.FormatAbsoluteURL(
+        mr, urls.COMPONENT_DETAIL,
+        component=new_path, saved=1, ts=int(time.time()))
diff --git a/tracker/field_helpers.py b/tracker/field_helpers.py
new file mode 100644
index 0000000..d15f5e0
--- /dev/null
+++ b/tracker/field_helpers.py
@@ -0,0 +1,542 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Helper functions for custom field sevlets."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import collections
+import itertools
+import logging
+import re
+
+from features import autolink_constants
+from framework import authdata
+from framework import exceptions
+from framework import framework_bizobj
+from framework import framework_constants
+from framework import permissions
+from framework import timestr
+from framework import validate
+from proto import tracker_pb2
+from services import config_svc
+from tracker import tracker_bizobj
+
+
+INVALID_USER_ID = -1
+
+ParsedFieldDef = collections.namedtuple(
+    'ParsedFieldDef',
+    'field_name, field_type_str, min_value, max_value, regex, '
+    'needs_member, needs_perm, grants_perm, notify_on, is_required, '
+    'is_niche, importance, is_multivalued, field_docstring, choices_text, '
+    'applicable_type, applicable_predicate, revised_labels, date_action_str, '
+    'approvers_str, survey, parent_approval_name, is_phase_field, '
+    'is_restricted_field')
+
+
+def ListApplicableFieldDefs(issues, config):
+  # type: (Sequence[proto.tracker_pb2.Issue],
+  #     proto.tracker_pb2.ProjectIssueConfig) ->
+  #     Sequence[proto.tracker_pb2.FieldDef]
+  """Return the applicable FieldDefs for the given issues. """
+  issue_labels = []
+  issue_approval_ids = []
+  for issue in issues:
+    issue_labels.extend(issue.labels)
+    issue_approval_ids.extend(
+        [approval.approval_id for approval in issue.approval_values])
+  labels_by_prefix = tracker_bizobj.LabelsByPrefix(list(set(issue_labels)), [])
+  types = set(labels_by_prefix.get('type', []))
+  types_lower = [t.lower() for t in types]
+  applicable_fds = []
+  for fd in config.field_defs:
+    if fd.is_deleted:
+      continue
+    if fd.field_id in issue_approval_ids:
+      applicable_fds.append(fd)
+    elif fd.field_type != tracker_pb2.FieldTypes.APPROVAL_TYPE and (
+        not fd.applicable_type or fd.applicable_type.lower() in types_lower):
+      applicable_fds.append(fd)
+  return applicable_fds
+
+
+def ParseFieldDefRequest(post_data, config):
+  """Parse the user's HTML form data to update a field definition."""
+  field_name = post_data.get('name', '')
+  field_type_str = post_data.get('field_type')
+  # TODO(jrobbins): once a min or max is set, it cannot be completely removed.
+  min_value_str = post_data.get('min_value')
+  try:
+    min_value = int(min_value_str)
+  except (ValueError, TypeError):
+    min_value = None
+  max_value_str = post_data.get('max_value')
+  try:
+    max_value = int(max_value_str)
+  except (ValueError, TypeError):
+    max_value = None
+  regex = post_data.get('regex')
+  needs_member = 'needs_member' in post_data
+  needs_perm = post_data.get('needs_perm', '').strip()
+  grants_perm = post_data.get('grants_perm', '').strip()
+  notify_on_str = post_data.get('notify_on')
+  if notify_on_str in config_svc.NOTIFY_ON_ENUM:
+    notify_on = config_svc.NOTIFY_ON_ENUM.index(notify_on_str)
+  else:
+    notify_on = 0
+  importance = post_data.get('importance')
+  is_required = (importance == 'required')
+  is_niche = (importance == 'niche')
+  is_multivalued = 'is_multivalued' in post_data
+  field_docstring = post_data.get('docstring', '')
+  choices_text = post_data.get('choices', '')
+  applicable_type = post_data.get('applicable_type', '')
+  applicable_predicate = ''  # TODO(jrobbins): placeholder for future feature
+  revised_labels = _ParseChoicesIntoWellKnownLabels(
+      choices_text, field_name, config, field_type_str)
+  date_action_str = post_data.get('date_action')
+  approvers_str = post_data.get('approver_names', '').strip().rstrip(',')
+  survey = post_data.get('survey', '')
+  parent_approval_name = post_data.get('parent_approval_name', '')
+  # TODO(jojwang): monorail:3774, remove enum_type condition when
+  # phases can have labels.
+  is_phase_field = ('is_phase_field' in post_data) and (
+      field_type_str not in ['approval_type', 'enum_type'])
+  is_restricted_field = 'is_restricted_field' in post_data
+
+  return ParsedFieldDef(
+      field_name, field_type_str, min_value, max_value, regex, needs_member,
+      needs_perm, grants_perm, notify_on, is_required, is_niche, importance,
+      is_multivalued, field_docstring, choices_text, applicable_type,
+      applicable_predicate, revised_labels, date_action_str, approvers_str,
+      survey, parent_approval_name, is_phase_field, is_restricted_field)
+
+
+def _ParseChoicesIntoWellKnownLabels(
+    choices_text, field_name, config, field_type_str):
+  """Parse a field's possible choices and integrate them into the config.
+
+  Args:
+    choices_text: string with one label and optional docstring per line.
+    field_name: string name of the field definition being edited.
+    config: ProjectIssueConfig PB of the current project.
+    field_type_str: string name of the new field's type. None if an existing
+      field is being updated
+
+  Returns:
+    A revised list of labels that can be used to update the config.
+  """
+  fd = tracker_bizobj.FindFieldDef(field_name, config)
+  matches = framework_constants.IDENTIFIER_DOCSTRING_RE.findall(choices_text)
+  maskingFieldNames = []
+  # wkls should only be masked by the field if it is an enum_type.
+  if (field_type_str == 'enum_type') or (
+      fd and fd.field_type is tracker_pb2.FieldTypes.ENUM_TYPE):
+    maskingFieldNames.append(field_name.lower())
+
+  new_labels = [
+      ('%s-%s' % (field_name, label), choice_docstring.strip(), False)
+      for label, choice_docstring in matches]
+  kept_labels = [
+      (wkl.label, wkl.label_docstring, wkl.deprecated)
+      for wkl in config.well_known_labels
+      if not tracker_bizobj.LabelIsMaskedByField(
+          wkl.label, maskingFieldNames)]
+  revised_labels = kept_labels + new_labels
+  return revised_labels
+
+
+def ShiftEnumFieldsIntoLabels(
+    labels, labels_remove, field_val_strs, field_val_strs_remove, config):
+  """Look at the custom field values and treat enum fields as labels.
+
+  Args:
+    labels: list of labels to add/set on the issue.
+    labels_remove: list of labels to remove from the issue.
+    field_val_strs: {field_id: [val_str, ...]} of custom fields to add/set.
+    field_val_strs_remove: {field_id: [val_str, ...]} of custom fields to
+        remove.
+    config: ProjectIssueConfig PB including custom field definitions.
+
+  SIDE-EFFECT: the labels and labels_remove lists will be extended with
+  key-value labels corresponding to the enum field values.  Those field
+  entries will be removed from field_val_strs and field_val_strs_remove.
+  """
+  for fd in config.field_defs:
+    if fd.field_type != tracker_pb2.FieldTypes.ENUM_TYPE:
+      continue
+
+    if fd.field_id in field_val_strs:
+      labels.extend(
+          '%s-%s' % (fd.field_name, val)
+          for val in field_val_strs[fd.field_id]
+          if val and val != '--')
+      del field_val_strs[fd.field_id]
+
+    if fd.field_id in field_val_strs_remove:
+      labels_remove.extend(
+          '%s-%s' % (fd.field_name, val)
+          for val in field_val_strs_remove[fd.field_id]
+          if val and val != '--')
+      del field_val_strs_remove[fd.field_id]
+
+
+def ReviseApprovals(approval_id, approver_ids, survey, config):
+  revised_approvals = [(
+      approval.approval_id, approval.approver_ids, approval.survey) for
+                       approval in config.approval_defs if
+                       approval.approval_id != approval_id]
+  revised_approvals.append((approval_id, approver_ids, survey))
+  return revised_approvals
+
+
+def ParseOneFieldValue(cnxn, user_service, fd, val_str):
+  """Make one FieldValue PB from the given user-supplied string."""
+  if fd.field_type == tracker_pb2.FieldTypes.INT_TYPE:
+    try:
+      return tracker_bizobj.MakeFieldValue(
+          fd.field_id, int(val_str), None, None, None, None, False)
+    except ValueError:
+      return None  # TODO(jrobbins): should bounce
+
+  elif fd.field_type == tracker_pb2.FieldTypes.STR_TYPE:
+    return tracker_bizobj.MakeFieldValue(
+        fd.field_id, None, val_str, None, None, None, False)
+
+  elif fd.field_type == tracker_pb2.FieldTypes.USER_TYPE:
+    if val_str:
+      try:
+        user_id = user_service.LookupUserID(cnxn, val_str, autocreate=False)
+      except exceptions.NoSuchUserException:
+        # Set to invalid user ID to display error during the validation step.
+        user_id = INVALID_USER_ID
+      return tracker_bizobj.MakeFieldValue(
+          fd.field_id, None, None, user_id, None, None, False)
+    else:
+      return None
+
+  elif fd.field_type == tracker_pb2.FieldTypes.DATE_TYPE:
+    try:
+      timestamp = timestr.DateWidgetStrToTimestamp(val_str)
+      return tracker_bizobj.MakeFieldValue(
+          fd.field_id, None, None, None, timestamp, None, False)
+    except ValueError:
+      return None  # TODO(jrobbins): should bounce
+
+  elif fd.field_type == tracker_pb2.FieldTypes.URL_TYPE:
+    val_str = FormatUrlFieldValue(val_str)
+    try:
+      return tracker_bizobj.MakeFieldValue(
+          fd.field_id, None, None, None, None, val_str, False)
+    except ValueError:
+      return None # TODO(jojwang): should bounce
+
+  else:
+    logging.error('Cant parse field with unexpected type %r', fd.field_type)
+    return None
+
+
+def ParseOnePhaseFieldValue(cnxn, user_service, fd, val_str, phase_ids):
+  """Return a list containing a FieldValue PB for each phase."""
+  phase_fvs = []
+  for phase_id in phase_ids:
+    # TODO(jojwang): monorail:3970, create the FieldValue once and find some
+    # proto2 CopyFrom() method to create a new one for each phase.
+    fv = ParseOneFieldValue(cnxn, user_service, fd, val_str)
+    if fv:
+      fv.phase_id = phase_id
+      phase_fvs.append(fv)
+
+  return phase_fvs
+
+
+def ParseFieldValues(cnxn, user_service, field_val_strs, phase_field_val_strs,
+                     config, phase_ids_by_name=None):
+  """Return a list of FieldValue PBs based on the given dict of strings."""
+  field_values = []
+  for fd in config.field_defs:
+    if fd.is_phase_field and (
+        fd.field_id in phase_field_val_strs) and phase_ids_by_name:
+      fvs_by_phase_name = phase_field_val_strs.get(fd.field_id, {})
+      for phase_name, val_strs in fvs_by_phase_name.items():
+        phase_ids = phase_ids_by_name.get(phase_name)
+        if not phase_ids:
+          continue
+        for val_str in val_strs:
+          field_values.extend(
+              ParseOnePhaseFieldValue(
+                  cnxn, user_service, fd, val_str, phase_ids=phase_ids))
+    # We do not save phase fields when there are no phases.
+    elif not fd.is_phase_field and (fd.field_id in field_val_strs):
+      for val_str in field_val_strs[fd.field_id]:
+        fv = ParseOneFieldValue(cnxn, user_service, fd, val_str)
+        if fv:
+          field_values.append(fv)
+
+  return field_values
+
+
+def ValidateCustomFieldValue(cnxn, project, services, field_def, field_val):
+  # type: (MonorailConnection, proto.tracker_pb2.Project, Services,
+  #     proto.tracker_pb2.FieldDef, proto.tracker_pb2.FieldValue) -> str
+  """Validate one custom field value and return an error string or None.
+
+  Args:
+    cnxn: MonorailConnection object.
+    project: Project PB with info on the project the custom field belongs to.
+    services: Services object referencing services that can be queried.
+    field_def: FieldDef for the custom field we're validating against.
+    field_val: The value of the custom field.
+
+  Returns:
+    A string containing an error message if there was one.
+  """
+  if field_def.field_type == tracker_pb2.FieldTypes.INT_TYPE:
+    if (field_def.min_value is not None and
+        field_val.int_value < field_def.min_value):
+      return 'Value must be >= %d.' % field_def.min_value
+    if (field_def.max_value is not None and
+        field_val.int_value > field_def.max_value):
+      return 'Value must be <= %d.' % field_def.max_value
+
+  elif field_def.field_type == tracker_pb2.FieldTypes.STR_TYPE:
+    if field_def.regex and field_val.str_value:
+      try:
+        regex = re.compile(field_def.regex)
+        if not regex.match(field_val.str_value):
+          return 'Value must match regular expression: %s.' % field_def.regex
+      except re.error:
+        logging.info('Failed to process regex %r with value %r. Allowing.',
+                     field_def.regex, field_val.str_value)
+        return None
+
+  elif field_def.field_type == tracker_pb2.FieldTypes.USER_TYPE:
+    field_val_user = services.user.GetUser(cnxn, field_val.user_id)
+    auth = authdata.AuthData.FromUser(cnxn, field_val_user, services)
+    if auth.user_pb.user_id == INVALID_USER_ID:
+      return 'User not found.'
+    if field_def.needs_member:
+      user_value_in_project = framework_bizobj.UserIsInProject(
+          project, auth.effective_ids)
+      if not user_value_in_project:
+        return 'User must be a member of the project.'
+      if field_def.needs_perm:
+        user_perms = permissions.GetPermissions(
+            auth.user_pb, auth.effective_ids, project)
+        has_perm = user_perms.CanUsePerm(
+            field_def.needs_perm, auth.effective_ids, project, [])
+        if not has_perm:
+          return 'User must have permission "%s".' % field_def.needs_perm
+    return None
+
+  elif field_def.field_type == tracker_pb2.FieldTypes.DATE_TYPE:
+    # TODO(jrobbins): date validation
+    pass
+
+  elif field_def.field_type == tracker_pb2.FieldTypes.URL_TYPE:
+    if field_val.url_value:
+      if not (validate.IsValidURL(field_val.url_value)
+              or autolink_constants.IS_A_SHORT_LINK_RE.match(
+                  field_val.url_value)
+              or autolink_constants.IS_A_NUMERIC_SHORT_LINK_RE.match(
+                  field_val.url_value)
+              or autolink_constants.IS_IMPLIED_LINK_RE.match(
+                  field_val.url_value)):
+        return 'Value must be a valid url.'
+
+  return None
+
+def ValidateCustomFields(
+    cnxn, services, field_values, config, project, ezt_errors=None, issue=None):
+  # type: (MonorailConnection, Services,
+  #     Collection[proto.tracker_pb2.FieldValue],
+  #     proto.tracker_pb2.ProjectConfig, proto.tracker_pb2.Project,
+  #     Optional[EZTError], Optional[proto.tracker_pb2.Issue]) ->
+  #     Sequence[str]
+  """Validate given fields and report problems in error messages."""
+  fds_by_id = {fd.field_id: fd for fd in config.field_defs}
+  err_msgs = []
+
+  # Create a set of field_ids that have required values. If this set still
+  # contains items by the end of the function, there is an error.
+  required_fds = set()
+  if issue:
+    applicable_fds = ListApplicableFieldDefs([issue], config)
+
+    lower_field_names = [fd.field_name.lower() for fd in applicable_fds]
+    label_prefixes = tracker_bizobj.LabelsByPrefix(
+        list(set(issue.labels)), lower_field_names)
+
+    # Add applicable required fields to required_fds.
+    for fd in applicable_fds:
+      if not fd.is_required:
+        continue
+
+      if (fd.field_type is tracker_pb2.FieldTypes.ENUM_TYPE and
+          fd.field_name.lower() in label_prefixes):
+        # Handle custom enum fields - they're a special case because their
+        # values are stored in labels instead of FieldValues.
+        continue
+
+      required_fds.add(fd.field_id)
+  # Ensure that every field value entered is valid. ie: That users exist.
+  for fv in field_values:
+    # Remove field_ids from the required set when found.
+    if fv.field_id in required_fds:
+      required_fds.remove(fv.field_id)
+
+    fd = fds_by_id.get(fv.field_id)
+    if fd:
+      err_msg = ValidateCustomFieldValue(cnxn, project, services, fd, fv)
+
+      if err_msg:
+        err_msgs.append('Error for %r: %s' % (fv, err_msg))
+        if ezt_errors:
+          ezt_errors.SetCustomFieldError(fv.field_id, err_msg)
+
+  # Add errors for any fields still left in the required set.
+  for field_id in required_fds:
+    fd = fds_by_id.get(field_id)
+    err_msg = '%s field is required.' % (fd.field_name)
+    err_msgs.append(err_msg)
+    if ezt_errors:
+      ezt_errors.SetCustomFieldError(field_id, err_msg)
+
+  return err_msgs
+
+
+def AssertCustomFieldsEditPerms(
+    mr, config, field_vals, field_vals_remove, fields_clear, labels,
+    labels_remove):
+  """Check permissions for any kind of custom field edition attempt."""
+  # TODO: When clearing phase_fields is possible, include it in this method.
+  field_ids = set()
+
+  for fv in field_vals:
+    field_ids.add(fv.field_id)
+  for fvr in field_vals_remove:
+    field_ids.add(fvr.field_id)
+  for fd_id in fields_clear:
+    field_ids.add(fd_id)
+
+  enum_fds_by_name = {
+      fd.field_name.lower(): fd.field_id
+      for fd in config.field_defs
+      if fd.field_type is tracker_pb2.FieldTypes.ENUM_TYPE and not fd.is_deleted
+  }
+  for label in itertools.chain(labels, labels_remove):
+    enum_field_name = tracker_bizobj.LabelIsMaskedByField(
+        label, enum_fds_by_name.keys())
+    if enum_field_name:
+      field_ids.add(enum_fds_by_name.get(enum_field_name))
+
+  fds_by_id = {fd.field_id: fd for fd in config.field_defs}
+  for field_id in field_ids:
+    fd = fds_by_id.get(field_id)
+    if fd:
+      assert permissions.CanEditValueForFieldDef(
+          mr.auth.effective_ids, mr.perms, mr.project,
+          fd), 'No permission to edit certain fields.'
+
+
+def ApplyRestrictedDefaultValues(
+    mr, config, field_vals, labels, template_field_vals, template_labels):
+  """Add default values of template fields that the user cannot edit.
+
+     This method can be called by servlets where restricted field values that
+     a user cannot edit are displayed but do not get returned when the user
+     submits the form (and also assumes that previous assertions ensure these
+     conditions). These missing default values still need to be passed to the
+     services layer when a 'write' is done so that these default values do
+     not get removed.
+
+     Args:
+       mr: MonorailRequest Object to hold info about the request and the user.
+       config: ProjectIssueConfig Object for the project.
+       field_vals: list of FieldValues that the user wants to save.
+       labels: list of labels that the user wants to save.
+       template_field_vals: list of FieldValues belonging to the template.
+       template_labels: list of labels belonging to the template.
+
+     Side Effect:
+       The default values of a template that the user cannot edit are added
+       to 'field_vals' and 'labels'.
+  """
+
+  fds_by_id = {fd.field_id: fd for fd in config.field_defs}
+  for fv in template_field_vals:
+    fd = fds_by_id.get(fv.field_id)
+    if fd and not permissions.CanEditValueForFieldDef(mr.auth.effective_ids,
+                                                      mr.perms, mr.project, fd):
+      field_vals.append(fv)
+
+  fds_by_name = {
+      fd.field_name.lower(): fd
+      for fd in config.field_defs
+      if fd.field_type is tracker_pb2.FieldTypes.ENUM_TYPE and not fd.is_deleted
+  }
+  for label in template_labels:
+    enum_field_name = tracker_bizobj.LabelIsMaskedByField(
+        label, fds_by_name.keys())
+    if enum_field_name:
+      fd = fds_by_name.get(enum_field_name)
+      if fd and not permissions.CanEditValueForFieldDef(
+          mr.auth.effective_ids, mr.perms, mr.project, fd):
+        labels.append(label)
+
+
+def FormatUrlFieldValue(url_str):
+  """Check for and add 'https://' to a url string"""
+  if not url_str.startswith('http'):
+    return 'http://' + url_str
+  return url_str
+
+
+def ReviseFieldDefFromParsed(parsed, old_fd):
+  """Creates new FieldDef based on an original FieldDef and parsed FieldDef"""
+  if parsed.date_action_str in config_svc.DATE_ACTION_ENUM:
+    date_action = config_svc.DATE_ACTION_ENUM.index(parsed.date_action_str)
+  else:
+    date_action = 0
+  return tracker_bizobj.MakeFieldDef(
+      old_fd.field_id, old_fd.project_id, old_fd.field_name, old_fd.field_type,
+      parsed.applicable_type, parsed.applicable_predicate, parsed.is_required,
+      parsed.is_niche, parsed.is_multivalued, parsed.min_value,
+      parsed.max_value, parsed.regex, parsed.needs_member, parsed.needs_perm,
+      parsed.grants_perm, parsed.notify_on, date_action, parsed.field_docstring,
+      False, approval_id=old_fd.approval_id or None,
+      is_phase_field=old_fd.is_phase_field)
+
+
+def ParsedFieldDefAssertions(mr, parsed):
+  """Checks if new/updated FieldDef is not violating basic assertions.
+      If the assertions are violated, the errors
+      will be included in the mr.errors.
+
+    Args:
+      mr: MonorailRequest object used to hold
+          commonly info parsed from the request.
+      parsed: ParsedFieldDef object used to contain parsed info,
+          in this case regarding a custom field definition.
+    """
+  # TODO(crbug/monorail/7275): This method is meant to eventually
+  # do all assertion checkings (shared by create/update fieldDef)
+  # and assign all mr.errors values.
+  if (parsed.is_required and parsed.is_niche):
+    mr.errors.is_niche = 'A field cannot be both required and niche.'
+  if parsed.date_action_str is not None and (
+      parsed.date_action_str not in config_svc.DATE_ACTION_ENUM):
+    mr.errors.date_action = 'The date action should be either: ' + ', '.join(
+        config_svc.DATE_ACTION_ENUM) + '.'
+  if (parsed.min_value is not None and parsed.max_value is not None and
+      parsed.min_value > parsed.max_value):
+    mr.errors.min_value = 'Minimum value must be less than maximum.'
+  if parsed.regex:
+    try:
+      re.compile(parsed.regex)
+    except re.error:
+      mr.errors.regex = 'Invalid regular expression.'
diff --git a/tracker/fieldcreate.py b/tracker/fieldcreate.py
new file mode 100644
index 0000000..ead72ad
--- /dev/null
+++ b/tracker/fieldcreate.py
@@ -0,0 +1,220 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""A servlet for project owners to create a new field def."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import re
+import time
+
+import ezt
+
+from framework import exceptions
+from framework import framework_helpers
+from framework import jsonfeed
+from framework import permissions
+from framework import servlet
+from framework import urls
+from proto import tracker_pb2
+from tracker import field_helpers
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+from tracker import tracker_helpers
+
+
+class FieldCreate(servlet.Servlet):
+  """Servlet allowing project owners to create a custom field."""
+
+  _MAIN_TAB_MODE = servlet.Servlet.MAIN_TAB_PROCESS
+  _PAGE_TEMPLATE = 'tracker/field-create-page.ezt'
+
+  def AssertBasePermission(self, mr):
+    """Check whether the user has any permission to visit this page.
+
+    Args:
+      mr: commonly used info parsed from the request.
+    """
+    super(FieldCreate, self).AssertBasePermission(mr)
+    if not self.CheckPerm(mr, permissions.EDIT_PROJECT):
+      raise permissions.PermissionException(
+          'You are not allowed to administer this project')
+
+  def GatherPageData(self, mr):
+    """Build up a dictionary of data values to use when rendering the page.
+
+    Args:
+      mr: commonly used info parsed from the request.
+
+    Returns:
+      Dict of values used by EZT for rendering the page.
+    """
+    config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+    well_known_issue_types = tracker_helpers.FilterIssueTypes(config)
+    approval_names = [fd.field_name for fd in config.field_defs if
+                      fd.field_type is tracker_pb2.FieldTypes.APPROVAL_TYPE and
+                      not fd.is_deleted]
+
+    return {
+        'admin_tab_mode': servlet.Servlet.PROCESS_TAB_LABELS,
+        'initial_field_name': '',
+        'initial_field_docstring': '',
+        'initial_importance': 'normal',
+        'initial_is_multivalued': ezt.boolean(False),
+        'initial_parent_approval_name': '',
+        'initial_choices': '',
+        'initial_admins': '',
+        'initial_editors': '',
+        'initial_type': 'enum_type',
+        'initial_applicable_type': '',  # That means any issue type
+        'initial_applicable_predicate': '',
+        'initial_needs_member': ezt.boolean(False),
+        'initial_needs_perm': '',
+        'initial_grants_perm': '',
+        'initial_notify_on': 0,
+        'initial_date_action': 'no_action',
+        'well_known_issue_types': well_known_issue_types,
+        'initial_approvers': '',
+        'initial_survey': '',
+        'approval_names': approval_names,
+        'initial_is_phase_field': ezt.boolean(False),
+        'initial_is_restricted_field': ezt.boolean(False),
+    }
+
+  def ProcessFormData(self, mr, post_data):
+    """Validate and store the contents of the issues tracker admin page.
+
+    Args:
+      mr: commonly used info parsed from the request.
+      post_data: HTML form data from the request.
+
+    Returns:
+      String URL to redirect the user to, or None if response was already sent.
+    """
+    config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+    parsed = field_helpers.ParseFieldDefRequest(post_data, config)
+
+    if not tracker_constants.FIELD_NAME_RE.match(parsed.field_name):
+      mr.errors.field_name = 'Invalid field name'
+
+    field_name_error_msg = FieldNameErrorMessage(parsed.field_name, config)
+    if field_name_error_msg:
+      mr.errors.field_name = field_name_error_msg
+
+    admin_ids, admin_str = tracker_helpers.ParsePostDataUsers(
+        mr.cnxn, post_data['admin_names'], self.services.user)
+    editor_ids, editor_str = tracker_helpers.ParsePostDataUsers(
+        mr.cnxn, post_data.get('editor_names', ''), self.services.user)
+
+    field_helpers.ParsedFieldDefAssertions(mr, parsed)
+
+    if not (parsed.is_restricted_field):
+      assert not editor_ids, 'Editors are only for restricted fields.'
+
+    # TODO(crbug/monorail/7275): This condition could potentially be
+    # included in the field_helpers.ParsedFieldDefAssertions method,
+    # just remember that it should be compatible with its usage in
+    # fielddetail.py where there is a very similar condition.
+    if parsed.field_type_str == 'approval_type':
+      assert not (
+          parsed.is_restricted_field), 'Approval fields cannot be restricted.'
+      if parsed.approvers_str:
+        approver_ids_dict = self.services.user.LookupUserIDs(
+            mr.cnxn, re.split('[,;\s]+', parsed.approvers_str),
+            autocreate=True)
+        approver_ids = list(set(approver_ids_dict.values()))
+      else:
+        mr.errors.approvers = 'Please provide at least one default approver.'
+
+    if mr.errors.AnyErrors():
+      self.PleaseCorrect(
+          mr,
+          initial_field_name=parsed.field_name,
+          initial_type=parsed.field_type_str,
+          initial_parent_approval_name=parsed.parent_approval_name,
+          initial_field_docstring=parsed.field_docstring,
+          initial_applicable_type=parsed.applicable_type,
+          initial_applicable_predicate=parsed.applicable_predicate,
+          initial_needs_member=ezt.boolean(parsed.needs_member),
+          initial_needs_perm=parsed.needs_perm,
+          initial_importance=parsed.importance,
+          initial_is_multivalued=ezt.boolean(parsed.is_multivalued),
+          initial_grants_perm=parsed.grants_perm,
+          initial_notify_on=parsed.notify_on,
+          initial_date_action=parsed.date_action_str,
+          initial_choices=parsed.choices_text,
+          initial_approvers=parsed.approvers_str,
+          initial_survey=parsed.survey,
+          initial_is_phase_field=parsed.is_phase_field,
+          initial_admins=admin_str,
+          initial_editors=editor_str,
+          initial_is_restricted_field=parsed.is_restricted_field)
+      return
+
+    approval_id = None
+    if parsed.parent_approval_name and (
+        parsed.field_type_str != 'approval_type'):
+      approval_fd = tracker_bizobj.FindFieldDef(
+          parsed.parent_approval_name, config)
+      if approval_fd:
+        approval_id = approval_fd.field_id
+    field_id = self.services.config.CreateFieldDef(
+        mr.cnxn,
+        mr.project_id,
+        parsed.field_name,
+        parsed.field_type_str,
+        parsed.applicable_type,
+        parsed.applicable_predicate,
+        parsed.is_required,
+        parsed.is_niche,
+        parsed.is_multivalued,
+        parsed.min_value,
+        parsed.max_value,
+        parsed.regex,
+        parsed.needs_member,
+        parsed.needs_perm,
+        parsed.grants_perm,
+        parsed.notify_on,
+        parsed.date_action_str,
+        parsed.field_docstring,
+        admin_ids,
+        editor_ids,
+        approval_id,
+        parsed.is_phase_field,
+        is_restricted_field=parsed.is_restricted_field)
+    if parsed.field_type_str == 'approval_type':
+      revised_approvals = field_helpers.ReviseApprovals(
+          field_id, approver_ids, parsed.survey, config)
+      self.services.config.UpdateConfig(
+          mr.cnxn, mr.project, approval_defs=revised_approvals)
+    if parsed.field_type_str == 'enum_type':
+      self.services.config.UpdateConfig(
+          mr.cnxn, mr.project, well_known_labels=parsed.revised_labels)
+
+    return framework_helpers.FormatAbsoluteURL(
+        mr, urls.ADMIN_LABELS, saved=1, ts=int(time.time()))
+
+
+def FieldNameErrorMessage(field_name, config):
+  """Return an error message for the given field name, or None."""
+  field_name_lower = field_name.lower()
+  if field_name_lower in tracker_constants.RESERVED_PREFIXES:
+    return 'That name is reserved.'
+  if field_name_lower.endswith(
+      tuple(tracker_constants.RESERVED_COL_NAME_SUFFIXES)):
+    return 'That suffix is reserved.'
+
+  for fd in config.field_defs:
+    fn_lower = fd.field_name.lower()
+    if field_name_lower == fn_lower:
+      return 'That name is already in use.'
+    if field_name_lower.startswith(fn_lower + '-'):
+      return 'An existing field name is a prefix of that name.'
+    if fn_lower.startswith(field_name_lower + '-'):
+      return 'That name is a prefix of an existing field name.'
+
+  return None
diff --git a/tracker/fielddetail.py b/tracker/fielddetail.py
new file mode 100644
index 0000000..3e7ebb3
--- /dev/null
+++ b/tracker/fielddetail.py
@@ -0,0 +1,249 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""A servlet for project and component owners to view and edit field defs."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import time
+import re
+
+import ezt
+
+from framework import exceptions
+from framework import framework_helpers
+from framework import framework_views
+from framework import permissions
+from framework import servlet
+from framework import urls
+from proto import tracker_pb2
+from tracker import field_helpers
+from tracker import tracker_bizobj
+from tracker import tracker_helpers
+from tracker import tracker_views
+
+
+class FieldDetail(servlet.Servlet):
+  """Servlet allowing project owners to view and edit a custom field."""
+
+  _MAIN_TAB_MODE = servlet.Servlet.MAIN_TAB_PROCESS
+  _PAGE_TEMPLATE = 'tracker/field-detail-page.ezt'
+
+  def _GetFieldDef(self, mr):
+    """Get the config and field definition to be viewed or edited."""
+    # TODO(jrobbins): since so many requests get the config object, and
+    # it is usually cached in RAM, just always get it and include it
+    # in the MonorailRequest, mr.
+    config = self.services.config.GetProjectConfig(
+        mr.cnxn, mr.project_id, use_cache=False)
+    field_def = tracker_bizobj.FindFieldDef(mr.field_name, config)
+    if not field_def:
+      self.abort(404, 'custom field not found')
+    return config, field_def
+
+  def AssertBasePermission(self, mr):
+    """Check whether the user has any permission to visit this page.
+
+    Args:
+      mr: commonly used info parsed from the request.
+    """
+    super(FieldDetail, self).AssertBasePermission(mr)
+    _config, field_def = self._GetFieldDef(mr)
+
+    allow_view = permissions.CanViewFieldDef(
+        mr.auth.effective_ids, mr.perms, mr.project, field_def)
+    if not allow_view:
+      raise permissions.PermissionException(
+          'User is not allowed to view this field definition')
+
+  def GatherPageData(self, mr):
+    """Build up a dictionary of data values to use when rendering the page.
+
+    Args:
+      mr: commonly used info parsed from the request.
+
+    Returns:
+      Dict of values used by EZT for rendering the page.
+    """
+    config, field_def = self._GetFieldDef(mr)
+    approval_def, subfields = None, []
+    if field_def.field_type == tracker_pb2.FieldTypes.APPROVAL_TYPE:
+      approval_def = tracker_bizobj.FindApprovalDefByID(
+          field_def.field_id, config)
+      user_views = framework_views.MakeAllUserViews(
+          mr.cnxn, self.services.user, field_def.admin_ids,
+          approval_def.approver_ids)
+      subfields = tracker_bizobj.FindApprovalsSubfields(
+          [field_def.field_id], config)[field_def.field_id]
+    else:
+      user_views = framework_views.MakeAllUserViews(
+          mr.cnxn, self.services.user, field_def.admin_ids,
+          field_def.editor_ids)
+    field_def_view = tracker_views.FieldDefView(
+        field_def, config, user_views=user_views, approval_def=approval_def)
+
+    well_known_issue_types = tracker_helpers.FilterIssueTypes(config)
+
+    allow_edit = permissions.CanEditFieldDef(
+        mr.auth.effective_ids, mr.perms, mr.project, field_def)
+
+    # Right now we do not allow renaming of enum fields.
+    _uneditable_name = field_def.field_type == tracker_pb2.FieldTypes.ENUM_TYPE
+
+    initial_choices = '\n'.join(
+        [choice.name if not choice.docstring else (
+            choice.name + ' = ' + choice.docstring) for
+         choice in field_def_view.choices])
+
+    initial_approvers = ', '.join(sorted([
+      approver_view.email for approver_view in field_def_view.approvers]))
+
+    initial_admins = ', '.join(sorted([
+        uv.email for uv in field_def_view.admins]))
+    initial_editors = ', '.join(
+        sorted([uv.email for uv in field_def_view.editors]))
+
+    return {
+        'admin_tab_mode': servlet.Servlet.PROCESS_TAB_LABELS,
+        'field_def': field_def_view,
+        'allow_edit': ezt.boolean(allow_edit),
+        # TODO(jojwang): update when name changes are actually saved
+        'uneditable_name': ezt.boolean(True),
+        'initial_admins': initial_admins,
+        'initial_editors': initial_editors,
+        'initial_applicable_type': field_def.applicable_type,
+        'initial_applicable_predicate': field_def.applicable_predicate,
+        'initial_approvers': initial_approvers,
+        'initial_choices': initial_choices,
+        'approval_subfields': [fd for fd in subfields if not fd.is_deleted],
+        'well_known_issue_types': well_known_issue_types,
+    }
+
+  def ProcessFormData(self, mr, post_data):
+    """Validate and store the contents of the issues tracker admin page.
+
+    Args:
+      mr: commonly used info parsed from the request.
+      post_data: HTML form data from the request.
+
+    Returns:
+      String URL to redirect the user to, or None if response was already sent.
+    """
+    config, field_def = self._GetFieldDef(mr)
+    allow_edit = permissions.CanEditFieldDef(
+        mr.auth.effective_ids, mr.perms, mr.project, field_def)
+    if not allow_edit:
+      raise permissions.PermissionException(
+          'User is not allowed to delete this field')
+
+    if 'deletefield' in post_data:
+      return self._ProcessDeleteField(mr, config, field_def)
+    elif 'cancel' in post_data:
+      return framework_helpers.FormatAbsoluteURL(
+          mr, urls.ADMIN_LABELS, ts=int(time.time()))
+    else:
+      return self._ProcessEditField(mr, post_data, config, field_def)
+
+
+  def _ProcessDeleteField(self, mr, config, field_def):
+    """The user wants to delete the specified custom field definition."""
+    field_ids = [field_def.field_id]
+    if field_def.field_type is tracker_pb2.FieldTypes.APPROVAL_TYPE:
+      for fd in config.field_defs:
+        if fd.approval_id == field_def.field_id:
+          field_ids.append(fd.field_id)
+    self.services.config.SoftDeleteFieldDefs(
+        mr.cnxn, mr.project_id, field_ids)
+
+    return framework_helpers.FormatAbsoluteURL(
+          mr, urls.ADMIN_LABELS, deleted=1, ts=int(time.time()))
+
+    # TODO(jrobbins): add logic to reaper cron task to look for
+    # soft deleted field definitions that have no issues with
+    # any value and hard deleted them.
+
+  def _ProcessEditField(self, mr, post_data, config, field_def):
+    """The user wants to edit this field definition."""
+    # TODO(jrobbins): future feature: editable field names
+
+    parsed = field_helpers.ParseFieldDefRequest(post_data, config)
+
+    admin_ids, admin_str = tracker_helpers.ParsePostDataUsers(
+        mr.cnxn, post_data['admin_names'], self.services.user)
+    editor_ids, editor_str = tracker_helpers.ParsePostDataUsers(
+        mr.cnxn, post_data.get('editor_names', ''), self.services.user)
+
+    field_helpers.ParsedFieldDefAssertions(mr, parsed)
+
+    if not parsed.is_restricted_field:
+      assert not editor_ids, 'Editors are only for restricted fields.'
+
+    if field_def.field_type == tracker_pb2.FieldTypes.APPROVAL_TYPE:
+      assert not (
+          parsed.is_restricted_field), 'Approval fields cannot be restricted.'
+      assert not editor_ids, 'Approval fields cannot have editors.'
+
+      if parsed.approvers_str:
+        approver_ids_dict = self.services.user.LookupUserIDs(
+            mr.cnxn, re.split('[,;\s]+', parsed.approvers_str),
+            autocreate=True)
+        approver_ids = list(set(approver_ids_dict.values()))
+      else:
+        mr.errors.approvers = 'Please provide at least one default approver.'
+
+    if mr.errors.AnyErrors():
+      new_field_def = field_helpers.ReviseFieldDefFromParsed(parsed, field_def)
+
+      new_field_def_view = tracker_views.FieldDefView(
+          new_field_def, config)
+
+      self.PleaseCorrect(
+          mr,
+          field_def=new_field_def_view,
+          initial_applicable_type=parsed.applicable_type,
+          initial_choices=parsed.choices_text,
+          initial_admins=admin_str,
+          initial_editors=editor_str,
+          initial_approvers=parsed.approvers_str,
+          initial_is_restricted_field=parsed.is_restricted_field)
+      return
+
+    self.services.config.UpdateFieldDef(
+        mr.cnxn,
+        mr.project_id,
+        field_def.field_id,
+        applicable_type=parsed.applicable_type,
+        applicable_predicate=parsed.applicable_predicate,
+        is_required=parsed.is_required,
+        is_niche=parsed.is_niche,
+        min_value=parsed.min_value,
+        max_value=parsed.max_value,
+        regex=parsed.regex,
+        needs_member=parsed.needs_member,
+        needs_perm=parsed.needs_perm,
+        grants_perm=parsed.grants_perm,
+        notify_on=parsed.notify_on,
+        is_multivalued=parsed.is_multivalued,
+        date_action=parsed.date_action_str,
+        docstring=parsed.field_docstring,
+        admin_ids=admin_ids,
+        editor_ids=editor_ids,
+        is_restricted_field=parsed.is_restricted_field)
+
+    if field_def.field_type == tracker_pb2.FieldTypes.APPROVAL_TYPE:
+      approval_defs = field_helpers.ReviseApprovals(
+          field_def.field_id, approver_ids, parsed.survey, config)
+      self.services.config.UpdateConfig(
+          mr.cnxn, mr.project, approval_defs=approval_defs)
+
+    if field_def.field_type == tracker_pb2.FieldTypes.ENUM_TYPE:
+      self.services.config.UpdateConfig(
+          mr.cnxn, mr.project, well_known_labels=parsed.revised_labels)
+
+    return framework_helpers.FormatAbsoluteURL(
+          mr, urls.FIELD_DETAIL, field=field_def.field_name,
+          saved=1, ts=int(time.time()))
diff --git a/tracker/fltconversion.py b/tracker/fltconversion.py
new file mode 100644
index 0000000..c26ab62
--- /dev/null
+++ b/tracker/fltconversion.py
@@ -0,0 +1,599 @@
+# Copyright 2018 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""FLT task to be manually triggered to convert launch issues."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import collections
+import logging
+import re
+import settings
+import time
+
+from businesslogic import work_env
+from framework import permissions
+from framework import exceptions
+from framework import jsonfeed
+from proto import tracker_pb2
+from tracker import template_helpers
+from tracker import tracker_bizobj
+
+PM_PREFIX = 'pm-'
+TL_PREFIX = 'tl-'
+TEST_PREFIX = 'test-'
+UX_PREFIX = 'ux-'
+
+PM_FIELD = 'pm'
+TL_FIELD = 'tl'
+TE_FIELD = 'te'
+UX_FIELD = 'ux'
+MTARGET_FIELD = 'm-target'
+MAPPROVED_FIELD = 'm-approved'
+
+CONVERSION_COMMENT = 'Automatic generating of FLT Launch data.'
+
+BROWSER_APPROVALS_TO_LABELS = {
+    'Chrome-Accessibility': 'Launch-Accessibility-',
+    'Chrome-Leadership-Exp': 'Launch-Exp-Leadership-',
+    'Chrome-Leadership-Full': 'Launch-Leadership-',
+    'Chrome-Legal': 'Launch-Legal-',
+    'Chrome-Privacy': 'Launch-Privacy-',
+    'Chrome-Security': 'Launch-Security-',
+    'Chrome-Test': 'Launch-Test-',
+    'Chrome-UX': 'Launch-UI-',
+    }
+
+OS_APPROVALS_TO_LABELS = {
+    'ChromeOS-Accessibility': 'Launch-Accessibility-',
+    'ChromeOS-Leadership-Exp': 'Launch-Exp-Leadership-',
+    'ChromeOS-Leadership-Full': 'Launch-Leadership-',
+    'ChromeOS-Legal': 'Launch-Legal-',
+    'ChromeOS-Privacy': 'Launch-Privacy-',
+    'ChromeOS-Security': 'Launch-Security-',
+    'ChromeOS-Test': 'Launch-Test-',
+    'ChromeOS-UX': 'Launch-UI-',
+    }
+
+# 'NotReviewed' not included because this should be converted to
+# the template approval's default value, eg NOT_SET OR NEEDS_REVIEW
+VALUE_TO_STATUS = {
+    'ReviewRequested': tracker_pb2.ApprovalStatus.REVIEW_REQUESTED,
+    'NeedInfo': tracker_pb2.ApprovalStatus.NEED_INFO,
+    'Yes': tracker_pb2.ApprovalStatus.APPROVED,
+    'No': tracker_pb2.ApprovalStatus.NOT_APPROVED,
+    'NA': tracker_pb2.ApprovalStatus.NA,
+    # 'Started' is not a valid label value in the chromium project,
+    # but for some reason, some labels have this value.
+    'Started': tracker_pb2.ApprovalStatus.REVIEW_STARTED,
+}
+
+# This works in the Browser and OS process because
+# BROWSER_APPROVALS_TO_LABELS and OS_APPROVALS_TO_LABELS have the same values.
+# Adding '^' before each label prefix to ensure Blah-Launch-UI-Yes is ignored
+REVIEW_LABELS_RE = re.compile('^' + '|^'.join(
+    list(OS_APPROVALS_TO_LABELS.values())))
+
+# Maps template phases to channel names in 'Launch-M-Target-80-[Channel]' labels
+BROWSER_PHASE_MAP = {
+    'beta': 'beta',
+    'stable': 'stable',
+    'stable-full': 'stable',
+    'stable-exp': 'stable-exp',
+    }
+
+PHASE_PAT = '$|'.join(list(BROWSER_PHASE_MAP.values()))
+# Matches launch milestone labels, eg. Launch-M-Target-70-Stable-Exp
+BROWSER_M_LABELS_RE = re.compile(
+    r'^Launch-M-(?P<type>Approved|Target)-(?P<m>\d\d)-'
+    r'(?P<channel>%s$)' % PHASE_PAT,
+    re.IGNORECASE)
+
+OS_PHASE_MAP = {'feature freeze': '',
+                 'branch': '',
+                'stable': 'stable',
+                'stable-full': 'stable',
+                'stable-exp': 'stable-exp',}
+# We only care about Launch-M-<type>-<m>-Stable|Stable-Exp labels for OS.
+OS_M_LABELS_RE = re.compile(
+    r'^Launch-M-(?P<type>Approved|Target)-(?P<m>\d\d)-'
+    r'(?P<channel>Stable$|Stable-Exp$)', re.IGNORECASE)
+
+CAN = 2  # Query for open issues only
+# Ensure empty group_by_spec and sort_spec so issues are sorted by 'ID'.
+GROUP_BY_SPEC = ''
+SORT_SPEC = ''
+
+CONVERT_NUM = 20
+CONVERT_START = 0
+VERIFY_NUM = 400
+
+# Queries
+QUERY_MAP = {
+    'default':
+    'Type=Launch Rollout-Type=Default OS=Windows,Mac,Linux,Android,iOS',
+    'finch': 'Type=Launch Rollout-Type=Finch OS=Windows,Mac,Linux,Android,iOS',
+    'os': 'Type=Launch OS=Chrome -OS=Windows,Mac,Linux,Android,iOS'
+    ' Rollout-Type=Default',
+    'os-finch': 'Type=Launch OS=Chrome -OS=Windows,Mac,Linux,Android,iOS'
+    ' Rollout-Type=Finch'}
+
+TEMPLATE_MAP = {
+    'default': 'Chrome Launch - Default',
+    'finch': 'Chrome Launch - Experimental',
+    'os': 'Chrome OS Launch - Default',
+    'os-finch': 'Chrome OS Launch - Experimental',
+}
+
+ProjectInfo = collections.namedtuple(
+    'ProjectInfo', 'config, q, approval_values, phases, '
+    'pm_fid, tl_fid, te_fid, ux_fid, m_target_id, m_approved_id, '
+    'phase_map, approvals_to_labels, labels_re')
+
+
+class FLTConvertTask(jsonfeed.InternalTask):
+  """FLTConvert converts current Type=Launch issues into Type=FLT-Launch."""
+
+  def AssertBasePermission(self, mr):
+    super(FLTConvertTask, self).AssertBasePermission(mr)
+    if not mr.auth.user_pb.is_site_admin:
+      raise permissions.PermissionException(
+          'Only site admins may trigger conversion job')
+
+  def UndoConversion(self, mr):
+    with work_env.WorkEnv(mr, self.services) as we:
+      pipeline = we.ListIssues(
+          'Type=FLT-Launch FLT=Conversion', ['chromium'], mr.auth.user_id,
+          CONVERT_NUM, CONVERT_START, 2, GROUP_BY_SPEC, SORT_SPEC, False)
+
+    project = self.services.project.GetProjectByName(mr.cnxn, 'chromium')
+    config = self.services.config.GetProjectConfig(mr.cnxn, project.project_id)
+    pm_id = tracker_bizobj.FindFieldDef('PM', config).field_id
+    tl_id = tracker_bizobj.FindFieldDef('TL', config).field_id
+    te_id = tracker_bizobj.FindFieldDef('TE', config).field_id
+    ux_id = tracker_bizobj.FindFieldDef('UX', config).field_id
+    for possible_stale_issue in pipeline.visible_results:
+      issue = self.services.issue.GetIssue(
+          mr.cnxn, possible_stale_issue.issue_id, use_cache=False)
+
+      issue.approval_values = []
+      issue.phases = []
+      issue.field_values = [fv for fv in issue.field_values
+                            if fv.phase_id is None]
+      issue.field_values = [fv for fv in issue.field_values
+                            if fv.field_id not in
+                            [pm_id, tl_id, te_id, ux_id]]
+      issue.labels.remove('Type-FLT-Launch')
+      issue.labels.remove('FLT-Conversion')
+      issue.labels.append('Type-Launch')
+
+      self.services.issue._UpdateIssuesApprovals(mr.cnxn, issue)
+      self.services.issue.UpdateIssue(mr.cnxn, issue)
+    return {'deleting': [issue.local_id for issue in pipeline.visible_results],
+            'num': len(pipeline.visible_results),
+    }
+
+  def VerifyConversion(self, mr):
+    """Verify that all FLT-Conversion issues were converted correctly."""
+    with work_env.WorkEnv(mr, self.services) as we:
+      pipeline = we.ListIssues(
+          'FLT=Conversion', ['chromium'], mr.auth.user_id, VERIFY_NUM,
+          CONVERT_START, 2, GROUP_BY_SPEC, SORT_SPEC, False)
+
+    project = self.services.project.GetProjectByName(mr.cnxn, 'chromium')
+    config = self.services.config.GetProjectConfig(mr.cnxn, project.project_id)
+    browser_approval_names = {fd.field_id: fd.field_name for fd
+                              in config.field_defs if fd.field_name in
+                              BROWSER_APPROVALS_TO_LABELS.keys()}
+    os_approval_names = {fd.field_id: fd.field_name for fd in config.field_defs
+                         if (fd.field_name in OS_APPROVALS_TO_LABELS.keys())
+                         or fd.field_name == 'ChromeOS-Enterprise'}
+    pm_id = tracker_bizobj.FindFieldDef('PM', config).field_id
+    tl_id = tracker_bizobj.FindFieldDef('TL', config).field_id
+    te_id = tracker_bizobj.FindFieldDef('TE', config).field_id
+    ux_id = tracker_bizobj.FindFieldDef('UX', config).field_id
+    mapproved_id = tracker_bizobj.FindFieldDef('M-Approved', config).field_id
+    mtarget_id = tracker_bizobj.FindFieldDef('M-Target', config).field_id
+
+    problems = []
+    for possible_stale_issue in pipeline.allowed_results:
+      issue = self.services.issue.GetIssue(
+          mr.cnxn, possible_stale_issue.issue_id, use_cache=False)
+      # Check correct template used
+      approval_names = browser_approval_names
+      approvals_to_labels = BROWSER_APPROVALS_TO_LABELS
+      m_labels_re = BROWSER_M_LABELS_RE
+      label_channel_to_phase_id = {
+          phase.name.lower(): phase.phase_id for phase in issue.phases}
+      if [l for l in issue.labels if l.startswith('OS-')] == ['OS-Chrome']:
+        approval_names = os_approval_names
+        m_labels_re = OS_M_LABELS_RE
+        approvals_to_labels = OS_APPROVALS_TO_LABELS
+        # OS default launch
+        if 'Rollout-Type-Default' in issue.labels:
+          if not all(phase.name in ['Feature Freeze', 'Branch', 'Stable']
+                     for phase in issue.phases):
+            problems.append((
+                issue.local_id, 'incorrect phases for OS default launch.'))
+        # OS finch launch
+        elif 'Rollout-Type-Finch' in issue.labels:
+          if not all(phase.name in (
+              'Feature Freeze', 'Branch', 'Stable-Exp', 'Stable-Full')
+                     for phase in issue.phases):
+            problems.append((
+                issue.local_id, 'incorrect phases for OS finch launch.'))
+        else:
+          problems.append((
+              issue.local_id,
+              'no rollout-type; should not have been converted'))
+      # Browser default launch
+      elif 'Rollout-Type-Default' in issue.labels:
+        if not all(phase.name.lower() in ['beta', 'stable']
+                   for phase in issue.phases):
+          problems.append((
+              issue.local_id, 'incorrect phases for Default rollout'))
+      # Browser finch launch
+      elif 'Rollout-Type-Finch' in issue.labels:
+        if not all(phase.name.lower() in ['beta', 'stable-exp', 'stable-full']
+                   for phase in issue.phases):
+          problems.append((
+              issue.local_id, 'incorrect phases for Finch rollout'))
+      else:
+        problems.append((
+            issue.local_id,
+            'no rollout-type; should not have been converted'))
+
+      # Check approval_values
+      for av in issue.approval_values:
+        name = approval_names.get(av.approval_id)
+        if name == 'ChromeOS-Enterprise':
+          if av.status != tracker_pb2.ApprovalStatus.NEEDS_REVIEW:
+            problems.append((issue.local_id, 'bad ChromeOS-Enterprise status'))
+          continue
+        label_pre = approvals_to_labels.get(name)
+        if not label_pre:
+          # either name was None or not found in APPROVALS_TO_LABELS
+          problems.append((issue.local_id, 'approval %s not recognized' % name))
+          continue
+        label_value = next((l[len(label_pre):] for l in issue.labels
+                            if l.startswith(label_pre)), None)
+        if (not label_value or label_value == 'NotReviewed') and av.status in [
+            tracker_pb2.ApprovalStatus.NOT_SET,
+            tracker_pb2.ApprovalStatus.NEEDS_REVIEW]:
+          continue
+        if av.status is VALUE_TO_STATUS.get(label_value):
+          continue
+        # neither of the above ifs passed
+        problems.append((issue.local_id,
+                         'approval %s has status %r for label value %s' % (
+                             name, av.status.name, label_value)))
+
+      # Check people field_values
+      expected_people_fvs = self.ConvertPeopleLabels(
+          mr, issue.labels, pm_id, tl_id, te_id, ux_id)
+      for people_fv in expected_people_fvs:
+        if people_fv not in issue.field_values:
+          if people_fv.field_id == tl_id:
+            role = 'TL'
+          elif people_fv.field_id == pm_id:
+            role = 'PM'
+          elif people_fv.field_id == ux_id:
+            role = 'UX'
+          else:
+            role = 'TE'
+          problems.append((issue.local_id, 'missing a field for %s' % role))
+
+      # Check M phase field_values
+      for label in issue.labels:
+        match = re.match(m_labels_re, label)
+        if match:
+          channel = match.group('channel')
+          if (channel.lower() == 'stable-exp'
+              and 'Rollout-Type-Default' in issue.labels):
+            # ignore stable-exp for default rollouts.
+            continue
+          milestone = match.group('m')
+          m_type = match.group('type')
+          m_id = mapproved_id if m_type == 'Approved' else mtarget_id
+          phase_id = label_channel_to_phase_id.get(
+              channel.lower(), label_channel_to_phase_id.get('stable-full'))
+          if not next((
+              fv for fv in issue.field_values
+              if fv.phase_id == phase_id and fv.field_id == m_id and
+              fv.int_value == int(milestone)), None):
+            problems.append((
+                issue.local_id, 'no phase field for label %s' % label))
+
+    return {
+        'problems found': ['issue %d: %s' % problem for problem in problems],
+        'issues verified': ['issue %d' % issue.local_id for
+                            issue in pipeline.allowed_results],
+        'num': len(pipeline.allowed_results),
+    }
+
+  def HandleRequest(self, mr):
+    """Convert Type=Launch issues to new Type=FLT-Launch issues."""
+    launch = mr.GetParam('launch')
+    if launch == 'delete':
+      return self.UndoConversion(mr)
+    if launch == 'verify':
+      return self.VerifyConversion(mr)
+    project_info = self.FetchAndAssertProjectInfo(mr)
+
+    # Search for issues:
+    with work_env.WorkEnv(mr, self.services) as we:
+      pipeline = we.ListIssues(
+          project_info.q, ['chromium'], mr.auth.user_id, CONVERT_NUM,
+          CONVERT_START, 2, GROUP_BY_SPEC, SORT_SPEC, False)
+
+    # Convert issues:
+    for possible_stale_issue in pipeline.visible_results:
+      # Note: These approval values and phases from templates will be used
+      # and modified to create approval values and phases for each issue.
+      # We need to create copies for each issue so changes are not carried
+      # over to the conversion of the next issue in the loop.
+      template_avs = self.CreateApprovalCopies(project_info.approval_values)
+      template_phases = self.CreatePhasesCopies(project_info.phases)
+      issue = self.services.issue.GetIssue(
+          mr.cnxn, possible_stale_issue.issue_id, use_cache=False)
+      new_approvals = ConvertLaunchLabels(
+          issue.labels, template_avs,
+          project_info.config.field_defs, project_info.approvals_to_labels)
+      m_fvs = ConvertMLabels(
+          issue.labels, template_phases,
+          project_info.m_target_id, project_info.m_approved_id,
+          project_info.labels_re, project_info.phase_map)
+      people_fvs = self.ConvertPeopleLabels(
+          mr, issue.labels,
+          project_info.pm_fid, project_info.tl_fid, project_info.te_fid,
+          project_info.ux_fid)
+      amendments = self.ExecuteIssueChanges(
+          project_info.config, issue, new_approvals,
+          template_phases, m_fvs + people_fvs)
+      logging.info(amendments)
+
+    return {
+        'converted_issues': [
+            issue.local_id for issue in pipeline.visible_results],
+        'num': len(pipeline.visible_results),
+        }
+
+  def CreateApprovalCopies(self, avs):
+    return [
+      tracker_pb2.ApprovalValue(
+          approval_id=av.approval_id,
+          status=av.status,
+          setter_id=av.setter_id,
+          set_on=av.set_on,
+          phase_id=av.phase_id) for av in avs
+    ]
+
+  def CreatePhasesCopies(self, phases):
+    return [
+      tracker_pb2.Phase(
+          phase_id=phase.phase_id,
+          name=phase.name,
+          rank=phase.rank) for phase in phases
+        ]
+
+  def FetchAndAssertProjectInfo(self, mr):
+    # Get request details
+    launch = mr.GetParam('launch')
+    logging.info(launch)
+    q = QUERY_MAP.get(launch)
+    template_name = TEMPLATE_MAP.get(launch)
+    assert q and template_name, 'bad launch type: %s' % launch
+
+    phase_map = (
+        OS_PHASE_MAP if launch in ['os', 'os-finch'] else BROWSER_PHASE_MAP)
+    approvals_to_labels = (
+        OS_APPROVALS_TO_LABELS if launch in ['os', 'os-finch']
+        else BROWSER_APPROVALS_TO_LABELS)
+    m_labels_re = (
+        OS_M_LABELS_RE if launch in ['os', 'os-finch'] else BROWSER_M_LABELS_RE)
+
+    # Get project, config, template, assert template in project
+    project = self.services.project.GetProjectByName(mr.cnxn, 'chromium')
+    config = self.services.config.GetProjectConfig(mr.cnxn, project.project_id)
+    template = self.services.template.GetTemplateByName(
+        mr.cnxn, template_name, project.project_id)
+    assert template, 'template %s not found in chromium project' % template_name
+
+    # Get template approval_values/phases and assert they are expected
+    approval_values, phases = template_helpers.FilterApprovalsAndPhases(
+        template.approval_values, template.phases, config)
+    assert approval_values and phases, (
+        'no approvals or phases in %s' % template_name)
+    assert all(phase.name.lower() in list(
+        phase_map.keys()) for phase in phases), (
+          'one or more phases not recognized')
+    if launch in ['finch', 'os', 'os-finch']:
+      assert all(
+          av.status is tracker_pb2.ApprovalStatus.NEEDS_REVIEW
+          for av in approval_values
+      ), '%s template not set up correctly' % launch
+
+    approval_fds = {fd.field_id: fd.field_name for fd in config.field_defs
+                    if fd.field_type is tracker_pb2.FieldTypes.APPROVAL_TYPE}
+    assert all(
+        approval_fds.get(av.approval_id) in list(approvals_to_labels.keys())
+        for av in approval_values
+        if approval_fds.get(av.approval_id) != 'ChromeOS-Enterprise'), (
+            'one or more approvals not recognized')
+    approval_def_ids = [ad.approval_id for ad in config.approval_defs]
+    assert all(av.approval_id in approval_def_ids for av in approval_values), (
+        'one or more approvals not in config.approval_defs')
+
+    # Get relevant USER_TYPE FieldDef ids and assert they exist
+    user_fds = {fd.field_name.lower(): fd.field_id for fd in config.field_defs
+                    if fd.field_type is tracker_pb2.FieldTypes.USER_TYPE}
+    logging.info('project USER_TYPE FieldDefs: %s' % user_fds)
+    pm_fid = user_fds.get(PM_FIELD)
+    assert pm_fid, 'project has no FieldDef %s' % PM_FIELD
+    tl_fid = user_fds.get(TL_FIELD)
+    assert tl_fid, 'project has no FieldDef %s' % TL_FIELD
+    te_fid = user_fds.get(TE_FIELD)
+    assert te_fid, 'project has no FieldDef %s' % TE_FIELD
+    ux_fid = user_fds.get(UX_FIELD)
+    assert ux_fid, 'project has no FieldDef %s' % UX_FIELD
+
+    # Get relevant M Phase INT_TYPE FieldDef ids and assert they exist
+    phase_int_fds = {fd.field_name.lower(): fd.field_id
+                     for fd in config.field_defs
+                     if fd.field_type is tracker_pb2.FieldTypes.INT_TYPE
+                     and fd.is_phase_field and fd.is_multivalued}
+    logging.info(
+        'project Phase INT_TYPE multivalued FieldDefs: %s' % phase_int_fds)
+    m_target_id = phase_int_fds.get(MTARGET_FIELD)
+    assert m_target_id, 'project has no FieldDef %s' % MTARGET_FIELD
+    m_approved_id = phase_int_fds.get(MAPPROVED_FIELD)
+    assert m_approved_id, 'project has no FieldDef %s' % MAPPROVED_FIELD
+
+    return ProjectInfo(config, q, approval_values, phases, pm_fid, tl_fid,
+                       te_fid, ux_fid, m_target_id, m_approved_id, phase_map,
+                       approvals_to_labels, m_labels_re)
+
+  # TODO(jojwang): mr needs to be passed in as arg and
+  # all self.mr should be changed to mr
+  def ExecuteIssueChanges(self, config, issue, new_approvals, phases, new_fvs):
+    # Apply Approval and phase changes
+    approval_defs_by_id = {ad.approval_id: ad for ad in config.approval_defs}
+    for av in new_approvals:
+      ad = approval_defs_by_id.get(av.approval_id)
+      if ad:
+        av.approver_ids = ad.approver_ids
+        survey = ''
+        if ad.survey:
+          questions = ad.survey.split('\n')
+          survey = '\n'.join(['<b>' + q + '</b>' for q in questions])
+        self.services.issue.InsertComment(
+            self.mr.cnxn, tracker_pb2.IssueComment(
+                issue_id=issue.issue_id, project_id=issue.project_id,
+                user_id=self.mr.auth.user_id, content=survey,
+                is_description=True, approval_id=av.approval_id,
+                timestamp=int(time.time())))
+      else:
+        logging.info(
+            'ERROR: ApprovalDef %r for ApprovalValue %r not valid', ad, av)
+    issue.approval_values = new_approvals
+    self.services.issue._UpdateIssuesApprovals(self.mr.cnxn, issue)
+
+    # Apply field value changes
+    issue.phases = phases
+    delta = tracker_bizobj.MakeIssueDelta(
+        None, None, [], [], [], [], ['Type-FLT-Launch', 'FLT-Conversion'],
+        ['Type-Launch'], new_fvs, [], [], [], [], [], [], None, None)
+    amendments, _ = self.services.issue.DeltaUpdateIssue(
+        self.mr.cnxn, self.services, self.mr.auth.user_id, issue.project_id,
+        config, issue, delta, comment=CONVERSION_COMMENT)
+
+    return amendments
+
+  def ConvertPeopleLabels(
+      self, mr, labels, pm_field_id, tl_field_id, te_field_id, ux_field_id):
+    field_values = []
+    pm_ldap, tl_ldap, test_ldaps, ux_ldaps = ExtractLabelLDAPs(labels)
+
+    pm_fv = self.CreateUserFieldValue(mr, pm_ldap, pm_field_id)
+    if pm_fv:
+      field_values.append(pm_fv)
+
+    tl_fv = self.CreateUserFieldValue(mr, tl_ldap, tl_field_id)
+    if tl_fv:
+      field_values.append(tl_fv)
+
+    for test_ldap in test_ldaps:
+      te_fv = self.CreateUserFieldValue(mr, test_ldap, te_field_id)
+      if te_fv:
+        field_values.append(te_fv)
+
+    for ux_ldap in ux_ldaps:
+      ux_fv = self.CreateUserFieldValue(mr, ux_ldap, ux_field_id)
+      if ux_fv:
+        field_values.append(ux_fv)
+    return field_values
+
+  def CreateUserFieldValue(self, mr, ldap, field_id):
+    if ldap is None:
+      return None
+    try:
+      user_id = self.services.user.LookupUserID(mr.cnxn, ldap+'@chromium.org')
+    except exceptions.NoSuchUserException:
+      try:
+        user_id = self.services.user.LookupUserID(mr.cnxn, ldap+'@google.com')
+      except exceptions.NoSuchUserException:
+        logging.info('No chromium.org or google.com accound found for %s', ldap)
+        return None
+    return tracker_bizobj.MakeFieldValue(
+        field_id, None, None, user_id, None, None, False)
+
+
+def ConvertMLabels(
+    labels, phases, m_target_id, m_approved_id, labels_re, phase_map):
+  field_values = []
+  for label in labels:
+    match = re.match(labels_re, label)
+    if match:
+      milestone = match.group('m')
+      m_type = match.group('type')
+      channel = match.group('channel')
+      for phase in phases:
+        # We know get(phase) will return something because
+        # we're checking before ConvertMLabels, that all phases
+        # exist in BROWSER_PHASE_MAP or OS_PHASE_MAP
+        if phase_map.get(phase.name.lower()) == channel.lower():
+          field_id = m_target_id if (
+              m_type.lower() == 'target') else m_approved_id
+          field_values.append(tracker_bizobj.MakeFieldValue(
+              field_id, int(milestone), None, None, None, None, False,
+              phase_id=phase.phase_id))
+          break  # exit phase loop if match is found.
+  return field_values
+
+
+def ConvertLaunchLabels(labels, approvals, project_fds, approvals_to_labels):
+  """Converts 'Launch-[Review]' values into statuses for given approvals."""
+  label_values = {}
+  for label in labels:
+    launch_match = REVIEW_LABELS_RE.match(label)
+    if launch_match:
+      prefix = launch_match.group()
+      value = label[len(prefix):]  # returns 'Yes' from 'Launch-UI-Yes'
+      label_values[prefix] = value
+
+  field_names_dict = {fd.field_id: fd.field_name for fd in project_fds}
+  for approval in approvals:
+    approval_name = field_names_dict.get(approval.approval_id, '')
+    old_prefix = approvals_to_labels.get(approval_name)
+    label_value = label_values.get(old_prefix, '')
+    # if label_value not found in VALUE_TO_STATUS, use current status.
+    approval.status = VALUE_TO_STATUS.get(label_value, approval.status)
+
+  return approvals
+
+
+def ExtractLabelLDAPs(labels):
+  """Extracts LDAPs from labels 'PM-', 'TL-', 'UX-', and 'test-'"""
+
+  pm_ldap = None
+  tl_ldap = None
+  test_ldaps = []
+  ux_ldaps = []
+  for label in labels:
+    label = label.lower()
+    if label.startswith(PM_PREFIX):
+      pm_ldap = label[len(PM_PREFIX):]
+    elif label.startswith(TL_PREFIX):
+      tl_ldap = label[len(TL_PREFIX):]
+    elif label.startswith(TEST_PREFIX):
+      ldap = label[len(TEST_PREFIX):]
+      if ldap:
+        test_ldaps.append(ldap)
+    elif label.startswith(UX_PREFIX):
+      ldap = label[len(UX_PREFIX):]
+      if ldap:
+        ux_ldaps.append(ldap)
+  return pm_ldap, tl_ldap, test_ldaps, ux_ldaps
diff --git a/tracker/issue-blocking-change-notification-email.ezt b/tracker/issue-blocking-change-notification-email.ezt
new file mode 100644
index 0000000..9e45c69
--- /dev/null
+++ b/tracker/issue-blocking-change-notification-email.ezt
@@ -0,0 +1,7 @@
+Issue [issue.local_id]: [format "raw"][summary][end]
+[detail_url]
+
+[if-any is_blocking]This issue is now blocking issue [downstream_issue_ref].
+See [downstream_issue_url]
+[else]This issue is no longer blocking issue [downstream_issue_ref].
+See [downstream_issue_url][end]
diff --git a/tracker/issue-bulk-change-notification-email.ezt b/tracker/issue-bulk-change-notification-email.ezt
new file mode 100644
index 0000000..2051220
--- /dev/null
+++ b/tracker/issue-bulk-change-notification-email.ezt
@@ -0,0 +1,18 @@
+[if-any amendments]Updates:
+[amendments]
+[end]
+Comment[if-any commenter] by [commenter.display_name][end]:
+[if-any comment_text][format "raw"][comment_text][end][else](No comment was entered for this change.)[end]
+
+Affected issues:
+[for issues]  issue [issues.local_id]: [format "raw"][issues.summary][end]
+    [format "raw"]http://[hostport][issues.detail_relative_url][end]
+
+[end]
+[is body_type "email"]
+--
+You received this message because you are listed in the owner
+or CC fields of these issues, or because you starred them.
+You may adjust your issue notification preferences at:
+http://[hostport]/hosting/settings
+[end]
diff --git a/tracker/issueadmin.py b/tracker/issueadmin.py
new file mode 100644
index 0000000..5c34f72
--- /dev/null
+++ b/tracker/issueadmin.py
@@ -0,0 +1,587 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Servlets for issue tracker configuration.
+
+These classes implement the Statuses, Labels and fields, Components, Rules, and
+Views subtabs under the Process tab.  Unlike most servlet modules, this single
+file holds a base class and several related servlet classes.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import collections
+import itertools
+import logging
+import time
+
+import ezt
+
+from features import filterrules_helpers
+from features import filterrules_views
+from features import savedqueries_helpers
+from framework import authdata
+from framework import framework_bizobj
+from framework import framework_constants
+from framework import framework_helpers
+from framework import framework_views
+from framework import monorailrequest
+from framework import permissions
+from framework import servlet
+from framework import urls
+from proto import tracker_pb2
+from tracker import field_helpers
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+from tracker import tracker_helpers
+from tracker import tracker_views
+
+
+class IssueAdminBase(servlet.Servlet):
+  """Base class for servlets allowing project owners to configure tracker."""
+
+  _MAIN_TAB_MODE = servlet.Servlet.MAIN_TAB_PROCESS
+  _PROCESS_SUBTAB = None  # specified in subclasses
+
+  def GatherPageData(self, mr):
+    """Build up a dictionary of data values to use when rendering the page.
+
+    Args:
+      mr: commonly used info parsed from the request.
+
+    Returns:
+      Dict of values used by EZT for rendering the page.
+    """
+    config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+    config_view = tracker_views.ConfigView(mr, self.services, config,
+        template=None, load_all_templates=True)
+    open_text, closed_text = tracker_views.StatusDefsAsText(config)
+    labels_text = tracker_views.LabelDefsAsText(config)
+
+    return {
+        'admin_tab_mode': self._PROCESS_SUBTAB,
+        'config': config_view,
+        'open_text': open_text,
+        'closed_text': closed_text,
+        'labels_text': labels_text,
+        }
+
+  def ProcessFormData(self, mr, post_data):
+    """Validate and store the contents of the issues tracker admin page.
+
+    Args:
+      mr: commonly used info parsed from the request.
+      post_data: HTML form data from the request.
+
+    Returns:
+      String URL to redirect the user to, or None if response was already sent.
+    """
+    page_url = self.ProcessSubtabForm(post_data, mr)
+
+    if page_url:
+      return framework_helpers.FormatAbsoluteURL(
+          mr, page_url, saved=1, ts=int(time.time()))
+
+
+class AdminStatuses(IssueAdminBase):
+  """Servlet allowing project owners to configure well-known statuses."""
+
+  _PAGE_TEMPLATE = 'tracker/admin-statuses-page.ezt'
+  _PROCESS_SUBTAB = servlet.Servlet.PROCESS_TAB_STATUSES
+
+  def ProcessSubtabForm(self, post_data, mr):
+    """Process the status definition section of the admin page.
+
+    Args:
+      post_data: HTML form data for the HTTP request being processed.
+      mr: commonly used info parsed from the request.
+
+    Returns:
+      The URL of the page to show after processing.
+    """
+    if not self.CheckPerm(mr, permissions.EDIT_PROJECT):
+      raise permissions.PermissionException(
+          'Only project owners may edit the status definitions')
+
+    wks_open_text = post_data.get('predefinedopen', '')
+    wks_open_matches = framework_constants.IDENTIFIER_DOCSTRING_RE.findall(
+        wks_open_text)
+    wks_open_tuples = [
+        (status.lstrip('#'), docstring.strip(), True, status.startswith('#'))
+        for status, docstring in wks_open_matches]
+    if not wks_open_tuples:
+      mr.errors.open_statuses = 'A project cannot have zero open statuses'
+
+    wks_closed_text = post_data.get('predefinedclosed', '')
+    wks_closed_matches = framework_constants.IDENTIFIER_DOCSTRING_RE.findall(
+        wks_closed_text)
+    wks_closed_tuples = [
+        (status.lstrip('#'), docstring.strip(), False, status.startswith('#'))
+        for status, docstring in wks_closed_matches]
+    if not wks_closed_tuples:
+      mr.errors.closed_statuses = 'A project cannot have zero closed statuses'
+
+    statuses_offer_merge_text = post_data.get('statuses_offer_merge', '')
+    statuses_offer_merge = framework_constants.IDENTIFIER_RE.findall(
+        statuses_offer_merge_text)
+
+    if mr.errors.AnyErrors():
+      self.PleaseCorrect(
+          mr, open_text=wks_open_text, closed_text=wks_closed_text)
+      return
+
+    self.services.config.UpdateConfig(
+        mr.cnxn, mr.project, statuses_offer_merge=statuses_offer_merge,
+        well_known_statuses=wks_open_tuples + wks_closed_tuples)
+
+    # TODO(jrobbins): define a "strict" mode that affects only statuses.
+
+    return urls.ADMIN_STATUSES
+
+
+class AdminLabels(IssueAdminBase):
+  """Servlet allowing project owners to labels and fields."""
+
+  _PAGE_TEMPLATE = 'tracker/admin-labels-page.ezt'
+  _PROCESS_SUBTAB = servlet.Servlet.PROCESS_TAB_LABELS
+
+  def GatherPageData(self, mr):
+    """Build up a dictionary of data values to use when rendering the page.
+
+    Args:
+      mr: commonly used info parsed from the request.
+
+    Returns:
+      Dict of values used by EZT for rendering the page.
+    """
+    page_data = super(AdminLabels, self).GatherPageData(mr)
+    config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+    field_def_views = [
+        tracker_views.FieldDefView(fd, config)
+        # TODO(jrobbins): future field-level view restrictions.
+        for fd in config.field_defs
+        if not fd.is_deleted]
+    page_data.update({
+        'field_defs': field_def_views,
+        })
+    return page_data
+
+  def ProcessSubtabForm(self, post_data, mr):
+    """Process changes to labels and custom field definitions.
+
+    Args:
+      post_data: HTML form data for the HTTP request being processed.
+      mr: commonly used info parsed from the request.
+
+    Returns:
+      The URL of the page to show after processing.
+    """
+    if not self.CheckPerm(mr, permissions.EDIT_PROJECT):
+      raise permissions.PermissionException(
+          'Only project owners may edit the label definitions')
+
+    wkl_text = post_data.get('predefinedlabels', '')
+    wkl_matches = framework_constants.IDENTIFIER_DOCSTRING_RE.findall(wkl_text)
+    wkl_tuples = [
+        (label.lstrip('#'), docstring.strip(), label.startswith('#'))
+        for label, docstring in wkl_matches]
+    if not wkl_tuples:
+      mr.errors.label_defs = 'A project cannot have zero labels'
+    label_counter = collections.Counter(wkl[0].lower() for wkl in wkl_tuples)
+    for lab, count in label_counter.items():
+      if count > 1:
+        mr.errors.label_defs = 'Duplicate label: %s' % lab
+
+    config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+    field_names = [fd.field_name for fd in config.field_defs
+                   if fd.field_type is tracker_pb2.FieldTypes.ENUM_TYPE
+                   and not fd.is_deleted]
+    masked_labels = tracker_helpers.LabelsMaskedByFields(config, field_names)
+    field_names_lower = [field_name.lower() for field_name in field_names]
+    for wkl in wkl_tuples:
+      conflict = tracker_bizobj.LabelIsMaskedByField(wkl[0], field_names_lower)
+      if conflict:
+        mr.errors.label_defs = (
+            'Label "%s" should be defined in enum "%s"' % (wkl[0], conflict))
+    wkl_tuples.extend([
+        (masked.name, masked.docstring, False) for masked in masked_labels])
+
+    excl_prefix_text = post_data.get('excl_prefixes', '')
+    excl_prefixes = framework_constants.IDENTIFIER_RE.findall(excl_prefix_text)
+
+    if mr.errors.AnyErrors():
+      self.PleaseCorrect(mr, labels_text=wkl_text)
+      return
+
+    self.services.config.UpdateConfig(
+        mr.cnxn, mr.project,
+        well_known_labels=wkl_tuples, excl_label_prefixes=excl_prefixes)
+
+    # TODO(jrobbins): define a "strict" mode that affects only labels.
+
+    return urls.ADMIN_LABELS
+
+
+class AdminTemplates(IssueAdminBase):
+  """Servlet allowing project owners to configure templates."""
+
+  _PAGE_TEMPLATE = 'tracker/admin-templates-page.ezt'
+  _PROCESS_SUBTAB = servlet.Servlet.PROCESS_TAB_TEMPLATES
+
+  def GatherPageData(self, mr):
+    """Build up a dictionary of data values to use when rendering the page.
+
+    Args:
+      mr: commonly used info parsed from the request.
+
+    Returns:
+      Dict of values used by EZT for rendering the page.
+    """
+    return super(AdminTemplates, self).GatherPageData(mr)
+
+  def ProcessSubtabForm(self, post_data, mr):
+    """Process changes to new issue templates.
+
+    Args:
+      post_data: HTML form data for the HTTP request being processed.
+      mr: commonly used info parsed from the request.
+
+    Returns:
+      The URL of the page to show after processing.
+    """
+    if not self.CheckPerm(mr, permissions.EDIT_PROJECT):
+      raise permissions.PermissionException(
+          'Only project owners may edit the default templates')
+
+    config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+
+    templates = self.services.template.GetProjectTemplates(mr.cnxn,
+        config.project_id)
+    default_template_id_for_developers, default_template_id_for_users = (
+        self._ParseDefaultTemplateSelections(post_data, templates))
+    if default_template_id_for_developers or default_template_id_for_users:
+      self.services.config.UpdateConfig(
+          mr.cnxn, mr.project,
+          default_template_for_developers=default_template_id_for_developers,
+          default_template_for_users=default_template_id_for_users)
+
+    return urls.ADMIN_TEMPLATES
+
+  def _ParseDefaultTemplateSelections(self, post_data, templates):
+    """Parse the input for the default templates to offer users."""
+    def GetSelectedTemplateID(name):
+      """Find the ID of the template specified in post_data[name]."""
+      if name not in post_data:
+        return None
+      selected_template_name = post_data[name]
+      for template in templates:
+        if selected_template_name == template.name:
+          return template.template_id
+
+      logging.error('User somehow selected an invalid template: %r',
+                    selected_template_name)
+      return None
+
+    return (GetSelectedTemplateID('default_template_for_developers'),
+            GetSelectedTemplateID('default_template_for_users'))
+
+
+class AdminComponents(IssueAdminBase):
+  """Servlet allowing project owners to view the list of components."""
+
+  _PAGE_TEMPLATE = 'tracker/admin-components-page.ezt'
+  _PROCESS_SUBTAB = servlet.Servlet.PROCESS_TAB_COMPONENTS
+
+  def GatherPageData(self, mr):
+    """Build up a dictionary of data values to use when rendering the page.
+
+    Args:
+      mr: commonly used info parsed from the request.
+
+    Returns:
+      Dict of values used by EZT for rendering the page.
+    """
+    page_data = super(AdminComponents, self).GatherPageData(mr)
+    config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+    users_by_id = framework_views.MakeAllUserViews(
+        mr.cnxn, self.services.user,
+        *[list(cd.admin_ids) + list(cd.cc_ids)
+          for cd in config.component_defs])
+    framework_views.RevealAllEmailsToMembers(
+        mr.cnxn, self.services, mr.auth, users_by_id, mr.project)
+    component_def_views = [
+        tracker_views.ComponentDefView(mr.cnxn, self.services, cd, users_by_id)
+        # TODO(jrobbins): future component-level view restrictions.
+        for cd in config.component_defs]
+    for cd in component_def_views:
+      if mr.auth.email in [user.email for user in cd.admins]:
+        cd.classes += 'myadmin '
+      if mr.auth.email in [user.email for user in cd.cc]:
+        cd.classes += 'mycc '
+
+    page_data.update({
+        'component_defs': component_def_views,
+        'failed_perm': mr.GetParam('failed_perm'),
+        'failed_subcomp': mr.GetParam('failed_subcomp'),
+        'failed_templ': mr.GetParam('failed_templ'),
+        })
+    return page_data
+
+  def _GetComponentDefs(self, _mr, post_data, config):
+    """Get the config and component definitions from the request."""
+    component_defs = []
+    component_paths = post_data.get('delete_components').split(',')
+    for component_path in component_paths:
+      component_def = tracker_bizobj.FindComponentDef(component_path, config)
+      component_defs.append(component_def)
+    return component_defs
+
+  def _ProcessDeleteComponent(self, mr, component_def):
+    """Delete the specified component and its references."""
+    self.services.issue.DeleteComponentReferences(
+        mr.cnxn, component_def.component_id)
+    self.services.config.DeleteComponentDef(
+        mr.cnxn, mr.project_id, component_def.component_id)
+
+  def ProcessFormData(self, mr, post_data):
+    """Processes a POST command to delete components.
+
+    Args:
+      mr: commonly used info parsed from the request.
+      post_data: HTML form data from the request.
+
+    Returns:
+      String URL to redirect the user to, or None if response was already sent.
+    """
+    config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+    component_defs = self._GetComponentDefs(mr, post_data, config)
+    # Reverse the component_defs so that we start deleting from subcomponents.
+    component_defs.reverse()
+
+    # Collect errors.
+    perm_errors = []
+    subcomponents_errors = []
+    templates_errors = []
+    # Collect successes.
+    deleted_components = []
+
+    for component_def in component_defs:
+      allow_edit = permissions.CanEditComponentDef(
+          mr.auth.effective_ids, mr.perms, mr.project, component_def, config)
+      if not allow_edit:
+        perm_errors.append(component_def.path)
+
+      subcomponents = tracker_bizobj.FindDescendantComponents(
+          config, component_def)
+      if subcomponents:
+        subcomponents_errors.append(component_def.path)
+
+      templates = self.services.template.TemplatesWithComponent(
+          mr.cnxn, component_def.component_id)
+      if templates:
+        templates_errors.append(component_def.path)
+
+      allow_delete = allow_edit and not subcomponents and not templates
+      if allow_delete:
+        self._ProcessDeleteComponent(mr, component_def)
+        deleted_components.append(component_def.path)
+        # Refresh project config after the component deletion.
+        config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+
+    return framework_helpers.FormatAbsoluteURL(
+        mr, urls.ADMIN_COMPONENTS, ts=int(time.time()),
+        failed_perm=','.join(perm_errors),
+        failed_subcomp=','.join(subcomponents_errors),
+        failed_templ=','.join(templates_errors),
+        deleted=','.join(deleted_components))
+
+
+class AdminViews(IssueAdminBase):
+  """Servlet for project owners to set default columns, axes, and sorting."""
+
+  _PAGE_TEMPLATE = 'tracker/admin-views-page.ezt'
+  _PROCESS_SUBTAB = servlet.Servlet.PROCESS_TAB_VIEWS
+
+  def GatherPageData(self, mr):
+    """Build up a dictionary of data values to use when rendering the page.
+
+    Args:
+      mr: commonly used info parsed from the request.
+
+    Returns:
+      Dict of values used by EZT for rendering the page.
+    """
+    page_data = super(AdminViews, self).GatherPageData(mr)
+    with mr.profiler.Phase('getting canned queries'):
+      canned_queries = self.services.features.GetCannedQueriesByProjectID(
+          mr.cnxn, mr.project_id)
+      canned_query_views = [
+          savedqueries_helpers.SavedQueryView(sq, idx + 1, None, None)
+          for idx, sq in enumerate(canned_queries)]
+
+    page_data.update({
+        'canned_queries': canned_query_views,
+        'new_query_indexes': list(range(
+            len(canned_queries) + 1, savedqueries_helpers.MAX_QUERIES + 1)),
+        'issue_notify': mr.project.issue_notify_address,
+        'max_queries': savedqueries_helpers.MAX_QUERIES,
+        })
+    return page_data
+
+  def ProcessSubtabForm(self, post_data, mr):
+    """Process the Views subtab.
+
+    Args:
+      post_data: HTML form data for the HTTP request being processed.
+      mr: commonly used info parsed from the request.
+
+    Returns:
+      The URL of the page to show after processing.
+    """
+    if not self.CheckPerm(mr, permissions.EDIT_PROJECT):
+      raise permissions.PermissionException(
+          'Only project owners may edit the default views')
+    existing_queries = savedqueries_helpers.ParseSavedQueries(
+        mr.cnxn, post_data, self.services.project)
+    added_queries = savedqueries_helpers.ParseSavedQueries(
+        mr.cnxn, post_data, self.services.project, prefix='new_')
+    canned_queries = existing_queries + added_queries
+
+    list_prefs = _ParseListPreferences(post_data)
+
+    if mr.errors.AnyErrors():
+      self.PleaseCorrect(mr)
+      return
+
+    self.services.config.UpdateConfig(
+        mr.cnxn, mr.project, list_prefs=list_prefs)
+    self.services.features.UpdateCannedQueries(
+        mr.cnxn, mr.project_id, canned_queries)
+
+    return urls.ADMIN_VIEWS
+
+
+def _ParseListPreferences(post_data):
+  """Parse the part of a project admin form about artifact list preferences."""
+  default_col_spec = ''
+  if 'default_col_spec' in post_data:
+    default_col_spec = post_data['default_col_spec']
+  # Don't allow empty colum spec
+  if not default_col_spec:
+    default_col_spec = tracker_constants.DEFAULT_COL_SPEC
+  col_spec_words = monorailrequest.ParseColSpec(
+      default_col_spec, max_parts=framework_constants.MAX_COL_PARTS)
+  col_spec = ' '.join(word for word in col_spec_words)
+
+  default_sort_spec = ''
+  if 'default_sort_spec' in post_data:
+    default_sort_spec = post_data['default_sort_spec']
+  sort_spec_words = monorailrequest.ParseColSpec(default_sort_spec)
+  sort_spec = ' '.join(sort_spec_words)
+
+  x_attr_str = ''
+  if 'default_x_attr' in post_data:
+    x_attr_str = post_data['default_x_attr']
+  x_attr_words = monorailrequest.ParseColSpec(x_attr_str)
+  x_attr = ''
+  if x_attr_words:
+    x_attr = x_attr_words[0]
+
+  y_attr_str = ''
+  if 'default_y_attr' in post_data:
+    y_attr_str = post_data['default_y_attr']
+  y_attr_words = monorailrequest.ParseColSpec(y_attr_str)
+  y_attr = ''
+  if y_attr_words:
+    y_attr = y_attr_words[0]
+
+  member_default_query = ''
+  if 'member_default_query' in post_data:
+    member_default_query = post_data['member_default_query']
+
+  return col_spec, sort_spec, x_attr, y_attr, member_default_query
+
+
+class AdminRules(IssueAdminBase):
+  """Servlet allowing project owners to configure filter rules."""
+
+  _PAGE_TEMPLATE = 'tracker/admin-rules-page.ezt'
+  _PROCESS_SUBTAB = servlet.Servlet.PROCESS_TAB_RULES
+
+  def AssertBasePermission(self, mr):
+    """Check whether the user has any permission to visit this page.
+
+    Args:
+      mr: commonly used info parsed from the request.
+    """
+    super(AdminRules, self).AssertBasePermission(mr)
+    if not self.CheckPerm(mr, permissions.EDIT_PROJECT):
+      raise permissions.PermissionException(
+          'User is not allowed to administer this project')
+
+  def GatherPageData(self, mr):
+    """Build up a dictionary of data values to use when rendering the page.
+
+    Args:
+      mr: commonly used info parsed from the request.
+
+    Returns:
+      Dict of values used by EZT for rendering the page.
+    """
+    page_data = super(AdminRules, self).GatherPageData(mr)
+    rules = self.services.features.GetFilterRules(
+        mr.cnxn, mr.project_id)
+    users_by_id = framework_views.MakeAllUserViews(
+        mr.cnxn, self.services.user,
+        [rule.default_owner_id for rule in rules],
+        *[rule.add_cc_ids for rule in rules])
+    framework_views.RevealAllEmailsToMembers(
+        mr.cnxn, self.services, mr.auth, users_by_id, mr.project)
+    rule_views = [filterrules_views.RuleView(rule, users_by_id)
+                  for rule in rules]
+
+    for idx, rule_view in enumerate(rule_views):
+      rule_view.idx = idx + 1  # EZT has no loop index, so we set idx.
+
+    page_data.update({
+        'rules': rule_views,
+        'new_rule_indexes': (
+            list(range(len(rules) + 1, filterrules_helpers.MAX_RULES + 1))),
+        'max_rules': filterrules_helpers.MAX_RULES,
+        })
+    return page_data
+
+  def ProcessSubtabForm(self, post_data, mr):
+    """Process the Rules subtab.
+
+    Args:
+      post_data: HTML form data for the HTTP request being processed.
+      mr: commonly used info parsed from the request.
+
+    Returns:
+      The URL of the page to show after processing.
+    """
+    old_rules = self.services.features.GetFilterRules(mr.cnxn, mr.project_id)
+    rules = filterrules_helpers.ParseRules(
+        mr.cnxn, post_data, self.services.user, mr.errors)
+    new_rules = filterrules_helpers.ParseRules(
+        mr.cnxn, post_data, self.services.user, mr.errors, prefix='new_')
+    rules.extend(new_rules)
+
+    if mr.errors.AnyErrors():
+      self.PleaseCorrect(mr)
+      return
+
+    config = self.services.features.UpdateFilterRules(
+        mr.cnxn, mr.project_id, rules)
+
+    if old_rules != rules:
+      logging.info('recomputing derived fields')
+      config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+      filterrules_helpers.RecomputeAllDerivedFields(
+          mr.cnxn, self.services, mr.project, config)
+
+    return urls.ADMIN_RULES
diff --git a/tracker/issueadvsearch.py b/tracker/issueadvsearch.py
new file mode 100644
index 0000000..d824098
--- /dev/null
+++ b/tracker/issueadvsearch.py
@@ -0,0 +1,123 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Classes that implement the advanced search feature page.
+
+The advanced search page simply displays an HTML page with a form.
+The form handler converts the widget-based query into a googley query
+string and redirects the user to the issue list servlet.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import re
+
+from features import savedqueries_helpers
+from framework import framework_helpers
+from framework import permissions
+from framework import servlet
+from framework import urls
+
+# Patterns for search values that can be words, labels,
+# component paths, or email addresses.
+VALUE_RE = re.compile(r'[-a-zA-Z0-9._>@]+')
+
+
+class IssueAdvancedSearch(servlet.Servlet):
+  """IssueAdvancedSearch shows a form to enter an advanced search."""
+
+  _PAGE_TEMPLATE = 'tracker/issue-advsearch-page.ezt'
+  _MAIN_TAB_MODE = servlet.Servlet.MAIN_TAB_ISSUES
+
+  # This form *only* redirects to a GET request, and permissions are checked
+  # in that handler.
+  CHECK_SECURITY_TOKEN = False
+
+  def GatherPageData(self, mr):
+    """Build up a dictionary of data values to use when rendering the page.
+
+    Args:
+      mr: commonly used info parsed from the request.
+
+    Returns:
+      Dict of values used by EZT for rendering the page.
+    """
+    # TODO(jrobbins): Allow deep-linking into this page.
+    canned_query_views = []
+    if mr.project_id:
+      with mr.profiler.Phase('getting canned queries'):
+        canned_queries = self.services.features.GetCannedQueriesByProjectID(
+            mr.cnxn, mr.project_id)
+      canned_query_views = [
+          savedqueries_helpers.SavedQueryView(sq, idx + 1, None, None)
+          for idx, sq in enumerate(canned_queries)]
+
+    saved_query_views = []
+    if mr.auth.user_id and self.services.features:
+      with mr.profiler.Phase('getting saved queries'):
+        saved_queries = self.services.features.GetSavedQueriesByUserID(
+            mr.cnxn, mr.me_user_id)
+        saved_query_views = [
+            savedqueries_helpers.SavedQueryView(sq, idx + 1, None, None)
+            for idx, sq in enumerate(saved_queries)
+            if (mr.project_id in sq.executes_in_project_ids or
+                not mr.project_id)]
+
+    return {
+        'issue_tab_mode': 'issueAdvSearch',
+        'page_perms': self.MakePagePerms(mr, None, permissions.CREATE_ISSUE),
+        'canned_queries': canned_query_views,
+        'saved_queries': saved_query_views,
+        }
+
+  def ProcessFormData(self, mr, post_data):
+    """Process a posted advanced query form.
+
+    Args:
+      mr: commonly used info parsed from the request.
+      post_data: HTML form data from the request.
+
+    Returns:
+      String URL to redirect the user to after processing.
+    """
+    # Default to searching open issues in this project.
+    can = post_data.get('can', 2)
+
+    terms = []
+    self._AccumulateANDTerm('', 'words', post_data, terms)
+    self._AccumulateANDTerm('-', 'without', post_data, terms)
+    self._AccumulateANDTerm('label:', 'labels', post_data, terms)
+    self._AccumulateORTerm('component:', 'components', post_data, terms)
+    self._AccumulateORTerm('status:', 'statuses', post_data, terms)
+    self._AccumulateORTerm('reporter:', 'reporters', post_data, terms)
+    self._AccumulateORTerm('owner:', 'owners', post_data, terms)
+    self._AccumulateORTerm('cc:', 'cc', post_data, terms)
+    self._AccumulateORTerm('commentby:', 'commentby', post_data, terms)
+
+    if 'starcount' in post_data:
+      starcount = int(post_data['starcount'])
+      if starcount >= 0:
+        terms.append('starcount:%s' % starcount)
+
+    return framework_helpers.FormatAbsoluteURL(
+        mr, urls.ISSUE_LIST, q=' '.join(terms), can=can)
+
+  def _AccumulateANDTerm(self, operator, form_field, post_data, search_query):
+    """Build a query that matches issues with ALL of the given field values."""
+    user_input = post_data.get(form_field)
+    if user_input:
+      values = VALUE_RE.findall(user_input)
+      search_terms = ['%s%s' % (operator, v) for v in values]
+      search_query.extend(search_terms)
+
+  def _AccumulateORTerm(self, operator, form_field, post_data, search_query):
+    """Build a query that matches issues with ANY of the given field values."""
+    user_input = post_data.get(form_field)
+    if user_input:
+      values = VALUE_RE.findall(user_input)
+      search_term = '%s%s' % (operator, ','.join(values))
+      search_query.append(search_term)
diff --git a/tracker/issueattachment.py b/tracker/issueattachment.py
new file mode 100644
index 0000000..d6fa978
--- /dev/null
+++ b/tracker/issueattachment.py
@@ -0,0 +1,93 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Issue Tracker code to serve out issue attachments.
+
+Summary of page classes:
+  AttachmentPage: Serve the content of an attachment w/ the appropriate
+                  MIME type.
+  IssueAttachmentDeletion: Form handler for deleting attachments.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import base64
+import logging
+import os
+import re
+import urllib
+
+import webapp2
+
+from google.appengine.api import app_identity
+from google.appengine.api import images
+
+from framework import exceptions
+from framework import framework_constants
+from framework import framework_helpers
+from framework import gcs_helpers
+from framework import permissions
+from framework import servlet
+from framework import urls
+from tracker import attachment_helpers
+from tracker import tracker_helpers
+from tracker import tracker_views
+
+
+# This will likely appear blank or as a broken image icon in the browser.
+NO_PREVIEW_ICON = ''
+NO_PREVIEW_MIME_TYPE = 'image/png'
+
+
+class AttachmentPage(servlet.Servlet):
+  """AttachmentPage serves issue attachments."""
+
+  def GatherPageData(self, mr):
+    """Parse the attachment ID from the request and serve its content.
+
+    Args:
+      mr: commonly used info parsed from the request.
+
+    Returns: dict of values used by EZT for rendering the page.
+    """
+    if mr.signed_aid != attachment_helpers.SignAttachmentID(mr.aid):
+      webapp2.abort(400, 'Please reload the issue page')
+
+    try:
+      attachment, _issue = tracker_helpers.GetAttachmentIfAllowed(
+          mr, self.services)
+    except exceptions.NoSuchIssueException:
+      webapp2.abort(404, 'issue not found')
+    except exceptions.NoSuchAttachmentException:
+      webapp2.abort(404, 'attachment not found')
+    except exceptions.NoSuchCommentException:
+      webapp2.abort(404, 'comment not found')
+
+    if not attachment.gcs_object_id:
+      webapp2.abort(404, 'attachment data not found')
+
+    bucket_name = app_identity.get_default_gcs_bucket_name()
+
+    gcs_object_id = attachment.gcs_object_id
+
+    logging.info('attachment id %d is %s', mr.aid, gcs_object_id)
+
+    # By default GCS will return images and attachments displayable inline.
+    if mr.thumb:
+      # Thumbnails are stored in a separate obj always displayed inline.
+      gcs_object_id = gcs_object_id + '-thumbnail'
+    elif not mr.inline:
+      # Downloads are stored in a separate obj with disposiiton set.
+      filename = attachment.filename
+      if not framework_constants.FILENAME_RE.match(filename):
+        logging.info('bad file name: %s' % attachment.attachment_id)
+        filename = 'attachment-%d.dat' % attachment.attachment_id
+      if gcs_helpers.MaybeCreateDownload(
+          bucket_name, gcs_object_id, filename):
+        gcs_object_id = gcs_object_id + '-download'
+
+    url = gcs_helpers.SignUrl(bucket_name, gcs_object_id)
+    self.redirect(url, abort=True)
diff --git a/tracker/issueattachmenttext.py b/tracker/issueattachmenttext.py
new file mode 100644
index 0000000..d3daaf9
--- /dev/null
+++ b/tracker/issueattachmenttext.py
@@ -0,0 +1,103 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Servlet to safely display textual issue attachments.
+
+Unlike most attachments, this is not a download, it is a full HTML page
+with safely escaped user content.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+
+import webapp2
+
+from google.appengine.api import app_identity
+
+from third_party import cloudstorage
+import ezt
+
+from features import prettify
+from framework import exceptions
+from framework import filecontent
+from framework import permissions
+from framework import servlet
+from framework import template_helpers
+from tracker import attachment_helpers
+from tracker import tracker_bizobj
+from tracker import tracker_helpers
+
+
+class AttachmentText(servlet.Servlet):
+  """AttachmentText displays textual attachments much like source browsing."""
+
+  _PAGE_TEMPLATE = 'tracker/issue-attachment-text.ezt'
+  _MAIN_TAB_MODE = servlet.Servlet.MAIN_TAB_ISSUES
+
+  def GatherPageData(self, mr):
+    """Parse the attachment ID from the request and serve its content.
+
+    Args:
+      mr: commonly used info parsed from the request.
+
+    Returns:
+      Dict of values used by EZT for rendering almost the page.
+    """
+    with mr.profiler.Phase('get issue, comment, and attachment'):
+      try:
+        attachment, issue = tracker_helpers.GetAttachmentIfAllowed(
+            mr, self.services)
+      except exceptions.NoSuchIssueException:
+        webapp2.abort(404, 'issue not found')
+      except exceptions.NoSuchAttachmentException:
+        webapp2.abort(404, 'attachment not found')
+      except exceptions.NoSuchCommentException:
+        webapp2.abort(404, 'comment not found')
+
+    content = []
+    if attachment.gcs_object_id:
+      bucket_name = app_identity.get_default_gcs_bucket_name()
+      full_path = '/' + bucket_name + attachment.gcs_object_id
+      logging.info("reading gcs: %s" % full_path)
+      with cloudstorage.open(full_path, 'r') as f:
+        content = f.read()
+
+    filesize = len(content)
+
+    # This servlet only displays safe textual attachments. The user should
+    # not have been given a link to this servlet for any other kind.
+    if not attachment_helpers.IsViewableText(attachment.mimetype, filesize):
+      self.abort(400, 'not a text file')
+
+    u_text, is_binary, too_large = filecontent.DecodeFileContents(content)
+    lines = prettify.PrepareSourceLinesForHighlighting(u_text.encode('utf8'))
+
+    config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+    granted_perms = tracker_bizobj.GetGrantedPerms(
+        issue, mr.auth.effective_ids, config)
+    page_perms = self.MakePagePerms(
+        mr, issue, permissions.DELETE_ISSUE, permissions.CREATE_ISSUE,
+        granted_perms=granted_perms)
+
+    page_data = {
+        'issue_tab_mode': 'issueDetail',
+        'local_id': issue.local_id,
+        'filename': attachment.filename,
+        'filesize': template_helpers.BytesKbOrMb(filesize),
+        'file_lines': lines,
+        'is_binary': ezt.boolean(is_binary),
+        'too_large': ezt.boolean(too_large),
+        'code_reviews': None,
+        'page_perms': page_perms,
+        }
+    if is_binary or too_large:
+      page_data['should_prettify'] = ezt.boolean(False)
+    else:
+      page_data.update(prettify.BuildPrettifyData(
+          len(lines), attachment.filename))
+
+    return page_data
diff --git a/tracker/issuebulkedit.py b/tracker/issuebulkedit.py
new file mode 100644
index 0000000..c1f5229
--- /dev/null
+++ b/tracker/issuebulkedit.py
@@ -0,0 +1,473 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Classes that implement the issue bulk edit page and related forms.
+
+Summary of classes:
+  IssueBulkEdit: Show a form for editing multiple issues and allow the
+     user to update them all at once.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import collections
+import httplib
+import itertools
+import logging
+import time
+
+import ezt
+
+from features import filterrules_helpers
+from features import send_notifications
+from framework import exceptions
+from framework import framework_constants
+from framework import framework_views
+from framework import permissions
+from framework import servlet
+from framework import template_helpers
+from services import tracker_fulltext
+from tracker import field_helpers
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+from tracker import tracker_helpers
+from tracker import tracker_views
+
+
+class IssueBulkEdit(servlet.Servlet):
+  """IssueBulkEdit lists multiple issues and allows an edit to all of them."""
+
+  _PAGE_TEMPLATE = 'tracker/issue-bulk-edit-page.ezt'
+  _MAIN_TAB_MODE = servlet.Servlet.MAIN_TAB_ISSUES
+  _SECONDS_OVERHEAD = 4
+  _SECONDS_PER_UPDATE = 0.12
+  _SLOWNESS_THRESHOLD = 10
+
+  def AssertBasePermission(self, mr):
+    """Check whether the user has any permission to visit this page.
+
+    Args:
+      mr: commonly used info parsed from the request.
+
+    Raises:
+      PermissionException: if the user is not allowed to enter an issue.
+    """
+    super(IssueBulkEdit, self).AssertBasePermission(mr)
+    can_edit = self.CheckPerm(mr, permissions.EDIT_ISSUE)
+    can_comment = self.CheckPerm(mr, permissions.ADD_ISSUE_COMMENT)
+    if not (can_edit and can_comment):
+      raise permissions.PermissionException('bulk edit forbidden')
+
+  def GatherPageData(self, mr):
+    """Build up a dictionary of data values to use when rendering the page.
+
+    Args:
+      mr: commonly used info parsed from the request.
+
+    Returns:
+      Dict of values used by EZT for rendering the page.
+    """
+    with mr.profiler.Phase('getting issues'):
+      if not mr.local_id_list:
+        raise exceptions.InputException()
+      requested_issues = self.services.issue.GetIssuesByLocalIDs(
+          mr.cnxn, mr.project_id, sorted(mr.local_id_list))
+
+    with mr.profiler.Phase('filtering issues'):
+      # TODO(jrobbins): filter out issues that the user cannot edit and
+      # provide that as feedback rather than just siliently ignoring them.
+      open_issues, closed_issues = (
+          tracker_helpers.GetAllowedOpenedAndClosedIssues(
+              mr, [issue.issue_id for issue in requested_issues],
+              self.services))
+      issues = open_issues + closed_issues
+
+    if not issues:
+      self.abort(404, 'no issues found')
+
+    config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+    type_label_set = {
+        lab.lower() for lab in issues[0].labels
+        if lab.lower().startswith('type-')}
+    for issue in issues[1:]:
+      new_type_set = {
+          lab.lower() for lab in issue.labels
+          if lab.lower().startswith('type-')}
+      type_label_set &= new_type_set
+
+    issue_phases = list(
+        itertools.chain.from_iterable(issue.phases for issue in issues))
+
+    field_views = tracker_views.MakeAllFieldValueViews(
+        config, type_label_set, [], [], {}, phases=issue_phases)
+    for fv in field_views:
+      # Explicitly set all field views to not required. We do not want to force
+      # users to have to set it for issues missing required fields.
+      # See https://bugs.chromium.org/p/monorail/issues/detail?id=500 for more
+      # details.
+      fv.field_def.is_required_bool = None
+
+      if permissions.CanEditValueForFieldDef(
+          mr.auth.effective_ids, mr.perms, mr.project, fv.field_def.field_def):
+        fv.is_editable = ezt.boolean(True)
+      else:
+        fv.is_editable = ezt.boolean(False)
+
+    with mr.profiler.Phase('making issue proxies'):
+      issue_views = [
+          template_helpers.EZTItem(
+              local_id=issue.local_id, summary=issue.summary,
+              closed=ezt.boolean(issue in closed_issues))
+          for issue in issues]
+
+    num_seconds = (int(len(issue_views) * self._SECONDS_PER_UPDATE) +
+                   self._SECONDS_OVERHEAD)
+
+    page_perms = self.MakePagePerms(
+        mr, None,
+        permissions.CREATE_ISSUE,
+        permissions.DELETE_ISSUE)
+
+    return {
+        'issue_tab_mode': 'issueBulkEdit',
+        'issues': issue_views,
+        'local_ids_str': ','.join([str(issue.local_id) for issue in issues]),
+        'num_issues': len(issue_views),
+        'show_progress': ezt.boolean(num_seconds > self._SLOWNESS_THRESHOLD),
+        'num_seconds': num_seconds,
+
+        'initial_blocked_on': '',
+        'initial_blocking': '',
+        'initial_comment': '',
+        'initial_status': '',
+        'initial_owner': '',
+        'initial_merge_into': '',
+        'initial_cc': '',
+        'initial_components': '',
+        'labels': [],
+        'fields': field_views,
+
+        'restrict_to_known': ezt.boolean(config.restrict_to_known),
+        'page_perms': page_perms,
+        'statuses_offer_merge': config.statuses_offer_merge,
+        'issue_phase_names': list(
+            {phase.name.lower() for phase in issue_phases}),
+        }
+
+  def ProcessFormData(self, mr, post_data):
+    # (...) -> str
+    """Process the posted issue update form.
+
+    Args:
+      mr: commonly used info parsed from the request.
+      post_data: HTML form data from the request.
+
+    Returns:
+      String URL to redirect the user to after processing.
+    """
+    if not mr.local_id_list:
+      logging.info('missing issue local IDs, probably tampered')
+      self.response.status = httplib.BAD_REQUEST
+      return
+
+    # Check that the user is logged in; anon users cannot update issues.
+    if not mr.auth.user_id:
+      logging.info('user was not logged in, cannot update issue')
+      self.response.status = httplib.BAD_REQUEST  # xxx should raise except
+      return
+
+    # Check that the user has permission to add a comment, and to enter
+    # metadata if they are trying to do that.
+    if not self.CheckPerm(mr, permissions.ADD_ISSUE_COMMENT):
+      logging.info('user has no permission to add issue comment')
+      self.response.status = httplib.BAD_REQUEST
+      return
+
+    if not self.CheckPerm(mr, permissions.EDIT_ISSUE):
+      logging.info('user has no permission to edit issue metadata')
+      self.response.status = httplib.BAD_REQUEST
+      return
+
+    move_to = post_data.get('move_to', '').lower()
+    if move_to and not self.CheckPerm(mr, permissions.DELETE_ISSUE):
+      logging.info('user has no permission to move issue')
+      self.response.status = httplib.BAD_REQUEST
+      return
+
+    config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+
+    parsed = tracker_helpers.ParseIssueRequest(
+        mr.cnxn, post_data, self.services, mr.errors, mr.project_name)
+    bounce_labels = (
+        parsed.labels[:] +
+        ['-%s' % lr for lr in parsed.labels_remove])
+    bounce_fields = tracker_views.MakeBounceFieldValueViews(
+        parsed.fields.vals, parsed.fields.phase_vals, config)
+    field_helpers.ShiftEnumFieldsIntoLabels(
+        parsed.labels, parsed.labels_remove,
+        parsed.fields.vals, parsed.fields.vals_remove,
+        config)
+    issue_list = self.services.issue.GetIssuesByLocalIDs(
+        mr.cnxn, mr.project_id, mr.local_id_list)
+    issue_phases = list(
+        itertools.chain.from_iterable(issue.phases for issue in issue_list))
+    phase_ids_by_name = collections.defaultdict(set)
+    for phase in issue_phases:
+      phase_ids_by_name[phase.name.lower()].add(phase.phase_id)
+    # Note: Not all parsed phase field values will be applicable to every issue.
+    # tracker_bizobj.ApplyFieldValueChanges will take care of not adding
+    # phase field values to issues that don't contain the correct phase.
+    field_vals = field_helpers.ParseFieldValues(
+        mr.cnxn, self.services.user, parsed.fields.vals,
+        parsed.fields.phase_vals, config,
+        phase_ids_by_name=phase_ids_by_name)
+    field_vals_remove = field_helpers.ParseFieldValues(
+        mr.cnxn, self.services.user, parsed.fields.vals_remove,
+        parsed.fields.phase_vals_remove, config,
+        phase_ids_by_name=phase_ids_by_name)
+
+    field_helpers.AssertCustomFieldsEditPerms(
+        mr, config, field_vals, field_vals_remove, parsed.fields.fields_clear,
+        parsed.labels, parsed.labels_remove)
+    field_helpers.ValidateCustomFields(
+        mr.cnxn, self.services, field_vals, config, mr.project,
+        ezt_errors=mr.errors)
+
+    # Treat status '' as no change and explicit 'clear' as clearing the status.
+    status = parsed.status
+    if status == '':
+      status = None
+    if post_data.get('op_statusenter') == 'clear':
+      status = ''
+
+    reporter_id = mr.auth.user_id
+    logging.info('bulk edit request by %s', reporter_id)
+
+    if parsed.users.owner_id is None:
+      mr.errors.owner = 'Invalid owner username'
+    else:
+      valid, msg = tracker_helpers.IsValidIssueOwner(
+          mr.cnxn, mr.project, parsed.users.owner_id, self.services)
+      if not valid:
+        mr.errors.owner = msg
+
+    if (status in config.statuses_offer_merge and
+        not post_data.get('merge_into')):
+      mr.errors.merge_into_id = 'Please enter a valid issue ID'
+
+    move_to_project = None
+    if move_to:
+      if mr.project_name == move_to:
+        mr.errors.move_to = 'The issues are already in project ' + move_to
+      else:
+        move_to_project = self.services.project.GetProjectByName(
+            mr.cnxn, move_to)
+        if not move_to_project:
+          mr.errors.move_to = 'No such project: ' + move_to
+
+    # Treat owner '' as no change, and explicit 'clear' as NO_USER_SPECIFIED
+    owner_id = parsed.users.owner_id
+    if parsed.users.owner_username == '':
+      owner_id = None
+    if post_data.get('op_ownerenter') == 'clear':
+      owner_id = framework_constants.NO_USER_SPECIFIED
+
+    comp_ids = tracker_helpers.LookupComponentIDs(
+        parsed.components.paths, config, mr.errors)
+    comp_ids_remove = tracker_helpers.LookupComponentIDs(
+        parsed.components.paths_remove, config, mr.errors)
+    if post_data.get('op_componententer') == 'remove':
+      comp_ids, comp_ids_remove = comp_ids_remove, comp_ids
+
+    cc_ids, cc_ids_remove = parsed.users.cc_ids, parsed.users.cc_ids_remove
+    if post_data.get('op_memberenter') == 'remove':
+      cc_ids, cc_ids_remove = parsed.users.cc_ids_remove, parsed.users.cc_ids
+
+    issue_list_iids = {issue.issue_id for issue in issue_list}
+    if post_data.get('op_blockedonenter') == 'append':
+      if issue_list_iids.intersection(parsed.blocked_on.iids):
+        mr.errors.blocked_on = 'Cannot block an issue on itself.'
+      blocked_on_add = parsed.blocked_on.iids
+      blocked_on_remove = []
+    else:
+      blocked_on_add = []
+      blocked_on_remove = parsed.blocked_on.iids
+    if post_data.get('op_blockingenter') == 'append':
+      if issue_list_iids.intersection(parsed.blocking.iids):
+        mr.errors.blocking = 'Cannot block an issue on itself.'
+      blocking_add = parsed.blocking.iids
+      blocking_remove = []
+    else:
+      blocking_add = []
+      blocking_remove = parsed.blocking.iids
+
+    if len(parsed.comment) > tracker_constants.MAX_COMMENT_CHARS:
+      mr.errors.comment = 'Comment is too long.'
+
+    iids_actually_changed = []
+    old_owner_ids = []
+    combined_amendments = []
+    merge_into_issue = None
+    new_starrers = set()
+
+    if not mr.errors.AnyErrors():
+      # Because we will modify issues, load from DB rather than cache.
+      issue_list = self.services.issue.GetIssuesByLocalIDs(
+          mr.cnxn, mr.project_id, mr.local_id_list, use_cache=False)
+
+      # Skip any individual issues that the user is not allowed to edit.
+      editable_issues = [
+          issue for issue in issue_list
+          if permissions.CanEditIssue(
+              mr.auth.effective_ids, mr.perms, mr.project, issue)]
+
+      # Skip any restrict issues that cannot be moved
+      if move_to:
+        editable_issues = [
+            issue for issue in editable_issues
+            if not permissions.GetRestrictions(issue)]
+
+      # If 'Duplicate' status is specified ensure there are no permission issues
+      # with the issue we want to merge with.
+      if post_data.get('merge_into'):
+        for issue in editable_issues:
+          _, merge_into_issue = tracker_helpers.ParseMergeFields(
+              mr.cnxn, self.services, mr.project_name, post_data, parsed.status,
+              config, issue, mr.errors)
+          if merge_into_issue:
+            merge_allowed = tracker_helpers.IsMergeAllowed(
+                merge_into_issue, mr, self.services)
+            if not merge_allowed:
+              mr.errors.merge_into_id = 'Target issue %s cannot be modified' % (
+                                            merge_into_issue.local_id)
+              break
+
+            # Update the new_starrers set.
+            new_starrers.update(tracker_helpers.GetNewIssueStarrers(
+                mr.cnxn, self.services, [issue.issue_id],
+                merge_into_issue.issue_id))
+
+      # Proceed with amendments only if there are no reported errors.
+      if not mr.errors.AnyErrors():
+        # Sort the issues: we want them in this order so that the
+        # corresponding old_owner_id are found in the same order.
+        editable_issues.sort(key=lambda issue: issue.local_id)
+
+        iids_to_invalidate = set()
+        rules = self.services.features.GetFilterRules(
+            mr.cnxn, config.project_id)
+        predicate_asts = filterrules_helpers.ParsePredicateASTs(
+            rules, config, [])
+        for issue in editable_issues:
+          old_owner_id = tracker_bizobj.GetOwnerId(issue)
+          merge_into_iid = (
+              merge_into_issue.issue_id if merge_into_issue else None)
+
+          delta = tracker_bizobj.MakeIssueDelta(
+            status, owner_id, cc_ids, cc_ids_remove, comp_ids, comp_ids_remove,
+            parsed.labels, parsed.labels_remove, field_vals, field_vals_remove,
+            parsed.fields.fields_clear, blocked_on_add, blocked_on_remove,
+            blocking_add, blocking_remove, merge_into_iid, None)
+          amendments, _ = self.services.issue.DeltaUpdateIssue(
+              mr.cnxn, self.services, mr.auth.user_id, mr.project_id, config,
+              issue, delta, comment=parsed.comment,
+              iids_to_invalidate=iids_to_invalidate, rules=rules,
+              predicate_asts=predicate_asts)
+
+          if amendments or parsed.comment:  # Avoid empty comments.
+            iids_actually_changed.append(issue.issue_id)
+            old_owner_ids.append(old_owner_id)
+            combined_amendments.extend(amendments)
+
+        self.services.issue.InvalidateIIDs(mr.cnxn, iids_to_invalidate)
+        self.services.project.UpdateRecentActivity(
+            mr.cnxn, mr.project.project_id)
+
+        # Add new_starrers and new CCs to merge_into_issue.
+        if merge_into_issue:
+          merge_into_project = self.services.project.GetProjectByName(
+              mr.cnxn, merge_into_issue.project_name)
+          tracker_helpers.AddIssueStarrers(
+              mr.cnxn, self.services, mr, merge_into_issue.issue_id,
+              merge_into_project, new_starrers)
+          # Load target issue again to get the updated star count.
+          merge_into_issue = self.services.issue.GetIssue(
+              mr.cnxn, merge_into_issue.issue_id, use_cache=False)
+          tracker_helpers.MergeCCsAndAddCommentMultipleIssues(
+              self.services, mr, editable_issues, merge_into_issue)
+
+        if move_to and editable_issues:
+          tracker_fulltext.UnindexIssues(
+              [issue.issue_id for issue in editable_issues])
+          for issue in editable_issues:
+            old_text_ref = 'issue %s:%s' % (issue.project_name, issue.local_id)
+            moved_back_iids = self.services.issue.MoveIssues(
+                mr.cnxn, move_to_project, [issue], self.services.user)
+            new_text_ref = 'issue %s:%s' % (issue.project_name, issue.local_id)
+            if issue.issue_id in moved_back_iids:
+              content = 'Moved %s back to %s again.' % (
+                  old_text_ref, new_text_ref)
+            else:
+              content = 'Moved %s to now be %s.' % (old_text_ref, new_text_ref)
+            self.services.issue.CreateIssueComment(
+                mr.cnxn, issue, mr.auth.user_id, content, amendments=[
+                   tracker_bizobj.MakeProjectAmendment(
+                       move_to_project.project_name)])
+
+        send_email = 'send_email' in post_data
+
+        users_by_id = framework_views.MakeAllUserViews(
+            mr.cnxn, self.services.user,
+            [owner_id], cc_ids, cc_ids_remove, old_owner_ids,
+            tracker_bizobj.UsersInvolvedInAmendments(combined_amendments))
+        if move_to and editable_issues:
+          iids_actually_changed = [
+              issue.issue_id for issue in editable_issues]
+
+        send_notifications.SendIssueBulkChangeNotification(
+            iids_actually_changed, mr.request.host,
+            old_owner_ids, parsed.comment,
+            reporter_id, combined_amendments, send_email, users_by_id)
+
+    if mr.errors.AnyErrors():
+      bounce_cc_parts = (
+          parsed.users.cc_usernames +
+          ['-%s' % ccur for ccur in parsed.users.cc_usernames_remove])
+      self.PleaseCorrect(
+          mr, initial_status=parsed.status,
+          initial_owner=parsed.users.owner_username,
+          initial_merge_into=post_data.get('merge_into', 0),
+          initial_cc=', '.join(bounce_cc_parts),
+          initial_comment=parsed.comment,
+          initial_components=parsed.components.entered_str,
+          labels=bounce_labels,
+          fields=bounce_fields)
+      return
+
+    with mr.profiler.Phase('reindexing issues'):
+      logging.info('starting reindexing')
+      start = time.time()
+      # Get the updated issues and index them
+      issue_list = self.services.issue.GetIssuesByLocalIDs(
+          mr.cnxn, mr.project_id, mr.local_id_list)
+      tracker_fulltext.IndexIssues(
+          mr.cnxn, issue_list, self.services.user, self.services.issue,
+          self.services.config)
+      logging.info('reindexing %d issues took %s sec',
+                   len(issue_list), time.time() - start)
+
+    # TODO(jrobbins): These could be put into the form action attribute.
+    mr.can = int(post_data['can'])
+    mr.query = post_data['q']
+    mr.col_spec = post_data['colspec']
+    mr.sort_spec = post_data['sort']
+    mr.group_by_spec = post_data['groupby']
+    mr.start = int(post_data['start'])
+    mr.num = int(post_data['num'])
+
+    # TODO(jrobbins): implement bulk=N param for a better confirmation alert.
+    return tracker_helpers.FormatIssueListURL(
+        mr, config, saved=len(mr.local_id_list), ts=int(time.time()))
diff --git a/tracker/issuedetailezt.py b/tracker/issuedetailezt.py
new file mode 100644
index 0000000..9460669
--- /dev/null
+++ b/tracker/issuedetailezt.py
@@ -0,0 +1,316 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Classes that implement the issue detail page and related forms.
+
+Summary of classes:
+  IssueDetailEzt: Show one issue in detail w/ all metadata and comments, and
+               process additional comments or metadata changes on it.
+  FlagSpamForm: Record the user's desire to report the issue as spam.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import httplib
+import json
+import logging
+import time
+import ezt
+
+import settings
+from api import converters
+from businesslogic import work_env
+from features import features_bizobj
+from features import send_notifications
+from features import hotlist_helpers
+from features import hotlist_views
+from framework import exceptions
+from framework import framework_bizobj
+from framework import framework_constants
+from framework import framework_helpers
+from framework import framework_views
+from framework import jsonfeed
+from framework import paginate
+from framework import permissions
+from framework import servlet
+from framework import servlet_helpers
+from framework import sorting
+from framework import sql
+from framework import template_helpers
+from framework import urls
+from framework import xsrf
+from proto import user_pb2
+from proto import tracker_pb2
+from services import features_svc
+from services import tracker_fulltext
+from tracker import field_helpers
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+from tracker import tracker_helpers
+from tracker import tracker_views
+
+from google.protobuf import json_format
+
+
+def CheckMoveIssueRequest(
+    services, mr, issue, move_selected, move_to, errors):
+  """Process the move issue portions of the issue update form.
+
+  Args:
+    services: A Services object
+    mr: commonly used info parsed from the request.
+    issue: Issue protobuf for the issue being moved.
+    move_selected: True if the user selected the Move action.
+    move_to: A project_name or url to move this issue to or None
+      if the project name wasn't sent in the form.
+    errors: The errors object for this request.
+
+    Returns:
+      The project pb for the project the issue will be moved to
+      or None if the move cannot be performed. Perhaps because
+      the project does not exist, in which case move_to and
+      move_to_project will be set on the errors object. Perhaps
+      the user does not have permission to move the issue to the
+      destination project, in which case the move_to field will be
+      set on the errors object.
+  """
+  if not move_selected:
+    return None
+
+  if not move_to:
+    errors.move_to = 'No destination project specified'
+    errors.move_to_project = move_to
+    return None
+
+  if issue.project_name == move_to:
+    errors.move_to = 'This issue is already in project ' + move_to
+    errors.move_to_project = move_to
+    return None
+
+  move_to_project = services.project.GetProjectByName(mr.cnxn, move_to)
+  if not move_to_project:
+    errors.move_to = 'No such project: ' + move_to
+    errors.move_to_project = move_to
+    return None
+
+  # permissions enforcement
+  if not servlet_helpers.CheckPermForProject(
+      mr, permissions.EDIT_ISSUE, move_to_project):
+    errors.move_to = 'You do not have permission to move issues to project'
+    errors.move_to_project = move_to
+    return None
+
+  elif permissions.GetRestrictions(issue):
+    errors.move_to = (
+        'Issues with Restrict labels are not allowed to be moved.')
+    errors.move_to_project = ''
+    return None
+
+  return move_to_project
+
+
+def _ComputeBackToListURL(mr, issue, config, hotlist, services):
+  """Construct a URL to return the user to the place that they came from."""
+  if hotlist:
+    back_to_list_url = hotlist_helpers.GetURLOfHotlist(
+        mr.cnxn, hotlist, services.user)
+  else:
+    back_to_list_url = tracker_helpers.FormatIssueListURL(
+        mr, config, cursor='%s:%d' % (issue.project_name, issue.local_id))
+
+  return back_to_list_url
+
+
+class FlipperRedirectBase(servlet.Servlet):
+
+  # pylint: disable=arguments-differ
+  # pylint: disable=unused-argument
+  def get(self, project_name=None, viewed_username=None, hotlist_id=None):
+    with work_env.WorkEnv(self.mr, self.services) as we:
+      hotlist_id = self.mr.GetIntParam('hotlist_id')
+      current_issue = we.GetIssueByLocalID(self.mr.project_id, self.mr.local_id,
+                                   use_cache=False)
+      hotlist = None
+      if hotlist_id:
+        try:
+          hotlist = self.services.features.GetHotlist(self.mr.cnxn, hotlist_id)
+        except features_svc.NoSuchHotlistException:
+          pass
+
+      try:
+        adj_issue = GetAdjacentIssue(
+            self.mr, we, current_issue, hotlist=hotlist,
+            next_issue=self.next_handler)
+        path = '/p/%s%s' % (adj_issue.project_name, urls.ISSUE_DETAIL)
+        url = framework_helpers.FormatURL(
+            [(name, self.mr.GetParam(name)) for
+             name in framework_helpers.RECOGNIZED_PARAMS],
+            path, id=adj_issue.local_id)
+      except exceptions.NoSuchIssueException:
+        config = we.GetProjectConfig(self.mr.project_id)
+        url = _ComputeBackToListURL(self.mr, current_issue, config,
+                                                 hotlist, self.services)
+      self.redirect(url)
+
+
+class FlipperNext(FlipperRedirectBase):
+  next_handler = True
+
+
+class FlipperPrev(FlipperRedirectBase):
+  next_handler = False
+
+
+class FlipperList(servlet.Servlet):
+  # pylint: disable=arguments-differ
+  # pylint: disable=unused-argument
+  def get(self, project_name=None, viewed_username=None, hotlist_id=None):
+    with work_env.WorkEnv(self.mr, self.services) as we:
+      hotlist_id = self.mr.GetIntParam('hotlist_id')
+      current_issue = we.GetIssueByLocalID(self.mr.project_id, self.mr.local_id,
+                                   use_cache=False)
+      hotlist = None
+      if hotlist_id:
+        try:
+          hotlist = self.services.features.GetHotlist(self.mr.cnxn, hotlist_id)
+        except features_svc.NoSuchHotlistException:
+          pass
+
+      config = we.GetProjectConfig(self.mr.project_id)
+
+      if hotlist:
+        self.mr.ComputeColSpec(hotlist)
+      else:
+        self.mr.ComputeColSpec(config)
+
+      url = _ComputeBackToListURL(self.mr, current_issue, config,
+                                               hotlist, self.services)
+    self.redirect(url)
+
+
+class FlipperIndex(jsonfeed.JsonFeed):
+  """Return a JSON object of an issue's index in search.
+
+  This is a distinct JSON endpoint because it can be expensive to compute.
+  """
+  CHECK_SECURITY_TOKEN = False
+
+  def HandleRequest(self, mr):
+    hotlist_id = mr.GetIntParam('hotlist_id')
+    list_url = None
+    with work_env.WorkEnv(mr, self.services) as we:
+      if not _ShouldShowFlipper(mr, self.services):
+        return {}
+      issue = we.GetIssueByLocalID(mr.project_id, mr.local_id, use_cache=False)
+      hotlist = None
+
+      if hotlist_id:
+        hotlist = self.services.features.GetHotlist(mr.cnxn, hotlist_id)
+
+        if not features_bizobj.IssueIsInHotlist(hotlist, issue.issue_id):
+          raise exceptions.InvalidHotlistException()
+
+        if not permissions.CanViewHotlist(
+            mr.auth.effective_ids, mr.perms, hotlist):
+          raise permissions.PermissionException()
+
+        (prev_iid, cur_index, next_iid, total_count
+            ) = we.GetIssuePositionInHotlist(
+                issue, hotlist, mr.can, mr.sort_spec, mr.group_by_spec)
+      else:
+        (prev_iid, cur_index, next_iid, total_count
+            ) = we.FindIssuePositionInSearch(issue)
+
+      config = we.GetProjectConfig(self.mr.project_id)
+
+      if hotlist:
+        mr.ComputeColSpec(hotlist)
+      else:
+        mr.ComputeColSpec(config)
+
+      list_url = _ComputeBackToListURL(mr, issue, config, hotlist,
+        self.services)
+
+    prev_url = None
+    next_url = None
+
+    recognized_params = [(name, mr.GetParam(name)) for name in
+                           framework_helpers.RECOGNIZED_PARAMS]
+    if prev_iid:
+      prev_issue = we.services.issue.GetIssue(mr.cnxn, prev_iid)
+      path = '/p/%s%s' % (prev_issue.project_name, urls.ISSUE_DETAIL)
+      prev_url = framework_helpers.FormatURL(
+          recognized_params, path, id=prev_issue.local_id)
+
+    if next_iid:
+      next_issue = we.services.issue.GetIssue(mr.cnxn, next_iid)
+      path = '/p/%s%s' % (next_issue.project_name, urls.ISSUE_DETAIL)
+      next_url = framework_helpers.FormatURL(
+          recognized_params, path, id=next_issue.local_id)
+
+    return {
+      'prev_iid': prev_iid,
+      'prev_url': prev_url,
+      'cur_index': cur_index,
+      'next_iid': next_iid,
+      'next_url': next_url,
+      'list_url': list_url,
+      'total_count': total_count,
+    }
+
+
+def _ShouldShowFlipper(mr, services):
+  """Return True if we should show the flipper."""
+
+  # Check if the user entered a specific issue ID of an existing issue.
+  if tracker_constants.JUMP_RE.match(mr.query):
+    return False
+
+  # Check if the user came directly to an issue without specifying any
+  # query or sort.  E.g., through crbug.com.  Generating the issue ref
+  # list can be too expensive in projects that have a large number of
+  # issues.  The all and open issues cans are broad queries, other
+  # canned queries should be narrow enough to not need this special
+  # treatment.
+  if (not mr.query and not mr.sort_spec and
+      mr.can in [tracker_constants.ALL_ISSUES_CAN,
+                 tracker_constants.OPEN_ISSUES_CAN]):
+    num_issues_in_project = services.issue.GetHighestLocalID(
+        mr.cnxn, mr.project_id)
+    if num_issues_in_project > settings.threshold_to_suppress_prev_next:
+      return False
+
+  return True
+
+
+def GetAdjacentIssue(
+    mr, we, issue, hotlist=None, next_issue=False):
+  """Compute next or previous issue given params of current issue.
+
+  Args:
+    mr: MonorailRequest, including can and sorting/grouping order.
+    we: A WorkEnv instance.
+    issue: The current issue (from which to compute prev/next).
+    hotlist (optional): The current hotlist.
+    next_issue (bool): If True, return next, issue, else return previous issue.
+
+  Returns:
+    The adjacent issue.
+
+  Raises:
+    NoSuchIssueException when there is no adjacent issue in the list.
+  """
+  if hotlist:
+    (prev_iid, _cur_index, next_iid, _total_count
+        ) = we.GetIssuePositionInHotlist(
+            issue, hotlist, mr.can, mr.sort_spec, mr.group_by_spec)
+  else:
+    (prev_iid, _cur_index, next_iid, _total_count
+        ) = we.FindIssuePositionInSearch(issue)
+  iid = next_iid if next_issue else prev_iid
+  if iid is None:
+    raise exceptions.NoSuchIssueException()
+  return we.GetIssue(iid)
diff --git a/tracker/issueentry.py b/tracker/issueentry.py
new file mode 100644
index 0000000..77de114
--- /dev/null
+++ b/tracker/issueentry.py
@@ -0,0 +1,630 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Servlet that implements the entry of new issues."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import collections
+import difflib
+import logging
+import string
+import time
+
+from businesslogic import work_env
+from features import hotlist_helpers
+from features import send_notifications
+from framework import exceptions
+from framework import framework_bizobj
+from framework import framework_constants
+from framework import framework_helpers
+from framework import framework_views
+from framework import permissions
+from framework import servlet
+from framework import template_helpers
+from framework import urls
+import ezt
+from tracker import field_helpers
+from tracker import template_helpers as issue_tmpl_helpers
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+from tracker import tracker_helpers
+from tracker import tracker_views
+from proto import tracker_pb2
+
+PLACEHOLDER_SUMMARY = 'Enter one-line summary'
+PHASES_WITH_MILESTONES = ['Beta', 'Stable', 'Stable-Exp', 'Stable-Full']
+CORP_RESTRICTION_LABEL = 'Restrict-View-Google'
+RESTRICTED_FLT_FIELDS = ['notice', 'whitepaper', 'm-approved']
+
+
+class IssueEntry(servlet.Servlet):
+  """IssueEntry shows a page with a simple form to enter a new issue."""
+
+  _PAGE_TEMPLATE = 'tracker/issue-entry-page.ezt'
+  _MAIN_TAB_MODE = servlet.Servlet.MAIN_TAB_ISSUES
+
+  # The issue filing wizard is a separate app that posted back to Monorail's
+  # issue entry page. To make this possible for the wizard, we need to allow
+  # XHR-scoped XSRF tokens.
+  ALLOW_XHR = True
+
+  def AssertBasePermission(self, mr):
+    """Check whether the user has any permission to visit this page.
+
+    Args:
+      mr: commonly used info parsed from the request.
+    """
+    super(IssueEntry, self).AssertBasePermission(mr)
+    if not self.CheckPerm(mr, permissions.CREATE_ISSUE):
+      raise permissions.PermissionException(
+          'User is not allowed to enter an issue')
+
+  def GatherPageData(self, mr):
+    """Build up a dictionary of data values to use when rendering the page.
+
+    Args:
+      mr: commonly used info parsed from the request.
+
+    Returns:
+      Dict of values used by EZT for rendering the page.
+    """
+    with mr.profiler.Phase('getting config'):
+      config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+
+    # In addition to checking perms, we adjust some default field values for
+    # project members.
+    is_member = framework_bizobj.UserIsInProject(
+        mr.project, mr.auth.effective_ids)
+    page_perms = self.MakePagePerms(
+        mr, None,
+        permissions.CREATE_ISSUE,
+        permissions.SET_STAR,
+        permissions.EDIT_ISSUE,
+        permissions.EDIT_ISSUE_SUMMARY,
+        permissions.EDIT_ISSUE_STATUS,
+        permissions.EDIT_ISSUE_OWNER,
+        permissions.EDIT_ISSUE_CC)
+
+
+    with work_env.WorkEnv(mr, self.services) as we:
+      userprefs = we.GetUserPrefs(mr.auth.user_id)
+      code_font = any(pref for pref in userprefs.prefs
+                      if pref.name == 'code_font' and pref.value == 'true')
+
+    template = self._GetTemplate(mr.cnxn, config, mr.template_name, is_member)
+
+    if template.summary:
+      initial_summary = template.summary
+      initial_summary_must_be_edited = template.summary_must_be_edited
+    else:
+      initial_summary = PLACEHOLDER_SUMMARY
+      initial_summary_must_be_edited = True
+
+    if template.status:
+      initial_status = template.status
+    elif is_member:
+      initial_status = 'Accepted'
+    else:
+      initial_status = 'New'  # not offering meta, only used in hidden field.
+
+    component_paths = []
+    for component_id in template.component_ids:
+      component_paths.append(
+          tracker_bizobj.FindComponentDefByID(component_id, config).path)
+    initial_components = ', '.join(component_paths)
+
+    if template.owner_id:
+      initial_owner = framework_views.MakeUserView(
+          mr.cnxn, self.services.user, template.owner_id)
+    elif template.owner_defaults_to_member and page_perms.EditIssue:
+      initial_owner = mr.auth.user_view
+    else:
+      initial_owner = None
+
+    if initial_owner:
+      initial_owner_name = initial_owner.email
+      owner_avail_state = initial_owner.avail_state
+      owner_avail_message_short = initial_owner.avail_message_short
+    else:
+      initial_owner_name = ''
+      owner_avail_state = None
+      owner_avail_message_short = None
+
+    # Check whether to allow attachments from the entry page
+    allow_attachments = tracker_helpers.IsUnderSoftAttachmentQuota(mr.project)
+
+    config_view = tracker_views.ConfigView(mr, self.services, config, template)
+    # If the user followed a link that specified the template name, make sure
+    # that it is also in the menu as the current choice.
+    # TODO(jeffcarp): Unit test this.
+    config_view.template_view.can_view = ezt.boolean(True)
+
+    # TODO(jeffcarp): Unit test this.
+    offer_templates = len(config_view.template_names) > 1
+    restrict_to_known = config.restrict_to_known
+    link_or_template_labels = mr.GetListParam('labels', template.labels)
+    labels, _derived_labels = tracker_bizobj.ExplicitAndDerivedNonMaskedLabels(
+        link_or_template_labels, [], config)
+
+    # Users with restrict_new_issues user pref automatically add R-V-G.
+    with work_env.WorkEnv(mr, self.services) as we:
+      userprefs = we.GetUserPrefs(mr.auth.user_id)
+      restrict_new_issues = any(
+          up.name == 'restrict_new_issues' and up.value == 'true'
+          for up in userprefs.prefs)
+      if restrict_new_issues:
+        if not any(lab.lower().startswith('restrict-view-') for lab in labels):
+          labels.append(CORP_RESTRICTION_LABEL)
+
+    field_user_views = tracker_views.MakeFieldUserViews(
+        mr.cnxn, template, self.services.user)
+    approval_ids = [av.approval_id for av in template.approval_values]
+    field_views = tracker_views.MakeAllFieldValueViews(
+        config, link_or_template_labels, [], template.field_values,
+        field_user_views, parent_approval_ids=approval_ids,
+        phases=template.phases)
+    # TODO(jojwang): monorail:6305, remove this hack when Edit perms for field
+    # values are implemented.
+    field_views = [view for view in field_views
+                   if view.field_name.lower() not in RESTRICTED_FLT_FIELDS]
+    uneditable_fields = ezt.boolean(False)
+    for fv in field_views:
+      if permissions.CanEditValueForFieldDef(
+          mr.auth.effective_ids, mr.perms, mr.project, fv.field_def.field_def):
+        fv.is_editable = ezt.boolean(True)
+      else:
+        fv.is_editable = ezt.boolean(False)
+        uneditable_fields = ezt.boolean(True)
+
+    # TODO(jrobbins): remove "or []" after next release.
+    (prechecked_approvals, required_approval_ids,
+     phases) = issue_tmpl_helpers.GatherApprovalsPageData(
+         template.approval_values or [], template.phases, config)
+    approvals = [view for view in field_views if view.field_id in
+                 approval_ids]
+
+    page_data = {
+        'issue_tab_mode':
+            'issueEntry',
+        'initial_summary':
+            initial_summary,
+        'template_summary':
+            initial_summary,
+        'clear_summary_on_click':
+            ezt.boolean(
+                initial_summary_must_be_edited and
+                'initial_summary' not in mr.form_overrides),
+        'must_edit_summary':
+            ezt.boolean(initial_summary_must_be_edited),
+        'initial_description':
+            template.content,
+        'template_name':
+            template.name,
+        'component_required':
+            ezt.boolean(template.component_required),
+        'initial_status':
+            initial_status,
+        'initial_owner':
+            initial_owner_name,
+        'owner_avail_state':
+            owner_avail_state,
+        'owner_avail_message_short':
+            owner_avail_message_short,
+        'initial_components':
+            initial_components,
+        'initial_cc':
+            '',
+        'initial_blocked_on':
+            '',
+        'initial_blocking':
+            '',
+        'initial_hotlists':
+            '',
+        'labels':
+            labels,
+        'fields':
+            field_views,
+        'any_errors':
+            ezt.boolean(mr.errors.AnyErrors()),
+        'page_perms':
+            page_perms,
+        'allow_attachments':
+            ezt.boolean(allow_attachments),
+        'max_attach_size':
+            template_helpers.BytesKbOrMb(
+                framework_constants.MAX_POST_BODY_SIZE),
+        'offer_templates':
+            ezt.boolean(offer_templates),
+        'config':
+            config_view,
+        'restrict_to_known':
+            ezt.boolean(restrict_to_known),
+        'is_member':
+            ezt.boolean(is_member),
+        'code_font':
+            ezt.boolean(code_font),
+        # The following are necessary for displaying phases that come with
+        # this template. These are read-only.
+        'allow_edit':
+            ezt.boolean(False),
+        'uneditable_fields':
+            uneditable_fields,
+        'initial_phases':
+            phases,
+        'approvals':
+            approvals,
+        'prechecked_approvals':
+            prechecked_approvals,
+        'required_approval_ids':
+            required_approval_ids,
+        # See monorail:4692 and the use of PHASES_WITH_MILESTONES
+        # in elements/flt/mr-launch-overview/mr-phase.js
+        'issue_phase_names':
+            list(
+                {
+                    phase.name.lower()
+                    for phase in phases
+                    if phase.name in PHASES_WITH_MILESTONES
+                }),
+    }
+
+    return page_data
+
+  def GatherHelpData(self, mr, page_data):
+    """Return a dict of values to drive on-page user help.
+
+    Args:
+      mr: commonly used info parsed from the request.
+      page_data: Dictionary of base and page template data.
+
+    Returns:
+      A dict of values to drive on-page user help, to be added to page_data.
+    """
+    help_data = super(IssueEntry, self).GatherHelpData(mr, page_data)
+    dismissed = []
+    if mr.auth.user_pb:
+      with work_env.WorkEnv(mr, self.services) as we:
+        userprefs = we.GetUserPrefs(mr.auth.user_id)
+      dismissed = [
+          pv.name for pv in userprefs.prefs if pv.value == 'true']
+    is_privileged_domain_user = framework_bizobj.IsPriviledgedDomainUser(
+        mr.auth.user_pb.email)
+    if (mr.auth.user_id and
+        'privacy_click_through' not in dismissed):
+      help_data['cue'] = 'privacy_click_through'
+    elif (mr.auth.user_id and
+        'code_of_conduct' not in dismissed):
+      help_data['cue'] = 'code_of_conduct'
+
+    help_data.update({
+        'is_privileged_domain_user': ezt.boolean(is_privileged_domain_user),
+        })
+    return help_data
+
+  def ProcessFormData(self, mr, post_data):
+    """Process the issue entry form.
+
+    Args:
+      mr: commonly used info parsed from the request.
+      post_data: The post_data dict for the current request.
+
+    Returns:
+      String URL to redirect the user to after processing.
+    """
+    config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+
+    parsed = tracker_helpers.ParseIssueRequest(
+        mr.cnxn, post_data, self.services, mr.errors, mr.project_name)
+
+    # Updates parsed.labels and parsed.fields in place.
+    field_helpers.ShiftEnumFieldsIntoLabels(
+        parsed.labels, parsed.labels_remove, parsed.fields.vals,
+        parsed.fields.vals_remove, config)
+
+    labels = _DiscardUnusedTemplateLabelPrefixes(parsed.labels)
+
+    is_member = framework_bizobj.UserIsInProject(
+        mr.project, mr.auth.effective_ids)
+    template = self._GetTemplate(
+        mr.cnxn, config, parsed.template_name, is_member)
+
+    (approval_values,
+     phases) = issue_tmpl_helpers.FilterApprovalsAndPhases(
+         template.approval_values or [], template.phases, config)
+
+    # Issue PB with only approval_values and labels filled out, for the purpose
+    # of computing applicable fields.
+    partial_issue = tracker_pb2.Issue(
+        approval_values=approval_values, labels=labels)
+    applicable_fields = field_helpers.ListApplicableFieldDefs(
+        [partial_issue], config)
+
+    bounce_labels = parsed.labels[:]
+    bounce_fields = tracker_views.MakeBounceFieldValueViews(
+        parsed.fields.vals,
+        parsed.fields.phase_vals,
+        config,
+        applicable_fields=applicable_fields)
+
+    phase_ids_by_name = {
+        phase.name.lower(): [phase.phase_id] for phase in template.phases}
+    field_values = field_helpers.ParseFieldValues(
+        mr.cnxn, self.services.user, parsed.fields.vals,
+        parsed.fields.phase_vals, config,
+        phase_ids_by_name=phase_ids_by_name)
+
+    component_ids = tracker_helpers.LookupComponentIDs(
+        parsed.components.paths, config, mr.errors)
+
+    if not parsed.summary.strip() or parsed.summary == PLACEHOLDER_SUMMARY:
+      mr.errors.summary = 'Summary is required'
+
+    if not parsed.comment.strip():
+      mr.errors.comment = 'A description is required'
+
+    if len(parsed.comment) > tracker_constants.MAX_COMMENT_CHARS:
+      mr.errors.comment = 'Comment is too long'
+    if len(parsed.summary) > tracker_constants.MAX_SUMMARY_CHARS:
+      mr.errors.summary = 'Summary is too long'
+
+    if _MatchesTemplate(parsed.comment, template):
+      mr.errors.comment = 'Template must be filled out.'
+
+    if parsed.users.owner_id is None:
+      mr.errors.owner = 'Invalid owner username'
+    else:
+      valid, msg = tracker_helpers.IsValidIssueOwner(
+          mr.cnxn, mr.project, parsed.users.owner_id, self.services)
+      if not valid:
+        mr.errors.owner = msg
+
+    if None in parsed.users.cc_ids:
+      mr.errors.cc = 'Invalid Cc username'
+
+    field_helpers.AssertCustomFieldsEditPerms(
+        mr, config, field_values, [], [], labels, [])
+    field_helpers.ApplyRestrictedDefaultValues(
+        mr, config, field_values, labels, template.field_values,
+        template.labels)
+
+    # This ValidateCustomFields call is redundant with work already done
+    # in CreateIssue. However, this instance passes in an ezt_errors object
+    # to allow showing related errors next to the fields they happen on.
+    field_helpers.ValidateCustomFields(
+        mr.cnxn,
+        self.services,
+        field_values,
+        config,
+        mr.project,
+        ezt_errors=mr.errors,
+        issue=partial_issue)
+
+    hotlist_pbs = ProcessParsedHotlistRefs(
+        mr, self.services, parsed.hotlists.hotlist_refs)
+
+    if not mr.errors.AnyErrors():
+      with work_env.WorkEnv(mr, self.services) as we:
+        try:
+          if parsed.attachments:
+            new_bytes_used = tracker_helpers.ComputeNewQuotaBytesUsed(
+                mr.project, parsed.attachments)
+            # TODO(jrobbins): Make quota be calculated and stored as
+            # part of applying the comment.
+            self.services.project.UpdateProject(
+                mr.cnxn, mr.project.project_id,
+                attachment_bytes_used=new_bytes_used)
+
+          marked_description = tracker_helpers.MarkupDescriptionOnInput(
+              parsed.comment, template.content)
+          has_star = 'star' in post_data and post_data['star'] == '1'
+
+          if approval_values:
+            _AttachDefaultApprovers(config, approval_values)
+
+          # To preserve previous behavior, do not raise filter rule errors.
+          issue, _ = we.CreateIssue(
+              mr.project_id,
+              parsed.summary,
+              parsed.status,
+              parsed.users.owner_id,
+              parsed.users.cc_ids,
+              labels,
+              field_values,
+              component_ids,
+              marked_description,
+              blocked_on=parsed.blocked_on.iids,
+              blocking=parsed.blocking.iids,
+              dangling_blocked_on=[
+                  tracker_pb2.DanglingIssueRef(ext_issue_identifier=ref_string)
+                  for ref_string in parsed.blocked_on.federated_ref_strings
+              ],
+              dangling_blocking=[
+                  tracker_pb2.DanglingIssueRef(ext_issue_identifier=ref_string)
+                  for ref_string in parsed.blocking.federated_ref_strings
+              ],
+              attachments=parsed.attachments,
+              approval_values=approval_values,
+              phases=phases,
+              raise_filter_errors=False)
+
+          if has_star:
+            we.StarIssue(issue, True)
+
+          if hotlist_pbs:
+            hotlist_ids = {hotlist.hotlist_id for hotlist in hotlist_pbs}
+            issue_tuple = (issue.issue_id, mr.auth.user_id, int(time.time()),
+                           '')
+            self.services.features.AddIssueToHotlists(
+                mr.cnxn, hotlist_ids, issue_tuple, self.services.issue,
+                self.services.chart)
+
+        except exceptions.OverAttachmentQuota:
+          mr.errors.attachments = 'Project attachment quota exceeded.'
+        except exceptions.InputException as e:
+          if 'Undefined or deprecated component with id' in e.message:
+            mr.errors.components = 'Undefined or deprecated component'
+
+    mr.template_name = parsed.template_name
+    if mr.errors.AnyErrors():
+      self.PleaseCorrect(
+          mr, initial_summary=parsed.summary, initial_status=parsed.status,
+          initial_owner=parsed.users.owner_username,
+          initial_cc=', '.join(parsed.users.cc_usernames),
+          initial_components=', '.join(parsed.components.paths),
+          initial_comment=parsed.comment, labels=bounce_labels,
+          fields=bounce_fields, template_name=parsed.template_name,
+          initial_blocked_on=parsed.blocked_on.entered_str,
+          initial_blocking=parsed.blocking.entered_str,
+          initial_hotlists=parsed.hotlists.entered_str,
+          component_required=ezt.boolean(template.component_required))
+      return
+
+    # format a redirect url
+    return framework_helpers.FormatAbsoluteURL(
+        mr, urls.ISSUE_DETAIL, id=issue.local_id)
+
+  def _GetTemplate(self, cnxn, config, template_name, is_member):
+    """Tries to fetch template by name and implements default template logic
+    if not found."""
+    template = None
+    if template_name:
+      template_name = template_name.replace('+', ' ')
+      template = self.services.template.GetTemplateByName(cnxn,
+          template_name, config.project_id)
+
+    if not template:
+      if is_member:
+        template_id = config.default_template_for_developers
+      else:
+        template_id = config.default_template_for_users
+      template = self.services.template.GetTemplateById(cnxn, template_id)
+      # If the default templates were deleted, load all and pick the first one.
+      if not template:
+        templates = self.services.template.GetProjectTemplates(cnxn,
+            config.project_id)
+        assert len(templates) > 0, 'Project has no templates!'
+        template = templates[0]
+
+    return template
+
+
+def _AttachDefaultApprovers(config, approval_values):
+  approval_defs_by_id = {ad.approval_id: ad for ad in config.approval_defs}
+  for av in approval_values:
+    ad = approval_defs_by_id.get(av.approval_id)
+    if ad:
+      av.approver_ids = ad.approver_ids[:]
+    else:
+      logging.info('ApprovalDef with approval_id %r could not be found',
+          av.approval_id)
+
+
+def _MatchesTemplate(content, template):
+  content = content.strip(string.whitespace)
+  template_content = template.content.strip(string.whitespace)
+  diff = difflib.unified_diff(content.splitlines(),
+      template_content.splitlines())
+  return len('\n'.join(diff)) == 0
+
+
+def _DiscardUnusedTemplateLabelPrefixes(labels):
+  """Drop any labels that end in '-?'.
+
+  Args:
+    labels: a list of label strings.
+
+  Returns:
+    A list of the same labels, but without any that end with '-?'.
+    Those label prefixes in the new issue templates are intended to
+    prompt the user to enter some label with that prefix, but if
+    nothing is entered there, we do not store anything.
+  """
+  return [lab for lab in labels
+          if not lab.endswith('-?')]
+
+
+def ProcessParsedHotlistRefs(mr, services, parsed_hotlist_refs):
+  """Process a list of ParsedHotlistRefs, returning referenced hotlists.
+
+  This function validates the given ParsedHotlistRefs using four checks; if all
+  of them succeed, then it returns the corresponding hotlist protobuf objects.
+  If any of them fail, it sets the appropriate error string in mr.errors, and
+  returns an empty list.
+
+  Args:
+    mr: the MonorailRequest object
+    services: the service manager
+    parsed_hotlist_refs: a list of ParsedHotlistRef objects
+
+  Returns:
+    on valid input, a list of hotlist protobuf objects
+    if a check fails (and the input is thus considered invalid), an empty list
+
+  Side-effects:
+    if any of the checks fails, set mr.errors.hotlists to a descriptive error
+  """
+  # Pre-processing; common pieces used by functions later.
+  user_hotlist_pbs = services.features.GetHotlistsByUserID(
+      mr.cnxn, mr.auth.user_id)
+  user_hotlist_owners_ids = {hotlist.owner_ids[0]
+      for hotlist in user_hotlist_pbs}
+  user_hotlist_owners_to_emails = services.user.LookupUserEmails(
+      mr.cnxn, user_hotlist_owners_ids)
+  user_hotlist_emails_to_owners = {v: k
+      for k, v in user_hotlist_owners_to_emails.items()}
+  user_hotlist_refs_to_pbs = {
+      hotlist_helpers.HotlistRef(hotlist.owner_ids[0], hotlist.name): hotlist
+      for hotlist in user_hotlist_pbs }
+  short_refs = list()
+  full_refs = list()
+  for parsed_ref in parsed_hotlist_refs:
+    if parsed_ref.user_email is None:
+      short_refs.append(parsed_ref)
+    else:
+      full_refs.append(parsed_ref)
+
+  invalid_names = hotlist_helpers.InvalidParsedHotlistRefsNames(
+      parsed_hotlist_refs, user_hotlist_pbs)
+  if invalid_names:
+    mr.errors.hotlists = (
+        'You have no hotlist(s) named: %s' % ', '.join(invalid_names))
+    return []
+
+  ambiguous_names = hotlist_helpers.AmbiguousShortrefHotlistNames(
+      short_refs, user_hotlist_pbs)
+  if ambiguous_names:
+    mr.errors.hotlists = (
+        'Ambiguous hotlist(s) specified: %s' % ', '.join(ambiguous_names))
+    return []
+
+  # At this point, all refs' named hotlists are guaranteed to exist, and
+  # short refs are guaranteed to be unambiguous;
+  # therefore, short refs are also valid.
+  short_refs_hotlist_names = {sref.hotlist_name for sref in short_refs}
+  shortref_valid_pbs = [hotlist for hotlist in user_hotlist_pbs
+      if hotlist.name in short_refs_hotlist_names]
+
+  invalid_emails = hotlist_helpers.InvalidParsedHotlistRefsEmails(
+      full_refs, user_hotlist_emails_to_owners)
+  if invalid_emails:
+    mr.errors.hotlists = (
+        'You have no hotlist(s) owned by: %s' % ', '.join(invalid_emails))
+    return []
+
+  fullref_valid_pbs, invalid_refs = (
+      hotlist_helpers.GetHotlistsOfParsedHotlistFullRefs(
+        full_refs, user_hotlist_emails_to_owners, user_hotlist_refs_to_pbs))
+  if invalid_refs:
+    invalid_refs_readable = [':'.join(parsed_ref)
+        for parsed_ref in invalid_refs]
+    mr.errors.hotlists = (
+        'Not in your hotlist(s): %s' % ', '.join(invalid_refs_readable))
+    return []
+
+  hotlist_pbs = shortref_valid_pbs + fullref_valid_pbs
+
+  return hotlist_pbs
diff --git a/tracker/issueentryafterlogin.py b/tracker/issueentryafterlogin.py
new file mode 100644
index 0000000..d25a7c1
--- /dev/null
+++ b/tracker/issueentryafterlogin.py
@@ -0,0 +1,32 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Redirect to /issues/entry or an external URL (like the wizard).
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+
+from framework import servlet
+from framework import servlet_helpers
+
+
+class IssueEntryAfterLogin(servlet.Servlet):
+  """Redirect after clicking "New issue" and logging in."""
+
+  # Note: This servlet does not use an HTML template.
+
+  def GatherPageData(self, mr):
+    """Build up a dictionary of data values to use when rendering the page."""
+    if not mr.auth.user_id:
+      self.abort(400, 'Only signed-in users should reach this URL.')
+
+    with mr.profiler.Phase('getting config'):
+      config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+    entry_page_url = servlet_helpers.ComputeIssueEntryURL(mr, config)
+    logging.info('Redirecting to %r', entry_page_url)
+    self.redirect(entry_page_url, abort=True)
diff --git a/tracker/issueexport.py b/tracker/issueexport.py
new file mode 100644
index 0000000..a457a17
--- /dev/null
+++ b/tracker/issueexport.py
@@ -0,0 +1,279 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Servlet to export a range of issues in JSON format.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import time
+
+import ezt
+
+from businesslogic import work_env
+from features import savedqueries_helpers
+from framework import permissions
+from framework import jsonfeed
+from framework import servlet
+from tracker import tracker_bizobj
+
+
+class IssueExport(servlet.Servlet):
+  """IssueExportControls let's an admin choose how to export issues."""
+
+  _PAGE_TEMPLATE = 'tracker/issue-export-page.ezt'
+  _MAIN_TAB_MODE = servlet.Servlet.MAIN_TAB_ISSUES
+
+  def AssertBasePermission(self, mr):
+    """Make sure that the logged in user has permission to view this page."""
+    super(IssueExport, self).AssertBasePermission(mr)
+    if not mr.auth.user_pb.is_site_admin:
+      raise permissions.PermissionException(
+          'Only site admins may export issues')
+
+  def GatherPageData(self, mr):
+    """Build up a dictionary of data values to use when rendering the page."""
+
+    canned_query_views = []
+    if mr.project_id:
+      with mr.profiler.Phase('getting canned queries'):
+        canned_queries = self.services.features.GetCannedQueriesByProjectID(
+            mr.cnxn, mr.project_id)
+      canned_query_views = [
+          savedqueries_helpers.SavedQueryView(sq, idx + 1, None, None)
+          for idx, sq in enumerate(canned_queries)
+      ]
+
+    saved_query_views = []
+    if mr.auth.user_id and self.services.features:
+      with mr.profiler.Phase('getting saved queries'):
+        saved_queries = self.services.features.GetSavedQueriesByUserID(
+            mr.cnxn, mr.me_user_id)
+        saved_query_views = [
+            savedqueries_helpers.SavedQueryView(sq, idx + 1, None, None)
+            for idx, sq in enumerate(saved_queries)
+            if
+            (mr.project_id in sq.executes_in_project_ids or not mr.project_id)
+        ]
+
+    return {
+        'issue_tab_mode': None,
+        'initial_start': mr.start,
+        'initial_num': mr.num,
+        'page_perms': self.MakePagePerms(mr, None, permissions.CREATE_ISSUE),
+        'canned_queries': canned_query_views,
+        'saved_queries': saved_query_views,
+    }
+
+
+class IssueExportJSON(jsonfeed.JsonFeed):
+  """IssueExport shows a range of issues in JSON format."""
+
+  # Pretty-print the JSON output.
+  JSON_INDENT = 4
+
+  def AssertBasePermission(self, mr):
+    """Make sure that the logged in user has permission to view this page."""
+    super(IssueExportJSON, self).AssertBasePermission(mr)
+    if not mr.auth.user_pb.is_site_admin:
+      raise permissions.PermissionException(
+          'Only site admins may export issues')
+
+  def HandleRequest(self, mr):
+    """Build up a dictionary of data values to use when rendering the page.
+
+    Args:
+      mr: commonly used info parsed from the request.
+
+    Returns:
+      Dict of values used by EZT for rendering the page.
+    """
+    if mr.query or mr.can != 1:
+      with work_env.WorkEnv(mr, self.services) as we:
+        pipeline = we.ListIssues(
+            mr.query, [mr.project.project_name], mr.auth.user_id, mr.num,
+            mr.start, mr.can, mr.group_by_spec, mr.sort_spec, False)
+      issues = pipeline.allowed_results
+    # no user query and mr.can == 1 (we want all issues)
+    elif not mr.start and not mr.num:
+      issues = self.services.issue.GetAllIssuesInProject(
+          mr.cnxn, mr.project.project_id)
+    else:
+      local_id_range = list(range(mr.start, mr.start + mr.num))
+      issues = self.services.issue.GetIssuesByLocalIDs(
+          mr.cnxn, mr.project.project_id, local_id_range)
+
+    user_id_set = tracker_bizobj.UsersInvolvedInIssues(issues)
+
+    comments_dict = self.services.issue.GetCommentsForIssues(
+        mr.cnxn, [issue.issue_id for issue in issues])
+    for comment_list in comments_dict.values():
+      user_id_set.update(
+        tracker_bizobj.UsersInvolvedInCommentList(comment_list))
+
+    starrers_dict = self.services.issue_star.LookupItemsStarrers(
+        mr.cnxn, [issue.issue_id for issue in issues])
+    for starrer_id_list in starrers_dict.values():
+      user_id_set.update(starrer_id_list)
+
+    # The value 0 indicates "no user", e.g., that an issue has no owner.
+    # We don't need to create a User row to represent that.
+    user_id_set.discard(0)
+    email_dict = self.services.user.LookupUserEmails(
+        mr.cnxn, user_id_set, ignore_missed=True)
+
+    issues_json = [
+      self._MakeIssueJSON(
+          mr, issue, email_dict,
+          comments_dict.get(issue.issue_id, []),
+          starrers_dict.get(issue.issue_id, []))
+      for issue in issues if not issue.deleted]
+
+    json_data = {
+        'metadata': {
+            'version': 1,
+            'when': int(time.time()),
+            'who': mr.auth.email,
+            'project': mr.project_name,
+            'start': mr.start,
+            'num': mr.num,
+        },
+        'issues': issues_json,
+        # This list could be derived from the 'issues', but we provide it for
+        # ease of processing.
+        'emails': list(email_dict.values()),
+    }
+    return json_data
+
+  def _MakeAmendmentJSON(self, amendment, email_dict):
+    amendment_json = {
+        'field': amendment.field.name,
+    }
+    if amendment.custom_field_name:
+      amendment_json.update({'custom_field_name': amendment.custom_field_name})
+    if amendment.newvalue:
+      amendment_json.update({'new_value': amendment.newvalue})
+    if amendment.added_user_ids:
+      amendment_json.update(
+          {'added_emails': [email_dict.get(user_id)
+                            for user_id in amendment.added_user_ids]})
+    if amendment.removed_user_ids:
+      amendment_json.update(
+          {'removed_emails': [email_dict.get(user_id)
+                              for user_id in amendment.removed_user_ids]})
+    return amendment_json
+
+  def _MakeAttachmentJSON(self, attachment):
+    if attachment.deleted:
+      return None
+    attachment_json = {
+      'name': attachment.filename,
+      'size': attachment.filesize,
+      'mimetype': attachment.mimetype,
+      'gcs_object_id': attachment.gcs_object_id,
+    }
+    return attachment_json
+
+  def _MakeCommentJSON(self, comment, email_dict):
+    if comment.deleted_by:
+      return None
+    amendments = [self._MakeAmendmentJSON(a, email_dict)
+                  for a in comment.amendments]
+    attachments = [self._MakeAttachmentJSON(a)
+                   for a in comment.attachments]
+    comment_json = {
+      'timestamp': comment.timestamp,
+      'commenter': email_dict.get(comment.user_id),
+      'content': comment.content,
+      'amendments': [a for a in amendments if a],
+      'attachments': [a for a in attachments if a],
+      'description_num': comment.description_num
+    }
+    return comment_json
+
+  def _MakePhaseJSON(self, phase):
+    return {'id': phase.phase_id, 'name': phase.name, 'rank': phase.rank}
+
+  def _MakeFieldValueJSON(self, field, fd_dict, email_dict, phase_dict):
+    fd = fd_dict.get(field.field_id)
+    field_value_json = {
+        'field': fd.field_name,
+        'phase': phase_dict.get(field.phase_id),
+    }
+    approval_fd = fd_dict.get(fd.approval_id)
+    if approval_fd:
+      field_value_json['approval'] = approval_fd.field_name
+
+    if field.int_value:
+      field_value_json['int_value'] = field.int_value
+    if field.str_value:
+      field_value_json['str_value'] = field.str_value
+    if field.user_id:
+      field_value_json['user_value'] = email_dict.get(field.user_id)
+    if field.date_value:
+      field_value_json['date_value'] = field.date_value
+    return field_value_json
+
+  def _MakeApprovalValueJSON(
+      self, approval_value, fd_dict, email_dict, phase_dict):
+    av_json = {
+        'approval': fd_dict.get(approval_value.approval_id).field_name,
+        'status': approval_value.status.name,
+        'setter': email_dict.get(approval_value.setter_id),
+        'set_on': approval_value.set_on,
+        'approvers': [email_dict.get(approver_id) for
+                      approver_id in approval_value.approver_ids],
+        'phase': phase_dict.get(approval_value.phase_id),
+    }
+    return av_json
+
+  def _MakeIssueJSON(
+        self, mr, issue, email_dict, comment_list, starrer_id_list):
+    """Return a dict of info about the issue and its comments."""
+    descriptions = [c for c in comment_list if c.is_description]
+    for i, d in enumerate(descriptions):
+      d.description_num = str(i+1)
+    comments = [self._MakeCommentJSON(c, email_dict) for c in comment_list]
+    phase_dict = {phase.phase_id: phase.name for phase in issue.phases}
+    config = self.services.config.GetProjectConfig(
+        mr.cnxn, mr.project.project_id)
+    fd_dict = {fd.field_id: fd for fd in config.field_defs}
+    issue_json = {
+        'local_id': issue.local_id,
+        'reporter': email_dict.get(issue.reporter_id),
+        'summary': issue.summary,
+        'owner': email_dict.get(issue.owner_id),
+        'status': issue.status,
+        'cc': [email_dict[cc_id] for cc_id in issue.cc_ids],
+        'labels': issue.labels,
+        'phases': [self._MakePhaseJSON(phase) for phase in issue.phases],
+        'fields': [
+            self._MakeFieldValueJSON(field, fd_dict, email_dict, phase_dict)
+            for field in issue.field_values],
+        'approvals': [self._MakeApprovalValueJSON(
+            approval, fd_dict, email_dict, phase_dict)
+                      for approval in issue.approval_values],
+        'starrers': [email_dict[starrer] for starrer in starrer_id_list],
+        'comments': [c for c in comments if c],
+        'opened': issue.opened_timestamp,
+        'modified': issue.modified_timestamp,
+        'closed': issue.closed_timestamp,
+    }
+    # TODO(http://crbug.com/monorail/7217): Export cross-project references.
+    if issue.blocked_on_iids:
+      issue_json['blocked_on'] = [i.local_id for i in
+           self.services.issue.GetIssues(mr.cnxn, issue.blocked_on_iids)
+           if i.project_id == mr.project.project_id]
+    if issue.blocking_iids:
+      issue_json['blocking'] = [i.local_id for i in
+           self.services.issue.GetIssues(mr.cnxn, issue.blocking_iids)
+           if i.project_id == mr.project.project_id]
+    if issue.merged_into:
+      merge = self.services.issue.GetIssue(mr.cnxn, issue.merged_into)
+      if merge.project_id == mr.project.project_id:
+        issue_json['merged_into'] = merge.local_id
+    return issue_json
diff --git a/tracker/issueimport.py b/tracker/issueimport.py
new file mode 100644
index 0000000..1e0289b
--- /dev/null
+++ b/tracker/issueimport.py
@@ -0,0 +1,310 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Servlet to import a file of issues in JSON format.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import collections
+import json
+import logging
+import time
+
+import ezt
+
+from features import filterrules_helpers
+from framework import framework_helpers
+from framework import jsonfeed
+from framework import permissions
+from framework import servlet
+from framework import urls
+from proto import tracker_pb2
+
+
+ParserState = collections.namedtuple(
+    'ParserState',
+    'user_id_dict, nonexist_emails, issue_list, comments_dict, starrers_dict, '
+    'relations_dict')
+
+
+class IssueImport(servlet.Servlet):
+  """IssueImport loads a file of issues in JSON format."""
+
+  _PAGE_TEMPLATE = 'tracker/issue-import-page.ezt'
+  _MAIN_TAB_MODE = servlet.Servlet.MAIN_TAB_ISSUES
+
+  def AssertBasePermission(self, mr):
+    """Make sure that the logged in user has permission to view this page."""
+    super(IssueImport, self).AssertBasePermission(mr)
+    if not mr.auth.user_pb.is_site_admin:
+      raise permissions.PermissionException(
+          'Only site admins may import issues')
+
+  def GatherPageData(self, mr):
+    """Build up a dictionary of data values to use when rendering the page."""
+    return {
+        'issue_tab_mode': None,
+        'page_perms': self.MakePagePerms(mr, None, permissions.CREATE_ISSUE),
+        'import_errors': [],
+    }
+
+  def ProcessFormData(self, mr, post_data):
+    """Process the issue entry form.
+
+    Args:
+      mr: commonly used info parsed from the request.
+      post_data: The post_data dict for the current request.
+
+    Returns:
+      String URL to redirect the user to after processing.
+    """
+    import_errors = []
+    json_data = None
+
+    pre_check_only = 'pre_check_only' in post_data
+
+    uploaded_file = post_data.get('jsonfile')
+    if uploaded_file is None:
+      import_errors.append('No file uploaded')
+    else:
+      try:
+        json_str = uploaded_file.value
+        if json_str.startswith(jsonfeed.XSSI_PREFIX):
+          json_str = json_str[len(jsonfeed.XSSI_PREFIX):]
+        json_data = json.loads(json_str)
+      except ValueError:
+        import_errors.append('error parsing JSON in file')
+
+    if uploaded_file and not json_data:
+      import_errors.append('JSON file was empty')
+
+    # Note that the project must already exist in order to even reach
+    # this servlet because it is hosted in the context of a project.
+    if json_data and mr.project_name != json_data['metadata']['project']:
+      import_errors.append(
+        'Project name does not match. '
+        'Edit the file if you want to import into this project anyway.')
+
+    if import_errors:
+      return self.PleaseCorrect(mr, import_errors=import_errors)
+
+    event_log = []  # We accumulate a list of messages to display to the user.
+
+    try:
+      # First we parse the JSON into objects, but we don't have DB IDs yet.
+      state = self._ParseObjects(mr.cnxn, mr.project_id, json_data, event_log)
+      # If that worked, go ahead and start saving the data to the DB.
+      if not pre_check_only:
+        self._SaveObjects(mr.cnxn, mr.project_id, state, event_log)
+    except JSONImportError:
+      # just report it to the user by displaying event_log
+      event_log.append('Aborted import processing')
+
+    # This is a little bit of a hack because it always uses the form validation
+    # error message display logic to show the results of this import run,
+    # which may include errors or not.
+    return self.PleaseCorrect(mr, import_errors=event_log)
+
+  def _ParseObjects(self, cnxn, project_id, json_data, event_log):
+    """Examine JSON data and return a parser state for further processing."""
+    # Decide which users need to be created.
+    needed_emails = json_data['emails']
+    user_id_dict = self.services.user.LookupExistingUserIDs(cnxn, needed_emails)
+    nonexist_emails = [email for email in needed_emails
+                       if email not in user_id_dict]
+
+    event_log.append('Need to create %d users: %r' %
+                     (len(nonexist_emails), nonexist_emails))
+    user_id_dict.update({
+        email.lower(): framework_helpers.MurmurHash3_x86_32(email.lower())
+        for email in nonexist_emails})
+
+    num_comments = 0
+    num_stars = 0
+    issue_list = []
+    comments_dict = collections.defaultdict(list)
+    starrers_dict = collections.defaultdict(list)
+    relations_dict = collections.defaultdict(list)
+    for issue_json in json_data.get('issues', []):
+      issue, comment_list, starrer_list, relation_list = self._ParseIssue(
+          cnxn, project_id, user_id_dict, issue_json, event_log)
+      issue_list.append(issue)
+      comments_dict[issue.local_id] = comment_list
+      starrers_dict[issue.local_id] = starrer_list
+      relations_dict[issue.local_id] = relation_list
+      num_comments += len(comment_list)
+      num_stars += len(starrer_list)
+
+    event_log.append(
+      'Found info for %d issues: %r' %
+      (len(issue_list), sorted([issue.local_id for issue in issue_list])))
+
+    event_log.append(
+      'Found %d total comments for %d issues' %
+      (num_comments, len(comments_dict)))
+
+    event_log.append(
+      'Found %d total stars for %d issues' %
+      (num_stars, len(starrers_dict)))
+
+    event_log.append(
+      'Found %d total relationships.' %
+      sum((len(dsts) for dsts in relations_dict.values())))
+
+    event_log.append('Parsing phase finished OK')
+    return ParserState(
+      user_id_dict, nonexist_emails, issue_list,
+      comments_dict, starrers_dict, relations_dict)
+
+  def _ParseIssue(self, cnxn, project_id, user_id_dict, issue_json, event_log):
+    issue = tracker_pb2.Issue(
+      project_id=project_id,
+      local_id=issue_json['local_id'],
+      reporter_id=user_id_dict[issue_json['reporter']],
+      summary=issue_json['summary'],
+      opened_timestamp=issue_json['opened'],
+      modified_timestamp=issue_json['modified'],
+      cc_ids=[user_id_dict[cc_email]
+              for cc_email in issue_json.get('cc', [])
+              if cc_email in user_id_dict],
+      status=issue_json.get('status', ''),
+      labels=issue_json.get('labels', []),
+      field_values=[self._ParseFieldValue(cnxn, project_id, user_id_dict, field)
+                    for field in issue_json.get('fields', [])])
+    if issue_json.get('owner'):
+      issue.owner_id = user_id_dict[issue_json['owner']]
+    if issue_json.get('closed'):
+      issue.closed_timestamp = issue_json['closed']
+    comments = [self._ParseComment(
+                    project_id, user_id_dict, comment_json, event_log)
+                for comment_json in issue_json.get('comments', [])]
+
+    starrers = [user_id_dict[starrer] for starrer in issue_json['starrers']]
+
+    relations = []
+    relations.extend(
+        [(i, 'blockedon') for i in issue_json.get('blocked_on', [])])
+    relations.extend(
+        [(i, 'blocking') for i in issue_json.get('blocking', [])])
+    if 'merged_into' in issue_json:
+      relations.append((issue_json['merged_into'], 'mergedinto'))
+
+    return issue, comments, starrers, relations
+
+  def _ParseFieldValue(self, cnxn, project_id, user_id_dict, field_json):
+    field = tracker_pb2.FieldValue(
+        field_id=self.services.config.LookupFieldID(cnxn, project_id,
+                                                    field_json['field']))
+    if 'int_value' in field_json:
+      field.int_value = field_json['int_value']
+    if 'str_value' in field_json:
+      field.str_value = field_json['str_value']
+    if 'user_value' in field_json:
+      field.user_value = user_id_dict.get(field_json['user_value'])
+
+    return field
+
+  def _ParseComment(self, project_id, user_id_dict, comment_json, event_log):
+    comment = tracker_pb2.IssueComment(
+        # Note: issue_id is filled in after the issue is saved.
+        project_id=project_id,
+        timestamp=comment_json['timestamp'],
+        user_id=user_id_dict[comment_json['commenter']],
+        content=comment_json.get('content'))
+
+    for amendment in comment_json['amendments']:
+      comment.amendments.append(
+          self._ParseAmendment(amendment, user_id_dict, event_log))
+
+    for attachment in comment_json['attachments']:
+      comment.attachments.append(
+          self._ParseAttachment(attachment, event_log))
+
+    if comment_json['description_num']:
+      comment.is_description = True
+
+    return comment
+
+  def _ParseAmendment(self, amendment_json, user_id_dict, _event_log):
+    amendment = tracker_pb2.Amendment(
+        field=tracker_pb2.FieldID(amendment_json['field']))
+
+    if 'new_value' in amendment_json:
+      amendment.newvalue = amendment_json['new_value']
+    if 'custom_field_name' in amendment_json:
+      amendment.custom_field_name = amendment_json['custom_field_name']
+    if 'added_users' in amendment_json:
+      amendment.added_user_ids.extend(
+          [user_id_dict[email] for email in amendment_json['added_users']])
+    if 'removed_users' in amendment_json:
+      amendment.removed_user_ids.extend(
+          [user_id_dict[email] for email in amendment_json['removed_users']])
+
+    return amendment
+
+  def _ParseAttachment(self, attachment_json, _event_log):
+    attachment = tracker_pb2.Attachment(
+        filename=attachment_json['name'],
+        filesize=attachment_json['size'],
+        mimetype=attachment_json['mimetype'],
+        gcs_object_id=attachment_json['gcs_object_id']
+    )
+    return attachment
+
+  def _SaveObjects(self, cnxn, project_id, state, event_log):
+    """Examine JSON data and create users, issues, and comments."""
+
+    created_user_ids = self.services.user.LookupUserIDs(
+      cnxn, state.nonexist_emails, autocreate=True)
+    for created_email, created_id in created_user_ids.items():
+      if created_id != state.user_id_dict[created_email]:
+        event_log.append('Mismatched user_id for %r' % created_email)
+        raise JSONImportError()
+    event_log.append('Created %d users' % len(state.nonexist_emails))
+
+    total_comments = 0
+    total_stars = 0
+    config = self.services.config.GetProjectConfig(cnxn, project_id)
+    for issue in state.issue_list:
+      # TODO(jrobbins): renumber issues if there is a local_id conflict.
+      if issue.local_id not in state.starrers_dict:
+        # Issues with stars will have filter rules applied in SetStar().
+        filterrules_helpers.ApplyFilterRules(
+            cnxn, self.services, issue, config)
+      issue_id = self.services.issue.InsertIssue(cnxn, issue)
+      for comment in state.comments_dict[issue.local_id]:
+        total_comments += 1
+        comment.issue_id = issue_id
+        self.services.issue.InsertComment(cnxn, comment)
+      self.services.issue_star.SetStarsBatch(
+          cnxn, self.services, config, issue_id,
+          state.starrers_dict[issue.local_id], True)
+      total_stars += len(state.starrers_dict[issue.local_id])
+
+    event_log.append('Created %d issues' % len(state.issue_list))
+    event_log.append('Created %d comments for %d issues' % (
+        total_comments, len(state.comments_dict)))
+    event_log.append('Set %d stars on %d issues' % (
+        total_stars, len(state.starrers_dict)))
+
+    global_relations_dict = collections.defaultdict(list)
+    for issue, rels in state.relations_dict.items():
+      src_iid = self.services.issue.GetIssueByLocalID(
+          cnxn, project_id, issue).issue_id
+      dst_iids = [i.issue_id for i in self.services.issue.GetIssuesByLocalIDs(
+          cnxn, project_id, [rel[0] for rel in rels])]
+      kinds = [rel[1] for rel in rels]
+      global_relations_dict[src_iid] = list(zip(dst_iids, kinds))
+    self.services.issue.RelateIssues(cnxn, global_relations_dict)
+
+    self.services.issue.SetUsedLocalID(cnxn, project_id)
+    event_log.append('Finished import')
+
+
+class JSONImportError(Exception):
+  """Exception to raise if imported JSON is invalid."""
+  pass
diff --git a/tracker/issueoriginal.py b/tracker/issueoriginal.py
new file mode 100644
index 0000000..55a494f
--- /dev/null
+++ b/tracker/issueoriginal.py
@@ -0,0 +1,98 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Servlet to show the original email that caused an issue comment.
+
+The text of the body the email is shown in an HTML page with <pre>.
+All the text is automatically escaped by EZT to make it safe to
+include in an HTML page.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import ezt
+
+from businesslogic import work_env
+from framework import filecontent
+from framework import permissions
+from framework import servlet
+from services import issue_svc
+
+
+class IssueOriginal(servlet.Servlet):
+  """IssueOriginal shows an inbound email that caused an issue comment."""
+
+  _PAGE_TEMPLATE = 'tracker/issue-original-page.ezt'
+
+  def AssertBasePermission(self, mr):
+    """Make sure that the logged in user has permission to view this page."""
+    super(IssueOriginal, self).AssertBasePermission(mr)
+    issue, comment = self._GetIssueAndComment(mr)
+
+    # TODO(jrobbins): take granted perms into account here.
+    if issue and not permissions.CanViewIssue(
+        mr.auth.effective_ids, mr.perms, mr.project, issue,
+        allow_viewing_deleted=True):
+      raise permissions.PermissionException(
+          'User is not allowed to view this issue')
+
+    can_view_inbound_message = self.CheckPerm(
+        mr, permissions.VIEW_INBOUND_MESSAGES, art=issue)
+    issue_perms = permissions.UpdateIssuePermissions(
+        mr.perms, mr.project, issue, mr.auth.effective_ids)
+    commenter = self.services.user.GetUser(mr.cnxn, comment.user_id)
+    can_view_comment = permissions.CanViewComment(
+        comment, commenter, mr.auth.user_id, issue_perms)
+    if not can_view_inbound_message or not can_view_comment:
+      raise permissions.PermissionException(
+          'Only project members may view original email text')
+
+  def GatherPageData(self, mr):
+    """Build up a dictionary of data values to use when rendering the page.
+
+    Args:
+      mr: commonly used info parsed from the request.
+
+    Returns:
+      Dict of values used by EZT for rendering the page.
+    """
+    issue, comment = self._GetIssueAndComment(mr)
+    message_body_unicode, is_binary, _is_long = (
+        filecontent.DecodeFileContents(comment.inbound_message))
+
+    # Take out the iso8859-1 non-breaking-space characters that gmail
+    # inserts between consecutive spaces when quoting text in a reply.
+    # You can see this in gmail by sending a plain text reply to a
+    # message that had multiple spaces on some line, then use the
+    # "Show original" menu item to view your reply, you will see "=A0".
+    #message_body_unicode = message_body_unicode.replace(u'\xa0', u' ')
+
+    page_data = {
+        'local_id': issue.local_id,
+        'seq': comment.sequence,
+        'is_binary': ezt.boolean(is_binary),
+        'message_body': message_body_unicode,
+        }
+
+    return page_data
+
+  def _GetIssueAndComment(self, mr):
+    """Wait on retriving the specified issue and issue comment."""
+    if mr.seq is None:
+      self.abort(404, 'comment not specified')
+
+    with work_env.WorkEnv(mr, self.services) as we:
+      issue = self.services.issue.GetIssueByLocalID(
+          mr.cnxn, mr.project_id, mr.local_id)
+      comments = we.ListIssueComments(issue)
+
+    try:
+      comment = comments[mr.seq]
+    except IndexError:
+      self.abort(404, 'comment not found')
+
+    return issue, comment
diff --git a/tracker/issuereindex.py b/tracker/issuereindex.py
new file mode 100644
index 0000000..de5d2f0
--- /dev/null
+++ b/tracker/issuereindex.py
@@ -0,0 +1,87 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Classes that implement an admin utility to re-index issues in bulk."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import urllib
+
+import settings
+from framework import permissions
+from framework import servlet
+from framework import urls
+from services import tracker_fulltext
+
+
+class IssueReindex(servlet.Servlet):
+  """IssueReindex shows a form to request that issues be indexed."""
+
+  _PAGE_TEMPLATE = 'tracker/issue-reindex-page.ezt'
+  _MAIN_TAB_MODE = servlet.Servlet.MAIN_TAB_ISSUES
+
+  def AssertBasePermission(self, mr):
+    """Check whether the user has any permission to visit this page.
+
+    Args:
+      mr: commonly used info parsed from the request.
+    """
+    super(IssueReindex, self).AssertBasePermission(mr)
+    if not self.CheckPerm(mr, permissions.EDIT_PROJECT):
+      raise permissions.PermissionException(
+          'You are not allowed to administer this project')
+
+  def GatherPageData(self, mr):
+    """Build up a dictionary of data values to use when rendering the page.
+
+    Args:
+      mr: commonly used info parsed from the request.
+
+    Returns:
+      Dict of values used by EZT for rendering the page.
+    """
+    return {
+        # start and num are already passed to the template.
+        'issue_tab_mode': None,
+        'auto_submit': mr.auto_submit,
+        'page_perms': self.MakePagePerms(mr, None, permissions.CREATE_ISSUE),
+        }
+
+  def ProcessFormData(self, mr, post_data):
+    """Process a posted issue reindex form.
+
+    Args:
+      mr: commonly used info parsed from the request.
+      post_data: HTML form data from the request.
+
+    Returns:
+      String URL to redirect the user to after processing. The URL will contain
+      a new start that is auto-incremented using the specified num value.
+    """
+    start = max(0, int(post_data['start']))
+    num = max(0, min(settings.max_artifact_search_results_per_page,
+                     int(post_data['num'])))
+
+    issues = self.services.issue.GetIssuesByLocalIDs(
+        mr.cnxn, mr.project_id, list(range(start, start + num)))
+    logging.info('got %d issues to index', len(issues))
+    if issues:
+      tracker_fulltext.IndexIssues(
+          mr.cnxn, issues, self.services.user, self.services.issue,
+          self.services.config)
+
+    # Make the browser keep submitting the form, if the user wants that,
+    # and we have not run out of issues to process.
+    auto_submit = issues and ('auto_submit' in post_data)
+
+    query_map = {
+      'start': start + num,  # auto-increment start.
+      'num': num,
+      'auto_submit': bool(auto_submit),
+    }
+    return '/p/%s%s?%s' % (mr.project_name, urls.ISSUE_REINDEX,
+                           urllib.urlencode(query_map))
diff --git a/tracker/issuetips.py b/tracker/issuetips.py
new file mode 100644
index 0000000..eb75265
--- /dev/null
+++ b/tracker/issuetips.py
@@ -0,0 +1,29 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""A class to render a page of issue tracker search tips."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+
+from framework import servlet
+from framework import permissions
+
+
+class IssueSearchTips(servlet.Servlet):
+  """IssueSearchTips on-line help on how to use issue search."""
+
+  _PAGE_TEMPLATE = 'tracker/issue-search-tips.ezt'
+  _MAIN_TAB_MODE = servlet.Servlet.MAIN_TAB_ISSUES
+
+  def GatherPageData(self, mr):
+    """Build up a dictionary of data values to use when rendering the page."""
+
+    return {
+        'issue_tab_mode': 'issueSearchTips',
+        'page_perms': self.MakePagePerms(mr, None, permissions.CREATE_ISSUE),
+    }
diff --git a/tracker/rerank_helpers.py b/tracker/rerank_helpers.py
new file mode 100644
index 0000000..f27582c
--- /dev/null
+++ b/tracker/rerank_helpers.py
@@ -0,0 +1,133 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Functions to help rerank issues in a lit.
+
+This file contains methods that implement a reranking algorithm for
+issues in a list.
+"""
+
+from __future__ import division
+from __future__ import print_function
+from __future__ import absolute_import
+
+import sys
+
+from framework import exceptions
+
+MAX_RANKING = sys.maxint
+MIN_RANKING = 0
+
+def GetHotlistRerankChanges(hotlist_items, moved_issue_ids, target_position):
+  # type: (int, Sequence[int], int) -> Collection[Tuple[int, int]]
+  """Computes the new ranks from reranking and or inserting of HotlistItems.
+
+  Args:
+    hotlist_items: The current list of HotlistItems in the Hotlist.
+    moved_issue_ids: A list of issue IDs to be moved/inserted together,
+      in the order
+      they should have the reranking.
+    target_position: The index, starting at 0, of the new position the
+      first issue in moved_issue_ids should occupy in the updated ordering.
+      Therefore this value cannot be greater than
+      (len(hotlist.items) - len(moved_issue_ids)).
+
+  Returns:
+    A list of [(issue_id, rank), ...] of HotlistItems that need to be updated.
+
+  Raises:
+    InputException: If the target_position is not valid.
+  """
+  # Sort hotlist items by rank.
+  sorted_hotlist_items = sorted(hotlist_items, key=lambda item: item.rank)
+  unmoved_hotlist_items = [
+      item for item in sorted_hotlist_items
+      if item.issue_id not in moved_issue_ids]
+  if target_position < 0:
+    raise exceptions.InputException(
+        'given `target_position`: %d, must be non-negative')
+  if target_position > len(unmoved_hotlist_items):
+    raise exceptions.InputException(
+        '`target_position` %d is higher than maximum allowed (%d) for'
+        'moving %d items in a hotlist with %d items total.' % (
+            target_position, len(unmoved_hotlist_items),
+            len(moved_issue_ids), len(sorted_hotlist_items)))
+  lower, higher = [], []
+  for i, item in enumerate(unmoved_hotlist_items):
+    item_tuple = (item.issue_id, item.rank)
+    if i < target_position:
+      lower.append(item_tuple)
+    else:
+      higher.append(item_tuple)
+
+  return GetInsertRankings(lower, higher, moved_issue_ids)
+
+def GetInsertRankings(lower, higher, moved_ids):
+  """Compute rankings for moved_ids to insert between the
+  lower and higher rankings
+
+  Args:
+    lower: a list of [(id, rank),...] of blockers that should have
+      a lower rank than the moved issues. Should be sorted from highest
+      to lowest rank.
+    higher: a list of [(id, rank),...] of blockers that should have
+      a higher rank than the moved issues. Should be sorted from highest
+      to lowest rank.
+    moved_ids: a list of global IDs for issues to re-rank.
+
+  Returns:
+    a list of [(id, rank),...] of blockers that need to be updated. rank
+    is the new rank of the issue with the specified id.
+  """
+  if lower:
+    lower_rank = lower[-1][1]
+  else:
+    lower_rank = MIN_RANKING
+
+  if higher:
+    higher_rank = higher[0][1]
+  else:
+    higher_rank = MAX_RANKING
+
+  slot_count = higher_rank - lower_rank - 1
+  if slot_count >= len(moved_ids):
+    new_ranks = _DistributeRanks(lower_rank, higher_rank, len(moved_ids))
+    return list(zip(moved_ids, new_ranks))
+  else:
+    new_lower, new_higher, new_moved_ids = _ResplitRanks(
+        lower, higher, moved_ids)
+    if not new_moved_ids:
+      return None
+    else:
+      return GetInsertRankings(new_lower, new_higher, new_moved_ids)
+
+
+def _DistributeRanks(low, high, rank_count):
+  """Compute evenly distributed ranks in a range"""
+  bucket_size = (high - low) // rank_count
+  first_rank = low + (bucket_size + 1) // 2
+  return list(range(first_rank, high, bucket_size))
+
+
+def _ResplitRanks(lower, higher, moved_ids):
+  if not (lower or higher):
+    return None, None, None
+
+  if not lower:
+    take_from = 'higher'
+  elif not higher:
+    take_from = 'lower'
+  else:
+    next_lower = lower[-2][1] if len(lower) >= 2 else MIN_RANKING
+    next_higher = higher[1][1] if len(higher) >= 2 else MAX_RANKING
+    if (lower[-1][1] - next_lower) > (next_higher - higher[0][1]):
+      take_from = 'lower'
+    else:
+      take_from = 'higher'
+
+  if take_from == 'lower':
+    return (lower[:-1], higher, [lower[-1][0]] + moved_ids)
+  else:
+    return (lower, higher[1:], moved_ids + [higher[0][0]])
diff --git a/tracker/spam.py b/tracker/spam.py
new file mode 100644
index 0000000..a30fc3e
--- /dev/null
+++ b/tracker/spam.py
@@ -0,0 +1,60 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Classes that implement spam flagging features.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import httplib
+import logging
+
+from framework import framework_helpers
+from framework import paginate
+from framework import permissions
+from framework import urls
+from framework import servlet
+from framework import template_helpers
+from framework import xsrf
+from tracker import spam_helpers
+from tracker import tracker_bizobj
+
+
+class ModerationQueue(servlet.Servlet):
+  _PAGE_TEMPLATE = 'tracker/spam-moderation-queue.ezt'
+
+  def GatherPageData(self, mr):
+    if not self.CheckPerm(mr, permissions.MODERATE_SPAM):
+      raise permissions.PermissionException()
+
+    page_perms = self.MakePagePerms(
+        mr, None, permissions.MODERATE_SPAM,
+        permissions.EDIT_ISSUE, permissions.CREATE_ISSUE,
+        permissions.SET_STAR)
+
+    # TODO(seanmccullough): Figure out how to get the IssueFlagQueue either
+    # integrated into this page data, or on its own subtab of spam moderation.
+    # Also figure out the same for Comments.
+    issue_items, total_count = self.services.spam.GetIssueClassifierQueue(
+        mr.cnxn, self.services.issue, mr.project.project_id, mr.start, mr.num)
+
+    issue_queue = spam_helpers.DecorateIssueClassifierQueue(mr.cnxn,
+        self.services.issue, self.services.spam, self.services.user,
+        issue_items)
+
+    url_params = [(name, mr.GetParam(name)) for name in
+                  framework_helpers.RECOGNIZED_PARAMS]
+    p = paginate.ArtifactPagination(
+        [], mr.num, mr.GetPositiveIntParam('start'),
+        mr.project_name, urls.SPAM_MODERATION_QUEUE, total_count=total_count,
+        url_params=url_params)
+
+    return {
+        'issue_queue': issue_queue,
+        'projectname': mr.project.project_name,
+        'pagination': p,
+        'page_perms': page_perms,
+    }
diff --git a/tracker/spam_helpers.py b/tracker/spam_helpers.py
new file mode 100644
index 0000000..2bf2c90
--- /dev/null
+++ b/tracker/spam_helpers.py
@@ -0,0 +1,47 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Set of helpers for constructing spam-related pages.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from framework import template_helpers
+import ezt
+
+from datetime import datetime
+
+def DecorateIssueClassifierQueue(
+    cnxn, issue_service, spam_service, user_service, moderation_items):
+  issue_ids = [item.issue_id for item in moderation_items]
+  issues = issue_service.GetIssues(cnxn, issue_ids)
+  issue_map = {}
+  for issue in issues:
+    issue_map[issue.issue_id] = issue
+
+  flag_counts = spam_service.LookupIssueFlagCounts(cnxn, issue_ids)
+
+  reporter_ids = [issue.reporter_id for issue in issues]
+  reporters = user_service.GetUsersByIDs(cnxn, reporter_ids)
+  comments = issue_service.GetCommentsForIssues(cnxn, issue_ids)
+
+  items = []
+  for item in moderation_items:
+    issue=issue_map[item.issue_id]
+    first_comment = comments.get(item.issue_id, ["[Empty]"])[0]
+
+    items.append(template_helpers.EZTItem(
+        issue=issue,
+        summary=template_helpers.FitUnsafeText(issue.summary, 80),
+        comment_text=template_helpers.FitUnsafeText(first_comment.content, 80),
+        reporter=reporters[issue.reporter_id],
+        flag_count=flag_counts.get(issue.issue_id, 0),
+        is_spam=ezt.boolean(item.is_spam),
+        verdict_time=item.verdict_time,
+        classifier_confidence=item.classifier_confidence,
+        reason=item.reason,
+    ))
+
+  return items
diff --git a/tracker/tablecell.py b/tracker/tablecell.py
new file mode 100644
index 0000000..afb6468
--- /dev/null
+++ b/tracker/tablecell.py
@@ -0,0 +1,506 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Classes that generate value cells in the issue list table."""
+
+from __future__ import division
+from __future__ import print_function
+from __future__ import absolute_import
+
+import logging
+import time
+import ezt
+
+from framework import framework_constants
+from framework import table_view_helpers
+from framework import template_helpers
+from framework import urls
+from tracker import tracker_bizobj
+from tracker import tracker_helpers
+
+# pylint: disable=unused-argument
+
+
+class TableCellNote(table_view_helpers.TableCell):
+  """TableCell subclass specifically for showing a hotlist issue's note."""
+
+  def __init__(self, issue, note=None, **_kw):
+    if note:
+      display_note = [note]
+    else:
+      display_note = []
+    table_view_helpers.TableCell.__init__(
+        self, table_view_helpers.CELL_TYPE_NOTE, display_note)
+
+
+class TableCellDateAdded(table_view_helpers.TableCell):
+  """TableCell subclass specifically for showing the date added of an issue."""
+
+  def __init__(self, issue, date_added=None, **_kw):
+    table_view_helpers.TableCell.__init__(
+        self, table_view_helpers.CELL_TYPE_ATTR, [date_added])
+
+
+class TableCellAdderID(table_view_helpers.TableCell):
+  """TableCell subclass specifically for showing an issue's adder_id."""
+
+  def __init__(self, issue, adder_id=None, users_by_id=None, **_kw):
+    if adder_id:
+      display_name = [users_by_id[adder_id].display_name]
+    else:
+      display_name = [None]
+    table_view_helpers.TableCell.__init__(
+        self, table_view_helpers.CELL_TYPE_ATTR,
+        display_name)
+
+
+class TableCellRank(table_view_helpers.TableCell):
+  """TableCell subclass specifically for showing issue rank."""
+
+  def __init__(self, issue, issue_rank=None, **_kw):
+    table_view_helpers.TableCell.__init__(
+        self, table_view_helpers.CELL_TYPE_ATTR, [issue_rank])
+
+
+class TableCellID(table_view_helpers.TableCell):
+  """TableCell subclass specifically for showing issue IDs."""
+
+  def __init__(self, issue, **_kw):
+    table_view_helpers.TableCell.__init__(
+        self, table_view_helpers.CELL_TYPE_ID, [str(issue.local_id)])
+
+
+class TableCellStatus(table_view_helpers.TableCell):
+  """TableCell subclass specifically for showing issue status values."""
+
+  def __init__(self, issue, **_kws):
+    values = []
+    derived_values = []
+    if issue.status:
+      values = [issue.status]
+    if issue.derived_status:
+      derived_values = [issue.derived_status]
+
+    table_view_helpers.TableCell.__init__(
+        self, table_view_helpers.CELL_TYPE_ATTR, values,
+        derived_values=derived_values)
+
+
+class TableCellOwner(table_view_helpers.TableCell):
+  """TableCell subclass specifically for showing issue owner name."""
+
+  def __init__(self, issue, users_by_id=None, **_kw):
+    values = []
+    derived_values = []
+    if issue.owner_id:
+      values = [users_by_id[issue.owner_id].display_name]
+    if issue.derived_owner_id:
+      derived_values = [users_by_id[issue.derived_owner_id].display_name]
+
+    table_view_helpers.TableCell.__init__(
+        self, table_view_helpers.CELL_TYPE_ATTR, values,
+        derived_values=derived_values)
+
+
+class TableCellReporter(table_view_helpers.TableCell):
+  """TableCell subclass specifically for showing issue reporter name."""
+
+  def __init__(self, issue, users_by_id=None, **_kw):
+    try:
+      values = [users_by_id[issue.reporter_id].display_name]
+    except KeyError:
+      logging.info('issue reporter %r not found', issue.reporter_id)
+      values = ['deleted?']
+
+    table_view_helpers.TableCell.__init__(
+        self, table_view_helpers.CELL_TYPE_ATTR, values)
+
+
+class TableCellCc(table_view_helpers.TableCell):
+  """TableCell subclass specifically for showing issue Cc user names."""
+
+  def __init__(self, issue, users_by_id=None, **_kw):
+    values = [users_by_id[cc_id].display_name
+              for cc_id in issue.cc_ids]
+
+    derived_values = [users_by_id[cc_id].display_name
+                      for cc_id in issue.derived_cc_ids]
+
+    table_view_helpers.TableCell.__init__(
+        self, table_view_helpers.CELL_TYPE_ATTR, values,
+        derived_values=derived_values)
+
+
+class TableCellAttachments(table_view_helpers.TableCell):
+  """TableCell subclass specifically for showing issue attachment count."""
+
+  def __init__(self, issue, **_kw):
+    table_view_helpers.TableCell.__init__(
+        self, table_view_helpers.CELL_TYPE_ATTR, [issue.attachment_count],
+        align='right')
+
+
+class TableCellOpened(table_view_helpers.TableCellDate):
+  """TableCell subclass specifically for showing issue opened date."""
+
+  def __init__(self, issue, **_kw):
+    table_view_helpers.TableCellDate.__init__(self, issue.opened_timestamp)
+
+
+class TableCellClosed(table_view_helpers.TableCellDate):
+  """TableCell subclass specifically for showing issue closed date."""
+
+  def __init__(self, issue, **_kw):
+    table_view_helpers.TableCellDate.__init__(self, issue.closed_timestamp)
+
+
+class TableCellModified(table_view_helpers.TableCellDate):
+  """TableCell subclass specifically for showing issue modified date."""
+
+  def __init__(self, issue, **_kw):
+    table_view_helpers.TableCellDate.__init__(self, issue.modified_timestamp)
+
+
+class TableCellOwnerModified(table_view_helpers.TableCellDate):
+  """TableCell subclass specifically for showing owner modified age."""
+
+  def __init__(self, issue, **_kw):
+    table_view_helpers.TableCellDate.__init__(
+        self, issue.owner_modified_timestamp, days_only=True)
+
+
+class TableCellStatusModified(table_view_helpers.TableCellDate):
+  """TableCell subclass specifically for showing status modified age."""
+
+  def __init__(self, issue, **_kw):
+    table_view_helpers.TableCellDate.__init__(
+        self, issue.status_modified_timestamp, days_only=True)
+
+
+class TableCellComponentModified(table_view_helpers.TableCellDate):
+  """TableCell subclass specifically for showing component modified age."""
+
+  def __init__(self, issue, **_kw):
+    table_view_helpers.TableCellDate.__init__(
+        self, issue.component_modified_timestamp, days_only=True)
+
+
+class TableCellOwnerLastVisit(table_view_helpers.TableCellDate):
+  """TableCell subclass specifically for showing owner last visit days ago."""
+
+  def __init__(self, issue, users_by_id=None, **_kw):
+    owner_view = users_by_id.get(issue.owner_id or issue.derived_owner_id)
+    last_visit = None
+    if owner_view:
+      last_visit = owner_view.user.last_visit_timestamp
+    table_view_helpers.TableCellDate.__init__(
+        self, last_visit, days_only=True)
+
+def _make_issue_view(default_pn, config, viewable_iids_set, ref_issue):
+  viewable = ref_issue.issue_id in viewable_iids_set
+  return template_helpers.EZTItem(
+      id=tracker_bizobj.FormatIssueRef(
+          (ref_issue.project_name, ref_issue.local_id),
+          default_project_name=default_pn),
+      href=tracker_helpers.FormatRelativeIssueURL(
+          ref_issue.project_name, urls.ISSUE_DETAIL, id=ref_issue.local_id),
+      closed=ezt.boolean(
+          viewable and
+          not tracker_helpers.MeansOpenInProject(ref_issue.status, config)),
+      title=ref_issue.summary if viewable else "")
+
+
+class TableCellBlockedOn(table_view_helpers.TableCell):
+  """TableCell subclass for listing issues the current issue is blocked on."""
+
+  def __init__(self, issue, related_issues=None, **_kw):
+    ref_issues = [related_issues[iid] for iid in issue.blocked_on_iids
+                  if iid in related_issues]
+    values = [_make_issue_view(issue.project_name, _kw["config"],
+                                _kw["viewable_iids_set"], ref_issue)
+              for ref_issue in ref_issues]
+    values.sort(key=lambda x: (x.closed, x.id))
+    table_view_helpers.TableCell.__init__(
+        self, table_view_helpers.CELL_TYPE_ISSUES, values, sort_values=False)
+
+
+class TableCellBlocking(table_view_helpers.TableCell):
+  """TableCell subclass for listing issues the current issue is blocking."""
+
+  def __init__(self, issue, related_issues=None, **_kw):
+    ref_issues = [related_issues[iid] for iid in issue.blocking_iids
+                  if iid in related_issues]
+    values = [_make_issue_view(issue.project_name, _kw["config"],
+                                _kw["viewable_iids_set"], ref_issue)
+              for ref_issue in ref_issues]
+    values.sort(key=lambda x: (x.closed, x.id))
+    table_view_helpers.TableCell.__init__(
+        self, table_view_helpers.CELL_TYPE_ISSUES, values, sort_values=False)
+
+
+class TableCellBlocked(table_view_helpers.TableCell):
+  """TableCell subclass for showing whether an issue is blocked."""
+
+  def __init__(self, issue, **_kw):
+    if issue.blocked_on_iids:
+      value = 'Yes'
+    else:
+      value = 'No'
+
+    table_view_helpers.TableCell.__init__(
+        self, table_view_helpers.CELL_TYPE_ATTR, [value])
+
+
+class TableCellMergedInto(table_view_helpers.TableCell):
+  """TableCell subclass for showing whether an issue is blocked."""
+
+  def __init__(self, issue, related_issues=None, **_kw):
+    if issue.merged_into:
+      ref_issue = related_issues[issue.merged_into]
+      values = [_make_issue_view(issue.project_name, _kw["config"],
+                                  _kw["viewable_iids_set"], ref_issue)]
+    else:   # Note: None means not merged into any issue.
+      values = []
+    table_view_helpers.TableCell.__init__(
+        self, table_view_helpers.CELL_TYPE_ISSUES, values)
+
+
+class TableCellComponent(table_view_helpers.TableCell):
+  """TableCell subclass for showing components."""
+
+  def __init__(self, issue, config=None, **_kw):
+    explicit_paths = []
+    for component_id in issue.component_ids:
+      cd = tracker_bizobj.FindComponentDefByID(component_id, config)
+      if cd:
+        explicit_paths.append(cd.path)
+
+    derived_paths = []
+    for component_id in issue.derived_component_ids:
+      cd = tracker_bizobj.FindComponentDefByID(component_id, config)
+      if cd:
+        derived_paths.append(cd.path)
+
+    table_view_helpers.TableCell.__init__(
+        self, table_view_helpers.CELL_TYPE_ATTR, explicit_paths,
+        derived_values=derived_paths)
+
+
+class TableCellAllLabels(table_view_helpers.TableCell):
+  """TableCell subclass specifically for showing all labels on an issue."""
+
+  def __init__(self, issue, **_kw):
+    values = []
+    derived_values = []
+    if issue.labels:
+      values = issue.labels[:]
+    if issue.derived_labels:
+      derived_values = issue.derived_labels[:]
+
+    table_view_helpers.TableCell.__init__(
+        self, table_view_helpers.CELL_TYPE_ATTR, values,
+        derived_values=derived_values)
+
+
+# This maps column names to factories/constructors that make table cells.
+# Subclasses can override this mapping, so any additions to this mapping
+# should also be added to subclasses.
+CELL_FACTORIES = {
+    'id': TableCellID,
+    'project': table_view_helpers.TableCellProject,
+    'component': TableCellComponent,
+    'summary': table_view_helpers.TableCellSummary,
+    'status': TableCellStatus,
+    'owner': TableCellOwner,
+    'reporter': TableCellReporter,
+    'cc': TableCellCc,
+    'stars': table_view_helpers.TableCellStars,
+    'attachments': TableCellAttachments,
+    'opened': TableCellOpened,
+    'closed': TableCellClosed,
+    'modified': TableCellModified,
+    'blockedon': TableCellBlockedOn,
+    'blocking': TableCellBlocking,
+    'blocked': TableCellBlocked,
+    'mergedinto': TableCellMergedInto,
+    'ownermodified': TableCellOwnerModified,
+    'statusmodified': TableCellStatusModified,
+    'componentmodified': TableCellComponentModified,
+    'ownerlastvisit': TableCellOwnerLastVisit,
+    'rank': TableCellRank,
+    'added': TableCellDateAdded,
+    'adder': TableCellAdderID,
+    'note': TableCellNote,
+    'alllabels': TableCellAllLabels,
+    }
+
+
+# Time format that spreadsheets seem to understand.
+# E.g.: "May 19 2008 13:30:23".  Tested with MS Excel 2003,
+# OpenOffice.org, NeoOffice, and Google Spreadsheets.
+CSV_DATE_TIME_FMT = '%b %d, %Y %H:%M:%S'
+
+
+def TimeStringForCSV(timestamp):
+  """Return a timestamp in a format that spreadsheets understand."""
+  return time.strftime(CSV_DATE_TIME_FMT, time.gmtime(timestamp))
+
+
+class TableCellOpenedCSV(table_view_helpers.TableCell):
+  """TableCell subclass specifically for showing issue opened date."""
+
+  def __init__(self, issue, **_kw):
+    date_str = TimeStringForCSV(issue.opened_timestamp)
+
+    table_view_helpers.TableCell.__init__(
+        self, table_view_helpers.CELL_TYPE_UNFILTERABLE, [date_str])
+
+
+class TableCellOpenedTimestamp(table_view_helpers.TableCell):
+  """TableCell subclass specifically for showing issue opened timestamp."""
+
+  def __init__(self, issue, **_kw):
+    table_view_helpers.TableCell.__init__(
+        self, table_view_helpers.CELL_TYPE_UNFILTERABLE,
+        [issue.opened_timestamp])
+
+
+class TableCellModifiedCSV(table_view_helpers.TableCell):
+  """TableCell subclass specifically for showing issue modified date."""
+
+  def __init__(self, issue, **_kw):
+    values = []
+    if issue.modified_timestamp:
+      values = [TimeStringForCSV(issue.modified_timestamp)]
+
+    table_view_helpers.TableCell.__init__(
+        self, table_view_helpers.CELL_TYPE_UNFILTERABLE, values)
+
+
+class TableCellModifiedTimestamp(table_view_helpers.TableCell):
+  """TableCell subclass specifically for showing issue modified timestamp."""
+
+  def __init__(self, issue, **_kw):
+    table_view_helpers.TableCell.__init__(
+        self, table_view_helpers.CELL_TYPE_UNFILTERABLE,
+        [issue.modified_timestamp])
+
+
+class TableCellClosedCSV(table_view_helpers.TableCell):
+  """TableCell subclass specifically for showing issue closed date."""
+
+  def __init__(self, issue, **_kw):
+    values = []
+    if issue.closed_timestamp:
+      values = [TimeStringForCSV(issue.closed_timestamp)]
+
+    table_view_helpers.TableCell.__init__(
+        self, table_view_helpers.CELL_TYPE_UNFILTERABLE, values)
+
+
+class TableCellClosedTimestamp(table_view_helpers.TableCell):
+  """TableCell subclass specifically for showing issue closed timestamp."""
+
+  def __init__(self, issue, **_kw):
+    table_view_helpers.TableCell.__init__(
+        self, table_view_helpers.CELL_TYPE_UNFILTERABLE,
+        [issue.closed_timestamp])
+
+
+class TableCellOwnerModifiedCSV(table_view_helpers.TableCell):
+  """TableCell subclass specifically for showing owner modified date."""
+
+  def __init__(self, issue, **_kw):
+    values = []
+    if issue.modified_timestamp:
+      values = [TimeStringForCSV(issue.owner_modified_timestamp)]
+
+    table_view_helpers.TableCell.__init__(
+        self, table_view_helpers.CELL_TYPE_UNFILTERABLE, values)
+
+
+class TableCellOwnerModifiedTimestamp(table_view_helpers.TableCell):
+  """TableCell subclass specifically for showing owner modified timestamp."""
+
+  def __init__(self, issue, **_kw):
+    table_view_helpers.TableCell.__init__(
+        self, table_view_helpers.CELL_TYPE_UNFILTERABLE,
+        [issue.owner_modified_timestamp])
+
+
+class TableCellStatusModifiedCSV(table_view_helpers.TableCell):
+  """TableCell subclass specifically for showing status modified date."""
+
+  def __init__(self, issue, **_kw):
+    values = []
+    if issue.modified_timestamp:
+      values = [TimeStringForCSV(issue.status_modified_timestamp)]
+
+    table_view_helpers.TableCell.__init__(
+        self, table_view_helpers.CELL_TYPE_UNFILTERABLE, values)
+
+
+class TableCellStatusModifiedTimestamp(table_view_helpers.TableCell):
+  """TableCell subclass specifically for showing status modified timestamp."""
+
+  def __init__(self, issue, **_kw):
+    table_view_helpers.TableCell.__init__(
+        self, table_view_helpers.CELL_TYPE_UNFILTERABLE,
+        [issue.status_modified_timestamp])
+
+
+class TableCellComponentModifiedCSV(table_view_helpers.TableCell):
+  """TableCell subclass specifically for showing component modified date."""
+
+  def __init__(self, issue, **_kw):
+    values = []
+    if issue.modified_timestamp:
+      values = [TimeStringForCSV(issue.component_modified_timestamp)]
+
+    table_view_helpers.TableCell.__init__(
+        self, table_view_helpers.CELL_TYPE_UNFILTERABLE, values)
+
+
+class TableCellComponentModifiedTimestamp(table_view_helpers.TableCell):
+  """TableCell subclass for showing component modified timestamp."""
+
+  def __init__(self, issue, **_kw):
+    table_view_helpers.TableCell.__init__(
+        self, table_view_helpers.CELL_TYPE_UNFILTERABLE,
+        [issue.component_modified_timestamp])
+
+
+class TableCellOwnerLastVisitDaysAgo(table_view_helpers.TableCell):
+  """TableCell subclass specifically for showing owner last visit days ago."""
+
+  def __init__(self, issue, users_by_id=None, **_kw):
+    owner_view = users_by_id.get(issue.owner_id or issue.derived_owner_id)
+    last_visit_days_ago = None
+    if owner_view and owner_view.user.last_visit_timestamp:
+      secs_ago = int(time.time()) - owner_view.user.last_visit_timestamp
+      last_visit_days_ago = secs_ago // framework_constants.SECS_PER_DAY
+    table_view_helpers.TableCell.__init__(
+        self, table_view_helpers.CELL_TYPE_UNFILTERABLE, [last_visit_days_ago])
+
+
+# Maps column names to factories/constructors that make table cells.
+# Uses the defaults in issuelist.py but changes the factory for the
+# summary cell to properly escape the data for CSV files.
+CSV_CELL_FACTORIES = CELL_FACTORIES.copy()
+CSV_CELL_FACTORIES.update({
+    'opened': TableCellOpenedCSV,
+    'openedtimestamp': TableCellOpenedTimestamp,
+    'closed': TableCellClosedCSV,
+    'closedtimestamp': TableCellClosedTimestamp,
+    'modified': TableCellModifiedCSV,
+    'modifiedtimestamp': TableCellModifiedTimestamp,
+    'ownermodified': TableCellOwnerModifiedCSV,
+    'ownermodifiedtimestamp': TableCellOwnerModifiedTimestamp,
+    'statusmodified': TableCellStatusModifiedCSV,
+    'statusmodifiedtimestamp': TableCellStatusModifiedTimestamp,
+    'componentmodified': TableCellComponentModifiedCSV,
+    'componentmodifiedtimestamp': TableCellComponentModifiedTimestamp,
+    'ownerlastvisitdaysago': TableCellOwnerLastVisitDaysAgo,
+    })
diff --git a/tracker/template_helpers.py b/tracker/template_helpers.py
new file mode 100644
index 0000000..c567b4a
--- /dev/null
+++ b/tracker/template_helpers.py
@@ -0,0 +1,261 @@
+# Copyright 2018 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Helper functions for issue template servlets"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import collections
+import logging
+
+from framework import authdata
+from framework import exceptions
+from framework import framework_bizobj
+from framework import framework_helpers
+from tracker import field_helpers
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+from tracker import tracker_helpers
+from proto import tracker_pb2
+
+MAX_NUM_PHASES = 6
+
+PHASE_INPUTS = [
+    'phase_0', 'phase_1', 'phase_2', 'phase_3', 'phase_4', 'phase_5']
+
+_NO_PHASE_VALUE = 'no_phase'
+
+ParsedTemplate = collections.namedtuple(
+    'ParsedTemplate', 'name, members_only, summary, summary_must_be_edited, '
+    'content, status, owner_str, labels, field_val_strs, component_paths, '
+    'component_required, owner_defaults_to_member, admin_str, add_approvals, '
+    'phase_names, approvals_to_phase_idx, required_approval_ids')
+
+
+def ParseTemplateRequest(post_data, config):
+  """Parse an issue template."""
+
+  name = post_data.get('name', '')
+  members_only = (post_data.get('members_only') == 'on')
+  summary = post_data.get('summary', '')
+  summary_must_be_edited = (
+      post_data.get('summary_must_be_edited') == 'on')
+  content = post_data.get('content', '')
+  content = framework_helpers.WordWrapSuperLongLines(content, max_cols=75)
+  status = post_data.get('status', '')
+  owner_str = post_data.get('owner', '')
+  labels = post_data.getall('label')
+  field_val_strs = collections.defaultdict(list)
+  for fd in config.field_defs:
+    field_value_key = 'custom_%d' % fd.field_id
+    if post_data.get(field_value_key):
+      field_val_strs[fd.field_id].append(post_data[field_value_key])
+
+  component_paths = []
+  if post_data.get('components'):
+    for component_path in post_data.get('components').split(','):
+      if component_path.strip() not in component_paths:
+        component_paths.append(component_path.strip())
+  component_required = post_data.get('component_required') == 'on'
+
+  owner_defaults_to_member = post_data.get('owner_defaults_to_member') == 'on'
+
+  admin_str = post_data.get('admin_names', '')
+
+  add_approvals = post_data.get('add_approvals') == 'on'
+  phase_names = [post_data.get(phase_input, '') for phase_input in PHASE_INPUTS]
+
+  required_approval_ids = []
+  approvals_to_phase_idx = {}
+
+  for approval_def in config.approval_defs:
+    phase_num = post_data.get('approval_%d' % approval_def.approval_id, '')
+    if phase_num == _NO_PHASE_VALUE:
+      approvals_to_phase_idx[approval_def.approval_id] = None
+    else:
+      try:
+        idx = PHASE_INPUTS.index(phase_num)
+        approvals_to_phase_idx[approval_def.approval_id] = idx
+      except ValueError:
+        logging.info('approval %d was omitted' % approval_def.approval_id)
+    required_name = 'approval_%d_required' % approval_def.approval_id
+    if (post_data.get(required_name) == 'on'):
+      required_approval_ids.append(approval_def.approval_id)
+
+  return ParsedTemplate(
+      name, members_only, summary, summary_must_be_edited, content, status,
+      owner_str, labels, field_val_strs, component_paths, component_required,
+      owner_defaults_to_member, admin_str, add_approvals, phase_names,
+      approvals_to_phase_idx, required_approval_ids)
+
+
+def GetTemplateInfoFromParsed(mr, services, parsed, config):
+  """Get Template field info and PBs from a ParsedTemplate."""
+
+  admin_ids, _ = tracker_helpers.ParsePostDataUsers(
+      mr.cnxn, parsed.admin_str, services.user)
+
+  owner_id = 0
+  if parsed.owner_str:
+    try:
+      user_id = services.user.LookupUserID(mr.cnxn, parsed.owner_str)
+      auth = authdata.AuthData.FromUserID(mr.cnxn, user_id, services)
+      if framework_bizobj.UserIsInProject(mr.project, auth.effective_ids):
+        owner_id = user_id
+      else:
+        mr.errors.owner = 'User is not a member of this project.'
+    except exceptions.NoSuchUserException:
+      mr.errors.owner = 'Owner not found.'
+
+  component_ids = tracker_helpers.LookupComponentIDs(
+      parsed.component_paths, config, mr.errors)
+
+  # TODO(jojwang): monorail:4678 Process phase field values.
+  phase_field_val_strs = {}
+  field_values = field_helpers.ParseFieldValues(
+      mr.cnxn, services.user, parsed.field_val_strs,
+      phase_field_val_strs, config)
+  for fv in field_values:
+    logging.info('field_value is %r: %r',
+                 fv.field_id, tracker_bizobj.GetFieldValue(fv, {}))
+
+  phases = []
+  approvals = []
+  if parsed.add_approvals:
+    phases, approvals = _GetPhasesAndApprovalsFromParsed(
+        mr, parsed.phase_names, parsed.approvals_to_phase_idx,
+        parsed.required_approval_ids)
+
+  return admin_ids, owner_id, component_ids, field_values, phases, approvals
+
+
+def _GetPhasesAndApprovalsFromParsed(
+    mr, phase_names, approvals_to_phase_idx, required_approval_ids):
+  """Get Phase PBs from a parsed phase_names and approvals_by_phase_idx."""
+
+  phases = []
+  approvals = []
+  valid_phase_names = []
+
+  for name in phase_names:
+    if name:
+      if not tracker_constants.PHASE_NAME_RE.match(name):
+        mr.errors.phase_approvals = 'Invalid gate name(s).'
+        return phases, approvals
+      valid_phase_names.append(name)
+  if len(valid_phase_names) != len(
+      set(name.lower() for name in valid_phase_names)):
+    mr.errors.phase_approvals = 'Duplicate gate names.'
+    return phases, approvals
+  valid_phase_idxs = [idx for idx, name in enumerate(phase_names) if name]
+  if set(valid_phase_idxs) != set([
+      idx for idx in approvals_to_phase_idx.values() if idx is not None]):
+    mr.errors.phase_approvals = 'Defined gates must have assigned approvals.'
+    return phases, approvals
+
+  # Distributing the ranks over a wider range is not necessary since
+  # any edits to template phases will cause a complete rewrite.
+  # phase_id is temporarily the idx for keeping track of which approvals
+  # belong to which phases.
+  for idx, phase_name in enumerate(phase_names):
+    if phase_name:
+      phase = tracker_pb2.Phase(name=phase_name, rank=idx, phase_id=idx)
+      phases.append(phase)
+
+  for approval_id, phase_idx in approvals_to_phase_idx.items():
+    av = tracker_pb2.ApprovalValue(
+        approval_id=approval_id, phase_id=phase_idx)
+    if approval_id in required_approval_ids:
+      av.status = tracker_pb2.ApprovalStatus.NEEDS_REVIEW
+    approvals.append(av)
+
+  return phases, approvals
+
+
+def FilterApprovalsAndPhases(approval_values, phases, config):
+  """Return lists without deleted approvals and empty phases."""
+  deleted_approval_ids = [fd.field_id for fd in config.field_defs if
+                          fd.is_deleted and
+                          fd.field_type is tracker_pb2.FieldTypes.APPROVAL_TYPE]
+  filtered_avs = [av for av in approval_values if
+                     av.approval_id not in deleted_approval_ids]
+
+  av_phase_ids = list(set([av.phase_id for av in filtered_avs]))
+  filtered_phases = [phase for phase in phases if
+                     phase.phase_id in av_phase_ids]
+  return filtered_avs, filtered_phases
+
+
+def GatherApprovalsPageData(approval_values, tmpl_phases, config):
+  """Create the page data necessary for filling in the launch-gates-table."""
+  filtered_avs, filtered_phases = FilterApprovalsAndPhases(
+      approval_values, tmpl_phases, config)
+  filtered_phases.sort(key=lambda phase: phase.rank)
+
+  required_approval_ids = []
+  prechecked_approvals = []
+
+  phase_idx_by_id = {
+        phase.phase_id:idx for idx, phase in enumerate(filtered_phases)}
+  for av in filtered_avs:
+    # approval is part of a phase and that phase can be found.
+    if phase_idx_by_id.get(av.phase_id) is not None:
+      idx = phase_idx_by_id.get(av.phase_id)
+      prechecked_approvals.append(
+          '%d_phase_%d' % (av.approval_id, idx))
+    else:
+      prechecked_approvals.append('%d' % av.approval_id)
+    if av.status is tracker_pb2.ApprovalStatus.NEEDS_REVIEW:
+      required_approval_ids.append(av.approval_id)
+
+  num_phases = len(filtered_phases)
+  filtered_phases.extend([tracker_pb2.Phase()] * (
+      MAX_NUM_PHASES - num_phases))
+  return prechecked_approvals, required_approval_ids, filtered_phases
+
+
+def GetCheckedApprovalsFromParsed(approvals_to_phase_idx):
+  checked_approvals = []
+  for approval_id, phs_idx in approvals_to_phase_idx.items():
+    if phs_idx is not None:
+      checked_approvals.append('%d_phase_%d' % (approval_id, phs_idx))
+    else:
+      checked_approvals.append('%d' % approval_id)
+  return checked_approvals
+
+
+def GetIssueFromTemplate(template, project_id, reporter_id):
+  # type: (proto.tracker_pb2.TemplateDef, int, int) ->
+  #     proto.tracker_pb2.Issue
+  """Build a templated issue from TemplateDef.
+
+  Args:
+    template: Template that issue creation is based on.
+    project_id: ID of the Project the template belongs to.
+    reporter_id: Requesting user's ID.
+
+  Returns:
+    protorpc Issue filled with data from given `template`.
+  """
+  owner_id = None
+  if template.owner_id:
+    owner_id = template.owner_id
+  elif template.owner_defaults_to_member:
+    owner_id = reporter_id
+
+  issue = tracker_pb2.Issue(
+      project_id=project_id,
+      summary=template.summary,
+      status=template.status,
+      owner_id=owner_id,
+      labels=template.labels,
+      component_ids=template.component_ids,
+      reporter_id=reporter_id,
+      field_values=template.field_values,
+      phases=template.phases,
+      approval_values=template.approval_values)
+
+  return issue
diff --git a/tracker/templatecreate.py b/tracker/templatecreate.py
new file mode 100644
index 0000000..e10a25b
--- /dev/null
+++ b/tracker/templatecreate.py
@@ -0,0 +1,193 @@
+# Copyright 2018 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""A servlet for project owners to create a new template"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import collections
+import logging
+import time
+
+import ezt
+
+from framework import authdata
+from framework import framework_bizobj
+from framework import framework_helpers
+from framework import servlet
+from framework import urls
+from framework import permissions
+from tracker import field_helpers
+from tracker import template_helpers
+from tracker import tracker_bizobj
+from tracker import tracker_helpers
+from tracker import tracker_views
+from services import user_svc
+from proto import tracker_pb2
+
+
+class TemplateCreate(servlet.Servlet):
+  """Servlet allowing project owners to create an issue template."""
+
+  _MAIN_TAB_MODE = servlet.Servlet.MAIN_TAB_PROCESS
+  _PAGE_TEMPLATE = 'tracker/template-detail-page.ezt'
+  _PROCESS_SUBTAB = servlet.Servlet.PROCESS_TAB_TEMPLATES
+
+  def AssertBasePermission(self, mr):
+    """Check whether the user has any permission to visit this page.
+
+    Args:
+      mr: commonly used info parsed from the request
+    """
+    super(TemplateCreate, self).AssertBasePermission(mr)
+    if not self.CheckPerm(mr, permissions.EDIT_PROJECT):
+      raise permissions.PermissionException(
+          'User is not allowed to administer this project')
+
+  def GatherPageData(self, mr):
+    """Build up a dictionary of data values to use when rendering the page.
+
+    Args:
+      mr: commonly used info parsed from the request.
+
+    Returns:
+      Dict of values used by EZT for rendering the page.
+    """
+
+    config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+    field_views = tracker_views.MakeAllFieldValueViews(
+        config, [], [], [], {})
+    approval_subfields_present = any(
+        fv.field_def.is_approval_subfield for fv in field_views)
+
+    initial_phases = [tracker_pb2.Phase()] * template_helpers.MAX_NUM_PHASES
+    return {
+        'admin_tab_mode':
+            self._PROCESS_SUBTAB,
+        'allow_edit':
+            ezt.boolean(True),
+        'uneditable_fields':
+            ezt.boolean(False),
+        'new_template_form':
+            ezt.boolean(True),
+        'initial_members_only':
+            ezt.boolean(False),
+        'template_name':
+            '',
+        'initial_content':
+            '',
+        'initial_must_edit_summary':
+            ezt.boolean(False),
+        'initial_summary':
+            '',
+        'initial_status':
+            '',
+        'initial_owner':
+            '',
+        'initial_owner_defaults_to_member':
+            ezt.boolean(False),
+        'initial_components':
+            '',
+        'initial_component_required':
+            ezt.boolean(False),
+        'initial_admins':
+            '',
+        'fields':
+            [
+                view for view in field_views
+                if view.field_def.type_name is not "APPROVAL_TYPE"
+            ],
+        'initial_add_approvals':
+            ezt.boolean(False),
+        'initial_phases':
+            initial_phases,
+        'approvals':
+            [
+                view for view in field_views
+                if view.field_def.type_name is "APPROVAL_TYPE"
+            ],
+        'prechecked_approvals': [],
+        'required_approval_ids': [],
+        'approval_subfields_present':
+            ezt.boolean(approval_subfields_present),
+        # We do not support setting phase field values during template creation.
+        'phase_fields_present':
+            ezt.boolean(False),
+    }
+
+  def ProcessFormData(self, mr, post_data):
+    """Validate and store the contents of the issues tracker admin page.
+
+    Args:
+      mr: commonly used info parsed from the request.
+      post_data: HTML form data from the request.
+
+    Returns:
+      String URL to redirect the user to, or None if response was already sent.
+    """
+
+    config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+    parsed = template_helpers.ParseTemplateRequest(post_data, config)
+    field_helpers.ShiftEnumFieldsIntoLabels(
+        parsed.labels, [], parsed.field_val_strs, [], config)
+
+    if not parsed.name:
+      mr.errors.name = 'Please provide a template name'
+    if self.services.template.GetTemplateByName(mr.cnxn, parsed.name,
+                                                mr.project_id):
+      mr.errors.name = 'Template with name %s already exists' % parsed.name
+
+    (admin_ids, owner_id, component_ids,
+     field_values, phases,
+     approvals) = template_helpers.GetTemplateInfoFromParsed(
+         mr, self.services, parsed, config)
+
+    labels = [label for label in parsed.labels if label]
+    field_helpers.AssertCustomFieldsEditPerms(
+        mr, config, field_values, [], [], labels, [])
+
+    if mr.errors.AnyErrors():
+      field_views = tracker_views.MakeAllFieldValueViews(
+          config, [], [], field_values, {})
+      prechecked_approvals = template_helpers.GetCheckedApprovalsFromParsed(
+          parsed.approvals_to_phase_idx)
+
+      self.PleaseCorrect(
+          mr,
+          initial_members_only=ezt.boolean(parsed.members_only),
+          template_name=parsed.name,
+          initial_content=parsed.summary,
+          initial_must_edit_summary=ezt.boolean(parsed.summary_must_be_edited),
+          initial_description=parsed.content,
+          initial_status=parsed.status,
+          initial_owner=parsed.owner_str,
+          initial_owner_defaults_to_member=ezt.boolean(
+              parsed.owner_defaults_to_member),
+          initial_components=', '.join(parsed.component_paths),
+          initial_component_required=ezt.boolean(parsed.component_required),
+          initial_admins=parsed.admin_str,
+          labels=parsed.labels,
+          fields=[view for view in field_views
+                  if view.field_def.type_name is not 'APPROVAL_TYPE'],
+          initial_add_approvals=ezt.boolean(parsed.add_approvals),
+          initial_phases=[tracker_pb2.Phase(name=name) for name in
+                          parsed.phase_names],
+          approvals=[view for view in field_views
+                     if view.field_def.type_name is 'APPROVAL_TYPE'],
+          prechecked_approvals=prechecked_approvals,
+          required_approval_ids=parsed.required_approval_ids
+      )
+      return
+
+    self.services.template.CreateIssueTemplateDef(
+        mr.cnxn, mr.project_id, parsed.name, parsed.content, parsed.summary,
+        parsed.summary_must_be_edited, parsed.status, parsed.members_only,
+        parsed.owner_defaults_to_member, parsed.component_required,
+        owner_id, labels, component_ids, admin_ids, field_values, phases=phases,
+        approval_values=approvals)
+
+    return framework_helpers.FormatAbsoluteURL(
+        mr, urls.ADMIN_TEMPLATES, saved=1, ts=int(time.time()))
diff --git a/tracker/templatedetail.py b/tracker/templatedetail.py
new file mode 100644
index 0000000..cd48a80
--- /dev/null
+++ b/tracker/templatedetail.py
@@ -0,0 +1,245 @@
+# Copyright 2018 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""A servlet for project owners to edit/delete a template"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import collections
+import logging
+import time
+
+import ezt
+
+from framework import authdata
+from framework import framework_bizobj
+from framework import framework_helpers
+from framework import framework_views
+from framework import servlet
+from framework import urls
+from framework import permissions
+from tracker import field_helpers
+from tracker import template_helpers
+from tracker import tracker_bizobj
+from tracker import tracker_helpers
+from tracker import tracker_views
+from proto import tracker_pb2
+from services import user_svc
+
+
+class TemplateDetail(servlet.Servlet):
+  """Servlet allowing project owners to edit/delete an issue template"""
+
+  _MAIN_TAB_MODE = servlet.Servlet.MAIN_TAB_PROCESS
+  _PAGE_TEMPLATE = 'tracker/template-detail-page.ezt'
+  _PROCESS_SUBTAB = servlet.Servlet.PROCESS_TAB_TEMPLATES
+
+  def AssertBasePermission(self, mr):
+    """Check whether the user has any permission to visit this page.
+
+    Args:
+      mr: commonly used info parsed from the request.
+    """
+    super(TemplateDetail, self).AssertBasePermission(mr)
+    template = self.services.template.GetTemplateByName(mr.cnxn,
+        mr.template_name, mr.project_id)
+
+    if template:
+      allow_view = permissions.CanViewTemplate(
+          mr.auth.effective_ids, mr.perms, mr.project, template)
+      if not allow_view:
+        raise permissions.PermissionException(
+            'User is not allowed to view this issue template')
+    else:
+      self.abort(404, 'issue template not found %s' % mr.template_name)
+
+  def GatherPageData(self, mr):
+    """Build up a dictionary of data values to use when rendering the page.
+
+    Args:
+      mr: commonly used info parsed from the request.
+
+    Returns:
+      Dict of values used by EZT for rendering the page.
+    """
+
+    config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+    template = self.services.template.GetTemplateByName(mr.cnxn,
+        mr.template_name, mr.project_id)
+    template_view = tracker_views.IssueTemplateView(
+        mr, template, self.services.user, config)
+    with mr.profiler.Phase('making user views'):
+      users_involved = tracker_bizobj.UsersInvolvedInTemplate(template)
+      users_by_id = framework_views.MakeAllUserViews(
+          mr.cnxn, self.services.user, users_involved)
+      framework_views.RevealAllEmailsToMembers(
+          mr.cnxn, self.services, mr.auth, users_by_id, mr.project)
+    field_name_set = {fd.field_name.lower() for fd in config.field_defs
+                      if fd.field_type is tracker_pb2.FieldTypes.ENUM_TYPE and
+                      not fd.is_deleted}
+    non_masked_labels = tracker_bizobj.NonMaskedLabels(
+        template.labels, field_name_set)
+
+    field_views = tracker_views.MakeAllFieldValueViews(
+        config, template.labels, [], template.field_values, users_by_id,
+        phases=template.phases)
+    uneditable_fields = ezt.boolean(False)
+    for fv in field_views:
+      if permissions.CanEditValueForFieldDef(
+          mr.auth.effective_ids, mr.perms, mr.project, fv.field_def.field_def):
+        fv.is_editable = ezt.boolean(True)
+      else:
+        fv.is_editable = ezt.boolean(False)
+        uneditable_fields = ezt.boolean(True)
+
+    (prechecked_approvals, required_approval_ids,
+     initial_phases) = template_helpers.GatherApprovalsPageData(
+         template.approval_values, template.phases, config)
+
+    allow_edit = permissions.CanEditTemplate(
+        mr.auth.effective_ids, mr.perms, mr.project, template)
+
+    return {
+        'admin_tab_mode':
+            self._PROCESS_SUBTAB,
+        'allow_edit':
+            ezt.boolean(allow_edit),
+        'uneditable_fields':
+            uneditable_fields,
+        'new_template_form':
+            ezt.boolean(False),
+        'initial_members_only':
+            template_view.members_only,
+        'template_name':
+            template_view.name,
+        'initial_summary':
+            template_view.summary,
+        'initial_must_edit_summary':
+            template_view.summary_must_be_edited,
+        'initial_content':
+            template_view.content,
+        'initial_status':
+            template_view.status,
+        'initial_owner':
+            template_view.ownername,
+        'initial_owner_defaults_to_member':
+            template_view.owner_defaults_to_member,
+        'initial_components':
+            template_view.components,
+        'initial_component_required':
+            template_view.component_required,
+        'fields':
+            [
+                view for view in field_views
+                if view.field_def.type_name is not 'APPROVAL_TYPE'
+            ],
+        'initial_add_approvals':
+            ezt.boolean(prechecked_approvals),
+        'initial_phases':
+            initial_phases,
+        'approvals':
+            [
+                view for view in field_views
+                if view.field_def.type_name is 'APPROVAL_TYPE'
+            ],
+        'prechecked_approvals':
+            prechecked_approvals,
+        'required_approval_ids':
+            required_approval_ids,
+        'labels':
+            non_masked_labels,
+        'initial_admins':
+            template_view.admin_names,
+    }
+
+  def ProcessFormData(self, mr, post_data):
+    """Validate and store the contents of the issues tracker admin page.
+
+    Args:
+      mr: commonly used info parsed from the request.
+      post_data: HTML form data from the request.
+
+    Returns:
+      String URL to redirect the user to, or None if response was already sent.
+    """
+
+    config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+    parsed = template_helpers.ParseTemplateRequest(post_data, config)
+    field_helpers.ShiftEnumFieldsIntoLabels(
+        parsed.labels, [], parsed.field_val_strs, [], config)
+    template = self.services.template.GetTemplateByName(mr.cnxn,
+        parsed.name, mr.project_id)
+    allow_edit = permissions.CanEditTemplate(
+        mr.auth.effective_ids, mr.perms, mr.project, template)
+    if not allow_edit:
+      raise permissions.PermissionException(
+          'User is not allowed edit this issue template.')
+
+    if 'deletetemplate' in post_data:
+      self.services.template.DeleteIssueTemplateDef(
+          mr.cnxn, mr.project_id, template.template_id)
+      return framework_helpers.FormatAbsoluteURL(
+          mr, urls.ADMIN_TEMPLATES, deleted=1, ts=int(time.time()))
+
+    (admin_ids, owner_id, component_ids,
+     field_values, phases,
+     approvals) = template_helpers.GetTemplateInfoFromParsed(
+         mr, self.services, parsed, config)
+
+    labels = [label for label in parsed.labels if label]
+    field_helpers.AssertCustomFieldsEditPerms(
+        mr, config, field_values, [], [], labels, [])
+    field_helpers.ApplyRestrictedDefaultValues(
+        mr, config, field_values, labels, template.field_values,
+        template.labels)
+
+    if mr.errors.AnyErrors():
+      field_views = tracker_views.MakeAllFieldValueViews(
+          config, [], [], field_values, {})
+
+      prechecked_approvals = template_helpers.GetCheckedApprovalsFromParsed(
+          parsed.approvals_to_phase_idx)
+
+      self.PleaseCorrect(
+          mr,
+          initial_members_only=ezt.boolean(parsed.members_only),
+          template_name=parsed.name,
+          initial_summary=parsed.summary,
+          initial_must_edit_summary=ezt.boolean(parsed.summary_must_be_edited),
+          initial_content=parsed.content,
+          initial_status=parsed.status,
+          initial_owner=parsed.owner_str,
+          initial_owner_defaults_to_member=ezt.boolean(
+              parsed.owner_defaults_to_member),
+          initial_components=', '.join(parsed.component_paths),
+          initial_component_required=ezt.boolean(parsed.component_required),
+          initial_admins=parsed.admin_str,
+          labels=parsed.labels,
+          fields=[view for view in field_views
+                  if view.field_def.type_name is not 'APPROVAL_TYPE'],
+          initial_add_approvals=ezt.boolean(parsed.add_approvals),
+          initial_phases=[tracker_pb2.Phase(name=name) for name in
+                          parsed.phase_names],
+          approvals=[view for view in field_views
+                     if view.field_def.type_name is 'APPROVAL_TYPE'],
+          prechecked_approvals=prechecked_approvals,
+          required_approval_ids=parsed.required_approval_ids
+      )
+      return
+
+    self.services.template.UpdateIssueTemplateDef(
+        mr.cnxn, mr.project_id, template.template_id, name=parsed.name,
+        content=parsed.content, summary=parsed.summary,
+        summary_must_be_edited=parsed.summary_must_be_edited,
+        status=parsed.status, members_only=parsed.members_only,
+        owner_defaults_to_member=parsed.owner_defaults_to_member,
+        component_required=parsed.component_required, owner_id=owner_id,
+        labels=labels, component_ids=component_ids, admin_ids=admin_ids,
+        field_values=field_values, phases=phases, approval_values=approvals)
+
+    return framework_helpers.FormatAbsoluteURL(
+        mr, urls.TEMPLATE_DETAIL, template=template.name,
+        saved=1, ts=int(time.time()))
diff --git a/tracker/test/__init__.py b/tracker/test/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tracker/test/__init__.py
diff --git a/tracker/test/attachment_helpers_test.py b/tracker/test/attachment_helpers_test.py
new file mode 100644
index 0000000..18e0efc
--- /dev/null
+++ b/tracker/test/attachment_helpers_test.py
@@ -0,0 +1,149 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unittest for the tracker helpers module."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+from mock import Mock, patch
+import unittest
+
+from proto import tracker_pb2
+from tracker import attachment_helpers
+
+
+class AttachmentHelpersFunctionsTest(unittest.TestCase):
+
+  def testIsViewableImage(self):
+    self.assertTrue(attachment_helpers.IsViewableImage('image/gif', 123))
+    self.assertTrue(attachment_helpers.IsViewableImage(
+        'image/gif; charset=binary', 123))
+    self.assertTrue(attachment_helpers.IsViewableImage('image/png', 123))
+    self.assertTrue(attachment_helpers.IsViewableImage(
+        'image/png; charset=binary', 123))
+    self.assertTrue(attachment_helpers.IsViewableImage('image/x-png', 123))
+    self.assertTrue(attachment_helpers.IsViewableImage('image/jpeg', 123))
+    self.assertTrue(attachment_helpers.IsViewableImage(
+        'image/jpeg; charset=binary', 123))
+    self.assertTrue(attachment_helpers.IsViewableImage(
+        'image/jpeg', 14 * 1024 * 1024))
+
+    self.assertFalse(attachment_helpers.IsViewableImage('junk/bits', 123))
+    self.assertFalse(attachment_helpers.IsViewableImage(
+        'junk/bits; charset=binary', 123))
+    self.assertFalse(attachment_helpers.IsViewableImage(
+        'image/jpeg', 16 * 1024 * 1024))
+
+  def testIsViewableVideo(self):
+    self.assertTrue(attachment_helpers.IsViewableVideo('video/ogg', 123))
+    self.assertTrue(attachment_helpers.IsViewableVideo(
+        'video/ogg; charset=binary', 123))
+    self.assertTrue(attachment_helpers.IsViewableVideo('video/mp4', 123))
+    self.assertTrue(attachment_helpers.IsViewableVideo(
+        'video/mp4; charset=binary', 123))
+    self.assertTrue(attachment_helpers.IsViewableVideo('video/mpg', 123))
+    self.assertTrue(attachment_helpers.IsViewableVideo(
+        'video/mpg; charset=binary', 123))
+    self.assertTrue(attachment_helpers.IsViewableVideo('video/mpeg', 123))
+    self.assertTrue(attachment_helpers.IsViewableVideo(
+        'video/mpeg; charset=binary', 123))
+    self.assertTrue(attachment_helpers.IsViewableVideo(
+        'video/mpeg', 14 * 1024 * 1024))
+
+    self.assertFalse(attachment_helpers.IsViewableVideo('junk/bits', 123))
+    self.assertFalse(attachment_helpers.IsViewableVideo(
+        'junk/bits; charset=binary', 123))
+    self.assertFalse(attachment_helpers.IsViewableVideo(
+        'video/mp4', 16 * 1024 * 1024))
+
+  def testIsViewableText(self):
+    self.assertTrue(attachment_helpers.IsViewableText('text/plain', 0))
+    self.assertTrue(attachment_helpers.IsViewableText('text/plain', 1000))
+    self.assertTrue(attachment_helpers.IsViewableText('text/html', 1000))
+    self.assertFalse(
+        attachment_helpers.IsViewableText('text/plain', 200 * 1024 * 1024))
+    self.assertFalse(attachment_helpers.IsViewableText('image/jpeg', 200))
+    self.assertFalse(
+        attachment_helpers.IsViewableText('image/jpeg', 200 * 1024 * 1024))
+
+  def testSignAttachmentID(self):
+    pass  # TODO(jrobbins): write tests
+
+  @patch('tracker.attachment_helpers.SignAttachmentID')
+  def testGetDownloadURL(self, mock_SignAttachmentID):
+    """The download URL is always our to attachment servlet."""
+    mock_SignAttachmentID.return_value = 67890
+    self.assertEqual(
+      'attachment?aid=12345&signed_aid=67890',
+      attachment_helpers.GetDownloadURL(12345))
+
+  def testGetViewURL(self):
+    """The view URL may add &inline=1, or use our text attachment servlet."""
+    attach = tracker_pb2.Attachment(
+        attachment_id=1, mimetype='see below', filesize=1000)
+    download_url = 'attachment?aid=1&signed_aid=2'
+
+    # Viewable image.
+    attach.mimetype = 'image/jpeg'
+    self.assertEqual(
+      download_url + '&inline=1',
+      attachment_helpers.GetViewURL(attach, download_url, 'proj'))
+
+    # Viewable video.
+    attach.mimetype = 'video/mpeg'
+    self.assertEqual(
+      download_url + '&inline=1',
+      attachment_helpers.GetViewURL(attach, download_url, 'proj'))
+
+    # Viewable text file.
+    attach.mimetype = 'text/html'
+    self.assertEqual(
+      '/p/proj/issues/attachmentText?aid=1',
+      attachment_helpers.GetViewURL(attach, download_url, 'proj'))
+
+    # Something we don't support.
+    attach.mimetype = 'audio/mp3'
+    self.assertIsNone(
+      attachment_helpers.GetViewURL(attach, download_url, 'proj'))
+
+  def testGetThumbnailURL(self):
+    """The thumbnail URL may add param thumb=1 or not."""
+    attach = tracker_pb2.Attachment(
+        attachment_id=1, mimetype='see below', filesize=1000)
+    download_url = 'attachment?aid=1&signed_aid=2'
+
+    # Viewable image.
+    attach.mimetype = 'image/jpeg'
+    self.assertEqual(
+      download_url + '&inline=1&thumb=1',
+      attachment_helpers.GetThumbnailURL(attach, download_url))
+
+    # Viewable video.
+    attach.mimetype = 'video/mpeg'
+    self.assertIsNone(
+      # Video thumbs are displayed via GetVideoURL rather than this.
+      attachment_helpers.GetThumbnailURL(attach, download_url))
+
+    # Something that we don't thumbnail.
+    attach.mimetype = 'audio/mp3'
+    self.assertIsNone(attachment_helpers.GetThumbnailURL(attach, download_url))
+
+  def testGetVideoURL(self):
+    """The video URL is the same as the view URL for actual videos."""
+    attach = tracker_pb2.Attachment(
+        attachment_id=1, mimetype='see below', filesize=1000)
+    download_url = 'attachment?aid=1&signed_aid=2'
+
+    # Viewable video.
+    attach.mimetype = 'video/mpeg'
+    self.assertEqual(
+      download_url + '&inline=1',
+      attachment_helpers.GetVideoURL(attach, download_url))
+
+    # Anything that is not a video.
+    attach.mimetype = 'audio/mp3'
+    self.assertIsNone(attachment_helpers.GetVideoURL(attach, download_url))
+
diff --git a/tracker/test/component_helpers_test.py b/tracker/test/component_helpers_test.py
new file mode 100644
index 0000000..ee7f56c
--- /dev/null
+++ b/tracker/test/component_helpers_test.py
@@ -0,0 +1,113 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unit tests for the component_helpers module."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from proto import tracker_pb2
+from services import service_manager
+from testing import fake
+from tracker import component_helpers
+from tracker import tracker_bizobj
+
+
+class ComponentHelpersTest(unittest.TestCase):
+
+  def setUp(self):
+    self.config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    self.cd1 = tracker_bizobj.MakeComponentDef(
+        1, 789, 'FrontEnd', 'doc', False, [], [111], 0, 0)
+    self.cd2 = tracker_bizobj.MakeComponentDef(
+        2, 789, 'FrontEnd>Splash', 'doc', False, [], [222], 0, 0)
+    self.cd3 = tracker_bizobj.MakeComponentDef(
+        3, 789, 'BackEnd', 'doc', True, [], [111, 333], 0, 0)
+    self.config.component_defs = [self.cd1, self.cd2, self.cd3]
+    self.services = service_manager.Services(
+        user=fake.UserService(),
+        config=fake.ConfigService())
+    self.services.user.TestAddUser('a@example.com', 111)
+    self.services.user.TestAddUser('b@example.com', 222)
+    self.services.user.TestAddUser('c@example.com', 333)
+    self.mr = fake.MonorailRequest(self.services)
+    self.mr.cnxn = fake.MonorailConnection()
+
+  def testParseComponentRequest_Empty(self):
+    post_data = fake.PostData(admins=[''], cc=[''], labels=[''])
+    parsed = component_helpers.ParseComponentRequest(
+        self.mr, post_data, self.services)
+    self.assertEqual('', parsed.leaf_name)
+    self.assertEqual('', parsed.docstring)
+    self.assertEqual([], parsed.admin_usernames)
+    self.assertEqual([], parsed.cc_usernames)
+    self.assertEqual([], parsed.admin_ids)
+    self.assertEqual([], parsed.cc_ids)
+    self.assertFalse(self.mr.errors.AnyErrors())
+
+  def testParseComponentRequest_Normal(self):
+    post_data = fake.PostData(
+        leaf_name=['FrontEnd'],
+        docstring=['The server-side app that serves pages'],
+        deprecated=[False],
+        admins=['a@example.com'],
+        cc=['b@example.com, c@example.com'],
+        labels=['Hot, Cold'])
+    parsed = component_helpers.ParseComponentRequest(
+        self.mr, post_data, self.services)
+    self.assertEqual('FrontEnd', parsed.leaf_name)
+    self.assertEqual('The server-side app that serves pages', parsed.docstring)
+    self.assertEqual(['a@example.com'], parsed.admin_usernames)
+    self.assertEqual(['b@example.com', 'c@example.com'], parsed.cc_usernames)
+    self.assertEqual(['Hot', 'Cold'], parsed.label_strs)
+    self.assertEqual([111], parsed.admin_ids)
+    self.assertEqual([222, 333], parsed.cc_ids)
+    self.assertEqual([0, 1], parsed.label_ids)
+    self.assertFalse(self.mr.errors.AnyErrors())
+
+  def testParseComponentRequest_InvalidUser(self):
+    post_data = fake.PostData(
+        leaf_name=['FrontEnd'],
+        docstring=['The server-side app that serves pages'],
+        deprecated=[False],
+        admins=['a@example.com, invalid_user'],
+        cc=['b@example.com, c@example.com'],
+        labels=[''])
+    parsed = component_helpers.ParseComponentRequest(
+        self.mr, post_data, self.services)
+    self.assertEqual('FrontEnd', parsed.leaf_name)
+    self.assertEqual('The server-side app that serves pages', parsed.docstring)
+    self.assertEqual(['a@example.com', 'invalid_user'], parsed.admin_usernames)
+    self.assertEqual(['b@example.com', 'c@example.com'], parsed.cc_usernames)
+    self.assertEqual([111], parsed.admin_ids)
+    self.assertEqual([222, 333], parsed.cc_ids)
+    self.assertTrue(self.mr.errors.AnyErrors())
+    self.assertEqual('invalid_user unrecognized', self.mr.errors.member_admins)
+
+  def testGetComponentCcIDs(self):
+    issue = tracker_pb2.Issue()
+    issues_components_cc_ids = component_helpers.GetComponentCcIDs(
+        issue, self.config)
+    self.assertEqual(set(), issues_components_cc_ids)
+
+    issue.component_ids = [1, 2]
+    issues_components_cc_ids = component_helpers.GetComponentCcIDs(
+        issue, self.config)
+    self.assertEqual({111, 222}, issues_components_cc_ids)
+
+  def testGetCcIDsForComponentAndAncestors(self):
+    components_cc_ids = component_helpers.GetCcIDsForComponentAndAncestors(
+        self.config, self.cd1)
+    self.assertEqual({111}, components_cc_ids)
+
+    components_cc_ids = component_helpers.GetCcIDsForComponentAndAncestors(
+        self.config, self.cd2)
+    self.assertEqual({111, 222}, components_cc_ids)
+
+    components_cc_ids = component_helpers.GetCcIDsForComponentAndAncestors(
+        self.config, self.cd3)
+    self.assertEqual({111, 333}, components_cc_ids)
diff --git a/tracker/test/componentcreate_test.py b/tracker/test/componentcreate_test.py
new file mode 100644
index 0000000..1325d9b
--- /dev/null
+++ b/tracker/test/componentcreate_test.py
@@ -0,0 +1,143 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unit tests for the componentcreate servlet."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from framework import permissions
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+from tracker import componentcreate
+from tracker import tracker_bizobj
+
+import webapp2
+
+
+class ComponentCreateTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        user=fake.UserService(),
+        config=fake.ConfigService(),
+        project=fake.ProjectService())
+    self.servlet = componentcreate.ComponentCreate(
+        'req', 'res', services=self.services)
+    self.project = self.services.project.TestAddProject('proj')
+    self.mr = testing_helpers.MakeMonorailRequest(
+        project=self.project, perms=permissions.OWNER_ACTIVE_PERMISSIONSET)
+    self.mr.auth.email = 'b@example.com'
+    self.config = self.services.config.GetProjectConfig(
+        'fake cnxn', self.project.project_id)
+    self.services.config.StoreConfig('fake cnxn', self.config)
+    self.cd = tracker_bizobj.MakeComponentDef(
+        1, self.project.project_id, 'BackEnd', 'doc', False, [], [111], 0,
+        122)
+    self.config.component_defs = [self.cd]
+    self.services.user.TestAddUser('a@example.com', 111)
+    self.services.user.TestAddUser('b@example.com', 122)
+
+  def testAssertBasePermission(self):
+    # Anon users can never do it
+    self.mr.perms = permissions.READ_ONLY_PERMISSIONSET
+    self.assertRaises(
+        permissions.PermissionException,
+        self.servlet.AssertBasePermission, self.mr)
+
+    # Project owner can do it.
+    self.mr.perms = permissions.OWNER_ACTIVE_PERMISSIONSET
+    self.servlet.AssertBasePermission(self.mr)
+
+    # Project member cannot do it
+    self.mr.perms = permissions.COMMITTER_ACTIVE_PERMISSIONSET
+    self.assertRaises(
+        permissions.PermissionException,
+        self.servlet.AssertBasePermission, self.mr)
+    self.mr.perms = permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET
+    self.assertRaises(
+        permissions.PermissionException,
+        self.servlet.AssertBasePermission, self.mr)
+
+  def testGatherPageData_CreatingAtTopLevel(self):
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.assertEqual(self.servlet.PROCESS_TAB_COMPONENTS,
+                     page_data['admin_tab_mode'])
+    self.assertIsNone(page_data['parent_path'])
+
+  def testGatherPageData_CreatingASubComponent(self):
+    self.mr.component_path = 'BackEnd'
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.assertEqual(self.servlet.PROCESS_TAB_COMPONENTS,
+                     page_data['admin_tab_mode'])
+    self.assertEqual('BackEnd', page_data['parent_path'])
+
+  def testProcessFormData_NotFound(self):
+    post_data = fake.PostData(
+        parent_path=['Monitoring'],
+        leaf_name=['Rules'],
+        docstring=['Detecting outages'],
+        deprecated=[False],
+        admins=[''],
+        cc=[''],
+        labels=[''])
+    self.assertRaises(
+        webapp2.HTTPException,
+        self.servlet.ProcessFormData, self.mr, post_data)
+
+  def testProcessFormData_Normal(self):
+    post_data = fake.PostData(
+        parent_path=['BackEnd'],
+        leaf_name=['DB'],
+        docstring=['A database'],
+        deprecated=[False],
+        admins=[''],
+        cc=[''],
+        labels=[''])
+    url = self.servlet.ProcessFormData(self.mr, post_data)
+    self.assertTrue('/adminComponents?saved=1&' in url)
+    config = self.services.config.GetProjectConfig(
+        self.mr.cnxn, self.mr.project_id)
+
+    cd = tracker_bizobj.FindComponentDef('BackEnd>DB', config)
+    self.assertEqual('BackEnd>DB', cd.path)
+    self.assertEqual('A database', cd.docstring)
+    self.assertEqual([], cd.admin_ids)
+    self.assertEqual([], cd.cc_ids)
+    self.assertTrue(cd.created > 0)
+    self.assertEqual(122, cd.creator_id)
+
+
+class ComponentCreateMethodsTest(unittest.TestCase):
+
+  def setUp(self):
+    self.config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    cd1 = tracker_bizobj.MakeComponentDef(
+        1, 789, 'BackEnd', 'doc', False, [], [111], 0, 122)
+    cd2 = tracker_bizobj.MakeComponentDef(
+        2, 789, 'BackEnd>DB', 'doc', True, [], [111], 0, 122)
+    self.config.component_defs = [cd1, cd2]
+
+  def testLeafNameErrorMessage_Invalid(self):
+    self.assertEqual(
+        'Invalid component name',
+        componentcreate.LeafNameErrorMessage('', 'bad name', self.config))
+
+  def testLeafNameErrorMessage_AlreadyInUse(self):
+    self.assertEqual(
+        'That name is already in use.',
+        componentcreate.LeafNameErrorMessage('', 'BackEnd', self.config))
+    self.assertEqual(
+        'That name is already in use.',
+        componentcreate.LeafNameErrorMessage('BackEnd', 'DB', self.config))
+
+  def testLeafNameErrorMessage_OK(self):
+    self.assertIsNone(
+        componentcreate.LeafNameErrorMessage('', 'FrontEnd', self.config))
+    self.assertIsNone(
+        componentcreate.LeafNameErrorMessage('BackEnd', 'Search', self.config))
diff --git a/tracker/test/componentdetail_test.py b/tracker/test/componentdetail_test.py
new file mode 100644
index 0000000..18886bc
--- /dev/null
+++ b/tracker/test/componentdetail_test.py
@@ -0,0 +1,320 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unit tests for the componentdetail servlet."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from mock import Mock, patch
+
+import mox
+
+from features import filterrules_helpers
+from framework import permissions
+from proto import project_pb2
+from services import service_manager
+from services import template_svc
+from testing import fake
+from testing import testing_helpers
+from tracker import componentdetail
+from tracker import tracker_bizobj
+
+import webapp2
+
+
+class ComponentDetailTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        user=fake.UserService(),
+        issue=fake.IssueService(),
+        config=fake.ConfigService(),
+        template=Mock(spec=template_svc.TemplateService),
+        project=fake.ProjectService())
+    self.servlet = componentdetail.ComponentDetail(
+        'req', 'res', services=self.services)
+    self.project = self.services.project.TestAddProject('proj')
+    self.mr = testing_helpers.MakeMonorailRequest(
+        project=self.project, perms=permissions.OWNER_ACTIVE_PERMISSIONSET)
+    self.mr.auth.email = 'b@example.com'
+    self.config = self.services.config.GetProjectConfig(
+        'fake cnxn', self.project.project_id)
+    self.services.config.StoreConfig('fake cnxn', self.config)
+    self.cd = tracker_bizobj.MakeComponentDef(
+        1, self.project.project_id, 'BackEnd', 'doc', False, [], [111], 100000,
+        122, 10000000, 133)
+    self.config.component_defs = [self.cd]
+    self.services.user.TestAddUser('a@example.com', 111)
+    self.services.user.TestAddUser('b@example.com', 122)
+    self.services.user.TestAddUser('c@example.com', 133)
+    self.mr.component_path = 'BackEnd'
+
+    self.mox = mox.Mox()
+
+  def tearDown(self):
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def testGetComponentDef_NotFound(self):
+    self.mr.component_path = 'NeverHeardOfIt'
+    self.assertRaises(
+        webapp2.HTTPException,
+        self.servlet._GetComponentDef, self.mr)
+
+  def testGetComponentDef_Normal(self):
+    actual_config, actual_cd = self.servlet._GetComponentDef(self.mr)
+    self.assertEqual(self.config, actual_config)
+    self.assertEqual(self.cd, actual_cd)
+
+  def testAssertBasePermission_AnyoneCanView(self):
+    self.servlet.AssertBasePermission(self.mr)
+    self.mr.perms = permissions.COMMITTER_ACTIVE_PERMISSIONSET
+    self.servlet.AssertBasePermission(self.mr)
+    self.mr.perms = permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET
+    self.servlet.AssertBasePermission(self.mr)
+    self.mr.perms = permissions.READ_ONLY_PERMISSIONSET
+    self.servlet.AssertBasePermission(self.mr)
+
+  def testAssertBasePermission_MembersOnly(self):
+    self.project.access = project_pb2.ProjectAccess.MEMBERS_ONLY
+    # The project members can view the component definition.
+    self.servlet.AssertBasePermission(self.mr)
+    self.mr.perms = permissions.COMMITTER_ACTIVE_PERMISSIONSET
+    self.servlet.AssertBasePermission(self.mr)
+    self.mr.perms = permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET
+    self.servlet.AssertBasePermission(self.mr)
+    # Non-member is not allowed to view anything in the project.
+    self.mr.perms = permissions.EMPTY_PERMISSIONSET
+    self.assertRaises(
+        permissions.PermissionException,
+        self.servlet.AssertBasePermission, self.mr)
+
+  def testGatherPageData_ReadWrite(self):
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.assertEqual(self.servlet.PROCESS_TAB_COMPONENTS,
+                     page_data['admin_tab_mode'])
+    self.assertTrue(page_data['allow_edit'])
+    self.assertEqual([], page_data['initial_admins'])
+    component_def_view = page_data['component_def']
+    self.assertEqual('BackEnd', component_def_view.path)
+
+  def testGatherPageData_ReadOnly(self):
+    self.mr.perms = permissions.READ_ONLY_PERMISSIONSET
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.assertEqual(self.servlet.PROCESS_TAB_COMPONENTS,
+                     page_data['admin_tab_mode'])
+    self.assertFalse(page_data['allow_edit'])
+    self.assertFalse(page_data['allow_delete'])
+    self.assertEqual([], page_data['initial_admins'])
+    component_def_view = page_data['component_def']
+    self.assertEqual('BackEnd', component_def_view.path)
+
+  def testGatherPageData_ObscuredCreatorModifier(self):
+    page_data = self.servlet.GatherPageData(self.mr)
+
+    self.assertEqual('b...@example.com', page_data['creator'].display_name)
+    self.assertEqual('/u/122/', page_data['creator'].profile_url)
+    self.assertEqual('Jan 1970', page_data['created'])
+    self.assertEqual('c...@example.com', page_data['modifier'].display_name)
+    self.assertEqual('/u/133/', page_data['modifier'].profile_url)
+    self.assertEqual('Apr 1970', page_data['modified'])
+
+  def testGatherPageData_VisibleCreatorModifierForAdmin(self):
+    self.mr.auth.user_pb.is_site_admin = True
+    page_data = self.servlet.GatherPageData(self.mr)
+
+    self.assertEqual('b@example.com', page_data['creator'].display_name)
+    self.assertEqual('/u/b@example.com/', page_data['creator'].profile_url)
+    self.assertEqual('Jan 1970', page_data['created'])
+    self.assertEqual('c@example.com', page_data['modifier'].display_name)
+    self.assertEqual('/u/c@example.com/', page_data['modifier'].profile_url)
+    self.assertEqual('Apr 1970', page_data['modified'])
+
+  def testGatherPageData_VisibleCreatorForSelf(self):
+    self.mr.auth.user_id = 122
+    page_data = self.servlet.GatherPageData(self.mr)
+
+    self.assertEqual('b@example.com', page_data['creator'].display_name)
+    self.assertEqual('/u/b@example.com/', page_data['creator'].profile_url)
+    self.assertEqual('Jan 1970', page_data['created'])
+    # Modifier should still be obscured.
+    self.assertEqual('c...@example.com', page_data['modifier'].display_name)
+    self.assertEqual('/u/133/', page_data['modifier'].profile_url)
+    self.assertEqual('Apr 1970', page_data['modified'])
+
+  def testGatherPageData_VisibleCreatorModifierForUnobscuredEmail(self):
+    creator = self.services.user.GetUser(self.mr.cnxn, 122)
+    creator.obscure_email = False
+    modifier = self.services.user.GetUser(self.mr.cnxn, 133)
+    modifier.obscure_email = False
+    page_data = self.servlet.GatherPageData(self.mr)
+
+    self.assertEqual('b@example.com', page_data['creator'].display_name)
+    self.assertEqual('/u/b@example.com/', page_data['creator'].profile_url)
+    self.assertEqual('Jan 1970', page_data['created'])
+    self.assertEqual('c@example.com', page_data['modifier'].display_name)
+    self.assertEqual('/u/c@example.com/', page_data['modifier'].profile_url)
+    self.assertEqual('Apr 1970', page_data['modified'])
+
+  def testGatherPageData_WithSubComponents(self):
+    subcd = tracker_bizobj.MakeComponentDef(
+        2, self.project.project_id, 'BackEnd>Worker', 'doc', False, [], [111],
+        0, 122)
+    self.config.component_defs.append(subcd)
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.assertFalse(page_data['allow_delete'])
+    self.assertEqual([subcd], page_data['subcomponents'])
+
+  def testGatherPageData_WithTemplates(self):
+    self.services.template.TemplatesWithComponent.return_value = ['template']
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.assertFalse(page_data['allow_delete'])
+    self.assertEqual(['template'], page_data['templates'])
+
+  def testProcessFormData_Permission(self):
+    """Only owners can edit components."""
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project,
+        perms=permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET)
+    mr.component_path = 'BackEnd'
+    post_data = fake.PostData(
+        name=['BackEnd'],
+        deletecomponent=['Submit'])
+    self.assertRaises(permissions.PermissionException,
+                      self.servlet.ProcessFormData, mr, post_data)
+
+    self.servlet.ProcessFormData(self.mr, post_data)
+
+  def testProcessFormData_Delete(self):
+    post_data = fake.PostData(
+        name=['BackEnd'],
+        deletecomponent=['Submit'])
+    url = self.servlet.ProcessFormData(self.mr, post_data)
+    self.assertTrue('/adminComponents?deleted=1&' in url)
+    self.assertIsNone(
+        tracker_bizobj.FindComponentDef('BackEnd', self.config))
+
+  def testProcessFormData_Delete_WithSubComponent(self):
+    subcd = tracker_bizobj.MakeComponentDef(
+        2, self.project.project_id, 'BackEnd>Worker', 'doc', False, [], [111],
+        0, 122)
+    self.config.component_defs.append(subcd)
+
+    post_data = fake.PostData(
+        name=['BackEnd'],
+        deletecomponent=['Submit'])
+    with self.assertRaises(permissions.PermissionException) as cm:
+      self.servlet.ProcessFormData(self.mr, post_data)
+    self.assertEqual(
+        'User tried to delete component that had subcomponents',
+        cm.exception.message)
+
+  def testProcessFormData_Edit(self):
+    post_data = fake.PostData(
+        leaf_name=['BackEnd'],
+        docstring=['This is where the magic happens'],
+        deprecated=[True],
+        admins=['a@example.com'],
+        cc=['a@example.com'],
+        labels=['Hot, Cold'])
+
+    url = self.servlet.ProcessFormData(self.mr, post_data)
+
+    self.mox.VerifyAll()
+    self.assertTrue('/components/detail?component=BackEnd&saved=1&' in url)
+    config = self.services.config.GetProjectConfig(
+        self.mr.cnxn, self.mr.project_id)
+
+    cd = tracker_bizobj.FindComponentDef('BackEnd', config)
+    self.assertEqual('BackEnd', cd.path)
+    self.assertEqual(
+        'This is where the magic happens',
+        cd.docstring)
+    self.assertEqual(True, cd.deprecated)
+    self.assertEqual([111], cd.admin_ids)
+    self.assertEqual([111], cd.cc_ids)
+
+  def testProcessDeleteComponent(self):
+    self.servlet._ProcessDeleteComponent(self.mr, self.cd)
+    self.assertIsNone(
+        tracker_bizobj.FindComponentDef('BackEnd', self.config))
+
+  def testProcessEditComponent(self):
+    post_data = fake.PostData(
+        leaf_name=['BackEnd'],
+        docstring=['This is where the magic happens'],
+        deprecated=[True],
+        admins=['a@example.com'],
+        cc=['a@example.com'],
+        labels=['Hot, Cold'])
+
+    self.servlet._ProcessEditComponent(
+        self.mr, post_data, self.config, self.cd)
+
+    self.mox.VerifyAll()
+    config = self.services.config.GetProjectConfig(
+        self.mr.cnxn, self.mr.project_id)
+    cd = tracker_bizobj.FindComponentDef('BackEnd', config)
+    self.assertEqual('BackEnd', cd.path)
+    self.assertEqual(
+        'This is where the magic happens',
+        cd.docstring)
+    self.assertEqual(True, cd.deprecated)
+    self.assertEqual([111], cd.admin_ids)
+    self.assertEqual([111], cd.cc_ids)
+    # Assert that creator and created were not updated.
+    self.assertEqual(122, cd.creator_id)
+    self.assertEqual(100000, cd.created)
+    # Assert that modifier and modified were updated.
+    self.assertEqual(122, cd.modifier_id)
+    self.assertTrue(cd.modified > 10000000)
+
+  def testProcessEditComponent_RenameWithSubComponents(self):
+    subcd_1 = tracker_bizobj.MakeComponentDef(
+        2, self.project.project_id, 'BackEnd>Worker1', 'doc', False, [], [111],
+        0, 125, 3, 126)
+    subcd_2 = tracker_bizobj.MakeComponentDef(
+        3, self.project.project_id, 'BackEnd>Worker2', 'doc', False, [], [111],
+        0, 125, 4, 127)
+    self.config.component_defs.extend([subcd_1, subcd_2])
+
+    self.mox.StubOutWithMock(filterrules_helpers, 'RecomputeAllDerivedFields')
+    filterrules_helpers.RecomputeAllDerivedFields(
+        self.mr.cnxn, self.services, self.mr.project, self.config)
+    self.mox.ReplayAll()
+    post_data = fake.PostData(
+        leaf_name=['BackEnds'],
+        docstring=['This is where the magic happens'],
+        deprecated=[True],
+        admins=['a@example.com'],
+        cc=['a@example.com'],
+        labels=[''])
+
+    self.servlet._ProcessEditComponent(
+        self.mr, post_data, self.config, self.cd)
+
+    self.mox.VerifyAll()
+    config = self.services.config.GetProjectConfig(
+        self.mr.cnxn, self.mr.project_id)
+    cd = tracker_bizobj.FindComponentDef('BackEnds', config)
+    self.assertEqual('BackEnds', cd.path)
+    subcd_1 = tracker_bizobj.FindComponentDef('BackEnds>Worker1', config)
+    self.assertEqual('BackEnds>Worker1', subcd_1.path)
+    # Assert that creator and modifier have not changed for subcd_1.
+    self.assertEqual(125, subcd_1.creator_id)
+    self.assertEqual(0, subcd_1.created)
+    self.assertEqual(126, subcd_1.modifier_id)
+    self.assertEqual(3, subcd_1.modified)
+
+    subcd_2 = tracker_bizobj.FindComponentDef('BackEnds>Worker2', config)
+    self.assertEqual('BackEnds>Worker2', subcd_2.path)
+    # Assert that creator and modifier have not changed for subcd_2.
+    self.assertEqual(125, subcd_2.creator_id)
+    self.assertEqual(0, subcd_2.created)
+    self.assertEqual(127, subcd_2.modifier_id)
+    self.assertEqual(4, subcd_2.modified)
diff --git a/tracker/test/field_helpers_test.py b/tracker/test/field_helpers_test.py
new file mode 100644
index 0000000..f49a147
--- /dev/null
+++ b/tracker/test/field_helpers_test.py
@@ -0,0 +1,1276 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unit tests for the field_helpers module."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import time
+import unittest
+import re
+
+from framework import exceptions
+from framework import permissions
+from framework import template_helpers
+from proto import project_pb2
+from proto import tracker_pb2
+from services import service_manager
+from services import config_svc
+from testing import fake
+from testing import testing_helpers
+from tracker import field_helpers
+from tracker import tracker_bizobj
+
+
+class FieldHelpersTest(unittest.TestCase):
+
+  def setUp(self):
+    self.config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    self.config.well_known_labels.append(tracker_pb2.LabelDef(
+        label='OldLabel', label_docstring='Do not use any longer',
+        deprecated=True))
+
+    self.services = service_manager.Services(
+        issue=fake.IssueService(),
+        usergroup=fake.UserGroupService(),
+        config=fake.ConfigService(),
+        user=fake.UserService())
+    self.project = fake.Project()
+    self.mr = testing_helpers.MakeMonorailRequest(
+        project=self.project, services=self.services)
+    self.mr.cnxn = fake.MonorailConnection()
+    self.errors = template_helpers.EZTError()
+
+  def testListApplicableFieldDefs(self):
+    issue_1 = fake.MakeTestIssue(
+        789,
+        1,
+        'sum',
+        'New',
+        111,
+        issue_id=78901,
+        labels=['type-defect', 'other-label'])
+    issue_2 = fake.MakeTestIssue(
+        789,
+        2,
+        'sum',
+        'New',
+        111,
+        issue_id=78902,
+        labels=['type-feedback', 'other-label1'])
+    issue_3 = fake.MakeTestIssue(
+        789,
+        3,
+        'sum',
+        'New',
+        111,
+        issue_id=78903,
+        labels=['type-defect'],
+        approval_values=[
+            tracker_pb2.ApprovalValue(approval_id=3),
+            tracker_pb2.ApprovalValue(approval_id=5)
+        ])
+    issue_4 = fake.MakeTestIssue(
+        789, 4, 'sum', 'New', 111, issue_id=78904)  # test no labels at all
+    issue_5 = fake.MakeTestIssue(
+        789,
+        5,
+        'sum',
+        'New',
+        111,
+        issue_id=78905,
+        labels=['type'],  # test labels ignored
+        approval_values=[tracker_pb2.ApprovalValue(approval_id=5)])
+    self.services.issue.TestAddIssue(issue_1)
+    self.services.issue.TestAddIssue(issue_2)
+    self.services.issue.TestAddIssue(issue_3)
+    self.services.issue.TestAddIssue(issue_4)
+    self.services.issue.TestAddIssue(issue_5)
+    fd_1 = tracker_pb2.FieldDef(
+        field_name='FirstField',
+        field_id=1,
+        field_type=tracker_pb2.FieldTypes.STR_TYPE,
+        applicable_type='feedback')  # applicable
+    fd_2 = tracker_pb2.FieldDef(
+        field_name='SecField',
+        field_id=2,
+        field_type=tracker_pb2.FieldTypes.INT_TYPE,
+        applicable_type='no')  # not applicable
+    fd_3 = tracker_pb2.FieldDef(
+        field_name='LegalApproval',
+        field_id=3,
+        field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE,
+        applicable_type='')  # applicable
+    fd_4 = tracker_pb2.FieldDef(
+        field_name='UserField',
+        field_id=4,
+        field_type=tracker_pb2.FieldTypes.USER_TYPE,
+        applicable_type='')  # applicable
+    fd_5 = tracker_pb2.FieldDef(
+        field_name='DogApproval',
+        field_id=5,
+        field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE,
+        applicable_type='')  # applicable
+    fd_6 = tracker_pb2.FieldDef(
+        field_name='SixthField',
+        field_id=6,
+        field_type=tracker_pb2.FieldTypes.INT_TYPE,
+        applicable_type='Defect')  # applicable
+    fd_7 = tracker_pb2.FieldDef(
+        field_name='CatApproval',
+        field_id=7,
+        field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE,
+        applicable_type='')  # not applicable
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    config.field_defs = [fd_1, fd_2, fd_3, fd_4, fd_5, fd_6, fd_7]
+    issues = [issue_1, issue_2, issue_3, issue_4, issue_5]
+
+    actual_fds = field_helpers.ListApplicableFieldDefs(issues, config)
+    self.assertEqual(actual_fds, [fd_1, fd_3, fd_4, fd_5, fd_6])
+
+  def testParseFieldDefRequest_Empty(self):
+    post_data = fake.PostData()
+    parsed = field_helpers.ParseFieldDefRequest(post_data, self.config)
+    self.assertEqual('', parsed.field_name)
+    self.assertEqual(None, parsed.field_type_str)
+    self.assertEqual(None, parsed.min_value)
+    self.assertEqual(None, parsed.max_value)
+    self.assertEqual(None, parsed.regex)
+    self.assertFalse(parsed.needs_member)
+    self.assertEqual('', parsed.needs_perm)
+    self.assertEqual('', parsed.grants_perm)
+    self.assertEqual(0, parsed.notify_on)
+    self.assertFalse(parsed.is_required)
+    self.assertFalse(parsed.is_niche)
+    self.assertFalse(parsed.is_multivalued)
+    self.assertEqual('', parsed.field_docstring)
+    self.assertEqual('', parsed.choices_text)
+    self.assertEqual('', parsed.applicable_type)
+    self.assertEqual('', parsed.applicable_predicate)
+    unchanged_labels = [
+        (label_def.label, label_def.label_docstring, label_def.deprecated)
+        for label_def in self.config.well_known_labels]
+    self.assertEqual(unchanged_labels, parsed.revised_labels)
+    self.assertEqual('', parsed.approvers_str)
+    self.assertEqual('', parsed.survey)
+    self.assertEqual('', parsed.parent_approval_name)
+    self.assertFalse(parsed.is_phase_field)
+
+  def testParseFieldDefRequest_Normal(self):
+    post_data = fake.PostData(
+        name=['somefield'],
+        field_type=['INT_TYPE'],
+        min_value=['11'],
+        max_value=['99'],
+        regex=['.*'],
+        needs_member=['Yes'],
+        needs_perm=['Commit'],
+        grants_perm=['View'],
+        notify_on=['any_comment'],
+        importance=['required'],
+        is_multivalued=['Yes'],
+        docstring=['It is just some field'],
+        choices=['Hot = Lots of activity\nCold = Not much activity'],
+        applicable_type=['Defect'],
+        approver_names=['approver@chromium.org'],
+        survey=['Are there UX changes?'],
+        parent_approval_name=['UIReview'],
+        is_phase_field=['on'],
+    )
+    parsed = field_helpers.ParseFieldDefRequest(post_data, self.config)
+    self.assertEqual('somefield', parsed.field_name)
+    self.assertEqual('INT_TYPE', parsed.field_type_str)
+    self.assertEqual(11, parsed.min_value)
+    self.assertEqual(99, parsed.max_value)
+    self.assertEqual('.*', parsed.regex)
+    self.assertTrue(parsed.needs_member)
+    self.assertEqual('Commit', parsed.needs_perm)
+    self.assertEqual('View', parsed.grants_perm)
+    self.assertEqual(1, parsed.notify_on)
+    self.assertTrue(parsed.is_required)
+    self.assertFalse(parsed.is_niche)
+    self.assertTrue(parsed.is_multivalued)
+    self.assertEqual('It is just some field', parsed.field_docstring)
+    self.assertEqual('Hot = Lots of activity\nCold = Not much activity',
+                     parsed.choices_text)
+    self.assertEqual('Defect', parsed.applicable_type)
+    self.assertEqual('', parsed.applicable_predicate)
+    unchanged_labels = [
+        (label_def.label, label_def.label_docstring, label_def.deprecated)
+        for label_def in self.config.well_known_labels]
+    new_labels = [
+        ('somefield-Hot', 'Lots of activity', False),
+        ('somefield-Cold', 'Not much activity', False)]
+    self.assertEqual(unchanged_labels + new_labels, parsed.revised_labels)
+    self.assertEqual('approver@chromium.org', parsed.approvers_str)
+    self.assertEqual('Are there UX changes?', parsed.survey)
+    self.assertEqual('UIReview', parsed.parent_approval_name)
+    self.assertTrue(parsed.is_phase_field)
+
+  def testParseFieldDefRequest_PreventPhaseApprovals(self):
+    post_data = fake.PostData(
+        field_type=['approval_type'],
+        is_phase_field=['on'],
+    )
+    parsed = field_helpers.ParseFieldDefRequest(post_data, self.config)
+    self.assertEqual('approval_type', parsed.field_type_str)
+    self.assertFalse(parsed.is_phase_field)
+
+  def testParseChoicesIntoWellKnownLabels_NewFieldDef(self):
+    choices_text = 'Hot = Lots of activity\nCold = Not much activity'
+    field_name = 'somefield'
+    revised_labels = field_helpers._ParseChoicesIntoWellKnownLabels(
+        choices_text, field_name, self.config, 'enum_type')
+    unchanged_labels = [
+        (label_def.label, label_def.label_docstring, label_def.deprecated)
+        for label_def in self.config.well_known_labels]
+    new_labels = [
+        ('somefield-Hot', 'Lots of activity', False),
+        ('somefield-Cold', 'Not much activity', False)]
+    self.assertEqual(unchanged_labels + new_labels, revised_labels)
+
+  def testParseChoicesIntoWellKnownLabels_ConvertExistingLabel(self):
+    choices_text = 'High = Must be fixed\nMedium = Might slip'
+    field_name = 'Priority'
+    revised_labels = field_helpers._ParseChoicesIntoWellKnownLabels(
+        choices_text, field_name, self.config, 'enum_type')
+    kept_labels = [
+        (label_def.label, label_def.label_docstring, label_def.deprecated)
+        for label_def in self.config.well_known_labels
+        if not label_def.label.startswith('Priority-')]
+    new_labels = [
+        ('Priority-High', 'Must be fixed', False),
+        ('Priority-Medium', 'Might slip', False)]
+    self.maxDiff = None
+    self.assertEqual(kept_labels + new_labels, revised_labels)
+
+    # TODO(jojwang): test this separately
+    # test None field_type_str, updating existing fielddef
+    self.config.field_defs.append(tracker_pb2.FieldDef(
+        field_id=13, field_name='Priority',
+        field_type=tracker_pb2.FieldTypes.ENUM_TYPE))
+    revised_labels = field_helpers._ParseChoicesIntoWellKnownLabels(
+        choices_text, field_name, self.config, None)
+    self.assertEqual(kept_labels + new_labels, revised_labels)
+
+  def testParseChoicesIntoWellKnownLabels_NotEnumField(self):
+    choices_text = ''
+    field_name = 'NotEnum'
+    self.config.well_known_labels = [
+        tracker_pb2.LabelDef(label='NotEnum-Should'),
+        tracker_pb2.LabelDef(label='NotEnum-Not-Be-Masked')]
+    revised_labels = field_helpers._ParseChoicesIntoWellKnownLabels(
+        choices_text, field_name, self.config, 'str_type')
+    new_labels = [
+        ('NotEnum-Should', None, False),
+        ('NotEnum-Not-Be-Masked', None, False)]
+    self.assertEqual(new_labels, revised_labels)
+
+    # TODO(jojwang): test this separately
+    # test None field_type_str, updating existing fielddef
+    self.config.field_defs.append(tracker_pb2.FieldDef(
+        field_id=13, field_name='NotEnum',
+        field_type=tracker_pb2.FieldTypes.STR_TYPE))
+    revised_labels = field_helpers._ParseChoicesIntoWellKnownLabels(
+        choices_text, field_name, self.config, None)
+    self.assertEqual(revised_labels, new_labels)
+
+  def testShiftEnumFieldsIntoLabels_Empty(self):
+    labels = []
+    labels_remove = []
+    field_val_strs = {}
+    field_val_strs_remove = {}
+    field_helpers.ShiftEnumFieldsIntoLabels(
+        labels, labels_remove, field_val_strs, field_val_strs_remove,
+        self.config)
+    self.assertEqual([], labels)
+    self.assertEqual([], labels_remove)
+    self.assertEqual({}, field_val_strs)
+    self.assertEqual({}, field_val_strs_remove)
+
+  def testShiftEnumFieldsIntoLabels_NoOp(self):
+    labels = ['Security', 'Performance', 'Pri-1', 'M-2']
+    labels_remove = ['ReleaseBlock']
+    field_val_strs = {123: ['CPU']}
+    field_val_strs_remove = {234: ['Small']}
+    field_helpers.ShiftEnumFieldsIntoLabels(
+        labels, labels_remove, field_val_strs, field_val_strs_remove,
+        self.config)
+    self.assertEqual(['Security', 'Performance', 'Pri-1', 'M-2'], labels)
+    self.assertEqual(['ReleaseBlock'], labels_remove)
+    self.assertEqual({123: ['CPU']}, field_val_strs)
+    self.assertEqual({234: ['Small']}, field_val_strs_remove)
+
+  def testShiftEnumFieldsIntoLabels_FoundSomeEnumFields(self):
+    self.config.field_defs.append(
+        tracker_bizobj.MakeFieldDef(
+            123, 789, 'Component', tracker_pb2.FieldTypes.ENUM_TYPE, None,
+            '', False, False, False, None, None, '', False, '', '',
+            tracker_pb2.NotifyTriggers.NEVER,
+            'no_action', 'What HW part is affected?',
+            False))
+    self.config.field_defs.append(
+        tracker_bizobj.MakeFieldDef(
+            234, 789, 'Size', tracker_pb2.FieldTypes.ENUM_TYPE, None,
+            '', False, False, False, None, None, '', False, '', '',
+            tracker_pb2.NotifyTriggers.NEVER,
+            'no_action', 'How big is this work item?',
+            False))
+    labels = ['Security', 'Performance', 'Pri-1', 'M-2']
+    labels_remove = ['ReleaseBlock']
+    field_val_strs = {123: ['CPU']}
+    field_val_strs_remove = {234: ['Small']}
+    field_helpers.ShiftEnumFieldsIntoLabels(
+        labels, labels_remove, field_val_strs, field_val_strs_remove,
+        self.config)
+    self.assertEqual(
+        ['Security', 'Performance', 'Pri-1', 'M-2', 'Component-CPU'],
+        labels)
+    self.assertEqual(['ReleaseBlock', 'Size-Small'], labels_remove)
+    self.assertEqual({}, field_val_strs)
+    self.assertEqual({}, field_val_strs_remove)
+
+  def testReviseApprovals_New(self):
+    self.config.field_defs.append(
+      tracker_bizobj.MakeFieldDef(
+          123, 789, 'UX Review', tracker_pb2.FieldTypes.APPROVAL_TYPE, None,
+          '', False, False, False, None, None, '', False, '', '',
+          tracker_pb2.NotifyTriggers.NEVER, 'no_action',
+          'Approval for UX review', False))
+    existing_approvaldef = tracker_pb2.ApprovalDef(
+        approval_id=123, approver_ids=[101, 102], survey='')
+    self.config.approval_defs = [existing_approvaldef]
+    revised_approvals = field_helpers.ReviseApprovals(
+        124, [103], '', self.config)
+    self.assertEqual(len(revised_approvals), 2)
+    self.assertEqual(revised_approvals,
+                     [(123, [101, 102], ''), (124, [103], '')])
+
+  def testReviseApprovals_Existing(self):
+    existing_approvaldef = tracker_pb2.ApprovalDef(
+        approval_id=123, approver_ids=[101, 102], survey='')
+    self.config.approval_defs = [existing_approvaldef]
+    revised_approvals = field_helpers.ReviseApprovals(
+        123, [103], '', self.config)
+    self.assertEqual(revised_approvals, [(123, [103], '')])
+
+  def testParseOneFieldValue_IntType(self):
+    fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'Foo', tracker_pb2.FieldTypes.INT_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    fv = field_helpers.ParseOneFieldValue(
+        self.mr.cnxn, self.services.user, fd, '8675309')
+    self.assertEqual(fv.field_id, 123)
+    self.assertEqual(fv.int_value, 8675309)
+
+  def testParseOneFieldValue_StrType(self):
+    fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'Foo', tracker_pb2.FieldTypes.STR_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    fv = field_helpers.ParseOneFieldValue(
+        self.mr.cnxn, self.services.user, fd, '8675309')
+    self.assertEqual(fv.field_id, 123)
+    self.assertEqual(fv.str_value, '8675309')
+
+  def testParseOneFieldValue_UserType(self):
+    self.services.user.TestAddUser('user@example.com', 111)
+    fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'Foo', tracker_pb2.FieldTypes.USER_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    fv = field_helpers.ParseOneFieldValue(
+        self.mr.cnxn, self.services.user, fd, 'user@example.com')
+    self.assertEqual(fv.field_id, 123)
+    self.assertEqual(fv.user_id, 111)
+
+  def testParseOneFieldValue_DateType(self):
+    fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'Deadline', tracker_pb2.FieldTypes.DATE_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    fv = field_helpers.ParseOneFieldValue(
+        self.mr.cnxn, self.services.user, fd, '2009-02-13')
+    self.assertEqual(fv.field_id, 123)
+    self.assertEqual(fv.date_value, 1234483200)
+
+  def testParseOneFieldValue_UrlType(self):
+    fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'Design Doc', tracker_pb2.FieldTypes.URL_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    fv = field_helpers.ParseOneFieldValue(
+        self.mr.cnxn, self.services.user, fd, 'www.google.com')
+    self.assertEqual(fv.field_id, 123)
+    self.assertEqual(fv.url_value, 'http://www.google.com')
+
+  def testParseOneFieldValue(self):
+    fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'Target', tracker_pb2.FieldTypes.INT_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'milestone target',
+        False, is_phase_field=True)
+    phase_fvs = field_helpers.ParseOnePhaseFieldValue(
+        self.mr.cnxn, self.services.user, fd, '70', [30, 40])
+    self.assertEqual(len(phase_fvs), 2)
+    self.assertEqual(phase_fvs[0].phase_id, 30)
+    self.assertEqual(phase_fvs[1].phase_id, 40)
+
+  def testParseFieldValues_Empty(self):
+    field_val_strs = {}
+    phase_field_val_strs = {}
+    field_values = field_helpers.ParseFieldValues(
+        self.mr.cnxn, self.services.user, field_val_strs,
+        phase_field_val_strs, self.config)
+    self.assertEqual([], field_values)
+
+  def testParseFieldValues_EmptyPhases(self):
+    field_val_strs = {126: ['70']}
+    phase_field_val_strs = {}
+    fd_phase = tracker_bizobj.MakeFieldDef(
+        126, 789, 'Target', tracker_pb2.FieldTypes.INT_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'milestone target',
+        False, is_phase_field=True)
+    self.config.field_defs.extend([fd_phase])
+    field_values = field_helpers.ParseFieldValues(
+        self.mr.cnxn, self.services.user, field_val_strs,
+        phase_field_val_strs, self.config)
+    self.assertEqual([], field_values)
+
+  def testParseFieldValues_Normal(self):
+    fd_int = tracker_bizobj.MakeFieldDef(
+        123, 789, 'CPU', tracker_pb2.FieldTypes.INT_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    fd_date = tracker_bizobj.MakeFieldDef(
+        124, 789, 'Deadline', tracker_pb2.FieldTypes.DATE_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    fd_url = tracker_bizobj.MakeFieldDef(
+        125, 789, 'Design Doc', tracker_pb2.FieldTypes.URL_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    fd_phase = tracker_bizobj.MakeFieldDef(
+        126, 789, 'Target', tracker_pb2.FieldTypes.INT_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'milestone target',
+        False, is_phase_field=True)
+    self.config.field_defs.extend([fd_int, fd_date, fd_url, fd_phase])
+    field_val_strs = {
+        123: ['80386', '68040'],
+        124: ['2009-02-13'],
+        125: ['www.google.com'],
+    }
+    phase_field_val_strs = {
+        126: {'beta': ['89'],
+              'stable': ['70'],
+              'missing': ['234'],
+        }}
+    field_values = field_helpers.ParseFieldValues(
+        self.mr.cnxn, self.services.user, field_val_strs,
+        phase_field_val_strs, self.config,
+        phase_ids_by_name={'stable': [30, 40], 'beta': [88]})
+    fv1 = tracker_bizobj.MakeFieldValue(
+        123, 80386, None, None, None, None, False)
+    fv2 = tracker_bizobj.MakeFieldValue(
+        123, 68040, None, None, None, None, False)
+    fv3 = tracker_bizobj.MakeFieldValue(
+        124, None, None, None, 1234483200, None, False)
+    fv4 = tracker_bizobj.MakeFieldValue(
+        125, None, None, None, None, 'http://www.google.com', False)
+    fv5 = tracker_bizobj.MakeFieldValue(
+        126, 89, None, None, None, None, False, phase_id=88)
+    fv6 = tracker_bizobj.MakeFieldValue(
+        126, 70, None, None, None, None, False, phase_id=30)
+    fv7 = tracker_bizobj.MakeFieldValue(
+        126, 70, None, None, None, None, False, phase_id=40)
+    self.assertEqual([fv1, fv2, fv3, fv4, fv5, fv6, fv7], field_values)
+
+  def test_IntType(self):
+    fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'CPU', tracker_pb2.FieldTypes.INT_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    fv = tracker_bizobj.MakeFieldValue(123, 8086, None, None, None, None, False)
+    msg = field_helpers.ValidateCustomFieldValue(
+        self.mr.cnxn, self.mr.project, self.services, fd, fv)
+    self.assertIsNone(msg)
+
+    fd.min_value = 1
+    fd.max_value = 999
+    msg = field_helpers.ValidateCustomFieldValue(
+        self.mr.cnxn, self.mr.project, self.services, fd, fv)
+    self.assertEqual('Value must be <= 999.', msg)
+
+    fv.int_value = 0
+    msg = field_helpers.ValidateCustomFieldValue(
+        self.mr.cnxn, self.mr.project, self.services, fd, fv)
+    self.assertEqual('Value must be >= 1.', msg)
+
+  def test_StrType(self):
+    fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'CPU', tracker_pb2.FieldTypes.STR_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    fv = tracker_bizobj.MakeFieldValue(
+        123, None, 'i386', None, None, None, False)
+    msg = field_helpers.ValidateCustomFieldValue(
+        self.mr.cnxn, self.mr.project, self.services, fd, fv)
+    self.assertIsNone(msg)
+
+    fd.regex = r'^\d*$'
+    msg = field_helpers.ValidateCustomFieldValue(
+        self.mr.cnxn, self.mr.project, self.services, fd, fv)
+    self.assertEqual(r'Value must match regular expression: ^\d*$.', msg)
+
+    fv.str_value = '386'
+    msg = field_helpers.ValidateCustomFieldValue(
+        self.mr.cnxn, self.mr.project, self.services, fd, fv)
+    self.assertIsNone(msg)
+
+  def test_UserType(self):
+    fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'Fake Field', tracker_pb2.FieldTypes.USER_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+
+    self.services.user.TestAddUser('owner@example.com', 111)
+    self.mr.project.owner_ids.extend([111])
+    owner = tracker_bizobj.MakeFieldValue(
+        fd.field_id, None, None, 111, None, None, False)
+
+    self.services.user.TestAddUser('committer@example.com', 222)
+    self.mr.project.committer_ids.extend([222])
+    self.mr.project.extra_perms = [
+        project_pb2.Project.ExtraPerms(
+            member_id=222,
+            perms=['FooPerm'])]
+    committer = tracker_bizobj.MakeFieldValue(
+        fd.field_id, None, None, 222, None, None, False)
+
+    self.services.user.TestAddUser('user@example.com', 333)
+    user = tracker_bizobj.MakeFieldValue(
+        fd.field_id, None, None, 333, None, None, False)
+
+    # Normal
+    for fv in (owner, committer, user):
+      msg = field_helpers.ValidateCustomFieldValue(
+          self.mr.cnxn, self.mr.project, self.services, fd, fv)
+      self.assertIsNone(msg)
+
+    # Needs to be member (user isn't a member).
+    fd.needs_member = True
+    for fv in (owner, committer):
+      msg = field_helpers.ValidateCustomFieldValue(
+          self.mr.cnxn, self.mr.project, self.services, fd, fv)
+      self.assertIsNone(msg)
+    msg = field_helpers.ValidateCustomFieldValue(
+        self.mr.cnxn, self.mr.project, self.services, fd, user)
+    self.assertEqual('User must be a member of the project.', msg)
+
+    # Needs DeleteAny permission (only owner has it).
+    fd.needs_perm = 'DeleteAny'
+    msg = field_helpers.ValidateCustomFieldValue(
+        self.mr.cnxn, self.mr.project, self.services, fd, owner)
+    self.assertIsNone(msg)
+    msg = field_helpers.ValidateCustomFieldValue(
+        self.mr.cnxn, self.mr.project, self.services, fd, committer)
+    self.assertEqual('User must have permission "DeleteAny".', msg)
+    msg = field_helpers.ValidateCustomFieldValue(
+        self.mr.cnxn, self.mr.project, self.services, fd, user)
+    self.assertEqual('User must be a member of the project.', msg)
+
+    # Needs custom permission (only committer has it).
+    fd.needs_perm = 'FooPerm'
+    msg = field_helpers.ValidateCustomFieldValue(
+        self.mr.cnxn, self.mr.project, self.services, fd, owner)
+    self.assertEqual('User must have permission "FooPerm".', msg)
+    msg = field_helpers.ValidateCustomFieldValue(
+        self.mr.cnxn, self.mr.project, self.services, fd, committer)
+    self.assertIsNone(msg)
+    msg = field_helpers.ValidateCustomFieldValue(
+        self.mr.cnxn, self.mr.project, self.services, fd, user)
+    self.assertEqual('User must be a member of the project.', msg)
+
+  def test_DateType(self):
+    pass  # TODO(jrobbins): write this test. @@@
+
+  def test_UrlType(self):
+    fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'CPU', tracker_pb2.FieldTypes.URL_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+
+    fv = tracker_bizobj.MakeFieldValue(
+        123, None, None, None, None, 'www.google.com', False)
+    msg = field_helpers.ValidateCustomFieldValue(
+        self.mr.cnxn, self.mr.project, self.services, fd, fv)
+    self.assertIsNone(msg)
+
+    fv.url_value = 'go/puppies'
+    msg = field_helpers.ValidateCustomFieldValue(
+        self.mr.cnxn, self.mr.project, self.services, fd, fv)
+    self.assertIsNone(msg)
+
+    fv.url_value = 'go/213'
+    msg = field_helpers.ValidateCustomFieldValue(
+        self.mr.cnxn, self.mr.project, self.services, fd, fv)
+    self.assertIsNone(msg)
+
+    fv.url_value = 'puppies'
+    msg = field_helpers.ValidateCustomFieldValue(
+        self.mr.cnxn, self.mr.project, self.services, fd, fv)
+    self.assertEqual('Value must be a valid url.', msg)
+
+  def test_OtherType(self):
+    # There are currently no validation options for date-type custom fields.
+    fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'Deadline', tracker_pb2.FieldTypes.DATE_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    fv = tracker_bizobj.MakeFieldValue(
+        123, None, None, None, 1234567890, None, False)
+    msg = field_helpers.ValidateCustomFieldValue(
+        self.mr.cnxn, self.mr.project, self.services, fd, fv)
+    self.assertIsNone(msg)
+
+  def testValidateCustomFields_NoCustomFieldValues(self):
+    err_msgs = field_helpers.ValidateCustomFields(
+        self.mr, self.services, [], self.config, self.mr.project,
+        ezt_errors=self.errors)
+    self.assertFalse(self.errors.AnyErrors())
+    self.assertEqual(err_msgs, [])
+
+  def testValidateCustomFields_NoErrors(self):
+    fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'CPU', tracker_pb2.FieldTypes.INT_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    self.config.field_defs.append(fd)
+    fv1 = tracker_bizobj.MakeFieldValue(
+        123, 8086, None, None, None, None, False)
+    fv2 = tracker_bizobj.MakeFieldValue(123, 486, None, None, None, None, False)
+
+    err_msgs = field_helpers.ValidateCustomFields(
+        self.mr, self.services, [fv1, fv2], self.config, self.mr.project,
+        ezt_errors=self.errors)
+    self.assertFalse(self.errors.AnyErrors())
+    self.assertEqual(err_msgs, [])
+
+  def testValidateCustomFields_SomeValueErrors(self):
+    fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'CPU', tracker_pb2.FieldTypes.INT_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    self.config.field_defs.append(fd)
+    fv1 = tracker_bizobj.MakeFieldValue(
+        123, 8086, None, None, None, None, False)
+    fv2 = tracker_bizobj.MakeFieldValue(123, 486, None, None, None, None, False)
+
+    fd.min_value = 1
+    fd.max_value = 999
+    err_msgs = field_helpers.ValidateCustomFields(
+        self.mr, self.services, [fv1, fv2], self.config, self.mr.project,
+        ezt_errors=self.errors)
+    self.assertTrue(self.errors.AnyErrors())
+    self.assertEqual(1, len(self.errors.custom_fields))
+    custom_field_error = self.errors.custom_fields[0]
+    self.assertEqual(123, custom_field_error.field_id)
+    self.assertEqual('Value must be <= 999.', custom_field_error.message)
+    self.assertEqual(len(err_msgs), 1)
+    self.assertTrue(re.search(r'Value must be <= 999.', err_msgs[0]))
+
+  def testValidateCustomFields_DeletedRequiredFields_Ignored(self):
+    issue = fake.MakeTestIssue(
+        789,
+        1,
+        'sum',
+        'New',
+        111,
+        issue_id=78901,
+        labels=['type-defect', 'other-label'])
+    fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'CPU', tracker_pb2.FieldTypes.INT_TYPE, None, '', False,
+        False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', True)
+    self.config.field_defs.append(fd)
+
+    err_msgs = field_helpers.ValidateCustomFields(
+        self.mr,
+        self.services, [],
+        self.config,
+        self.mr.project,
+        ezt_errors=self.errors,
+        issue=issue)
+    self.assertFalse(self.errors.AnyErrors())
+    self.assertEqual(err_msgs, [])
+
+  def testValidateCustomFields_RequiredFields_Normal(self):
+    issue = fake.MakeTestIssue(
+        789,
+        1,
+        'sum',
+        'New',
+        111,
+        issue_id=78901,
+        labels=['type-defect', 'other-label'])
+
+    required = True
+    fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'CPU', tracker_pb2.FieldTypes.INT_TYPE, None, '', required,
+        False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    self.config.field_defs.append(fd)
+    fv1 = tracker_bizobj.MakeFieldValue(
+        123, 8086, None, None, None, None, False)
+    fv2 = tracker_bizobj.MakeFieldValue(123, 486, None, None, None, None, False)
+
+    err_msgs = field_helpers.ValidateCustomFields(
+        self.mr,
+        self.services, [fv1, fv2],
+        self.config,
+        self.mr.project,
+        issue=issue)
+    self.assertEqual(len(err_msgs), 0)
+
+  def testValidateCustomFields_RequiredFields_ErrorsWhenMissing(self):
+    issue = fake.MakeTestIssue(
+        789,
+        1,
+        'sum',
+        'New',
+        111,
+        issue_id=78901,
+        labels=['type-defect', 'other-label'])
+
+    required = True
+    fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'CPU', tracker_pb2.FieldTypes.INT_TYPE, None, '', required,
+        False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    self.config.field_defs.append(fd)
+
+    err_msgs = field_helpers.ValidateCustomFields(
+        self.mr, self.services, [], self.config, self.mr.project, issue=issue)
+    self.assertEqual(len(err_msgs), 1)
+    self.assertTrue(re.search(r'CPU field is required.', err_msgs[0]))
+
+  def testValidateCustomFields_RequiredFields_EnumFieldNormal(self):
+    # Enums are a special case because their values are stored in labels.
+    issue = fake.MakeTestIssue(
+        789,
+        1,
+        'sum',
+        'New',
+        111,
+        issue_id=78901,
+        labels=['type-defect', 'other-label', 'CPU-enum-value'])
+    required = True
+    fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'CPU', tracker_pb2.FieldTypes.ENUM_TYPE, None, '', required,
+        False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    self.config.field_defs.append(fd)
+
+    err_msgs = field_helpers.ValidateCustomFields(
+        self.mr, self.services, [], self.config, self.mr.project, issue=issue)
+    self.assertEqual(len(err_msgs), 0)
+
+  def testValidateCustomFields_RequiredFields_EnumFieldMultiWord(self):
+    # Enum fields with dashes in them require special label prefix parsing.
+    issue = fake.MakeTestIssue(
+        789,
+        1,
+        'sum',
+        'New',
+        111,
+        issue_id=78901,
+        labels=['type-defect', 'other-label', 'an-enum-value'])
+    required = True
+    fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'an-enum', tracker_pb2.FieldTypes.ENUM_TYPE, None, '',
+        required, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    self.config.field_defs.append(fd)
+
+    err_msgs = field_helpers.ValidateCustomFields(
+        self.mr, self.services, [], self.config, self.mr.project, issue=issue)
+    self.assertEqual(len(err_msgs), 0)
+
+  def testValidateCustomFields_RequiredFields_EnumFieldError(self):
+    # Enums are a special case because their values are stored in labels.
+    issue = fake.MakeTestIssue(
+        789,
+        1,
+        'sum',
+        'New',
+        111,
+        issue_id=78901,
+        labels=['type-defect', 'other-label'])
+    required = True
+    fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'CPU', tracker_pb2.FieldTypes.ENUM_TYPE, None, '', required,
+        False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    self.config.field_defs.append(fd)
+
+    err_msgs = field_helpers.ValidateCustomFields(
+        self.mr, self.services, [], self.config, self.mr.project, issue=issue)
+    self.assertEqual(len(err_msgs), 1)
+    self.assertTrue(re.search(r'CPU field is required.', err_msgs[0]))
+
+  def testAssertCustomFieldsEditPerms_Empty(self):
+    self.assertIsNone(
+        field_helpers.AssertCustomFieldsEditPerms(
+            self.mr, self.config, [], [], [], [], []))
+
+  def testAssertCustomFieldsEditPerms_Normal(self):
+    self.services.user.TestAddUser('user@example.com', 222)
+    self.mr.auth.effective_ids = {222}
+    fd_int = tracker_bizobj.MakeFieldDef(
+        11111,
+        1,
+        'fdInt',
+        tracker_pb2.FieldTypes.INT_TYPE,
+        None,
+        '',
+        False,
+        False,
+        False,
+        None,
+        None,
+        '',
+        False,
+        '',
+        '',
+        tracker_pb2.NotifyTriggers.NEVER,
+        'no_action',
+        'doc',
+        False,
+        is_restricted_field=True)
+    fd_str = tracker_bizobj.MakeFieldDef(
+        22222,
+        1,
+        'fdStr',
+        tracker_pb2.FieldTypes.STR_TYPE,
+        None,
+        '',
+        False,
+        False,
+        False,
+        None,
+        None,
+        '',
+        False,
+        '',
+        '',
+        tracker_pb2.NotifyTriggers.NEVER,
+        'no_action',
+        'doc',
+        False,
+        is_restricted_field=True)
+    fd_date = tracker_bizobj.MakeFieldDef(
+        33333,
+        1,
+        'fdDate',
+        tracker_pb2.FieldTypes.DATE_TYPE,
+        None,
+        '',
+        False,
+        False,
+        False,
+        None,
+        None,
+        '',
+        False,
+        '',
+        '',
+        tracker_pb2.NotifyTriggers.NEVER,
+        'no_action',
+        'doc',
+        False,
+        is_restricted_field=True)
+    fd_enum1 = tracker_bizobj.MakeFieldDef(
+        44444,
+        1,
+        'fdEnum1',
+        tracker_pb2.FieldTypes.ENUM_TYPE,
+        None,
+        '',
+        False,
+        False,
+        False,
+        None,
+        None,
+        '',
+        False,
+        '',
+        '',
+        tracker_pb2.NotifyTriggers.NEVER,
+        'no_action',
+        'doc',
+        False,
+        is_restricted_field=True)
+    fd_enum2 = tracker_bizobj.MakeFieldDef(
+        55555,
+        1,
+        'fdEnum2',
+        tracker_pb2.FieldTypes.ENUM_TYPE,
+        None,
+        '',
+        False,
+        False,
+        False,
+        None,
+        None,
+        '',
+        False,
+        '',
+        '',
+        tracker_pb2.NotifyTriggers.NEVER,
+        'no_action',
+        'doc',
+        False,
+        is_restricted_field=True)
+    self.config.field_defs = [fd_int, fd_str, fd_date, fd_enum1, fd_enum2]
+    fv1 = tracker_bizobj.MakeFieldValue(
+        11111, 37, None, None, None, None, False)
+    fv2 = tracker_bizobj.MakeFieldValue(
+        22222, None, 'Chicken', None, None, None, False)
+    self.assertIsNone(
+        field_helpers.AssertCustomFieldsEditPerms(
+            self.mr, self.config, [fv1], [fv2], [33333], ['Dog', 'fdEnum1-a'],
+            ['Cat', 'fdEnum2-b']))
+
+  def testAssertCustomFieldsEditPerms_Reject(self):
+    self.mr.perms = permissions.PermissionSet([])
+    fd_int = tracker_bizobj.MakeFieldDef(
+        11111,
+        1,
+        'fdInt',
+        tracker_pb2.FieldTypes.INT_TYPE,
+        None,
+        '',
+        False,
+        False,
+        False,
+        None,
+        None,
+        '',
+        False,
+        '',
+        '',
+        tracker_pb2.NotifyTriggers.NEVER,
+        'no_action',
+        'doc',
+        False,
+        is_restricted_field=True)
+    fd_enum = tracker_bizobj.MakeFieldDef(
+        44444,
+        1,
+        'fdEnum',
+        tracker_pb2.FieldTypes.ENUM_TYPE,
+        None,
+        '',
+        False,
+        False,
+        False,
+        None,
+        None,
+        '',
+        False,
+        '',
+        '',
+        tracker_pb2.NotifyTriggers.NEVER,
+        'no_action',
+        'doc',
+        False,
+        is_restricted_field=True)
+    self.config.field_defs = [fd_int, fd_enum]
+    fv = tracker_bizobj.MakeFieldValue(11111, 37, None, None, None, None, False)
+
+    self.assertRaises(
+        AssertionError, field_helpers.AssertCustomFieldsEditPerms, self.mr,
+        self.config, [fv], [], [], [], [])
+
+    self.assertRaises(
+        AssertionError, field_helpers.AssertCustomFieldsEditPerms, self.mr,
+        self.config, [], [fv], [], [], [])
+
+    self.assertRaises(
+        AssertionError, field_helpers.AssertCustomFieldsEditPerms, self.mr,
+        self.config, [], [], [11111], [], [])
+
+    self.assertRaises(
+        AssertionError, field_helpers.AssertCustomFieldsEditPerms, self.mr,
+        self.config, [], [], [], ['Dog', 'fdEnum-a'], [])
+
+    self.assertRaises(
+        AssertionError, field_helpers.AssertCustomFieldsEditPerms, self.mr,
+        self.config, [], [], [], [], ['Cat', 'fdEnum-b'])
+
+  def testApplyRestrictedDefaultValues(self):
+    self.mr.perms = permissions.PermissionSet([])
+    self.services.user.TestAddUser('user@example.com', 222)
+    self.mr.auth.effective_ids = {222}
+    fd_int = tracker_bizobj.MakeFieldDef(
+        11111,
+        1,
+        'fdInt',
+        tracker_pb2.FieldTypes.INT_TYPE,
+        None,
+        '',
+        False,
+        False,
+        False,
+        None,
+        None,
+        '',
+        False,
+        '',
+        '',
+        tracker_pb2.NotifyTriggers.NEVER,
+        'no_action',
+        'doc',
+        False,
+        is_restricted_field=False)
+    fd_str = tracker_bizobj.MakeFieldDef(
+        22222,
+        1,
+        'fdStr',
+        tracker_pb2.FieldTypes.STR_TYPE,
+        None,
+        '',
+        False,
+        False,
+        False,
+        None,
+        None,
+        '',
+        False,
+        '',
+        '',
+        tracker_pb2.NotifyTriggers.NEVER,
+        'no_action',
+        'doc',
+        False,
+        is_restricted_field=True)
+    fd_str_2 = tracker_bizobj.MakeFieldDef(
+        33333,
+        1,
+        'fdStr_2',
+        tracker_pb2.FieldTypes.STR_TYPE,
+        None,
+        '',
+        False,
+        False,
+        False,
+        None,
+        None,
+        '',
+        False,
+        '',
+        '',
+        tracker_pb2.NotifyTriggers.NEVER,
+        'no_action',
+        'doc',
+        False,
+        is_restricted_field=False)
+    fd_enum = tracker_bizobj.MakeFieldDef(
+        44444,
+        1,
+        'fdEnum',
+        tracker_pb2.FieldTypes.ENUM_TYPE,
+        None,
+        '',
+        False,
+        False,
+        False,
+        None,
+        None,
+        '',
+        False,
+        '',
+        '',
+        tracker_pb2.NotifyTriggers.NEVER,
+        'no_action',
+        'doc',
+        False,
+        is_restricted_field=False)
+    fd_restricted_enum = tracker_bizobj.MakeFieldDef(
+        55555,
+        1,
+        'fdRestrictedEnum',
+        tracker_pb2.FieldTypes.ENUM_TYPE,
+        None,
+        '',
+        False,
+        False,
+        False,
+        None,
+        None,
+        '',
+        False,
+        '',
+        '',
+        tracker_pb2.NotifyTriggers.NEVER,
+        'no_action',
+        'doc',
+        False,
+        is_restricted_field=True)
+    self.config.field_defs = [
+        fd_int, fd_str, fd_str_2, fd_enum, fd_restricted_enum
+    ]
+    fv = tracker_bizobj.MakeFieldValue(
+        33333, None, 'Happy', None, None, None, False)
+    temp_fv = tracker_bizobj.MakeFieldValue(
+        11111, 37, None, None, None, None, False)
+    temp_restricted_fv = tracker_bizobj.MakeFieldValue(
+        22222, None, 'Chicken', None, None, None, False)
+    field_vals = [fv]
+    labels = ['Car', 'Bus']
+    temp_field_vals = [temp_fv, temp_restricted_fv]
+    temp_labels = ['Bike', 'fdEnum-a', 'fdRestrictedEnum-b']
+    field_helpers.ApplyRestrictedDefaultValues(
+        self.mr, self.config, field_vals, labels, temp_field_vals, temp_labels)
+    self.assertEqual(labels, ['Car', 'Bus', 'fdRestrictedEnum-b'])
+    self.assertEqual(field_vals, [fv, temp_restricted_fv])
+
+  def testFormatUrlFieldValue(self):
+    self.assertEqual('http://www.google.com',
+                     field_helpers.FormatUrlFieldValue('www.google.com'))
+    self.assertEqual('https://www.bing.com',
+                     field_helpers.FormatUrlFieldValue('https://www.bing.com'))
+
+  def testReviseFieldDefFromParsed_INT(self):
+    parsed_field_def = field_helpers.ParsedFieldDef(
+        'EstDays',
+        'int_type',
+        min_value=5,
+        max_value=7,
+        regex='',
+        needs_member=True,
+        needs_perm='Commit',
+        grants_perm='View',
+        notify_on=tracker_pb2.NotifyTriggers.ANY_COMMENT,
+        is_required=True,
+        is_niche=True,
+        importance='required',
+        is_multivalued=True,
+        field_docstring='updated doc',
+        choices_text='',
+        applicable_type='Launch',
+        applicable_predicate='',
+        revised_labels=[],
+        date_action_str='ping_participants',
+        approvers_str='',
+        survey='',
+        parent_approval_name='',
+        is_phase_field=False,
+        is_restricted_field=False)
+
+    fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'EstDays', tracker_pb2.FieldTypes.INT_TYPE, None,
+        '', False, False, False, 4, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False,
+        approval_id=3)
+
+    new_fd = field_helpers.ReviseFieldDefFromParsed(parsed_field_def, fd)
+    # assert INT fields
+    self.assertEqual(new_fd.min_value, 5)
+    self.assertEqual(new_fd.max_value, 7)
+
+    # assert USER fields
+    self.assertEqual(new_fd.notify_on, tracker_pb2.NotifyTriggers.ANY_COMMENT)
+    self.assertTrue(new_fd.needs_member)
+    self.assertEqual(new_fd.needs_perm, 'Commit')
+    self.assertEqual(new_fd.grants_perm, 'View')
+
+    # assert DATE fields
+    self.assertEqual(new_fd.date_action,
+                     tracker_pb2.DateAction.PING_PARTICIPANTS)
+
+    # assert general fields
+    self.assertTrue(new_fd.is_required)
+    self.assertTrue(new_fd.is_niche)
+    self.assertEqual(new_fd.applicable_type, 'Launch')
+    self.assertEqual(new_fd.docstring, 'updated doc')
+    self.assertTrue(new_fd.is_multivalued)
+    self.assertEqual(new_fd.approval_id, 3)
+    self.assertFalse(new_fd.is_phase_field)
+    self.assertFalse(new_fd.is_restricted_field)
+
+  def testParsedFieldDefAssertions_Accepted(self):
+    parsed_fd = field_helpers.ParsedFieldDef(
+        'EstDays',
+        'int_type',
+        min_value=5,
+        max_value=7,
+        regex='',
+        needs_member=True,
+        needs_perm='Commit',
+        grants_perm='View',
+        notify_on=tracker_pb2.NotifyTriggers.ANY_COMMENT,
+        is_required=True,
+        is_niche=False,
+        importance='required',
+        is_multivalued=True,
+        field_docstring='updated doc',
+        choices_text='',
+        applicable_type='Launch',
+        applicable_predicate='',
+        revised_labels=[],
+        date_action_str='ping_participants',
+        approvers_str='',
+        survey='',
+        parent_approval_name='',
+        is_phase_field=False,
+        is_restricted_field=False)
+
+    field_helpers.ParsedFieldDefAssertions(self.mr, parsed_fd)
+    self.assertFalse(self.mr.errors.AnyErrors())
+
+  def testParsedFieldDefAssertions_Rejected(self):
+    parsed_fd = field_helpers.ParsedFieldDef(
+        'restrictApprovalField',
+        'approval_type',
+        min_value=10,
+        max_value=7,
+        regex='/foo(?)/',
+        needs_member=True,
+        needs_perm='Commit',
+        grants_perm='View',
+        notify_on=tracker_pb2.NotifyTriggers.ANY_COMMENT,
+        is_required=True,
+        is_niche=True,
+        importance='required',
+        is_multivalued=True,
+        field_docstring='updated doc',
+        choices_text='',
+        applicable_type='Launch',
+        applicable_predicate='',
+        revised_labels=[],
+        date_action_str='custom_date_action_str',
+        approvers_str='',
+        survey='',
+        parent_approval_name='',
+        is_phase_field=False,
+        is_restricted_field=True)
+
+    field_helpers.ParsedFieldDefAssertions(self.mr, parsed_fd)
+    self.assertTrue(self.mr.errors.AnyErrors())
+
+    self.assertEqual(
+        self.mr.errors.is_niche, 'A field cannot be both required and niche.')
+    self.assertEqual(
+        self.mr.errors.date_action,
+        'The date action should be either: ' + ', '.join(
+            config_svc.DATE_ACTION_ENUM) + '.')
+    self.assertEqual(
+        self.mr.errors.min_value, 'Minimum value must be less than maximum.')
+    self.assertEqual(self.mr.errors.regex, 'Invalid regular expression.')
diff --git a/tracker/test/fieldcreate_test.py b/tracker/test/fieldcreate_test.py
new file mode 100644
index 0000000..580d095
--- /dev/null
+++ b/tracker/test/fieldcreate_test.py
@@ -0,0 +1,301 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unit tests for the fieldcreate servlet."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import mox
+import mock
+import unittest
+import logging
+
+import ezt
+
+from framework import permissions
+from proto import tracker_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+from tracker import fieldcreate
+from tracker import tracker_bizobj
+
+
+class FieldCreateTest(unittest.TestCase):
+
+  def setUp(self):
+    self.cnxn = 'fake cnxn'
+    self.services = service_manager.Services(
+        user=fake.UserService(),
+        config=fake.ConfigService(),
+        project=fake.ProjectService())
+    self.servlet = fieldcreate.FieldCreate(
+        'req', 'res', services=self.services)
+    self.project = self.services.project.TestAddProject('proj')
+    self.mr = testing_helpers.MakeMonorailRequest(
+        project=self.project, perms=permissions.OWNER_ACTIVE_PERMISSIONSET)
+    self.services.user.TestAddUser('gatsby@example.com', 111)
+    self.services.user.TestAddUser('sport@example.com', 222)
+
+    self.mox = mox.Mox()
+
+  def tearDown(self):
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def testAssertBasePermission(self):
+    # Anon users can never do it
+    self.mr.perms = permissions.READ_ONLY_PERMISSIONSET
+    self.assertRaises(
+        permissions.PermissionException,
+        self.servlet.AssertBasePermission, self.mr)
+
+    # Project owner can do it.
+    self.mr.perms = permissions.OWNER_ACTIVE_PERMISSIONSET
+    self.servlet.AssertBasePermission(self.mr)
+
+    # Project member cannot do it
+    self.mr.perms = permissions.COMMITTER_ACTIVE_PERMISSIONSET
+    self.assertRaises(
+        permissions.PermissionException,
+        self.servlet.AssertBasePermission, self.mr)
+    self.mr.perms = permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET
+    self.assertRaises(
+        permissions.PermissionException,
+        self.servlet.AssertBasePermission, self.mr)
+
+  def testGatherPageData(self):
+    approval_fd = tracker_bizobj.MakeFieldDef(
+        1, self.mr.project_id, 'LaunchApproval',
+        tracker_pb2.FieldTypes.APPROVAL_TYPE, None, '', False,
+        False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action',
+        'some approval thing', False)
+    config = self.services.config.GetProjectConfig(
+        self.mr.cnxn, self.mr.project_id)
+    config.field_defs.append(approval_fd)
+    self.services.config.StoreConfig(self.cnxn, config)
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.assertEqual(self.servlet.PROCESS_TAB_LABELS,
+                     page_data['admin_tab_mode'])
+    self.assertItemsEqual(
+        ['Defect', 'Enhancement', 'Task', 'Other'],
+        page_data['well_known_issue_types'])
+    self.assertEqual(['LaunchApproval'], page_data['approval_names'])
+    self.assertEqual('', page_data['initial_admins'])
+    self.assertEqual('', page_data['initial_editors'])
+    self.assertIsNone(page_data['initial_is_restricted_field'])
+
+  def testProcessFormData(self):
+    post_data = fake.PostData(
+        name=['somefield'],
+        field_type=['INT_TYPE'],
+        min_value=['1'],
+        max_value=['99'],
+        notify_on=['any_comment'],
+        importance=['required'],
+        is_multivalued=['Yes'],
+        docstring=['It is just some field'],
+        applicable_type=['Defect'],
+        date_action=['no_action'],
+        admin_names=['gatsby@example.com'],
+        editor_names=['sport@example.com'],
+        is_restricted_field=['Yes'])
+    url = self.servlet.ProcessFormData(self.mr, post_data)
+    self.assertTrue('/adminLabels?saved=1&' in url)
+    config = self.services.config.GetProjectConfig(
+        self.mr.cnxn, self.mr.project_id)
+
+    fd = tracker_bizobj.FindFieldDef('somefield', config)
+    self.assertEqual('somefield', fd.field_name)
+    self.assertEqual(tracker_pb2.FieldTypes.INT_TYPE, fd.field_type)
+    self.assertEqual(1, fd.min_value)
+    self.assertEqual(99, fd.max_value)
+    self.assertEqual(tracker_pb2.NotifyTriggers.ANY_COMMENT, fd.notify_on)
+    self.assertTrue(fd.is_required)
+    self.assertFalse(fd.is_niche)
+    self.assertTrue(fd.is_multivalued)
+    self.assertEqual('It is just some field', fd.docstring)
+    self.assertEqual('Defect', fd.applicable_type)
+    self.assertEqual('', fd.applicable_predicate)
+    self.assertEqual([111], fd.admin_ids)
+    self.assertEqual([222], fd.editor_ids)
+
+  def testProcessFormData_Reject_EditorsForNonRestrictedField(self):
+    # This method tests that an exception is raised
+    # when trying to add editors to a non restricted field.
+    post_data = fake.PostData(
+        name=['somefield'],
+        field_type=['INT_TYPE'],
+        min_value=['1'],
+        max_value=['99'],
+        notify_on=['any_comment'],
+        importance=['required'],
+        is_multivalued=['Yes'],
+        docstring=['It is just some field'],
+        applicable_type=['Defect'],
+        date_action=['no_action'],
+        admin_names=['gatsby@example.com'],
+        editor_names=['sport@example.com'])
+
+    self.assertRaises(
+        AssertionError, self.servlet.ProcessFormData, self.mr, post_data)
+
+  def testProcessFormData_Reject_EditorsForApprovalField(self):
+    #This method tests that an exception is raised
+    #when trying to add editors to an approval field.
+    post_data = fake.PostData(
+        name=['approval_field'],
+        field_type=['approval_type'],
+        notify_on=['any_comment'],
+        importance=['required'],
+        is_multivalued=['Yes'],
+        docstring=['It is just some field'],
+        applicable_type=['Defect'],
+        date_action=['no_action'],
+        approver_names=['gatsby@example.com'],
+        is_restricted_field=['Yes'],
+        admin_names=['gatsby@example.com'],
+        editor_names=[''])
+
+    self.assertRaises(
+        AssertionError, self.servlet.ProcessFormData, self.mr, post_data)
+
+  @mock.patch('framework.servlet.Servlet.PleaseCorrect')
+  def testProcessFormData_RejectAssertions(self, fake_servlet_pc):
+    #This method tests when errors are found using when the
+    #field_helpers.ParsedFieldDefAssertions is triggered.
+    post_data = fake.PostData(
+        name=['somefield'],
+        field_type=['INT_TYPE'],
+        min_value=['1'],
+        max_value=['99'],
+        notify_on=['any_comment'],
+        importance=['required'],
+        is_multivalued=['Yes'],
+        docstring=['It is just some field'],
+        applicable_type=['Defect'],
+        date_action=['wrong_date_action'],
+        admin_names=['gatsby@example.com'],
+        editor_names=[''])
+
+    self.servlet.ProcessFormData(self.mr, post_data)
+
+    fake_servlet_pc.assert_called_once_with(
+        self.mr,
+        initial_field_name='somefield',
+        initial_type='INT_TYPE',
+        initial_parent_approval_name='',
+        initial_field_docstring='It is just some field',
+        initial_applicable_type='Defect',
+        initial_applicable_predicate='',
+        initial_needs_member=None,
+        initial_needs_perm='',
+        initial_importance='required',
+        initial_is_multivalued='yes',
+        initial_grants_perm='',
+        initial_notify_on=1,
+        initial_date_action='wrong_date_action',
+        initial_choices='',
+        initial_approvers='',
+        initial_survey='',
+        initial_is_phase_field=False,
+        initial_admins='gatsby@example.com',
+        initial_editors='',
+        initial_is_restricted_field=False)
+    self.assertTrue(self.mr.errors.AnyErrors())
+
+
+  def testProcessFormData_RejectNoApprover(self):
+    post_data = fake.PostData(
+        name=['approvalField'],
+        field_type=['approval_type'],
+        approver_names=[''],
+        admin_names=[''],
+        editor_names=[''],
+        parent_approval_name=['UIApproval'],
+        is_phase_field=['on'])
+
+    self.mox.StubOutWithMock(self.servlet, 'PleaseCorrect')
+    self.servlet.PleaseCorrect(
+        self.mr,
+        initial_field_name=post_data.get('name'),
+        initial_type=post_data.get('field_type'),
+        initial_field_docstring=post_data.get('docstring', ''),
+        initial_applicable_type=post_data.get('applical_type', ''),
+        initial_applicable_predicate='',
+        initial_needs_member=ezt.boolean('needs_member' in post_data),
+        initial_needs_perm=post_data.get('needs_perm', '').strip(),
+        initial_importance=post_data.get('importance'),
+        initial_is_multivalued=ezt.boolean('is_multivalued' in post_data),
+        initial_grants_perm=post_data.get('grants_perm', '').strip(),
+        initial_notify_on=0,
+        initial_date_action=post_data.get('date_action'),
+        initial_choices=post_data.get('choices', ''),
+        initial_approvers=post_data.get('approver_names'),
+        initial_parent_approval_name=post_data.get('parent_approval_name', ''),
+        initial_survey=post_data.get('survey', ''),
+        initial_is_phase_field=False,
+        initial_admins=post_data.get('admin_names'),
+        initial_editors=post_data.get('editor_names'),
+        initial_is_restricted_field=False)
+    self.mox.ReplayAll()
+    url = self.servlet.ProcessFormData(self.mr, post_data)
+    self.mox.VerifyAll()
+    self.assertEqual(
+        'Please provide at least one default approver.',
+        self.mr.errors.approvers)
+    self.assertIsNone(url)
+
+
+class FieldCreateMethodsTest(unittest.TestCase):
+
+  def setUp(self):
+    self.config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+
+  def testFieldNameErrorMessage_NoConflict(self):
+    self.assertIsNone(fieldcreate.FieldNameErrorMessage(
+        'somefield', self.config))
+
+  def testFieldNameErrorMessage_PrefixReserved(self):
+    self.assertEqual(
+        'That name is reserved.',
+        fieldcreate.FieldNameErrorMessage('owner', self.config))
+
+  def testFieldNameErrorMessage_SuffixReserved(self):
+    self.assertEqual(
+        'That suffix is reserved.',
+        fieldcreate.FieldNameErrorMessage('doh-approver', self.config))
+
+  def testFieldNameErrorMessage_AlreadyInUse(self):
+    fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'CPU', tracker_pb2.FieldTypes.INT_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    self.config.field_defs.append(fd)
+    self.assertEqual(
+        'That name is already in use.',
+        fieldcreate.FieldNameErrorMessage('CPU', self.config))
+
+  def testFieldNameErrorMessage_PrefixOfExisting(self):
+    fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'sign-off', tracker_pb2.FieldTypes.INT_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    self.config.field_defs.append(fd)
+    self.assertEqual(
+        'That name is a prefix of an existing field name.',
+        fieldcreate.FieldNameErrorMessage('sign', self.config))
+
+  def testFieldNameErrorMessage_IncludesExisting(self):
+    fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'opt', tracker_pb2.FieldTypes.INT_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    self.config.field_defs.append(fd)
+    self.assertEqual(
+        'An existing field name is a prefix of that name.',
+        fieldcreate.FieldNameErrorMessage('opt-in', self.config))
diff --git a/tracker/test/fielddetail_test.py b/tracker/test/fielddetail_test.py
new file mode 100644
index 0000000..f9f27b4
--- /dev/null
+++ b/tracker/test/fielddetail_test.py
@@ -0,0 +1,355 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unit tests for the fielddetail servlet."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import mox
+import unittest
+import logging
+
+import webapp2
+
+import ezt
+
+from framework import permissions
+from proto import project_pb2
+from proto import tracker_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+from tracker import fielddetail
+from tracker import tracker_bizobj
+from tracker import tracker_views
+
+
+class FieldDetailTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        user=fake.UserService(),
+        config=fake.ConfigService(),
+        project=fake.ProjectService())
+    self.servlet = fielddetail.FieldDetail(
+        'req', 'res', services=self.services)
+    self.project = self.services.project.TestAddProject('proj')
+    self.mr = testing_helpers.MakeMonorailRequest(
+        project=self.project, perms=permissions.OWNER_ACTIVE_PERMISSIONSET)
+    self.config = self.services.config.GetProjectConfig(
+        'fake cnxn', self.project.project_id)
+    self.services.config.StoreConfig('fake cnxn', self.config)
+    self.fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'CPU', tracker_pb2.FieldTypes.INT_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    self.fd.admin_ids = [111]
+    self.fd.editor_ids = [222]
+    self.config.field_defs.append(self.fd)
+    self.services.user.TestAddUser('gatsby@example.com', 111)
+    self.services.user.TestAddUser('sport@example.com', 222)
+    self.mr.field_name = 'CPU'
+
+    # Approvals
+    self.approval_def = tracker_pb2.ApprovalDef(
+        approval_id=234, approver_ids=[111], survey='Question 1?')
+    self.sub_fd = tracker_pb2.FieldDef(
+        field_name='UIMocks', approval_id=234, applicable_type='')
+    self.sub_fd_deleted = tracker_pb2.FieldDef(
+        field_name='UIMocksDeleted', approval_id=234, applicable_type='',
+        is_deleted=True)
+    self.config.field_defs.extend([self.sub_fd, self.sub_fd_deleted])
+    self.config.approval_defs.append(self.approval_def)
+    self.approval_fd = tracker_bizobj.MakeFieldDef(
+        234, 789, 'UIReview', tracker_pb2.FieldTypes.APPROVAL_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    self.config.field_defs.append(self.approval_fd)
+
+    self.mox = mox.Mox()
+
+  def tearDown(self):
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def testGetFieldDef_NotFound(self):
+    self.mr.field_name = 'NeverHeardOfIt'
+    self.assertRaises(
+        webapp2.HTTPException,
+        self.servlet._GetFieldDef, self.mr)
+
+  def testGetFieldDef_Normal(self):
+    actual_config, actual_fd = self.servlet._GetFieldDef(self.mr)
+    self.assertEqual(self.config, actual_config)
+    self.assertEqual(self.fd, actual_fd)
+
+  def testAssertBasePermission_AnyoneCanView(self):
+    self.servlet.AssertBasePermission(self.mr)
+    self.mr.perms = permissions.COMMITTER_ACTIVE_PERMISSIONSET
+    self.servlet.AssertBasePermission(self.mr)
+    self.mr.perms = permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET
+    self.servlet.AssertBasePermission(self.mr)
+    self.mr.perms = permissions.READ_ONLY_PERMISSIONSET
+    self.servlet.AssertBasePermission(self.mr)
+
+  def testAssertBasePermission_MembersOnly(self):
+    self.project.access = project_pb2.ProjectAccess.MEMBERS_ONLY
+    # The project members can view the field definition.
+    self.servlet.AssertBasePermission(self.mr)
+    self.mr.perms = permissions.COMMITTER_ACTIVE_PERMISSIONSET
+    self.servlet.AssertBasePermission(self.mr)
+    self.mr.perms = permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET
+    self.servlet.AssertBasePermission(self.mr)
+    # Non-member is not allowed to view anything in the project.
+    self.mr.perms = permissions.EMPTY_PERMISSIONSET
+    self.assertRaises(
+        permissions.PermissionException,
+        self.servlet.AssertBasePermission, self.mr)
+
+  def testGatherPageData_ReadWrite(self):
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.assertEqual(self.servlet.PROCESS_TAB_LABELS,
+                     page_data['admin_tab_mode'])
+    self.assertTrue(page_data['allow_edit'])
+    self.assertEqual('gatsby@example.com', page_data['initial_admins'])
+    self.assertEqual('sport@example.com', page_data['initial_editors'])
+    field_def_view = page_data['field_def']
+    self.assertEqual('CPU', field_def_view.field_name)
+    self.assertEqual(page_data['approval_subfields'], [])
+    self.assertEqual(page_data['initial_approvers'], '')
+
+  def testGatherPageData_ReadOnly(self):
+    self.mr.perms = permissions.READ_ONLY_PERMISSIONSET
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.assertEqual(self.servlet.PROCESS_TAB_LABELS,
+                     page_data['admin_tab_mode'])
+    self.assertFalse(page_data['allow_edit'])
+    self.assertEqual('gatsby@example.com', page_data['initial_admins'])
+    self.assertEqual('sport@example.com', page_data['initial_editors'])
+    field_def_view = page_data['field_def']
+    self.assertEqual('CPU', field_def_view.field_name)
+    self.assertEqual(page_data['approval_subfields'], [])
+    self.assertEqual(page_data['initial_approvers'], '')
+
+  def testGatherPageData_Approval(self):
+    self.mr.field_name = 'UIReview'
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.assertEqual(page_data['approval_subfields'], [self.sub_fd])
+    self.assertEqual(page_data['initial_approvers'], 'gatsby@example.com')
+    field_def_view = page_data['field_def']
+    self.assertEqual(field_def_view.field_name, 'UIReview')
+    self.assertEqual(field_def_view.survey, 'Question 1?')
+
+  def testProcessFormData_Permission(self):
+    """Only owners can edit fields."""
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project,
+        perms=permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET)
+    mr.field_name = 'CPU'
+    post_data = fake.PostData(
+        name=['CPU'],
+        deletefield=['Submit'])
+    self.assertRaises(permissions.PermissionException,
+                      self.servlet.ProcessFormData, mr, post_data)
+
+    self.servlet.ProcessFormData(self.mr, post_data)
+
+  def testProcessFormData_Delete(self):
+    post_data = fake.PostData(
+        name=['CPU'],
+        deletefield=['Submit'])
+    url = self.servlet.ProcessFormData(self.mr, post_data)
+    self.assertTrue('/adminLabels?deleted=1&' in url)
+    fd = tracker_bizobj.FindFieldDef('CPU', self.config)
+    self.assertEqual('CPU', fd.field_name)
+    self.assertTrue(fd.is_deleted)
+
+  def testProcessFormData_Cancel(self):
+    post_data = fake.PostData(
+        name=['CPU'],
+        cancel=['Submit'],
+        max_value=['200'])
+    url = self.servlet.ProcessFormData(self.mr, post_data)
+    logging.info(url)
+    self.assertTrue('/adminLabels?ts=' in url)
+    config = self.services.config.GetProjectConfig(
+        self.mr.cnxn, self.mr.project_id)
+
+    fd = tracker_bizobj.FindFieldDef('CPU', config)
+    self.assertIsNone(fd.max_value)
+    self.assertIsNone(fd.min_value)
+
+  def testProcessFormData_Edit(self):
+    post_data = fake.PostData(
+        name=['CPU'],
+        field_type=['INT_TYPE'],
+        min_value=['2'],
+        max_value=['98'],
+        notify_on=['never'],
+        is_required=[],
+        is_multivalued=[],
+        docstring=['It is just some field'],
+        applicable_type=['Defect'],
+        admin_names=['gatsby@example.com'])
+    url = self.servlet.ProcessFormData(self.mr, post_data)
+    self.assertTrue('/fields/detail?field=CPU&saved=1&' in url)
+    config = self.services.config.GetProjectConfig(
+        self.mr.cnxn, self.mr.project_id)
+
+    fd = tracker_bizobj.FindFieldDef('CPU', config)
+    self.assertEqual('CPU', fd.field_name)
+    self.assertEqual(2, fd.min_value)
+    self.assertEqual(98, fd.max_value)
+    self.assertEqual([111], fd.admin_ids)
+    self.assertEqual([], fd.editor_ids)
+
+  def testProcessDeleteField(self):
+    self.servlet._ProcessDeleteField(self.mr, self.config, self.fd)
+    self.assertTrue(self.fd.is_deleted)
+
+  def testProcessDeleteField_subfields(self):
+    approval_fd = tracker_bizobj.MakeFieldDef(
+        3, 789, 'Legal', tracker_pb2.FieldTypes.APPROVAL_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    self.fd.approval_id=3
+    self.config.field_defs.append(approval_fd)
+    self.servlet._ProcessDeleteField(self.mr, self.config, approval_fd)
+    self.assertTrue(self.fd.is_deleted)
+    self.assertTrue(approval_fd.is_deleted)
+
+  def testProcessEditField_Normal(self):
+    post_data = fake.PostData(
+        name=['CPU'],
+        field_type=['INT_TYPE'],
+        min_value=['2'],
+        admin_names=['gatsby@example.com'],
+        editor_names=['sport@example.com'],
+        is_restricted_field=['Yes'])
+    self.servlet._ProcessEditField(
+        self.mr, post_data, self.config, self.fd)
+    fd = tracker_bizobj.FindFieldDef('CPU', self.config)
+    self.assertEqual('CPU', fd.field_name)
+    self.assertEqual(2, fd.min_value)
+    self.assertEqual([111], fd.admin_ids)
+    self.assertEqual([222], fd.editor_ids)
+
+  def testProcessEditField_Reject(self):
+    post_data = fake.PostData(
+        name=['CPU'],
+        field_type=['INT_TYPE'],
+        min_value=['4'],
+        max_value=['1'],
+        admin_names=[''],
+        editor_names=[''])
+
+    self.mox.StubOutWithMock(self.servlet, 'PleaseCorrect')
+    self.servlet.PleaseCorrect(
+        self.mr,
+        field_def=mox.IgnoreArg(),
+        initial_applicable_type='',
+        initial_choices='',
+        initial_admins='',
+        initial_editors='',
+        initial_approvers='',
+        initial_is_restricted_field=False)
+    self.mox.ReplayAll()
+
+    url = self.servlet._ProcessEditField(
+        self.mr, post_data, self.config, self.fd)
+    self.assertEqual('Minimum value must be less than maximum.',
+                     self.mr.errors.min_value)
+    self.assertIsNone(url)
+
+    fd = tracker_bizobj.FindFieldDef('CPU', self.config)
+    self.assertIsNone(fd.min_value)
+    self.assertIsNone(fd.max_value)
+
+  def testProcessEditField_Reject_EditorsForNonRestrictedField(self):
+    # This method tests that an exception is raised
+    # when trying to add editors to a non restricted field.
+    post_data = fake.PostData(
+        name=['CPU'],
+        field_type=['INT_TYPE'],
+        min_value=['2'],
+        admin_names=[''],
+        editor_names=['gatsby@example.com'])
+
+    self.assertRaises(
+        AssertionError, self.servlet._ProcessEditField, self.mr, post_data,
+        self.config, self.fd)
+
+  def testProcessEditField_RejectAssertions_1(self):
+    # This method tests that an exception is raised
+    # when trying to add editors to an approval field.
+    post_data = fake.PostData(
+        name=['CPU'],
+        approver_names=['gatsby@example.com'],
+        admin_names=[''],
+        editor_names=['sports@example.com'])
+
+    self.assertRaises(
+        AssertionError, self.servlet._ProcessEditField, self.mr, post_data,
+        self.config, self.approval_fd)
+
+  def testProcessEditField_RejectAssertions_2(self):
+    #This method tests that an exception is raised
+    #when trying to restrict an approval field.
+    post_data = fake.PostData(
+        name=['CPU'],
+        approver_names=['gatsby@example.com'],
+        is_restricted_field=['Yes'],
+        admin_names=[''],
+        editor_names=[''])
+
+    self.assertRaises(
+        AssertionError, self.servlet._ProcessEditField, self.mr, post_data,
+        self.config, self.approval_fd)
+
+  def testProcessEditField_RejectApproval(self):
+    self.mr.field_name = 'UIReview'
+    post_data = fake.PostData(
+        name=['UIReview'],
+        admin_names=[''],
+        editor_names=[''],
+        survey=['WIll there be UI changes?'],
+        approver_names=[''])
+
+    self.mox.StubOutWithMock(self.servlet, 'PleaseCorrect')
+    self.servlet.PleaseCorrect(
+        self.mr,
+        field_def=mox.IgnoreArg(),
+        initial_applicable_type='',
+        initial_choices='',
+        initial_admins='',
+        initial_editors='',
+        initial_approvers='',
+        initial_is_restricted_field=False)
+    self.mox.ReplayAll()
+
+    url = self.servlet._ProcessEditField(
+        self.mr, post_data, self.config, self.approval_fd)
+    self.assertEqual('Please provide at least one default approver.',
+                     self.mr.errors.approvers)
+    self.assertIsNone(url)
+
+  def testProcessEditField_Approval(self):
+    self.mr.field_name = 'UIReview'
+    post_data = fake.PostData(
+        name=['UIReview'],
+        admin_names=[''],
+        editor_names=[''],
+        survey=['WIll there be UI changes?'],
+        approver_names=['sport@example.com, gatsby@example.com'])
+
+
+    url = self.servlet._ProcessEditField(
+        self.mr, post_data, self.config, self.approval_fd)
+    self.assertTrue('/fields/detail?field=UIReview&saved=1&' in url)
+
+    approval_def = tracker_bizobj.FindApprovalDef('UIReview', self.config)
+    self.assertEqual(len(approval_def.approver_ids), 2)
+    self.assertEqual(sorted(approval_def.approver_ids), sorted([111, 222]))
diff --git a/tracker/test/fltconversion_test.py b/tracker/test/fltconversion_test.py
new file mode 100644
index 0000000..e0bae41
--- /dev/null
+++ b/tracker/test/fltconversion_test.py
@@ -0,0 +1,930 @@
+# Copyright 2018 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unittests for the flt launch issues conversion task."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+import copy
+import unittest
+import settings
+import mock
+
+from businesslogic import work_env
+from framework import exceptions
+from framework import permissions
+from services import service_manager
+from services import template_svc
+from tracker import fltconversion
+from tracker import tracker_bizobj
+from testing import fake
+from testing import testing_helpers
+from proto import tracker_pb2
+
+class FLTConvertTask(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        issue=fake.IssueService(),
+        user=fake.UserService(),
+        project=fake.ProjectService(),
+        config=fake.ConfigService(),
+        template=mock.Mock(spec=template_svc.TemplateService),)
+    self.mr = testing_helpers.MakeMonorailRequest()
+    self.task = fltconversion.FLTConvertTask(
+        'req', 'res', services=self.services)
+    self.task.mr = self.mr
+    self.issue = fake.MakeTestIssue(
+        789, 1, 'summary', 'New', 111, issue_id=78901)
+    self.config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    self.work_env = work_env.WorkEnv(
+        self.mr, self.services, 'Testing')
+    self.issue1 = fake.MakeTestIssue(
+        789, 1, 'sum', 'New', 111, issue_id=78901,
+        labels=[
+            'Launch-M-Approved-71-Stable', 'Launch-M-Target-70-Beta',
+            'Launch-UI-Yes', 'Launch-Privacy-NeedInfo',
+            'pm-jojwang', 'tl-annajo', 'ux-shiba', 'Type-Launch'])
+    self.issue2 = fake.MakeTestIssue(
+        789, 2, 'sum', 'New', 111, issue_id=78902,
+        labels=[
+            'Launch-M-Target-71-Stable', 'Launch-M-Approved-70-Beta',
+            'pm-jojwang', 'tl-annajo', 'OS-Chrome', 'OS-Android',
+            'Type-Launch'])
+    self.issue3 = fake.MakeTestIssue(
+        789, 3, 'sum', 'New', 111, issue_id=78903,
+        labels=['Launch-M-Approved-71-Stable',
+                'Launch-M-Approved-79-Stable-Exp', 'Launch-M-Target-70-Beta',
+                'Launch-M-Target-70-Stable', 'Launch-UI-Yes',
+                'Launch-Exp-Leadership-Yes', 'pm-annajo', 'tl-jojwang',
+                'OS-Chrome', 'Type-Launch'])
+    self.issue4 = fake.MakeTestIssue(
+        789, 4, 'sum', 'New', 111, issue_id=78904,
+        labels=['Launch-UI-Yes', 'OS-Chrome', 'Type-Launch'])
+    self.issue5 = fake.MakeTestIssue(
+        789, 5, 'sum', 'New', 111, issue_id=78905,
+        labels=['Launch-M-Approved-71-Stable',
+                'Launch-M-Approved-79-Stable-Exp', 'Launch-M-Target-70-Beta',
+                'Launch-M-Target-70-Stable', 'Launch-UI-Yes',
+                'Launch-Privacy-NeedInfo', 'Launch-Exp-Leadership-Yes',
+                'pm-annajo', 'tl-jojwang', 'OS-Chrome', 'Type-Launch'])
+
+    self.approval_values = [
+        tracker_pb2.ApprovalValue(
+            approval_id=7, status=tracker_pb2.ApprovalStatus.NOT_SET),
+        tracker_pb2.ApprovalValue(
+            approval_id=8, status=tracker_pb2.ApprovalStatus.NEEDS_REVIEW)]
+    self.phases = [tracker_pb2.Phase(name='Stable', phase_id=88),
+              tracker_pb2.Phase(name='Beta', phase_id=89)]
+
+  def testAssertBasePermission(self):
+    self.mr.auth.user_pb.is_site_admin = True
+    settings.app_id = 'monorail-staging'
+    self.task.AssertBasePermission(self.mr)
+
+    settings.app_id = 'monorail-prod'
+    self.task.AssertBasePermission(self.mr)
+
+    self.mr.auth.user_pb.is_site_admin = False
+    self.assertRaises(permissions.PermissionException,
+                      self.task.AssertBasePermission, self.mr)
+
+  def testHandleRequest(self):
+    # Set up Objects
+    project_info = fltconversion.ProjectInfo(
+        self.config, 'q=query', self.approval_values, self.phases,
+        11, 12, 13, 16, 14, 15, fltconversion.BROWSER_PHASE_MAP,
+        fltconversion.BROWSER_APPROVALS_TO_LABELS,
+        fltconversion.BROWSER_M_LABELS_RE)
+
+    self.config.field_defs = [
+        tracker_pb2.FieldDef(field_id=7, field_name='Chrome-UX',
+                             field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE),
+        tracker_pb2.FieldDef(field_id=8, field_name='Chrome-Privacy',
+                             field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE)
+    ]
+
+    # Set up mocks
+    patcher = mock.patch(
+        'search.frontendsearchpipeline.FrontendSearchPipeline',
+        spec=True, visible_results=[self.issue1, self.issue2])
+    mockPipeline = patcher.start()
+
+    self.task.services.issue.GetIssue = mock.Mock(
+        side_effect=[self.issue1, self.issue2])
+
+    self.task.FetchAndAssertProjectInfo = mock.Mock(return_value=project_info)
+
+    with self.work_env as we:
+      we.ListIssues = mock.Mock(return_value=mockPipeline)
+
+    def side_effect(_cnxn, email):
+      if email == 'jojwang@chromium.org':
+        return 111
+      if email == 'annajo@google.com':
+        return 222
+      if email == 'shiba@google.com':
+        return 333
+      raise exceptions.NoSuchUserException
+    self.task.services.user.LookupUserID = mock.Mock(side_effect=side_effect)
+
+    self.task.ExecuteIssueChanges = mock.Mock(return_value=[])
+
+    # Call
+    json = self.task.HandleRequest(self.mr)
+
+    # assert
+    self.assertEqual(json['converted_issues'], [1, 2])
+
+    new_approvals1 = [
+        tracker_pb2.ApprovalValue(
+            approval_id=7, status=tracker_pb2.ApprovalStatus.APPROVED),
+        tracker_pb2.ApprovalValue(
+            approval_id=8, status=tracker_pb2.ApprovalStatus.NEED_INFO)]
+    new_fvs1 = [
+      # M-Approved Stable
+      tracker_bizobj.MakeFieldValue(
+          15, 71, None, None, None, None, False, phase_id=88),
+      # M-Target Beta
+      tracker_bizobj.MakeFieldValue(
+          14, 70, None, None, None, None, False, phase_id=89),
+      # PM field
+      tracker_bizobj.MakeFieldValue(
+          11, None, None, 111, None, None, False),
+      # TL field
+      tracker_bizobj.MakeFieldValue(
+          12, None, None, 222, None, None, False),
+      # UX field
+      tracker_bizobj.MakeFieldValue(
+          16, None, None, 333, None, None, False)
+    ]
+
+
+    new_approvals2 = [
+        tracker_pb2.ApprovalValue(
+            approval_id=7, status=tracker_pb2.ApprovalStatus.NOT_SET),
+        tracker_pb2.ApprovalValue(
+            approval_id=8, status=tracker_pb2.ApprovalStatus.NEEDS_REVIEW)
+    ]
+    new_fvs2 = [
+        tracker_bizobj.MakeFieldValue(
+            14, 71, None, None, None, None, False, phase_id=88),
+        tracker_bizobj.MakeFieldValue(
+            15, 70, None, None, None, None, False, phase_id=89),
+        # PM field
+        tracker_bizobj.MakeFieldValue(
+            11, None, None, 111, None, None, False),
+        # TL field
+        tracker_bizobj.MakeFieldValue(
+            12, None, None, 222, None, None, False)]
+
+    execute_calls = [
+        mock.call(
+            self.config, self.issue1, new_approvals1, self.phases, new_fvs1),
+        mock.call(
+            self.config, self.issue2, new_approvals2, self.phases, new_fvs2)]
+    self.task.ExecuteIssueChanges.assert_has_calls(execute_calls)
+
+    patcher.stop()
+
+  def testHandleRequest_UndoConversion(self):
+    # test Delete() is actually called
+    mr = testing_helpers.MakeMonorailRequest(path='url/url?launch=delete')
+    self.task.UndoConversion = mock.Mock(return_value={'deleteing': [1, 2]})
+    actualReturn = self.task.HandleRequest(mr)
+    self.assertEqual({'deleteing': [1, 2]}, actualReturn)
+
+  def testUndoConversion(self):
+    # Set up objects
+    self.issue1.field_values = [
+        # Test non phase and TL/PM/TE field_values are not deleted
+        tracker_bizobj.MakeFieldValue(
+            17, None, 'strvalue', None, None, None, False)]
+    issue1 = copy.deepcopy(self.issue1)
+    issue2 = copy.deepcopy(self.issue2)
+    fvs = [
+        tracker_bizobj.MakeFieldValue(
+            11, None, None, 222, None, None, False),
+        tracker_bizobj.MakeFieldValue(
+            12, None, None, 111, None, None, False),
+        tracker_bizobj.MakeFieldValue(
+            16, None, None, 111, None, None, False)]
+    self.config.field_defs = [
+        tracker_pb2.FieldDef(field_id=11, field_name='PM'),
+        tracker_pb2.FieldDef(field_id=12, field_name='TL'),
+        tracker_pb2.FieldDef(field_id=13, field_name='TE'),
+        tracker_pb2.FieldDef(field_id=16, field_name='UX')]
+    # Make element edits made during conversion that should be undone.
+    issue1.labels.extend(['Type-FLT-Launch', 'FLT-Conversion'])
+    issue1.labels.remove('Type-Launch')
+    issue2.labels.extend(['Type-FLT-Launch', 'FLT-Conversion'])
+    issue2.labels.remove('Type-Launch')
+    issue1.approval_values = self.approval_values
+    issue2.approval_values = self.approval_values
+    issue1.phases = self.phases
+    issue2.phases = self.phases
+    issue1.field_values.extend(fvs)
+
+    # Set up mocks
+    patcher = mock.patch(
+        'search.frontendsearchpipeline.FrontendSearchPipeline',
+        spec=True, visible_results=[issue1, issue2])  # converted issues
+    mockPipeline = patcher.start()
+    self.task.services.project.GetProjectByName = mock.Mock()
+    self.task.services.config.GetProjectConfig = mock.Mock(
+        return_value=self.config)
+    self.task.services.issue.GetIssue = mock.Mock(
+        side_effect=[issue1, issue2])
+    self.task.services.issue._UpdateIssuesApprovals = mock.Mock()
+    self.task.services.issue.UpdateIssue = mock.Mock()
+
+    with self.work_env as we:
+      we.ListIssues = mock.Mock(return_value=mockPipeline)
+
+    json = self.task.UndoConversion(self.mr)
+    self.assertEqual(json['deleting'], [1, 2])
+    # assert convert issue1 is back to the pre-conversion state, self.issue1.
+    self.assertEqual(issue1, self.issue1)
+    self.assertEqual(issue2, self.issue2)
+
+    # assert UpdateIssue calls were made with pre-conversion state issues.
+    update_calls = [
+        mock.call(self.mr.cnxn, self.issue1),
+        mock.call(self.mr.cnxn, self.issue2)]
+    self.task.services.issue._UpdateIssuesApprovals.assert_has_calls(
+        update_calls)
+    self.task.services.issue.UpdateIssue.assert_has_calls(update_calls)
+    patcher.stop()
+
+  def testVerifyConversion(self):
+    # Set up objects
+    self.issue1.labels.extend(
+        # Launch-M-Target-70-Stable-Exp should be ignored
+        ['Rollout-Type-Default', 'Launch-M-Target-70-Stable-Exp'])
+    self.issue1.phases = [tracker_pb2.Phase(name='Beta', phase_id=1),
+                          tracker_pb2.Phase(name='Stable', phase_id=2)]
+    self.issue1.approval_values = [
+      tracker_pb2.ApprovalValue(
+          approval_id=1, status=tracker_pb2.ApprovalStatus.NOT_SET),
+      tracker_pb2.ApprovalValue(
+          approval_id=2, status=tracker_pb2.ApprovalStatus.APPROVED),
+      tracker_pb2.ApprovalValue(
+          approval_id=3, status=tracker_pb2.ApprovalStatus.NEED_INFO),
+    ]
+    self.issue1.field_values = [
+        # problem = expected field for TL
+        tracker_bizobj.MakeFieldValue(4, None, None, 111, None, None, False),
+        tracker_pb2.FieldValue(field_id=7, int_value=70, phase_id=1),
+        tracker_pb2.FieldValue(field_id=8, int_value=71, phase_id=2),
+    ]
+
+    self.issue2.labels.extend(['Rollout-Type-Finch'])
+    self.issue2.phases = [tracker_pb2.Phase(name='Beta', phase_id=1),
+                          tracker_pb2.Phase(name='Stable-Full', phase_id=2),
+                          tracker_pb2.Phase(name='Stable-Exp', phase_id=3)]
+    self.issue2.approval_values = [
+        tracker_pb2.ApprovalValue(
+            approval_id=1, status=tracker_pb2.ApprovalStatus.NOT_SET),
+        tracker_pb2.ApprovalValue(
+            approval_id=2, status=tracker_pb2.ApprovalStatus.NOT_SET),
+        tracker_pb2.ApprovalValue(
+            # problem = approval Chrome-Privacy has status approved for None
+            approval_id=3, status=tracker_pb2.ApprovalStatus.APPROVED),
+    ]
+    self.issue2.field_values = [
+        # problem = no phase field for label 'Launch-M-Approved-70-Beta'
+        tracker_pb2.FieldValue(field_id=7, int_value=71, phase_id=2),
+        tracker_bizobj.MakeFieldValue(4, None, None, 111, None, None, False),
+        tracker_bizobj.MakeFieldValue(5, None, None, 111, None, None, False),
+        ]
+
+    self.issue3.labels.extend(['Rollout-Type-Default'])
+    self.issue3.phases = [tracker_pb2.Phase(name='Feature Freeze', phase_id=4),
+                          tracker_pb2.Phase(name='Branch', phase_id=5),
+                          tracker_pb2.Phase(name='Stable', phase_id=6)]
+    self.issue3.approval_values = [
+      tracker_pb2.ApprovalValue(
+          approval_id=9, status=tracker_pb2.ApprovalStatus.APPROVED),
+      tracker_pb2.ApprovalValue(
+          approval_id=10, status=tracker_pb2.ApprovalStatus.NEEDS_REVIEW)]
+    # problem = no phase field label Launch-M-Target-70-Stable
+    # problem = missing a field for TL
+    self.issue3.field_values = [
+        tracker_pb2.FieldValue(field_id=8, int_value=71, phase_id=6),
+        tracker_bizobj.MakeFieldValue(4, None, None, 111, None, None, False)
+    ]
+
+    self.issue4.labels.extend(['Rollout-Type-Default'])
+    # problem = incorrect phases for OS default launch
+    self.issue4.phases = [tracker_pb2.Phase(name='Branch', phase_id=5),
+                          tracker_pb2.Phase(name='Stable-Exp', phase_id=7)]
+    # problem = approval ChromeOS-UX has status 'NEEDS_REVIEW'
+    # for label value Yes
+    self.issue4.approval_values = [
+      tracker_pb2.ApprovalValue(
+          approval_id=9, status=tracker_pb2.ApprovalStatus.NEEDS_REVIEW)]
+
+    self.issue5.labels.extend(['Rollout-Type-Finch'])
+    self.issue5.phases = [tracker_pb2.Phase(name='Branch', phase_id=5),
+                          tracker_pb2.Phase(name='Feature Freeze', phase_id=4),
+                          tracker_pb2.Phase(name='Stable-Exp', phase_id=7),
+                          tracker_pb2.Phase(name='Stable-Full', phase_id=8)]
+    self.issue5.approval_values = [
+        tracker_pb2.ApprovalValue(
+            approval_id=9, status=tracker_pb2.ApprovalStatus.APPROVED),
+        # problem = approval ChromeOS-Privacy has status 'REVIEW_REQUESTED'
+        # for label value NeedInfo
+        tracker_pb2.ApprovalValue(
+            approval_id=11, status=tracker_pb2.ApprovalStatus.REVIEW_REQUESTED),
+        # problem = approval ChromeOS-Leadership-Exp has status 'NA' for label
+        # value Yes.
+        tracker_pb2.ApprovalValue(
+            approval_id=13, status=tracker_pb2.ApprovalStatus.NA)
+    ]
+
+    # problem = no phase field for label Launch-M-Approved-79-Stable-Exp
+    # problem = no phase field for label Launch-M-Target-70-Stable
+    self.issue5.field_values = [
+        tracker_pb2.FieldValue(field_id=8, int_value=71, phase_id=8),
+        tracker_bizobj.MakeFieldValue(4, None, None, 111, None, None, False),
+        tracker_bizobj.MakeFieldValue(5, None, None, 111, None, None, False)]
+
+    self.config.field_defs = [
+        tracker_pb2.FieldDef(field_id=1, field_name='Chrome-Test'),
+        tracker_pb2.FieldDef(field_id=2, field_name='Chrome-UX'),
+        tracker_pb2.FieldDef(field_id=3, field_name='Chrome-Privacy'),
+        tracker_pb2.FieldDef(field_id=4, field_name='PM'),
+        tracker_pb2.FieldDef(field_id=5, field_name='TL'),
+        tracker_pb2.FieldDef(field_id=6, field_name='TE'),
+        tracker_pb2.FieldDef(field_id=12, field_name='UX'),
+        tracker_pb2.FieldDef(field_id=7, field_name='M-Target'),
+        tracker_pb2.FieldDef(field_id=8, field_name='M-Approved'),
+        tracker_pb2.FieldDef(field_id=9, field_name='ChromeOS-UX'),
+        tracker_pb2.FieldDef(field_id=10, field_name='ChromeOS-Enterprise'),
+        tracker_pb2.FieldDef(field_id=11, field_name='ChromeOS-Privacy'),
+        tracker_pb2.FieldDef(field_id=13, field_name='ChromeOS-Leadership-Exp')
+    ]
+
+    # Set up mocks
+    patcher = mock.patch(
+        'search.frontendsearchpipeline.FrontendSearchPipeline',
+        spec=True, allowed_results=[
+            self.issue1, self.issue2, self.issue3, self.issue4, self.issue5])
+    mockPipeline = patcher.start()
+    self.task.services.project.GetProjectByName = mock.Mock()
+    self.task.services.config.GetProjectConfig = mock.Mock(
+        return_value=self.config)
+    self.task.services.issue.GetIssue = mock.Mock(
+        side_effect=[
+            self.issue1, self.issue2, self.issue3, self.issue4, self.issue5])
+    self.task.services.user.LookupUserID = mock.Mock(return_value=111)
+    with self.work_env as we:
+      we.ListIssues = mock.Mock(return_value=mockPipeline)
+
+    # Assert
+    json = self.task.VerifyConversion(self.mr)
+    self.assertEqual(json['issues verified'],
+                     ['issue 1', 'issue 2', 'issue 3', 'issue 4', 'issue 5'])
+    problems = json['problems found']
+    expected_problems = [
+        'issue 1: missing a field for TL',
+        'issue 1: missing a field for UX',
+        'issue 2: approval Chrome-Privacy has status \'APPROVED\' for '
+        'label value None',
+        'issue 2: no phase field for label Launch-M-Approved-70-Beta',
+        'issue 3: missing a field for TL',
+        'issue 3: no phase field for label Launch-M-Target-70-Stable',
+        'issue 4: incorrect phases for OS default launch.',
+        'issue 4: approval ChromeOS-UX has status \'NEEDS_REVIEW\' for '
+        'label value Yes',
+        'issue 5: approval ChromeOS-Privacy has status \'REVIEW_REQUESTED\' '
+        'for label value NeedInfo',
+        'issue 5: approval ChromeOS-Leadership-Exp has status \'NA\' for label '
+        'value Yes',
+        'issue 5: no phase field for label Launch-M-Approved-79-Stable-Exp',
+        'issue 5: no phase field for label Launch-M-Target-70-Stable',
+    ]
+    self.assertEqual(problems, expected_problems)
+    patcher.stop()
+
+  def testFetchAndAssertProjectInfo(self):
+
+    # test no 'launch' in request
+    self.assertRaisesRegexp(
+        AssertionError, r'bad launch type:',
+        self.task.FetchAndAssertProjectInfo, self.mr)
+
+    # test bad 'launch' in request
+    mr = testing_helpers.MakeMonorailRequest(path='url/url?launch=bad')
+    self.assertRaisesRegexp(
+        AssertionError, r'bad launch type: bad',
+        self.task.FetchAndAssertProjectInfo, mr)
+
+    self.task.services.project.GetProjectByName = mock.Mock()
+    self.task.services.config.GetProjectConfig = mock.Mock(
+        return_value=self.config)
+
+    mr = testing_helpers.MakeMonorailRequest(path='url/url?launch=default')
+    # test no template
+    self.task.services.template.GetTemplateByName = mock.Mock(
+        return_value=None)
+    self.assertRaisesRegexp(
+        AssertionError, r'not found in chromium project',
+        self.task.FetchAndAssertProjectInfo, mr)
+
+    # test template has no phases/approvals
+    template = tracker_bizobj.MakeIssueTemplate(
+        'template', 'sum', 'New', 111, 'content', [], [], [], [])
+    self.task.services.template.GetTemplateByName = mock.Mock(
+        return_value=template)
+    self.assertRaisesRegexp(
+        AssertionError, 'no approvals or phases in',
+        self.task.FetchAndAssertProjectInfo, mr)
+
+    # test phases not recognized
+    template.phases = [tracker_pb2.Phase(name='WeirdPhase')]
+    template.approval_values = [tracker_pb2.ApprovalValue()]
+    self.assertRaisesRegexp(
+        AssertionError, 'one or more phases not recognized',
+        self.task.FetchAndAssertProjectInfo, mr)
+
+    template.phases = [tracker_pb2.Phase(name='Stable'),
+                       tracker_pb2.Phase(name='Stable-Exp')]
+    template.approval_values = [
+        tracker_pb2.ApprovalValue(approval_id=1),
+        tracker_pb2.ApprovalValue(approval_id=2),
+        tracker_pb2.ApprovalValue(approval_id=3)]
+
+    # test approvals not recognized
+    self.assertRaisesRegexp(
+        AssertionError, 'one or more approvals not recognized',
+        self.task.FetchAndAssertProjectInfo, mr)
+
+    self.config.field_defs = [
+        tracker_pb2.FieldDef(field_id=1, field_name='ChromeOS-Enterprise',
+                             field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE),
+        tracker_pb2.FieldDef(field_id=2, field_name='Chrome-UX',
+                             field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE),
+        tracker_pb2.FieldDef(field_id=3, field_name='Chrome-Privacy',
+                             field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE)
+    ]
+
+    # test approvals not in config's approval_defs
+    self.assertRaisesRegexp(
+        AssertionError, 'one or more approvals not in config.approval_defs',
+        self.task.FetchAndAssertProjectInfo, mr)
+
+    self.config.approval_defs = [
+        tracker_pb2.ApprovalDef(approval_id=1),
+        tracker_pb2.ApprovalDef(approval_id=2),
+        tracker_pb2.ApprovalDef(approval_id=3)]
+
+    # test no pm field exists in project
+    self.assertRaisesRegexp(
+        AssertionError, 'project has no FieldDef %s' % fltconversion.PM_FIELD,
+        self.task.FetchAndAssertProjectInfo, mr)
+
+    self.config.field_defs.extend([
+      tracker_pb2.FieldDef(field_id=4, field_name='PM',
+                           field_type=tracker_pb2.FieldTypes.USER_TYPE),
+      tracker_pb2.FieldDef(field_id=5, field_name='TL',
+                           field_type=tracker_pb2.FieldTypes.USER_TYPE),
+      tracker_pb2.FieldDef(field_id=9, field_name='UX',
+                           field_type=tracker_pb2.FieldTypes.USER_TYPE),
+      tracker_pb2.FieldDef(field_id=6, field_name='TE')
+    ])
+
+    # test no USER_TYPE te field exists in project
+    self.assertRaisesRegexp(
+        AssertionError, 'project has no FieldDef %s' % fltconversion.TE_FIELD,
+        self.task.FetchAndAssertProjectInfo, mr)
+
+    self.config.field_defs[-1].field_type = tracker_pb2.FieldTypes.USER_TYPE
+    self.config.field_defs.extend([
+        tracker_pb2.FieldDef(
+            field_id=7, field_name='M-Target', is_phase_field=True),
+        tracker_pb2.FieldDef(
+            field_id=8, field_name='M-Approved', is_multivalued=True,
+            field_type=tracker_pb2.FieldTypes.INT_TYPE)
+        ])
+
+    # test no M-Target INT_TYPE multivalued Phase FieldDefs
+    self.assertRaisesRegexp(
+        AssertionError,
+        'project has no FieldDef %s' % fltconversion.MTARGET_FIELD,
+        self.task.FetchAndAssertProjectInfo, mr)
+
+    self.config.field_defs[-2].field_type = tracker_pb2.FieldTypes.INT_TYPE
+    self.config.field_defs[-2].is_multivalued = True
+
+    # test no M-Approved INT_TYPE multivalued Phase FieldDefs
+    self.assertRaisesRegexp(
+        AssertionError,
+        'project has no FieldDef %s' % fltconversion.MAPPROVED_FIELD,
+        self.task.FetchAndAssertProjectInfo, mr)
+
+    self.config.field_defs[-1].is_phase_field = True
+
+    self.assertEqual(
+        self.task.FetchAndAssertProjectInfo(mr),
+        fltconversion.ProjectInfo(
+            self.config, fltconversion.QUERY_MAP['default'],
+            template.approval_values, template.phases, 4, 5, 6, 9, 7, 8,
+            fltconversion.BROWSER_PHASE_MAP,
+            fltconversion.BROWSER_APPROVALS_TO_LABELS,
+            fltconversion.BROWSER_M_LABELS_RE))
+
+    # FINCH special case
+    # test approvals for Finch not required
+    mr = testing_helpers.MakeMonorailRequest(path='url/url?launch=finch')
+    self.assertRaisesRegexp(
+        AssertionError, 'finch template not set up correctly',
+        self.task.FetchAndAssertProjectInfo, mr)
+
+    for av in template.approval_values:
+      av.status = tracker_pb2.ApprovalStatus.NEEDS_REVIEW
+
+    self.assertEqual(
+        self.task.FetchAndAssertProjectInfo(mr),
+        fltconversion.ProjectInfo(
+            self.config, fltconversion.QUERY_MAP['finch'],
+            template.approval_values, template.phases, 4, 5, 6, 9, 7, 8,
+            fltconversion.BROWSER_PHASE_MAP,
+            fltconversion.BROWSER_APPROVALS_TO_LABELS,
+            fltconversion.BROWSER_M_LABELS_RE))
+
+  def testFetchAndAssertProjectInfo_OS(self):
+    self.task.services.project.GetProjectByName = mock.Mock()
+    self.task.services.config.GetProjectConfig = mock.Mock(
+        return_value=self.config)
+
+    mr = testing_helpers.MakeMonorailRequest(path='url/url?launch=os')
+    template = tracker_bizobj.MakeIssueTemplate(
+        'template', 'sum', 'New', 111, 'content', [], [], [], [])
+    self.task.services.template.GetTemplateByName = mock.Mock(
+        return_value=template)
+
+    # test phases not recognized
+    template.phases = [tracker_pb2.Phase(name='Chrome-Test')]
+    template.approval_values = [tracker_pb2.ApprovalValue()]
+    self.assertRaisesRegexp(
+        AssertionError, 'one or more phases not recognized',
+        self.task.FetchAndAssertProjectInfo, mr)
+
+    template.phases = [tracker_pb2.Phase(name='feature freeze'),
+                       tracker_pb2.Phase(name='branch')]
+
+    # test template not set up correctly
+    template.approval_values = [
+        tracker_pb2.ApprovalValue(approval_id=1),
+        tracker_pb2.ApprovalValue(approval_id=2),
+        tracker_pb2.ApprovalValue(approval_id=3)]
+    self.assertRaisesRegexp(
+        AssertionError, 'os template not set up correctly',
+        self.task.FetchAndAssertProjectInfo, mr)
+
+    for av in template.approval_values:
+      av.status = tracker_pb2.ApprovalStatus.NEEDS_REVIEW
+
+    # test approvals not recognized
+    self.assertRaisesRegexp(
+        AssertionError, 'one or more approvals not recognized',
+        self.task.FetchAndAssertProjectInfo, mr)
+
+    self.config.field_defs = [
+        tracker_pb2.FieldDef(field_id=1, field_name='ChromeOS-Enterprise',
+                             field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE),
+        tracker_pb2.FieldDef(field_id=2, field_name='ChromeOS-UX',
+                             field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE),
+        tracker_pb2.FieldDef(field_id=3, field_name='ChromeOS-Privacy',
+                             field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE)
+    ]
+
+    # Skip remaining checks. No different from Browser process.
+    self.config.approval_defs = [
+        tracker_pb2.ApprovalDef(approval_id=1),
+        tracker_pb2.ApprovalDef(approval_id=2),
+        tracker_pb2.ApprovalDef(approval_id=3)]
+
+    self.config.field_defs.extend([
+      tracker_pb2.FieldDef(field_id=4, field_name='PM',
+                           field_type=tracker_pb2.FieldTypes.USER_TYPE),
+      tracker_pb2.FieldDef(field_id=5, field_name='TL',
+                           field_type=tracker_pb2.FieldTypes.USER_TYPE),
+      tracker_pb2.FieldDef(field_id=6, field_name='TE',
+                           field_type=tracker_pb2.FieldTypes.USER_TYPE),
+      tracker_pb2.FieldDef(field_id=9, field_name='UX',
+                           field_type=tracker_pb2.FieldTypes.USER_TYPE)
+    ])
+    self.config.field_defs.extend([
+        tracker_pb2.FieldDef(
+            field_id=7, field_name='M-Target', is_phase_field=True,
+            is_multivalued=True, field_type=tracker_pb2.FieldTypes.INT_TYPE),
+        tracker_pb2.FieldDef(
+            field_id=8, field_name='M-Approved', is_phase_field=True,
+            is_multivalued=True, field_type=tracker_pb2.FieldTypes.INT_TYPE)
+    ])
+
+    self.assertEqual(
+        self.task.FetchAndAssertProjectInfo(mr),
+        fltconversion.ProjectInfo(
+            self.config, fltconversion.QUERY_MAP['os'],
+            template.approval_values, template.phases, 4, 5, 6, 9, 7, 8,
+            fltconversion.OS_PHASE_MAP, fltconversion.OS_APPROVALS_TO_LABELS,
+            fltconversion.OS_M_LABELS_RE))
+
+  @mock.patch('time.time')
+  def testExecuteIssueChanges(self, mockTime):
+    mockTime.return_value = 123
+    self.task.services.issue._UpdateIssuesApprovals = mock.Mock()
+    self.task.services.issue.DeltaUpdateIssue = mock.Mock(
+        return_value=([], None))
+    self.task.services.issue.InsertComment = mock.Mock()
+    self.config.approval_defs = [
+        tracker_pb2.ApprovalDef(
+            # test empty survey
+            approval_id=1, survey='', approver_ids=[111, 222]),
+        tracker_pb2.ApprovalDef(approval_id=2), # test missing survey
+        tracker_pb2.ApprovalDef(survey='Missing approval_id should not error.'),
+        tracker_pb2.ApprovalDef(approval_id=3, survey='Q1\nQ2\n\nQ3'),
+        tracker_pb2.ApprovalDef(approval_id=4, survey='Q1\nQ2\n\nQ3 two'),
+        tracker_pb2.ApprovalDef()]
+
+    new_avs = [tracker_pb2.ApprovalValue(
+        approval_id=1, status=tracker_pb2.ApprovalStatus.APPROVED),
+               tracker_pb2.ApprovalValue(approval_id=4),
+               tracker_pb2.ApprovalValue(approval_id=2),
+               tracker_pb2.ApprovalValue(approval_id=3)]
+
+    phases = [tracker_pb2.Phase(phase_id=1, name='Phase1', rank=1)]
+    new_fvs = [tracker_bizobj.MakeFieldValue(
+        11, 70, None, None, None, None, False, phase_id=1),
+               tracker_bizobj.MakeFieldValue(
+                   12, None, 'strfield', None, None, None, False)]
+    _amendments = self.task.ExecuteIssueChanges(
+        self.config, self.issue, new_avs, phases, new_fvs)
+
+    # approver_ids set in ExecuteIssueChanges()
+    new_avs[0].approver_ids = [111, 222]
+    self.issue.approval_values = new_avs
+    self.issue.phases = phases
+    delta = tracker_pb2.IssueDelta(
+        labels_add=['Type-FLT-Launch', 'FLT-Conversion'],
+        labels_remove=['Type-Launch'], field_vals_add=new_fvs)
+    cmt_1 = tracker_pb2.IssueComment(
+        issue_id=78901, project_id=789, user_id=self.mr.auth.user_id,
+        content='', is_description=True, approval_id=1, timestamp=123)
+    cmt_2 = tracker_pb2.IssueComment(
+        issue_id=78901, project_id=789, user_id=self.mr.auth.user_id,
+        content='', is_description=True, approval_id=2, timestamp=123)
+    cmt_3 = tracker_pb2.IssueComment(
+        issue_id=78901, project_id=789, user_id=self.mr.auth.user_id,
+        content='<b>Q1</b>\n<b>Q2</b>\n<b></b>\n<b>Q3</b>',
+        is_description=True, approval_id=3, timestamp=123)
+    cmt_4 = tracker_pb2.IssueComment(
+        issue_id=78901, project_id=789, user_id=self.mr.auth.user_id,
+        content='<b>Q1</b>\n<b>Q2</b>\n<b></b>\n<b>Q3 two</b>',
+        is_description=True, approval_id=4, timestamp=123)
+
+
+    comment_calls = [mock.call(self.mr.cnxn, cmt_1),
+                     mock.call(self.mr.cnxn, cmt_4),
+                     mock.call(self.mr.cnxn, cmt_2),
+                     mock.call(self.mr.cnxn, cmt_3)]
+    self.task.services.issue.InsertComment.assert_has_calls(comment_calls)
+
+    self.task.services.issue._UpdateIssuesApprovals.assert_called_once_with(
+        self.mr.cnxn, self.issue)
+    self.task.services.issue.DeltaUpdateIssue.assert_called_once_with(
+        self.mr.cnxn, self.task.services, self.mr.auth.user_id, 789,
+        self.config, self.issue, delta,
+        comment=fltconversion.CONVERSION_COMMENT)
+
+  def testConvertPeopleLabels(self):
+    self.task.services.user.LookupUserID = mock.Mock(
+        side_effect=[1, 2, 3, 4, 5, 6])
+    labels = [
+        'pm-u1', 'pm-u2', 'tl-u2', 'test-3', 'test-4', 'ux-u5', 'ux-6']
+    fvs = self.task.ConvertPeopleLabels(self.mr, labels, 11, 12, 13, 14)
+    expected = [
+        tracker_bizobj.MakeFieldValue(11, None, None, 1, None, None, False),
+        tracker_bizobj.MakeFieldValue(12, None, None, 2, None, None, False),
+        tracker_bizobj.MakeFieldValue(13, None, None, 3, None, None, False),
+        tracker_bizobj.MakeFieldValue(13, None, None, 4, None, None, False),
+        tracker_bizobj.MakeFieldValue(14, None, None, 5, None, None, False),
+        tracker_bizobj.MakeFieldValue(14, None, None, 6, None, None, False),
+        ]
+    self.assertEqual(fvs, expected)
+
+  def testConvertPeopleLabels_NoUsers(self):
+    def side_effect(_cnxn, _email):
+      raise exceptions.NoSuchUserException()
+    labels = []
+    self.task.services.user.LookupUserID = mock.Mock(side_effect=side_effect)
+    self.assertFalse(
+        len(self.task.ConvertPeopleLabels(self.mr, labels, 11, 12, 13, 14)))
+
+  def testCreateUserFieldValue_Chromium(self):
+    self.task.services.user.LookupUserID = mock.Mock(return_value=1)
+    actual = self.task.CreateUserFieldValue(self.mr, 'ldap', 11)
+    expected = tracker_bizobj.MakeFieldValue(
+        11, None, None, 1, None, None, False)
+    self.assertEqual(actual, expected)
+    self.task.services.user.LookupUserID.assert_called_once_with(
+        self.mr.cnxn, 'ldap@chromium.org')
+
+  def testCreateUserFieldValue_Goog(self):
+    def side_effect(_cnxn, email):
+      if email.endswith('chromium.org'):
+        raise exceptions.NoSuchUserException()
+      else:
+        return 2
+    self.task.services.user.LookupUserID = mock.Mock(side_effect=side_effect)
+    actual = self.task.CreateUserFieldValue(self.mr, 'ldap', 11)
+    expected = tracker_bizobj.MakeFieldValue(
+        11, None, None, 2, None, None, False)
+    self.assertEqual(actual, expected)
+    self.task.services.user.LookupUserID.assert_any_call(
+        self.mr.cnxn, 'ldap@chromium.org')
+    self.task.services.user.LookupUserID.assert_any_call(
+        self.mr.cnxn, 'ldap@google.com')
+
+  def testCreateUserFieldValue_NoUserFound(self):
+    def side_effect(_cnxn, _email):
+      raise exceptions.NoSuchUserException()
+    self.task.services.user.LookupUserID = mock.Mock(side_effect=side_effect)
+    self.assertIsNone(self.task.CreateUserFieldValue(self.mr, 'ldap', 11))
+
+
+class ConvertMLabels(unittest.TestCase):
+
+  def setUp(self):
+    self.target_id = 24
+    self.approved_id = 27
+    self.beta_phase = tracker_pb2.Phase(phase_id=1, name='bEtA')
+    self.stable_phase = tracker_pb2.Phase(phase_id=2, name='StAbLe')
+    self.stable_full_phase = tracker_pb2.Phase(phase_id=3, name='stable-FULL')
+    self.stable_exp_phase = tracker_pb2.Phase(phase_id=4, name='STABLE-exp')
+    self.feature_freeze_phase = tracker_pb2.Phase(
+        phase_id=5, name='FEATURE Freeze')
+    self.branch_phase = tracker_pb2.Phase(phase_id=6, name='bRANCH')
+
+  def testConvertMLabels_NormalFinch(self):
+
+    phases = [self.stable_exp_phase, self.beta_phase, self.stable_full_phase]
+    labels = [
+        'launch-m-approved-81-beta',  # beta:M-Approved=81
+        'launch-m-target-80-stable-car',  # ignore
+        'a-Launch-M-Target-80-Stable-car',  # ignore
+        'launch-m-target-70-Stable',  # stable-full:M-Target=70
+        'LAUNCH-M-TARGET-71-STABLE',  # stable-full:M-Target=71
+        'launch-m-target-70-stable-exp',  # stable-exp:M-Target=70
+        'launch-m-target-69-stable-exp',  # stable-exp:M-Target=69
+        'launch-M-APPROVED-70-Stable-Exp',  # stable-exp:M-Approved-70
+        'launch-m-approved-73-stable',  # stable-full:M-Approved-73
+        'launch-m-error-73-stable',  # ignore
+        'launch-m-approved-8-stable',  #ignore
+        'irrelevant label-weird',  # ignore
+    ]
+    actual_fvs = fltconversion.ConvertMLabels(
+        labels, phases, self.target_id, self.approved_id,
+        fltconversion.BROWSER_M_LABELS_RE, fltconversion.BROWSER_PHASE_MAP)
+
+    expected_fvs = [
+      tracker_pb2.FieldValue(
+          field_id=self.approved_id, int_value=81,
+          phase_id=self.beta_phase.phase_id, derived=False,),
+      tracker_pb2.FieldValue(
+          field_id=self.target_id, int_value=70,
+          phase_id=self.stable_full_phase.phase_id, derived=False),
+      tracker_pb2.FieldValue(
+          field_id=self.target_id, int_value=71,
+          phase_id=self.stable_full_phase.phase_id, derived=False),
+      tracker_pb2.FieldValue(
+          field_id=self.target_id, int_value=70,
+          phase_id=self.stable_exp_phase.phase_id, derived=False),
+      tracker_pb2.FieldValue(
+          field_id=self.target_id, int_value=69,
+          phase_id=self.stable_exp_phase.phase_id, derived=False),
+      tracker_pb2.FieldValue(
+          field_id=self.approved_id, int_value=70,
+          phase_id=self.stable_exp_phase.phase_id, derived=False),
+      tracker_pb2.FieldValue(
+          field_id=self.approved_id, int_value=73,
+          phase_id=self.stable_full_phase.phase_id, derived=False)
+    ]
+
+    self.assertEqual(actual_fvs, expected_fvs)
+
+  def testConvertMLabels_OS(self):
+    phases = [self.feature_freeze_phase, self.branch_phase, self.stable_phase]
+    labels = [
+        'launch-m-approved-81-beta',  # ignore
+        'launch-m-target-80-stable-car',  # ignore
+        'a-Launch-M-Target-80-Stable-car',  # ignore
+        'launch-m-target-70-Stable',  # stable:M-Target=70
+        'LAUNCH-M-TARGET-71-STABLE',  # stable:M-Target=71
+        'launch-m-target-70-stable-exp',  # ignore
+        'launch-M-APPROVED-70-Stable-Exp',  # ignore
+        'launch-m-approved-73-stable',  # stable:M-Approved-73
+        'launch-m-error-73-stable',  # ignore
+        'launch-m-approved-8-stable',  #ignore
+        'irrelevant label-weird',  # ignore
+        ]
+    actual_fvs = fltconversion.ConvertMLabels(
+        labels, phases, self.target_id, self.approved_id,
+        fltconversion.OS_M_LABELS_RE, fltconversion.OS_PHASE_MAP)
+
+    expected_fvs = [
+      tracker_pb2.FieldValue(
+          field_id=self.target_id, int_value=70,
+          phase_id=self.stable_phase.phase_id, derived=False,),
+      tracker_pb2.FieldValue(
+          field_id=self.target_id, int_value=71,
+          phase_id=self.stable_phase.phase_id, derived=False),
+      tracker_pb2.FieldValue(
+          field_id=self.approved_id, int_value=73,
+          phase_id=self.stable_phase.phase_id, derived=False)
+    ]
+
+    self.assertEqual(actual_fvs, expected_fvs)
+
+
+class ConvertLaunchLabels(unittest.TestCase):
+
+  def setUp(self):
+    self.project_fds = [
+        tracker_pb2.FieldDef(
+            field_id=1, project_id=789, field_name='String',
+            field_type=tracker_pb2.FieldTypes.STR_TYPE),
+        tracker_pb2.FieldDef(
+            field_id=2, project_id=789, field_name='Chrome-UX',
+            field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE),
+        tracker_pb2.FieldDef(
+            field_id=3, project_id=789, field_name='Chrome-Privacy',
+            field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE)
+        ]
+    approvalUX = tracker_pb2.ApprovalValue(
+        approval_id=2, status=tracker_pb2.ApprovalStatus.NEEDS_REVIEW)
+    approvalPrivacy = tracker_pb2.ApprovalValue(approval_id=3)
+    self.approvals = [approvalUX, approvalPrivacy]
+
+  def testConvertLaunchLabels_Normal(self):
+    labels = [
+        'Launch-UX-NotReviewed', 'Launch-Privacy-Yes', 'Launch-NotRelevant']
+    actual = fltconversion.ConvertLaunchLabels(
+        labels, self.approvals, self.project_fds,
+        fltconversion.BROWSER_APPROVALS_TO_LABELS)
+    expected = [
+      tracker_pb2.ApprovalValue(
+          approval_id=2, status=tracker_pb2.ApprovalStatus.NEEDS_REVIEW),
+      tracker_pb2.ApprovalValue(
+          approval_id=3, status=tracker_pb2.ApprovalStatus.APPROVED)
+    ]
+    self.assertEqual(actual, expected)
+
+  def testConvertLaunchLabels_ExtraAndMissingLabels(self):
+    labels = [
+        'Blah-Launch-Privacy-Yes',  # Missing, this is not a valid Label
+        'Launch-Security-Yes',  # Extra, no matching approval in given approvals
+        'Launch-UI-Yes']  # Missing Launch-Privacy
+    actual = fltconversion.ConvertLaunchLabels(
+        labels, self.approvals, self.project_fds,
+        fltconversion.BROWSER_APPROVALS_TO_LABELS)
+    expected = [
+        tracker_pb2.ApprovalValue(
+            approval_id=2, status=tracker_pb2.ApprovalStatus.APPROVED),
+      tracker_pb2.ApprovalValue(
+          approval_id=3, status=tracker_pb2.ApprovalStatus.NOT_SET)
+        ]
+    self.assertEqual(actual, expected)
+
+class ExtractLabelLDAPs(unittest.TestCase):
+
+  def testExtractLabelLDAPs_Normal(self):
+    labels = [
+        'tl-USER1',
+        'pm-',
+        'tL-User2',
+        'test-user4',
+        'PM-USER3',
+        'pm',
+        'test-user5',
+        'test-',
+        'ux-user9']
+    (actual_pm, actual_tl, actual_tests,
+     actual_ux) = fltconversion.ExtractLabelLDAPs(labels)
+    self.assertEqual(actual_pm, 'user3')
+    self.assertEqual(actual_tl, 'user2')
+    self.assertEqual(actual_tests, ['user4', 'user5'])
+    self.assertEqual(actual_ux, ['user9'])
+
+  def testExtractLabelLDAPs_NoLabels(self):
+    (actual_pm, actual_tl, actual_tests,
+     actual_ux) = fltconversion.ExtractLabelLDAPs([])
+    self.assertIsNone(actual_pm)
+    self.assertIsNone(actual_tl)
+    self.assertFalse(len(actual_tests))
+    self.assertFalse(len(actual_ux))
diff --git a/tracker/test/issueadmin_test.py b/tracker/test/issueadmin_test.py
new file mode 100644
index 0000000..751e414
--- /dev/null
+++ b/tracker/test/issueadmin_test.py
@@ -0,0 +1,464 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Tests for the issue admin pages."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import mox
+import unittest
+
+from mock import Mock, patch
+
+from framework import permissions
+from framework import urls
+from proto import tracker_pb2
+from services import service_manager
+from services import template_svc
+from testing import fake
+from testing import testing_helpers
+from tracker import issueadmin
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+
+
+class TestBase(unittest.TestCase):
+
+  def setUpServlet(self, servlet_factory):
+    # pylint: disable=attribute-defined-outside-init
+    self.services = service_manager.Services(
+        project=fake.ProjectService(),
+        config=fake.ConfigService(),
+        user=fake.UserService(),
+        issue=fake.IssueService(),
+        template=Mock(spec=template_svc.TemplateService),
+        features=fake.FeaturesService())
+    self.servlet = servlet_factory('req', 'res', services=self.services)
+    self.project = self.services.project.TestAddProject(
+        'proj', project_id=789, contrib_ids=[333])
+    self.config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    self.services.config.StoreConfig(None, self.config)
+    self.cnxn = fake.MonorailConnection()
+    self.mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/admin', project=self.project)
+    self.mox = mox.Mox()
+    self.test_template = tracker_bizobj.MakeIssueTemplate(
+        'Test Template', 'sum', 'New', 111, 'content', [], [], [], [])
+    self.test_template.template_id = 12345
+    self.test_templates = testing_helpers.DefaultTemplates()
+    self.test_templates.append(self.test_template)
+    self.services.template.GetProjectTemplates\
+        .return_value = self.test_templates
+    self.services.template.GetTemplateSetForProject\
+        .return_value = [(12345, 'Test template', 0)]
+
+  def tearDown(self):
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def _mockGetUser(self):
+    self.mox.StubOutWithMock(self.services.user, 'GetUser')
+    user = self.services.user.TestAddUser('user@invalid', 100)
+    self.services.user.GetUser(
+        mox.IgnoreArg(), mox.IgnoreArg()).MultipleTimes().AndReturn(user)
+
+
+class IssueAdminBaseTest(TestBase):
+
+  def setUp(self):
+    super(IssueAdminBaseTest, self).setUpServlet(issueadmin.IssueAdminBase)
+
+  def testGatherPageData(self):
+    self._mockGetUser()
+    self.mox.ReplayAll()
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.mox.VerifyAll()
+
+    self.assertItemsEqual(
+        ['admin_tab_mode', 'config', 'open_text', 'closed_text', 'labels_text'],
+        list(page_data.keys()))
+    config_view = page_data['config']
+    self.assertEqual(789, config_view.project_id)
+
+
+class AdminStatusesTest(TestBase):
+
+  def setUp(self):
+    super(AdminStatusesTest, self).setUpServlet(issueadmin.AdminStatuses)
+
+  @patch('framework.servlet.Servlet.PleaseCorrect')
+  def testProcessSubtabForm_MissingInput(self, mock_pc):
+    post_data = fake.PostData()
+    next_url = self.servlet.ProcessSubtabForm(post_data, self.mr)
+    self.assertIsNone(next_url)
+    mock_pc.assert_called_once()
+    self.assertEqual(len(tracker_constants.DEFAULT_WELL_KNOWN_STATUSES),
+                     len(self.config.well_known_statuses))
+    self.assertEqual(tracker_constants.DEFAULT_STATUSES_OFFER_MERGE,
+                     self.config.statuses_offer_merge)
+
+  @patch('framework.servlet.Servlet.PleaseCorrect')
+  def testProcessSubtabForm_EmptyInput(self, mock_pc):
+    post_data = fake.PostData(
+        predefinedopen=[''], predefinedclosed=[''], statuses_offer_merge=[''])
+    next_url = self.servlet.ProcessSubtabForm(post_data, self.mr)
+    self.assertIsNone(next_url)
+    mock_pc.assert_called_once()
+    self.assertEqual(len(tracker_constants.DEFAULT_WELL_KNOWN_STATUSES),
+                     len(self.config.well_known_statuses))
+    self.assertEqual(tracker_constants.DEFAULT_STATUSES_OFFER_MERGE,
+                     self.config.statuses_offer_merge)
+
+  def testProcessSubtabForm_Normal(self):
+    post_data = fake.PostData(
+        predefinedopen=['New = newly reported'],
+        predefinedclosed=['Fixed\nDuplicate'],
+        statuses_offer_merge=['Duplicate'])
+    next_url = self.servlet.ProcessSubtabForm(post_data, self.mr)
+    self.assertEqual(urls.ADMIN_STATUSES, next_url)
+    self.assertEqual(3, len(self.config.well_known_statuses))
+    self.assertEqual('New', self.config.well_known_statuses[0].status)
+    self.assertTrue(self.config.well_known_statuses[0].means_open)
+    self.assertEqual('Fixed', self.config.well_known_statuses[1].status)
+    self.assertFalse(self.config.well_known_statuses[1].means_open)
+    self.assertEqual('Duplicate', self.config.well_known_statuses[2].status)
+    self.assertFalse(self.config.well_known_statuses[2].means_open)
+    self.assertEqual(['Duplicate'], self.config.statuses_offer_merge)
+
+
+class AdminLabelsTest(TestBase):
+
+  def setUp(self):
+    super(AdminLabelsTest, self).setUpServlet(issueadmin.AdminLabels)
+
+  def testGatherPageData(self):
+    self._mockGetUser()
+    self.mox.ReplayAll()
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.mox.VerifyAll()
+
+    self.assertItemsEqual(
+        ['admin_tab_mode', 'config', 'field_defs',
+         'open_text', 'closed_text', 'labels_text'],
+        list(page_data.keys()))
+    config_view = page_data['config']
+    self.assertEqual(789, config_view.project_id)
+    self.assertEqual([], page_data['field_defs'])
+
+  @patch('framework.servlet.Servlet.PleaseCorrect')
+  def testProcessSubtabForm_MissingInput(self, mock_pc):
+    post_data = fake.PostData()
+    next_url = self.servlet.ProcessSubtabForm(post_data, self.mr)
+    self.assertIsNone(next_url)
+    mock_pc.assert_called_once()
+    self.assertEqual(len(tracker_constants.DEFAULT_WELL_KNOWN_LABELS),
+                     len(self.config.well_known_labels))
+    self.assertEqual(tracker_constants.DEFAULT_EXCL_LABEL_PREFIXES,
+                     self.config.exclusive_label_prefixes)
+
+  @patch('framework.servlet.Servlet.PleaseCorrect')
+  def testProcessSubtabForm_EmptyInput(self, mock_pc):
+    post_data = fake.PostData(
+        predefinedlabels=[''], excl_prefixes=[''])
+    next_url = self.servlet.ProcessSubtabForm(post_data, self.mr)
+    self.assertIsNone(next_url)  # Because PleaseCorrect() was called.
+    mock_pc.assert_called_once()
+    self.assertEqual(len(tracker_constants.DEFAULT_WELL_KNOWN_LABELS),
+                     len(self.config.well_known_labels))
+    self.assertEqual(tracker_constants.DEFAULT_EXCL_LABEL_PREFIXES,
+                     self.config.exclusive_label_prefixes)
+
+  def testProcessSubtabForm_Normal(self):
+    post_data = fake.PostData(
+        predefinedlabels=['Pri-0 = Burning issue\nPri-4 = It can wait'],
+        excl_prefixes=['pri'])
+    next_url = self.servlet.ProcessSubtabForm(post_data, self.mr)
+    self.assertEqual(urls.ADMIN_LABELS, next_url)
+    self.assertEqual(2, len(self.config.well_known_labels))
+    self.assertEqual('Pri-0', self.config.well_known_labels[0].label)
+    self.assertEqual('Pri-4', self.config.well_known_labels[1].label)
+    self.assertEqual(['pri'], self.config.exclusive_label_prefixes)
+
+  @patch('framework.servlet.Servlet.PleaseCorrect')
+  def testProcessSubtabForm_Duplicates(self, mock_pc):
+    post_data = fake.PostData(
+        predefinedlabels=['Pri-0\nPri-4\npri-0'],
+        excl_prefixes=['pri'])
+    next_url = self.servlet.ProcessSubtabForm(post_data, self.mr)
+    self.assertIsNone(next_url)
+    mock_pc.assert_called_once()
+    self.assertEqual(
+        'Duplicate label: pri-0',
+        self.mr.errors.label_defs)
+
+  @patch('framework.servlet.Servlet.PleaseCorrect')
+  def testProcessSubtabForm_Conflict(self, mock_pc):
+    post_data = fake.PostData(
+        predefinedlabels=['Multi-Part-One\nPri-4\npri-0'],
+        excl_prefixes=['pri'])
+    self.config.field_defs = [
+        tracker_pb2.FieldDef(
+            field_name='Multi-Part',
+            field_type=tracker_pb2.FieldTypes.ENUM_TYPE)]
+    next_url = self.servlet.ProcessSubtabForm(post_data, self.mr)
+    self.assertIsNone(next_url)
+    mock_pc.assert_called_once()
+    self.assertEqual(
+        'Label "Multi-Part-One" should be defined in enum "multi-part"',
+        self.mr.errors.label_defs)
+
+
+class AdminTemplatesTest(TestBase):
+
+  def setUp(self):
+    super(AdminTemplatesTest, self).setUpServlet(issueadmin.AdminTemplates)
+    self.mr.auth.user_id = 333
+    self.mr.auth.effective_ids = {333}
+
+  def testGatherPageData(self):
+    self._mockGetUser()
+    self.mox.ReplayAll()
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.mox.VerifyAll()
+
+    config_view = page_data['config']
+    self.assertEqual(789, config_view.project_id)
+
+  def testProcessSubtabForm_NoEditProjectPerm(self):
+    """If user lacks perms, raise an exception."""
+    post_data = fake.PostData(
+        default_template_for_developers=['Test Template'],
+        default_template_for_users=['Test Template'])
+    self.mr.perms = permissions.EMPTY_PERMISSIONSET
+    self.assertRaises(
+        permissions.PermissionException,
+        self.servlet.ProcessSubtabForm, post_data, self.mr)
+    self.assertEqual(0, self.config.default_template_for_developers)
+    self.assertEqual(0, self.config.default_template_for_users)
+
+  def testProcessSubtabForm_Normal(self):
+    """If user has perms, set default templates."""
+    post_data = fake.PostData(
+        default_template_for_developers=['Test Template'],
+        default_template_for_users=['Test Template'])
+    next_url = self.servlet.ProcessSubtabForm(post_data, self.mr)
+    self.assertEqual(urls.ADMIN_TEMPLATES, next_url)
+    self.assertEqual(12345, self.config.default_template_for_developers)
+    self.assertEqual(12345, self.config.default_template_for_users)
+
+  def testParseDefaultTemplateSelections_NotSpecified(self):
+    post_data = fake.PostData()
+    for_devs, for_users = self.servlet._ParseDefaultTemplateSelections(
+        post_data, self.test_templates)
+    self.assertEqual(None, for_devs)
+    self.assertEqual(None, for_users)
+
+  def testParseDefaultTemplateSelections_TemplateNotFoundIsIgnored(self):
+    post_data = fake.PostData(
+        default_template_for_developers=['Bad value'],
+        default_template_for_users=['Bad value'])
+    for_devs, for_users = self.servlet._ParseDefaultTemplateSelections(
+        post_data, self.test_templates)
+    self.assertEqual(None, for_devs)
+    self.assertEqual(None, for_users)
+
+  def testParseDefaultTemplateSelections_Normal(self):
+    post_data = fake.PostData(
+        default_template_for_developers=['Test Template'],
+        default_template_for_users=['Test Template'])
+    for_devs, for_users = self.servlet._ParseDefaultTemplateSelections(
+        post_data, self.test_templates)
+    self.assertEqual(12345, for_devs)
+    self.assertEqual(12345, for_users)
+
+
+class AdminComponentsTest(TestBase):
+
+  def setUp(self):
+    super(AdminComponentsTest, self).setUpServlet(issueadmin.AdminComponents)
+    self.cd_clean = tracker_bizobj.MakeComponentDef(
+        1, self.project.project_id, 'BackEnd', 'doc', False, [], [111], 100000,
+        122, 10000000, 133)
+    self.cd_with_subcomp = tracker_bizobj.MakeComponentDef(
+        2, self.project.project_id, 'FrontEnd', 'doc', False, [], [111],
+        100000, 122, 10000000, 133)
+    self.subcd = tracker_bizobj.MakeComponentDef(
+        3, self.project.project_id, 'FrontEnd>Worker', 'doc', False, [], [111],
+        100000, 122, 10000000, 133)
+    self.cd_with_template = tracker_bizobj.MakeComponentDef(
+        4, self.project.project_id, 'Middle', 'doc', False, [], [111],
+        100000, 122, 10000000, 133)
+
+  def testGatherPageData(self):
+    self._mockGetUser()
+    self.mox.ReplayAll()
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.mox.VerifyAll()
+    self.assertItemsEqual(
+        ['admin_tab_mode', 'failed_templ', 'component_defs', 'failed_perm',
+         'config', 'failed_subcomp',
+         'open_text', 'closed_text', 'labels_text'],
+        list(page_data.keys()))
+    config_view = page_data['config']
+    self.assertEqual(789, config_view.project_id)
+    self.assertEqual([], page_data['component_defs'])
+
+  def testProcessFormData_NoErrors(self):
+    self.config.component_defs = [
+        self.cd_clean, self.cd_with_subcomp, self.subcd, self.cd_with_template]
+    self.services.template.TemplatesWithComponent.return_value = []
+    post_data = {
+        'delete_components' : '%s,%s,%s' % (
+            self.cd_clean.path, self.cd_with_subcomp.path, self.subcd.path)}
+    url = self.servlet.ProcessFormData(self.mr, post_data)
+    self.assertTrue(
+        url.startswith('http://127.0.0.1/p/proj/adminComponents?deleted='
+                       'FrontEnd%3EWorker%2CFrontEnd%2CBackEnd&failed_perm=&'
+                       'failed_subcomp=&failed_templ=&ts='))
+
+  def testProcessFormData_SubCompError(self):
+    self.config.component_defs = [
+        self.cd_clean, self.cd_with_subcomp, self.subcd, self.cd_with_template]
+    self.services.template.TemplatesWithComponent.return_value = []
+    post_data = {
+        'delete_components' : '%s,%s' % (
+            self.cd_clean.path, self.cd_with_subcomp.path)}
+    url = self.servlet.ProcessFormData(self.mr, post_data)
+    self.assertTrue(
+        url.startswith('http://127.0.0.1/p/proj/adminComponents?deleted='
+                       'BackEnd&failed_perm=&failed_subcomp=FrontEnd&'
+                       'failed_templ=&ts='))
+
+  def testProcessFormData_TemplateError(self):
+    self.config.component_defs = [
+        self.cd_clean, self.cd_with_subcomp, self.subcd, self.cd_with_template]
+
+    def mockTemplatesWithComponent(_cnxn, component_id):
+      if component_id == 4:
+        return 'template'
+    self.services.template.TemplatesWithComponent\
+        .side_effect = mockTemplatesWithComponent
+
+    post_data = {
+        'delete_components' : '%s,%s,%s,%s' % (
+            self.cd_clean.path, self.cd_with_subcomp.path, self.subcd.path,
+            self.cd_with_template.path)}
+    url = self.servlet.ProcessFormData(self.mr, post_data)
+    self.assertTrue(
+        url.startswith('http://127.0.0.1/p/proj/adminComponents?deleted='
+                       'FrontEnd%3EWorker%2CFrontEnd%2CBackEnd&failed_perm=&'
+                       'failed_subcomp=&failed_templ=Middle&ts='))
+
+
+class AdminViewsTest(TestBase):
+
+  def setUp(self):
+    super(AdminViewsTest, self).setUpServlet(issueadmin.AdminViews)
+
+  def testGatherPageData(self):
+    self._mockGetUser()
+    self.mox.ReplayAll()
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.mox.VerifyAll()
+
+    self.assertItemsEqual(
+        ['canned_queries', 'admin_tab_mode', 'config', 'issue_notify',
+         'new_query_indexes', 'max_queries',
+         'open_text', 'closed_text', 'labels_text'],
+        list(page_data.keys()))
+    config_view = page_data['config']
+    self.assertEqual(789, config_view.project_id)
+
+  def testProcessSubtabForm(self):
+    post_data = fake.PostData(
+        default_col_spec=['id pri mstone owner status summary'],
+        default_sort_spec=['mstone pri'],
+        default_x_attr=['owner'], default_y_attr=['mstone'])
+    next_url = self.servlet.ProcessSubtabForm(post_data, self.mr)
+    self.assertEqual(urls.ADMIN_VIEWS, next_url)
+    self.assertEqual(
+        'id pri mstone owner status summary', self.config.default_col_spec)
+    self.assertEqual('mstone pri', self.config.default_sort_spec)
+    self.assertEqual('owner', self.config.default_x_attr)
+    self.assertEqual('mstone', self.config.default_y_attr)
+
+
+class AdminViewsFunctionsTest(unittest.TestCase):
+
+  def testParseListPreferences(self):
+    # If no input, col_spec will be default column spec.
+    # For other fiels empty strings should be returned.
+    (col_spec, sort_spec, x_attr, y_attr, member_default_query,
+     ) = issueadmin._ParseListPreferences({})
+    self.assertEqual(tracker_constants.DEFAULT_COL_SPEC, col_spec)
+    self.assertEqual('', sort_spec)
+    self.assertEqual('', x_attr)
+    self.assertEqual('', y_attr)
+    self.assertEqual('', member_default_query)
+
+    # Test how hyphens in input are treated.
+    spec = 'label1-sub1  label2  label3-sub3'
+    (col_spec, sort_spec, x_attr, y_attr, member_default_query,
+     ) = issueadmin._ParseListPreferences(
+        fake.PostData(default_col_spec=[spec],
+                      default_sort_spec=[spec],
+                      default_x_attr=[spec],
+                      default_y_attr=[spec]),
+        )
+
+    # Hyphens (and anything following) should be stripped from each term.
+    self.assertEqual('label1-sub1 label2 label3-sub3', col_spec)
+
+    # The sort spec should be as given (except with whitespace condensed).
+    self.assertEqual(' '.join(spec.split()), sort_spec)
+
+    # Only the first term (up to the first hyphen) should be used for x- or
+    # y-attr.
+    self.assertEqual('label1-sub1', x_attr)
+    self.assertEqual('label1-sub1', y_attr)
+
+    # Test that multibyte strings are not mangled.
+    spec = ('\xe7\xaa\xbf\xe8\x8b\xa5-\xe7\xb9\xb9 '
+            '\xe5\x9c\xb0\xe3\x81\xa6-\xe5\xbd\x93-\xe3\x81\xbe\xe3\x81\x99')
+    spec = spec.decode('utf-8')
+    (col_spec, sort_spec, x_attr, y_attr, member_default_query,
+     ) = issueadmin._ParseListPreferences(
+        fake.PostData(default_col_spec=[spec],
+                      default_sort_spec=[spec],
+                      default_x_attr=[spec],
+                      default_y_attr=[spec],
+                      member_default_query=[spec]),
+        )
+    self.assertEqual(spec, col_spec)
+    self.assertEqual(' '.join(spec.split()), sort_spec)
+    self.assertEqual('\xe7\xaa\xbf\xe8\x8b\xa5-\xe7\xb9\xb9'.decode('utf-8'),
+                     x_attr)
+    self.assertEqual('\xe7\xaa\xbf\xe8\x8b\xa5-\xe7\xb9\xb9'.decode('utf-8'),
+                     y_attr)
+    self.assertEqual(spec, member_default_query)
+
+
+class AdminRulesTest(TestBase):
+
+  def setUp(self):
+    super(AdminRulesTest, self).setUpServlet(issueadmin.AdminRules)
+
+  def testGatherPageData(self):
+    self._mockGetUser()
+    self.mox.ReplayAll()
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.mox.VerifyAll()
+
+    self.assertItemsEqual(
+        ['admin_tab_mode', 'config', 'rules', 'new_rule_indexes',
+         'max_rules', 'open_text', 'closed_text', 'labels_text'],
+        list(page_data.keys()))
+    config_view = page_data['config']
+    self.assertEqual(789, config_view.project_id)
+    self.assertEqual([], page_data['rules'])
+
+  def testProcessSubtabForm(self):
+    pass  # TODO(jrobbins): write this test
diff --git a/tracker/test/issueadvsearch_test.py b/tracker/test/issueadvsearch_test.py
new file mode 100644
index 0000000..fd1ee2e
--- /dev/null
+++ b/tracker/test/issueadvsearch_test.py
@@ -0,0 +1,78 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Tests for monorail.tracker.issueadvsearch."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+from tracker import issueadvsearch
+
+class IssueAdvSearchTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        config=fake.ConfigService(),
+        features=fake.FeaturesService(),
+        issue=fake.IssueService(),
+        user=fake.UserService(),
+        project=fake.ProjectService())
+    self.project = self.services.project.TestAddProject('proj', project_id=987)
+    self.servlet = issueadvsearch.IssueAdvancedSearch(
+        'req', 'res', services=self.services)
+
+  def testGatherData(self):
+    mr = testing_helpers.MakeMonorailRequest(
+      path='/p/proj/issues/advsearch')
+    page_data = self.servlet.GatherPageData(mr)
+
+    self.assertTrue('issue_tab_mode' in page_data)
+    self.assertTrue('page_perms' in page_data)
+
+  def testProcessFormData(self):
+    mr = testing_helpers.MakeMonorailRequest(
+      path='/p/proj/issues/advsearch')
+    post_data = {}
+    url = self.servlet.ProcessFormData(mr, post_data)
+    self.assertTrue('can=2' in url)
+
+    post_data['can'] = 42
+    url = self.servlet.ProcessFormData(mr, post_data)
+    self.assertTrue('can=42' in url)
+
+    post_data['starcount'] = 42
+    url = self.servlet.ProcessFormData(mr, post_data)
+    self.assertTrue('starcount%3A42' in url)
+
+    post_data['starcount'] = -1
+    url = self.servlet.ProcessFormData(mr, post_data)
+    self.assertTrue('starcount' not in url)
+
+  def _testAND(self, operator, field, post_data, query):
+    self.servlet._AccumulateANDTerm(operator, field, post_data, query)
+    return query
+
+  def test_AccumulateANDTerm(self):
+    query = self._testAND('', 'foo', {'foo': 'bar'}, [])
+    self.assertEqual(['bar'], query)
+
+    query = self._testAND('', 'bar', {'bar': 'baz=zippy'}, query)
+    self.assertEqual(['bar', 'baz', 'zippy'], query)
+
+  def _testOR(self, operator, field, post_data, query):
+    self.servlet._AccumulateORTerm(operator, field, post_data, query)
+    return query
+
+  def test_AccumulateORTerm(self):
+    query = self._testOR('', 'foo', {'foo': 'bar'}, [])
+    self.assertEqual(['bar'], query)
+
+    query = self._testOR('', 'bar', {'bar': 'baz=zippy'}, query)
+    self.assertEqual(['bar', 'baz,zippy'], query)
diff --git a/tracker/test/issueattachment_test.py b/tracker/test/issueattachment_test.py
new file mode 100644
index 0000000..8c65014
--- /dev/null
+++ b/tracker/test/issueattachment_test.py
@@ -0,0 +1,198 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Tests for monorail.tracker.issueattachment."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from google.appengine.api import images
+from google.appengine.ext import testbed
+
+import mox
+import webapp2
+
+from framework import gcs_helpers
+from framework import permissions
+from framework import servlet
+from proto import tracker_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+from tracker import attachment_helpers
+from tracker import issueattachment
+from tracker import tracker_helpers
+
+from third_party import cloudstorage
+
+
+class IssueattachmentTest(unittest.TestCase):
+
+  def setUp(self):
+    self.mox = mox.Mox()
+    self.testbed = testbed.Testbed()
+    self.testbed.activate()
+    self.testbed.init_memcache_stub()
+    self.testbed.init_app_identity_stub()
+    self.testbed.init_urlfetch_stub()
+    self.attachment_data = ""
+
+    self._old_gcs_open = cloudstorage.open
+    cloudstorage.open = fake.gcs_open
+
+    services = service_manager.Services(
+        project=fake.ProjectService(),
+        config=fake.ConfigService(),
+        issue=fake.IssueService(),
+        user=fake.UserService())
+    self.project = services.project.TestAddProject('proj')
+    self.servlet = issueattachment.AttachmentPage(
+        'req', webapp2.Response(), services=services)
+    services.user.TestAddUser('commenter@example.com', 111)
+    self.issue = fake.MakeTestIssue(
+        self.project.project_id, 1, 'summary', 'New', 111)
+    services.issue.TestAddIssue(self.issue)
+    self.comment = tracker_pb2.IssueComment(
+        id=123, issue_id=self.issue.issue_id,
+        project_id=self.project.project_id, user_id=111,
+        content='this is a comment')
+    services.issue.TestAddComment(self.comment, self.issue.local_id)
+    self.attachment = tracker_pb2.Attachment(
+        attachment_id=54321, filename='hello.txt', filesize=23432,
+        mimetype='text/plain', gcs_object_id='/pid/attachments/object_id')
+    services.issue.TestAddAttachment(
+        self.attachment, self.comment.id, self.issue.issue_id)
+    self.orig_sign_attachment_id = attachment_helpers.SignAttachmentID
+    attachment_helpers.SignAttachmentID = (
+        lambda aid: 'signed_%d' % aid)
+
+  def tearDown(self):
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+    self.testbed.deactivate()
+    cloudstorage.open = self._old_gcs_open
+    attachment_helpers.SignAttachmentID = self.orig_sign_attachment_id
+
+  def testGatherPageData_NotFound(self):
+    aid = 12345
+    path = '/p/proj/issues/attachment?aid=%s&signed_aid=signed_%d' % (
+        aid, aid)
+    # But, no such attachment is in the database.
+    _request, mr = testing_helpers.GetRequestObjects(
+        project=self.project, path=path,
+        perms=permissions.EMPTY_PERMISSIONSET)
+    with self.assertRaises(webapp2.HTTPException) as cm:
+      self.servlet.GatherPageData(mr)
+    self.assertEqual(404, cm.exception.code)
+
+  # TODO(jrobbins): test cases for missing comment and missing issue.
+
+  def testGatherPageData_PermissionDenied(self):
+    aid = self.attachment.attachment_id
+    path = '/p/proj/issues/attachment?aid=%s&signed_aid=signed_%d' % (
+        aid, aid)
+    _request, mr = testing_helpers.GetRequestObjects(
+        project=self.project, path=path,
+        perms=permissions.EMPTY_PERMISSIONSET)  # not even VIEW
+    self.assertRaises(
+        permissions.PermissionException,
+        self.servlet.GatherPageData, mr)
+
+    _request, mr = testing_helpers.GetRequestObjects(
+        project=self.project, path=path,
+        perms=permissions.READ_ONLY_PERMISSIONSET)  # includes VIEW
+
+    # issue is now deleted
+    self.issue.deleted = True
+    self.assertRaises(
+        permissions.PermissionException,
+        self.servlet.GatherPageData, mr)
+    self.issue.deleted = False
+
+    # issue is now restricted
+    self.issue.labels.extend(['Restrict-View-PermYouLack'])
+    self.assertRaises(
+        permissions.PermissionException,
+        self.servlet.GatherPageData, mr)
+
+  def testGatherPageData_Download_WithDisposition(self):
+    aid = self.attachment.attachment_id
+    self.mox.StubOutWithMock(gcs_helpers, 'MaybeCreateDownload')
+    gcs_helpers.MaybeCreateDownload(
+        'app_default_bucket',
+        '/pid/attachments/object_id',
+        self.attachment.filename).AndReturn(True)
+    self.mox.StubOutWithMock(gcs_helpers, 'SignUrl')
+    gcs_helpers.SignUrl(
+        'app_default_bucket',
+        '/pid/attachments/object_id-download'
+        ).AndReturn('googleusercontent.com/...-download...')
+    self.mox.StubOutWithMock(self.servlet, 'redirect')
+    path = '/p/proj/issues/attachment?aid=%s&signed_aid=signed_%d' % (
+        aid, aid)
+    _request, mr = testing_helpers.GetRequestObjects(
+        project=self.project, path=path,
+        perms=permissions.READ_ONLY_PERMISSIONSET)  # includes VIEW
+    self.servlet.redirect(
+      mox.And(mox.StrContains('googleusercontent.com'),
+              mox.StrContains('-download')), abort=True)
+    self.mox.ReplayAll()
+    self.servlet.GatherPageData(mr)
+    self.mox.VerifyAll()
+
+  def testGatherPageData_Download_WithoutDisposition(self):
+    aid = self.attachment.attachment_id
+    path = '/p/proj/issues/attachment?aid=%s&signed_aid=signed_%d' % (
+        aid, aid)
+    self.mox.StubOutWithMock(gcs_helpers, 'MaybeCreateDownload')
+    gcs_helpers.MaybeCreateDownload(
+        'app_default_bucket',
+        '/pid/attachments/object_id',
+        self.attachment.filename).AndReturn(False)
+    self.mox.StubOutWithMock(gcs_helpers, 'SignUrl')
+    gcs_helpers.SignUrl(
+        'app_default_bucket',
+        '/pid/attachments/object_id'
+        ).AndReturn('googleusercontent.com/...')
+    self.mox.StubOutWithMock(self.servlet, 'redirect')
+    _request, mr = testing_helpers.GetRequestObjects(
+        project=self.project, path=path,
+        perms=permissions.READ_ONLY_PERMISSIONSET)  # includes VIEW
+    self.servlet.redirect(
+      mox.And(mox.StrContains('googleusercontent.com'),
+              mox.Not(mox.StrContains('-download'))), abort=True)
+    self.mox.ReplayAll()
+    self.servlet.GatherPageData(mr)
+    self.mox.VerifyAll()
+
+  def testGatherPageData_DownloadBadFilename(self):
+    aid = self.attachment.attachment_id
+    path = '/p/proj/issues/attachment?aid=%s&signed_aid=signed_%d' % (
+        aid, aid)
+    self.attachment.filename = '<script>alert("xsrf")</script>.txt';
+    safe_filename = 'attachment-%d.dat' % aid
+    self.mox.StubOutWithMock(gcs_helpers, 'MaybeCreateDownload')
+    gcs_helpers.MaybeCreateDownload(
+        'app_default_bucket',
+        '/pid/attachments/object_id',
+        safe_filename).AndReturn(True)
+    self.mox.StubOutWithMock(gcs_helpers, 'SignUrl')
+    gcs_helpers.SignUrl(
+        'app_default_bucket',
+        '/pid/attachments/object_id-download'
+        ).AndReturn('googleusercontent.com/...-download...')
+    self.mox.StubOutWithMock(self.servlet, 'redirect')
+    _request, mr = testing_helpers.GetRequestObjects(
+        project=self.project,
+        path=path,
+        perms=permissions.READ_ONLY_PERMISSIONSET)  # includes VIEW
+    self.servlet.redirect(mox.And(
+        mox.Not(mox.StrContains(self.attachment.filename)),
+        mox.StrContains('googleusercontent.com')), abort=True)
+    self.mox.ReplayAll()
+    self.servlet.GatherPageData(mr)
+    self.mox.VerifyAll()
diff --git a/tracker/test/issueattachmenttext_test.py b/tracker/test/issueattachmenttext_test.py
new file mode 100644
index 0000000..187aa42
--- /dev/null
+++ b/tracker/test/issueattachmenttext_test.py
@@ -0,0 +1,191 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Tests for issueattachmenttext."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import unittest
+from mock import patch
+
+from google.appengine.ext import testbed
+
+from third_party import cloudstorage
+import ezt
+
+import webapp2
+
+from framework import filecontent
+from framework import permissions
+from proto import tracker_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+from tracker import issueattachmenttext
+
+
+class IssueAttachmentTextTest(unittest.TestCase):
+
+  def setUp(self):
+    self.testbed = testbed.Testbed()
+    self.testbed.activate()
+    self.testbed.init_app_identity_stub()
+
+    services = service_manager.Services(
+        project=fake.ProjectService(),
+        config=fake.ConfigService(),
+        issue=fake.IssueService(),
+        user=fake.UserService())
+    self.project = services.project.TestAddProject('proj')
+    self.servlet = issueattachmenttext.AttachmentText(
+        'req', 'res', services=services)
+
+    services.user.TestAddUser('commenter@example.com', 111)
+
+    self.issue = tracker_pb2.Issue()
+    self.issue.local_id = 1
+    self.issue.issue_id = 1
+    self.issue.summary = 'sum'
+    self.issue.project_name = 'proj'
+    self.issue.project_id = self.project.project_id
+    services.issue.TestAddIssue(self.issue)
+
+    self.comment0 = tracker_pb2.IssueComment()
+    self.comment0.content = 'this is the description'
+    self.comment0.user_id = 111
+    self.comment1 = tracker_pb2.IssueComment()
+    self.comment1.content = 'this is a comment'
+    self.comment1.user_id = 111
+
+    self.attach0 = tracker_pb2.Attachment(
+        attachment_id=4567, filename='b.txt', mimetype='text/plain',
+        gcs_object_id='/pid/attachments/abcd')
+    self.comment0.attachments.append(self.attach0)
+
+    self.attach1 = tracker_pb2.Attachment(
+        attachment_id=1234, filename='a.txt', mimetype='text/plain',
+        gcs_object_id='/pid/attachments/abcdefg')
+    self.comment0.attachments.append(self.attach1)
+
+    self.bin_attach = tracker_pb2.Attachment(
+        attachment_id=2468, mimetype='application/octets',
+        gcs_object_id='/pid/attachments/\0\0\0\0\0\1\2\3')
+    self.comment1.attachments.append(self.bin_attach)
+
+    self.comment0.project_id = self.project.project_id
+    services.issue.TestAddComment(self.comment0, self.issue.local_id)
+    self.comment1.project_id = self.project.project_id
+    services.issue.TestAddComment(self.comment1, self.issue.local_id)
+    services.issue.TestAddAttachment(
+        self.attach0, self.comment0.id, self.issue.issue_id)
+    services.issue.TestAddAttachment(
+        self.attach1, self.comment1.id, self.issue.issue_id)
+    # TODO(jrobbins): add tests for binary content
+    self._old_gcs_open = cloudstorage.open
+    cloudstorage.open = fake.gcs_open
+
+  def tearDown(self):
+    self.testbed.deactivate()
+    cloudstorage.open = self._old_gcs_open
+
+  def testGatherPageData_CommentDeleted(self):
+    """If the attachment's comment was deleted, give a 403."""
+    _request, mr = testing_helpers.GetRequestObjects(
+        project=self.project,
+        path='/a/d.com/p/proj/issues/attachmentText?aid=1234',
+        perms=permissions.READ_ONLY_PERMISSIONSET)
+    self.servlet.GatherPageData(mr)  # OK
+    self.comment1.deleted_by = 111
+    self.assertRaises(  # 403
+        permissions.PermissionException,
+        self.servlet.GatherPageData, mr)
+
+  def testGatherPageData_IssueNotViewable(self):
+    """If the attachment's issue is not viewable, give a 403."""
+    _request, mr = testing_helpers.GetRequestObjects(
+        project=self.project,
+        path='/p/proj/issues/attachment?aid=1234',
+        perms=permissions.EMPTY_PERMISSIONSET)  # No VIEW
+    self.assertRaises(
+        permissions.PermissionException,
+        self.servlet.GatherPageData, mr)
+
+  def testGatherPageData_IssueDeleted(self):
+    _request, mr = testing_helpers.GetRequestObjects(
+        project=self.project,
+        path='/p/proj/issues/attachment?aid=1234',
+        perms=permissions.READ_ONLY_PERMISSIONSET)
+    self.issue.deleted = True
+    self.assertRaises(  # Issue was deleted
+        permissions.PermissionException,
+        self.servlet.GatherPageData, mr)
+
+  def testGatherPageData_IssueRestricted(self):
+    _request, mr = testing_helpers.GetRequestObjects(
+        project=self.project,
+        path='/p/proj/issues/attachment?aid=1234',
+        perms=permissions.READ_ONLY_PERMISSIONSET)
+    self.issue.labels.append('Restrict-View-Nobody')
+    self.assertRaises(  # Issue is restricted
+        permissions.PermissionException,
+        self.servlet.GatherPageData, mr)
+
+  def testGatherPageData_NoSuchAttachment(self):
+    _request, mr = testing_helpers.GetRequestObjects(
+        project=self.project,
+        path='/p/proj/issues/attachmentText?aid=9999',
+        perms=permissions.READ_ONLY_PERMISSIONSET)
+    with self.assertRaises(webapp2.HTTPException) as cm:
+      self.servlet.GatherPageData(mr)
+    self.assertEqual(404, cm.exception.code)
+
+  def testGatherPageData_AttachmentDeleted(self):
+    """If the attachment was deleted, give a 404."""
+    _request, mr = testing_helpers.GetRequestObjects(
+        project=self.project,
+        path='/p/proj/issues/attachmentText?aid=1234',
+        perms=permissions.READ_ONLY_PERMISSIONSET)
+    self.attach1.deleted = True
+    with self.assertRaises(webapp2.HTTPException) as cm:
+      self.servlet.GatherPageData(mr)
+    self.assertEqual(404, cm.exception.code)
+
+  def testGatherPageData_Normal(self):
+    _request, mr = testing_helpers.GetRequestObjects(
+        project=self.project,
+        path='/p/proj/issues/attachmentText?id=1&aid=1234',
+        perms=permissions.READ_ONLY_PERMISSIONSET)
+    page_data = self.servlet.GatherPageData(mr)
+    self.assertEqual(1, page_data['local_id'])
+    self.assertEqual('a.txt', page_data['filename'])
+    self.assertEqual('43 bytes', page_data['filesize'])
+    self.assertEqual(ezt.boolean(False), page_data['should_prettify'])
+    self.assertEqual(ezt.boolean(False), page_data['is_binary'])
+    self.assertEqual(ezt.boolean(False), page_data['too_large'])
+
+    file_lines = page_data['file_lines']
+    self.assertEqual(1, len(file_lines))
+    self.assertEqual(1, file_lines[0].num)
+    self.assertEqual('/app_default_bucket/pid/attachments/abcdefg',
+                     file_lines[0].line)
+
+    self.assertEqual(None, page_data['code_reviews'])
+
+  @patch('framework.filecontent.DecodeFileContents')
+  def testGatherPageData_HugeFile(self, mock_DecodeFileContents):
+    _request, mr = testing_helpers.GetRequestObjects(
+        project=self.project,
+        path='/p/proj/issues/attachmentText?id=1&aid=1234',
+        perms=permissions.READ_ONLY_PERMISSIONSET)
+    mock_DecodeFileContents.return_value = (
+        'too large text', False, True)
+
+    page_data = self.servlet.GatherPageData(mr)
+
+    self.assertEqual(ezt.boolean(False), page_data['should_prettify'])
+    self.assertEqual(ezt.boolean(False), page_data['is_binary'])
+    self.assertEqual(ezt.boolean(True), page_data['too_large'])
diff --git a/tracker/test/issuebulkedit_test.py b/tracker/test/issuebulkedit_test.py
new file mode 100644
index 0000000..89d9bc3
--- /dev/null
+++ b/tracker/test/issuebulkedit_test.py
@@ -0,0 +1,892 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unittests for monorail.tracker.issuebulkedit."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import mock
+import os
+import unittest
+import webapp2
+
+from google.appengine.api import memcache
+from google.appengine.ext import testbed
+
+from framework import exceptions
+from framework import permissions
+from proto import tracker_pb2
+from services import service_manager
+from services import tracker_fulltext
+from testing import fake
+from testing import testing_helpers
+from tracker import issuebulkedit
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+
+
+class Response(object):
+
+  def __init__(self):
+    self.status = None
+
+
+class IssueBulkEditTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        features=fake.FeaturesService(),
+        project=fake.ProjectService(),
+        config=fake.ConfigService(),
+        issue=fake.IssueService(),
+        issue_star=fake.IssueStarService(),
+        user=fake.UserService(),
+        usergroup=fake.UserGroupService())
+    self.servlet = issuebulkedit.IssueBulkEdit(
+        'req', 'res', services=self.services)
+    self.mr = testing_helpers.MakeMonorailRequest(
+        perms=permissions.OWNER_ACTIVE_PERMISSIONSET)
+    self.project = self.services.project.TestAddProject(
+        name='proj', project_id=789, owner_ids=[111])
+    self.cnxn = 'fake connection'
+    self.config = self.services.config.GetProjectConfig(
+        self.cnxn, self.project.project_id)
+    self.services.config.StoreConfig(self.cnxn, self.config)
+    self.owner = self.services.user.TestAddUser('owner@example.com', 111)
+
+    self.testbed = testbed.Testbed()
+    self.testbed.activate()
+    self.testbed.init_memcache_stub()
+    self.testbed.init_datastore_v3_stub()
+
+    self.mocked_methods = {}
+
+  def tearDown(self):
+    """Restore mocked objects of other modules."""
+    self.testbed.deactivate()
+    for obj, items in self.mocked_methods.items():
+      for member, previous_value in items.items():
+        setattr(obj, member, previous_value)
+
+  def testAssertBasePermission(self):
+    """Permit users with EDIT_ISSUE and ADD_ISSUE_COMMENT permissions."""
+    mr = testing_helpers.MakeMonorailRequest(
+        perms=permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET)
+    self.assertRaises(permissions.PermissionException,
+                      self.servlet.AssertBasePermission, mr)
+
+    self.servlet.AssertBasePermission(self.mr)
+
+  def testGatherPageData(self):
+    """Test GPD works in a normal no-corner-cases case."""
+    created_issue_1 = fake.MakeTestIssue(
+        789, 1, 'issue summary', 'New', 0, reporter_id=111)
+    self.services.issue.TestAddIssue(created_issue_1)
+    local_id_1 = created_issue_1.local_id
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project)
+    mr.local_id_list = [local_id_1]
+
+    page_data = self.servlet.GatherPageData(mr)
+    self.assertEqual(1, page_data['num_issues'])
+
+  def testGatherPageData_CustomFieldEdition(self):
+    """Test GPD works in a normal no-corner-cases case."""
+    created_issue_1 = fake.MakeTestIssue(
+        789, 1, 'issue summary', 'New', 0, reporter_id=111)
+    self.services.issue.TestAddIssue(created_issue_1)
+    local_id_1 = created_issue_1.local_id
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project, perms=permissions.PermissionSet([]))
+    mr.local_id_list = [local_id_1]
+    mr.auth.effective_ids = {222}
+
+    fd_not_restricted = tracker_bizobj.MakeFieldDef(
+        123,
+        789,
+        'CPU',
+        tracker_pb2.FieldTypes.INT_TYPE,
+        None,
+        '',
+        False,
+        False,
+        False,
+        None,
+        None,
+        '',
+        False,
+        '',
+        '',
+        tracker_pb2.NotifyTriggers.NEVER,
+        'no_action',
+        'doc',
+        False,
+        is_restricted_field=False)
+    self.config.field_defs.append(fd_not_restricted)
+
+    fd_restricted = tracker_bizobj.MakeFieldDef(
+        124,
+        789,
+        'CPU',
+        tracker_pb2.FieldTypes.INT_TYPE,
+        None,
+        '',
+        False,
+        False,
+        False,
+        None,
+        None,
+        '',
+        False,
+        '',
+        '',
+        tracker_pb2.NotifyTriggers.NEVER,
+        'no_action',
+        'doc',
+        False,
+        is_restricted_field=True)
+    self.config.field_defs.append(fd_restricted)
+
+    page_data = self.servlet.GatherPageData(mr)
+    self.assertTrue(page_data['fields'][0].is_editable)
+    self.assertFalse(page_data['fields'][1].is_editable)
+
+  def testGatherPageData_NoIssues(self):
+    """Test GPD when no issues are specified in the mr."""
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project)
+    self.assertRaises(exceptions.InputException,
+                      self.servlet.GatherPageData, mr)
+
+  def testGatherPageData_FilteredIssues(self):
+    """Test GPD when all specified issues get filtered out."""
+    created_issue_1 = fake.MakeTestIssue(
+        789,
+        1,
+        'issue summary',
+        'New',
+        0,
+        reporter_id=111,
+        labels=['restrict-view-Googler'])
+    self.services.issue.TestAddIssue(created_issue_1)
+    local_id_1 = created_issue_1.local_id
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project)
+    mr.local_id_list = [local_id_1]
+
+    self.assertRaises(webapp2.HTTPException,
+                      self.servlet.GatherPageData, mr)
+
+  def testGatherPageData_TypeLabels(self):
+    """Test that GPD displays a custom field for appropriate issues."""
+    created_issue_1 = fake.MakeTestIssue(
+        789,
+        1,
+        'issue summary',
+        'New',
+        0,
+        reporter_id=111,
+        labels=['type-customlabels'])
+    self.services.issue.TestAddIssue(created_issue_1)
+    local_id_1 = created_issue_1.local_id
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project)
+    mr.local_id_list = [local_id_1]
+
+    fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'CPU', tracker_pb2.FieldTypes.INT_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    self.config.field_defs.append(fd)
+
+    page_data = self.servlet.GatherPageData(mr)
+    self.assertEqual(1, len(page_data['fields']))
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testProcessFormData(self, _create_task_mock):
+    """Test that PFD works in a normal no-corner-cases case."""
+    created_issue_1 = fake.MakeTestIssue(
+        789, 1, 'issue summary', 'New', 111, reporter_id=111)
+    self.services.issue.TestAddIssue(created_issue_1)
+    local_id_1 = created_issue_1.local_id
+
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project,
+        perms=permissions.OWNER_ACTIVE_PERMISSIONSET,
+        user_info={'user_id': 111})
+    mr.local_id_list = [local_id_1]
+
+    post_data = fake.PostData(
+        owner=['owner@example.com'], can=[1],
+        q=[''], colspec=[''], sort=[''], groupby=[''], start=[0], num=[100])
+    self._MockMethods()
+    url = self.servlet.ProcessFormData(mr, post_data)
+    self.assertTrue('list?can=1&q=&saved=1' in url)
+
+  def testProcessFormData_NoIssues(self):
+    """Test PFD when no issues are specified."""
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project,
+        perms=permissions.OWNER_ACTIVE_PERMISSIONSET,
+        user_info={'user_id': 111})
+    post_data = fake.PostData()
+    self.servlet.response = Response()
+    self.servlet.ProcessFormData(mr, post_data)
+    # 400 == bad request
+    self.assertEqual(400, self.servlet.response.status)
+
+  def testProcessFormData_NoUser(self):
+    """Test PFD when the user is not logged in."""
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project)
+    mr.local_id_list = [99999]
+    post_data = fake.PostData()
+    self.servlet.response = Response()
+    self.servlet.ProcessFormData(mr, post_data)
+    # 400 == bad request
+    self.assertEqual(400, self.servlet.response.status)
+
+  def testProcessFormData_CantComment(self):
+    """Test PFD when the user can't comment on any of the issues."""
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project,
+        perms=permissions.EMPTY_PERMISSIONSET,
+        user_info={'user_id': 111})
+    mr.local_id_list = [99999]
+    post_data = fake.PostData()
+    self.servlet.response = Response()
+    self.servlet.ProcessFormData(mr, post_data)
+    # 400 == bad request
+    self.assertEqual(400, self.servlet.response.status)
+
+  def testProcessFormData_CantEdit(self):
+    """Test PFD when the user can't edit any issue metadata."""
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project,
+        perms=permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET,
+        user_info={'user_id': 111})
+    mr.local_id_list = [99999]
+    post_data = fake.PostData()
+    self.servlet.response = Response()
+    self.servlet.ProcessFormData(mr, post_data)
+    # 400 == bad request
+    self.assertEqual(400, self.servlet.response.status)
+
+  def testProcessFormData_CantMove(self):
+    """Test PFD when the user can't move issues."""
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project,
+        perms=permissions.COMMITTER_ACTIVE_PERMISSIONSET,
+        user_info={'user_id': 111})
+    mr.local_id_list = [99999]
+    post_data = fake.PostData(move_to=['proj'])
+    self.servlet.response = Response()
+    self.servlet.ProcessFormData(mr, post_data)
+    # 400 == bad request
+    self.assertEqual(400, self.servlet.response.status)
+
+    created_issue_1 = fake.MakeTestIssue(
+        789, 1, 'issue summary', 'New', 111, reporter_id=111)
+    self.services.issue.TestAddIssue(created_issue_1)
+    local_id_1 = created_issue_1.local_id
+    mr.perms = permissions.OWNER_ACTIVE_PERMISSIONSET
+    mr.local_id_list = [local_id_1]
+    mr.project_name = 'proj'
+    self._MockMethods()
+    self.servlet.ProcessFormData(mr, post_data)
+    self.assertEqual(
+        'The issues are already in project proj', mr.errors.move_to)
+
+    post_data = fake.PostData(move_to=['notexist'])
+    self.servlet.ProcessFormData(mr, post_data)
+    self.assertEqual('No such project: notexist', mr.errors.move_to)
+
+  def _MockMethods(self):
+    # Mock methods of other modules to avoid unnecessary testing
+    self.mocked_methods[tracker_fulltext] = {
+        'IndexIssues': tracker_fulltext.IndexIssues,
+        'UnindexIssues': tracker_fulltext.UnindexIssues}
+    def DoNothing(*_args, **_kwargs):
+      pass
+    self.servlet.PleaseCorrect = DoNothing
+    tracker_fulltext.IndexIssues = DoNothing
+    tracker_fulltext.UnindexIssues = DoNothing
+
+  def GetFirstAmendment(self, project_id, local_id):
+    issue = self.services.issue.GetIssueByLocalID(
+        self.cnxn, project_id, local_id)
+    issue_id = issue.issue_id
+    comments = self.services.issue.GetCommentsForIssue(self.cnxn, issue_id)
+    last_comment = comments[-1]
+    first_amendment = last_comment.amendments[0]
+    return first_amendment.field, first_amendment.newvalue
+
+  def testProcessFormData_BadUserField(self):
+    """Test PFD when a nonexistent user is added as a field value."""
+    created_issue_1 = fake.MakeTestIssue(
+        789, 1, 'issue summary', 'New', 111, reporter_id=111)
+    self.services.issue.TestAddIssue(created_issue_1)
+    local_id_1 = created_issue_1.local_id
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project,
+        perms=permissions.OWNER_ACTIVE_PERMISSIONSET,
+        user_info={'user_id': 111})
+    mr.local_id_list = [local_id_1]
+
+    fd = tracker_bizobj.MakeFieldDef(
+        12345, 789, 'PM', tracker_pb2.FieldTypes.USER_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    self.config.field_defs.append(fd)
+
+    post_data = fake.PostData(
+        custom_12345=['ghost@gmail.com'], owner=['owner@example.com'], can=[1],
+        q=[''], colspec=[''], sort=[''], groupby=[''], start=[0], num=[100])
+    self._MockMethods()
+    self.servlet.ProcessFormData(mr, post_data)
+    self.assertEqual('User not found.', mr.errors.custom_fields[0].message)
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testProcessFormData_CustomFields(self, _create_task_mock):
+    """Test PFD processes edits to custom fields."""
+    created_issue_1 = fake.MakeTestIssue(
+        789, 1, 'issue summary', 'New', 111, reporter_id=111)
+    self.services.issue.TestAddIssue(created_issue_1)
+    local_id_1 = created_issue_1.local_id
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project,
+        perms=permissions.OWNER_ACTIVE_PERMISSIONSET,
+        user_info={'user_id': 111})
+    mr.local_id_list = [local_id_1]
+
+    fd = tracker_bizobj.MakeFieldDef(
+        12345, 789, 'CPU', tracker_pb2.FieldTypes.INT_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    self.config.field_defs.append(fd)
+
+    post_data = fake.PostData(
+        custom_12345=['10'],
+        owner=['owner@example.com'],
+        can=[1],
+        q=[''],
+        colspec=[''],
+        sort=[''],
+        groupby=[''],
+        start=[0],
+        num=[100])
+    self._MockMethods()
+    self.servlet.ProcessFormData(mr, post_data)
+    self.assertEqual(
+        (tracker_pb2.FieldID.CUSTOM, '10'),
+        self.GetFirstAmendment(789, local_id_1))
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testProcessFormData_RestrictedCustomFieldsAccept(self, _create_task_mock):
+    """We accept edits to restricted fields by editors (or admins)."""
+    created_issue_1 = fake.MakeTestIssue(
+        789, 1, 'issue summary', 'New', 111, reporter_id=111)
+    self.services.issue.TestAddIssue(created_issue_1)
+    local_id_1 = created_issue_1.local_id
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project,
+        perms=permissions.PermissionSet(
+            [
+                permissions.EDIT_ISSUE, permissions.ADD_ISSUE_COMMENT,
+                permissions.VIEW
+            ]),
+        user_info={'user_id': 111})
+    mr.local_id_list = [local_id_1]
+
+    fd = tracker_bizobj.MakeFieldDef(
+        12345,
+        789,
+        'CPU',
+        tracker_pb2.FieldTypes.INT_TYPE,
+        None,
+        '',
+        False,
+        False,
+        False,
+        None,
+        None,
+        '',
+        False,
+        '',
+        '',
+        tracker_pb2.NotifyTriggers.NEVER,
+        'no_action',
+        'doc',
+        False,
+        is_restricted_field=True)
+    fd.editor_ids = [111]
+    self.config.field_defs.append(fd)
+
+    post_data = fake.PostData(
+        custom_12345=['10'],
+        owner=['owner@example.com'],
+        can=[1],
+        q=[''],
+        colspec=[''],
+        sort=[''],
+        groupby=[''],
+        start=[0],
+        num=[100])
+    self._MockMethods()
+    self.servlet.ProcessFormData(mr, post_data)
+    self.assertEqual(
+        (tracker_pb2.FieldID.CUSTOM, '10'),
+        self.GetFirstAmendment(789, local_id_1))
+
+  def testProcessFormData_RestrictedCustomFieldsReject(self):
+    """We reject edits to restricted fields by non-editors (and non-admins)."""
+    created_issue_1 = fake.MakeTestIssue(
+        789, 1, 'issue summary', 'New', 111, reporter_id=111)
+    self.services.issue.TestAddIssue(created_issue_1)
+    local_id_1 = created_issue_1.local_id
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project,
+        perms=permissions.PermissionSet(
+            [
+                permissions.EDIT_ISSUE, permissions.ADD_ISSUE_COMMENT,
+                permissions.VIEW
+            ]),
+        user_info={'user_id': 111})
+    mr.local_id_list = [local_id_1]
+
+    fd_int = tracker_bizobj.MakeFieldDef(
+        11111,
+        789,
+        'fd_int',
+        tracker_pb2.FieldTypes.INT_TYPE,
+        None,
+        '',
+        False,
+        False,
+        False,
+        None,
+        None,
+        '',
+        False,
+        '',
+        '',
+        tracker_pb2.NotifyTriggers.NEVER,
+        'no_action',
+        'doc',
+        False,
+        is_restricted_field=True)
+    fd_enum = tracker_bizobj.MakeFieldDef(
+        44444,
+        789,
+        'fdEnum',
+        tracker_pb2.FieldTypes.ENUM_TYPE,
+        None,
+        '',
+        False,
+        False,
+        False,
+        None,
+        None,
+        '',
+        False,
+        '',
+        '',
+        tracker_pb2.NotifyTriggers.NEVER,
+        'no_action',
+        'doc',
+        False,
+        is_restricted_field=True)
+    fd_int.admin_ids = [222]
+    fd_enum.editor_ids = [333]
+    self.config.field_defs = [fd_int, fd_enum]
+
+    post_data_add_fv = fake.PostData(
+        custom_11111=['10'],
+        owner=['owner@example.com'],
+        can=[1],
+        q=[''],
+        colspec=[''],
+        sort=[''],
+        groupby=[''],
+        start=[0],
+        num=[100])
+    post_data_rm_fv = fake.PostData(
+        op_custom_11111=['remove'],
+        custom_11111=['10'],
+        owner=['owner@example.com'],
+        can=[1],
+        q=[''],
+        colspec=[''],
+        sort=[''],
+        groupby=[''],
+        start=[0],
+        num=[100])
+    post_data_clear_fd = fake.PostData(
+        op_custom_11111=['clear'],
+        owner=['owner@example.com'],
+        can=[1],
+        q=[''],
+        colspec=[''],
+        sort=[''],
+        groupby=[''],
+        start=[0],
+        num=[100])
+    post_data_label_edits_enum = fake.PostData(
+        label=['fdEnum-a'],
+        owner=['owner@example.com'],
+        can=[1],
+        q=[''],
+        colspec=[''],
+        sort=[''],
+        groupby=[''],
+        start=[0],
+        num=[100])
+    post_data_label_rm_enum = fake.PostData(
+        label=['-fdEnum-b'],
+        owner=['owner@example.com'],
+        can=[1],
+        q=[''],
+        colspec=[''],
+        sort=[''],
+        groupby=[''],
+        start=[0],
+        num=[100])
+
+    self._MockMethods()
+    self.assertRaises(
+        AssertionError, self.servlet.ProcessFormData, mr, post_data_add_fv)
+    self.assertRaises(
+        AssertionError, self.servlet.ProcessFormData, mr, post_data_rm_fv)
+    self.assertRaises(
+        AssertionError, self.servlet.ProcessFormData, mr, post_data_clear_fd)
+    self.assertRaises(
+        AssertionError, self.servlet.ProcessFormData, mr,
+        post_data_label_edits_enum)
+    self.assertRaises(
+        AssertionError, self.servlet.ProcessFormData, mr,
+        post_data_label_rm_enum)
+
+  def testProcessFormData_DuplicateStatus_MergeSameIssue(self):
+    """Test PFD processes null/cleared status values."""
+    created_issue_1 = fake.MakeTestIssue(
+        789, 1, 'issue summary', 'New', 111, reporter_id=111)
+    self.services.issue.TestAddIssue(created_issue_1)
+    local_id_1 = created_issue_1.local_id
+
+    created_issue_2 = fake.MakeTestIssue(
+        789, 1, 'issue summary', 'New', 112, reporter_id=112)
+    self.services.issue.TestAddIssue(created_issue_2)
+    merge_into_local_id_2 = created_issue_2.local_id
+
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project,
+        perms=permissions.OWNER_ACTIVE_PERMISSIONSET,
+        user_info={'user_id': 111})
+    mr.local_id_list = [local_id_1, merge_into_local_id_2]
+    mr.project_name = 'proj'
+
+    # Add required project_name to merge_into_issue.
+    merge_into_issue = self.services.issue.GetIssueByLocalID(
+        mr.cnxn, self.project.project_id, merge_into_local_id_2)
+    merge_into_issue.project_name = 'proj'
+
+    post_data = fake.PostData(status=['Duplicate'],
+        merge_into=[str(merge_into_local_id_2)], owner=['owner@example.com'],
+        can=[1], q=[''], colspec=[''], sort=[''], groupby=[''], start=[0],
+        num=[100])
+    self._MockMethods()
+    self.servlet.ProcessFormData(mr, post_data)
+    self.assertEqual('Cannot merge issue into itself', mr.errors.merge_into_id)
+
+  def testProcessFormData_DuplicateStatus_MergeMissingIssue(self):
+    """Test PFD processes null/cleared status values."""
+    created_issue_1 = fake.MakeTestIssue(
+        789, 1, 'issue summary', 'New', 111, reporter_id=111)
+    self.services.issue.TestAddIssue(created_issue_1)
+    local_id_1 = created_issue_1.local_id
+    created_issue_2 = fake.MakeTestIssue(
+        789, 1, 'issue summary2', 'New', 112, reporter_id=112)
+    self.services.issue.TestAddIssue(created_issue_2)
+    local_id_2 = created_issue_2.local_id
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project,
+        perms=permissions.OWNER_ACTIVE_PERMISSIONSET,
+        user_info={'user_id': 111})
+    mr.local_id_list = [local_id_1, local_id_2]
+    mr.project_name = 'proj'
+
+    post_data = fake.PostData(status=['Duplicate'],
+        merge_into=['non existant id'], owner=['owner@example.com'],
+        can=[1], q=[''], colspec=[''], sort=[''], groupby=[''], start=[0],
+        num=[100])
+    self._MockMethods()
+    self.servlet.ProcessFormData(mr, post_data)
+    self.assertEqual('Please enter an issue ID', mr.errors.merge_into_id)
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testProcessFormData_DuplicateStatus_Success(self, _create_task_mock):
+    """Test PFD processes null/cleared status values."""
+    created_issue_1 = fake.MakeTestIssue(
+        789, 1, 'issue summary', 'New', 111, reporter_id=111)
+    self.services.issue.TestAddIssue(created_issue_1)
+    local_id_1 = created_issue_1.local_id
+    created_issue_2 = fake.MakeTestIssue(
+        789, 2, 'issue summary2', 'New', 111, reporter_id=111)
+    self.services.issue.TestAddIssue(created_issue_2)
+    local_id_2 = created_issue_2.local_id
+    created_issue_3 = fake.MakeTestIssue(
+        789, 3, 'issue summary3', 'New', 112, reporter_id=112)
+    self.services.issue.TestAddIssue(created_issue_3)
+    merge_into_local_id_3 = created_issue_3.local_id
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project,
+        perms=permissions.OWNER_ACTIVE_PERMISSIONSET,
+        user_info={'user_id': 111})
+    mr.local_id_list = [local_id_1, local_id_2]
+    mr.project_name = 'proj'
+
+    post_data = fake.PostData(status=['Duplicate'],
+        merge_into=[str(merge_into_local_id_3)], owner=['owner@example.com'],
+        can=[1], q=[''], colspec=[''], sort=[''], groupby=[''], start=[0],
+        num=[100])
+    self._MockMethods()
+
+    # Add project_name, CCs and starrers to the merge_into_issue.
+    merge_into_issue = self.services.issue.GetIssueByLocalID(
+        mr.cnxn, self.project.project_id, merge_into_local_id_3)
+    merge_into_issue.project_name = 'proj'
+    merge_into_issue.cc_ids = [113, 120]
+    self.services.issue_star.SetStar(
+        mr.cnxn, self.services, None, merge_into_issue.issue_id, 120, True)
+
+    # Add project_name, CCs and starrers to the source issues.
+    # Issue 1
+    issue_1 = self.services.issue.GetIssueByLocalID(
+        mr.cnxn, self.project.project_id, local_id_1)
+    issue_1.project_name = 'proj'
+    issue_1.cc_ids = [113, 114]
+    self.services.issue_star.SetStar(
+        mr.cnxn, self.services, None, issue_1.issue_id, 113, True)
+    # Issue 2
+    issue_2 = self.services.issue.GetIssueByLocalID(
+        mr.cnxn, self.project.project_id, local_id_2)
+    issue_2.project_name = 'proj'
+    issue_2.cc_ids = [113, 115, 118]
+    self.services.issue_star.SetStar(
+        mr.cnxn, self.services, None, issue_2.issue_id, 114, True)
+    self.services.issue_star.SetStar(
+        mr.cnxn, self.services, None, issue_2.issue_id, 115, True)
+
+    self.servlet.ProcessFormData(mr, post_data)
+
+    # Verify both source issues were updated.
+    self.assertEqual(
+        (tracker_pb2.FieldID.STATUS, 'Duplicate'),
+        self.GetFirstAmendment(self.project.project_id, local_id_1))
+    self.assertEqual(
+        (tracker_pb2.FieldID.STATUS, 'Duplicate'),
+        self.GetFirstAmendment(self.project.project_id, local_id_2))
+
+    # Verify that the merge into issue was updated with a comment.
+    comments = self.services.issue.GetCommentsForIssue(
+        self.cnxn, merge_into_issue.issue_id)
+    self.assertEqual(
+        'Issue 1 has been merged into this issue.\n'
+        'Issue 2 has been merged into this issue.', comments[-1].content)
+
+    # Verify CC lists and owner were merged to the merge_into issue.
+    self.assertEqual(
+            [113, 120, 114, 115, 118, 111], merge_into_issue.cc_ids)
+    # Verify new starrers were added to the merge_into issue.
+    self.assertEqual(4,
+                      self.services.issue_star.CountItemStars(
+                          self.cnxn, merge_into_issue.issue_id))
+    self.assertEqual([120, 113, 114, 115],
+                      self.services.issue_star.LookupItemStarrers(
+                          self.cnxn, merge_into_issue.issue_id))
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testProcessFormData_ClearStatus(self, _create_task_mock):
+    """Test PFD processes null/cleared status values."""
+    created_issue_1 = fake.MakeTestIssue(
+        789, 1, 'issue summary', 'New', 111, reporter_id=111)
+    self.services.issue.TestAddIssue(created_issue_1)
+    local_id_1 = created_issue_1.local_id
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project,
+        perms=permissions.OWNER_ACTIVE_PERMISSIONSET,
+        user_info={'user_id': 111})
+    mr.local_id_list = [local_id_1]
+
+    post_data = fake.PostData(
+        op_statusenter=['clear'], owner=['owner@example.com'], can=[1],
+        q=[''], colspec=[''], sort=[''], groupby=[''], start=[0], num=[100])
+    self._MockMethods()
+    self.servlet.ProcessFormData(mr, post_data)
+    self.assertEqual(
+        (tracker_pb2.FieldID.STATUS, ''), self.GetFirstAmendment(
+            789, local_id_1))
+
+  def testProcessFormData_InvalidOwner(self):
+    """Test PFD rejects invalid owner emails."""
+    created_issue_1 = fake.MakeTestIssue(
+        789, 1, 'issue summary', 'New', 0, reporter_id=111)
+    self.services.issue.TestAddIssue(created_issue_1)
+    local_id_1 = created_issue_1.local_id
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project,
+        perms=permissions.OWNER_ACTIVE_PERMISSIONSET,
+        user_info={'user_id': 111})
+    mr.local_id_list = [local_id_1]
+    post_data = fake.PostData(
+        owner=['invalid'])
+    self.servlet.response = Response()
+    self._MockMethods()
+    self.servlet.ProcessFormData(mr, post_data)
+    self.assertTrue(mr.errors.AnyErrors())
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testProcessFormData_MoveTo(self, _create_task_mock):
+    """Test PFD processes move_to values."""
+    created_issue_1 = fake.MakeTestIssue(
+        789, 1, 'issue summary', 'New', 111, reporter_id=111)
+    self.services.issue.TestAddIssue(created_issue_1)
+    local_id_1 = created_issue_1.local_id
+    move_to_project = self.services.project.TestAddProject(
+        name='proj2', project_id=790, owner_ids=[111])
+
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project,
+        perms=permissions.OWNER_ACTIVE_PERMISSIONSET,
+        user_info={'user_id': 111})
+    mr.project_name = 'proj'
+    mr.local_id_list = [local_id_1]
+
+    self._MockMethods()
+    post_data = fake.PostData(
+        move_to=['proj2'], can=[1], q=[''],
+        colspec=[''], sort=[''], groupby=[''], start=[0], num=[100])
+    self.servlet.response = Response()
+    self.servlet.ProcessFormData(mr, post_data)
+
+    issue = self.services.issue.GetIssueByLocalID(
+        self.cnxn, move_to_project.project_id, local_id_1)
+    self.assertIsNotNone(issue)
+
+  def testProcessFormData_InvalidBlockIssues(self):
+    """Test PFD processes invalid blocked_on and blocking values."""
+    created_issue_1 = fake.MakeTestIssue(
+        789, 1, 'issue summary', 'New', 111, reporter_id=111)
+    self.services.issue.TestAddIssue(created_issue_1)
+    local_id_1 = created_issue_1.local_id
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project,
+        perms=permissions.OWNER_ACTIVE_PERMISSIONSET,
+        user_info={'user_id': 111})
+    mr.project_name = 'proj'
+    mr.local_id_list = [local_id_1]
+
+    self._MockMethods()
+    post_data = fake.PostData(
+        op_blockedonenter=['append'], blocked_on=['12345'],
+        op_blockingenter=['append'], blocking=['54321'],
+        can=[1], q=[''],
+        colspec=[''], sort=[''], groupby=[''], start=[0], num=[100])
+    self.servlet.ProcessFormData(mr, post_data)
+
+    self.assertEqual('Invalid issue ID 12345', mr.errors.blocked_on)
+    self.assertEqual('Invalid issue ID 54321', mr.errors.blocking)
+
+  def testProcessFormData_BlockIssuesOnItself(self):
+    """Test PFD processes invalid blocked_on and blocking values."""
+    created_issue_1 = fake.MakeTestIssue(
+        789, 1, 'issue summary', 'New', 111, reporter_id=111)
+    self.services.issue.TestAddIssue(created_issue_1)
+    local_id_1 = created_issue_1.local_id
+    created_issue_2 = fake.MakeTestIssue(
+        789, 1, 'issue summary', 'New', 111, reporter_id=111)
+    self.services.issue.TestAddIssue(created_issue_2)
+    local_id_2 = created_issue_2.local_id
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project,
+        perms=permissions.OWNER_ACTIVE_PERMISSIONSET,
+        user_info={'user_id': 111})
+    mr.project_name = 'proj'
+    mr.local_id_list = [local_id_1, local_id_2]
+
+    self._MockMethods()
+    post_data = fake.PostData(
+        op_blockedonenter=['append'], blocked_on=[str(local_id_1)],
+        op_blockingenter=['append'], blocking=[str(local_id_2)],
+        can=[1], q=[''],
+        colspec=[''], sort=[''], groupby=[''], start=[0], num=[100])
+    self.servlet.ProcessFormData(mr, post_data)
+
+    self.assertEqual('Cannot block an issue on itself.', mr.errors.blocked_on)
+    self.assertEqual('Cannot block an issue on itself.', mr.errors.blocking)
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testProcessFormData_NormalBlockIssues(self, _create_task_mock):
+    """Test PFD processes blocked_on and blocking values."""
+    created_issue_1 = fake.MakeTestIssue(
+        789, 1, 'issue summary', 'New', 111, reporter_id=111)
+    self.services.issue.TestAddIssue(created_issue_1)
+    local_id_1 = created_issue_1.local_id
+
+    created_issueid = fake.MakeTestIssue(
+        789, 2, 'blocking', 'New', 111, reporter_id=111)
+    self.services.issue.TestAddIssue(created_issueid)
+    blocking_id = created_issueid.local_id
+
+    created_issueid = fake.MakeTestIssue(
+        789, 3, 'blocked on', 'New', 111, reporter_id=111)
+    self.services.issue.TestAddIssue(created_issueid)
+    blocked_on_id = created_issueid.local_id
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project,
+        perms=permissions.OWNER_ACTIVE_PERMISSIONSET,
+        user_info={'user_id': 111})
+    mr.project_name = 'proj'
+    mr.local_id_list = [local_id_1]
+
+    self._MockMethods()
+    post_data = fake.PostData(
+        op_blockedonenter=['append'], blocked_on=[str(blocked_on_id)],
+        op_blockingenter=['append'], blocking=[str(blocking_id)],
+        can=[1], q=[''],
+        colspec=[''], sort=[''], groupby=[''], start=[0], num=[100])
+    self.servlet.ProcessFormData(mr, post_data)
+
+    self.assertIsNone(mr.errors.blocked_on)
+    self.assertIsNone(mr.errors.blocking)
+
+  def testProcessFormData_TooLongComment(self):
+    """Test PFD rejects comments that are too long."""
+    created_issue_1 = fake.MakeTestIssue(
+        789, 1, 'issue summary', 'New', 111, reporter_id=111)
+    self.services.issue.TestAddIssue(created_issue_1)
+    local_id_1 = created_issue_1.local_id
+
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project,
+        perms=permissions.OWNER_ACTIVE_PERMISSIONSET,
+        user_info={'user_id': 111})
+    mr.local_id_list = [local_id_1]
+
+    post_data = fake.PostData(
+        owner=['owner@example.com'],
+        can=[1],
+        q=[''],
+        colspec=[''],
+        sort=[''],
+        groupby=[''],
+        start=[0],
+        num=[100],
+        comment=['   ' + 'c' * tracker_constants.MAX_COMMENT_CHARS + '  '])
+    self._MockMethods()
+    self.servlet.ProcessFormData(mr, post_data)
+    self.assertTrue(mr.errors.AnyErrors())
+    self.assertEqual('Comment is too long.', mr.errors.comment)
diff --git a/tracker/test/issuedetailezt_test.py b/tracker/test/issuedetailezt_test.py
new file mode 100644
index 0000000..d3b8327
--- /dev/null
+++ b/tracker/test/issuedetailezt_test.py
@@ -0,0 +1,306 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unittests for monorail.tracker.issuedetailezt."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import mock
+import mox
+import time
+import unittest
+
+import settings
+from businesslogic import work_env
+from proto import features_pb2
+from features import hotlist_views
+from features import send_notifications
+from framework import authdata
+from framework import exceptions
+from framework import framework_views
+from framework import framework_helpers
+from framework import urls
+from framework import permissions
+from framework import profiler
+from framework import sorting
+from framework import template_helpers
+from proto import project_pb2
+from proto import tracker_pb2
+from proto import user_pb2
+from services import service_manager
+from services import issue_svc
+from services import tracker_fulltext
+from testing import fake
+from testing import testing_helpers
+from tracker import issuedetailezt
+from tracker import tracker_constants
+from tracker import tracker_helpers
+
+
+class GetAdjacentIssueTest(unittest.TestCase):
+
+  def setUp(self):
+    self.cnxn = 'fake cnxn'
+    self.services = service_manager.Services(
+        config=fake.ConfigService(),
+        issue=fake.IssueService(),
+        user=fake.UserService(),
+        project=fake.ProjectService(),
+        issue_star=fake.IssueStarService(),
+        spam=fake.SpamService())
+    self.services.project.TestAddProject('proj', project_id=789)
+    self.mr = testing_helpers.MakeMonorailRequest()
+    self.mr.auth.user_id = 111
+    self.mr.auth.effective_ids = {111}
+    self.mr.me_user_id = 111
+    self.work_env = work_env.WorkEnv(
+      self.mr, self.services, 'Testing phase')
+
+  def testGetAdjacentIssue_PrevIssue(self):
+    cur_issue = fake.MakeTestIssue(789, 2, 'sum', 'New', 111, issue_id=78902)
+    next_issue = fake.MakeTestIssue(789, 3, 'sum', 'New', 111, issue_id=78903)
+    prev_issue = fake.MakeTestIssue(789, 1, 'sum', 'New', 111, issue_id=78901)
+    self.services.issue.TestAddIssue(cur_issue)
+    self.services.issue.TestAddIssue(next_issue)
+    self.services.issue.TestAddIssue(prev_issue)
+
+    with self.work_env as we:
+      we.FindIssuePositionInSearch = mock.Mock(
+          return_value=[78901, 1, 78903, 3])
+
+      actual_issue = issuedetailezt.GetAdjacentIssue(
+         self.mr, we, cur_issue)
+      self.assertEqual(prev_issue, actual_issue)
+      we.FindIssuePositionInSearch.assert_called_once_with(cur_issue)
+
+  def testGetAdjacentIssue_NextIssue(self):
+    cur_issue = fake.MakeTestIssue(789, 2, 'sum', 'New', 111, issue_id=78902)
+    next_issue = fake.MakeTestIssue(789, 3, 'sum', 'New', 111, issue_id=78903)
+    prev_issue = fake.MakeTestIssue(789, 1, 'sum', 'New', 111, issue_id=78901)
+    self.services.issue.TestAddIssue(cur_issue)
+    self.services.issue.TestAddIssue(next_issue)
+    self.services.issue.TestAddIssue(prev_issue)
+
+    with self.work_env as we:
+      we.FindIssuePositionInSearch = mock.Mock(
+          return_value=[78901, 1, 78903, 3])
+
+      actual_issue = issuedetailezt.GetAdjacentIssue(
+          self.mr, we, cur_issue, next_issue=True)
+      self.assertEqual(next_issue, actual_issue)
+      we.FindIssuePositionInSearch.assert_called_once_with(cur_issue)
+
+  def testGetAdjacentIssue_NotFound(self):
+    cur_issue = fake.MakeTestIssue(789, 2, 'sum', 'New', 111, issue_id=78902)
+    prev_issue = fake.MakeTestIssue(789, 1, 'sum', 'New', 111, issue_id=78901)
+    self.services.issue.TestAddIssue(cur_issue)
+    self.services.issue.TestAddIssue(prev_issue)
+
+    with self.work_env as we:
+      we.FindIssuePositionInSearch = mock.Mock(
+          return_value=[78901, 1, 78903, 3])
+
+      with self.assertRaises(exceptions.NoSuchIssueException):
+        issuedetailezt.GetAdjacentIssue(
+            self.mr, we, cur_issue, next_issue=True)
+      we.FindIssuePositionInSearch.assert_called_once_with(cur_issue)
+
+  def testGetAdjacentIssue_Hotlist(self):
+    cur_issue = fake.MakeTestIssue(789, 2, 'sum', 'New', 111, issue_id=78902)
+    next_issue = fake.MakeTestIssue(789, 3, 'sum', 'New', 111, issue_id=78903)
+    prev_issue = fake.MakeTestIssue(789, 1, 'sum', 'New', 111, issue_id=78901)
+    self.services.issue.TestAddIssue(cur_issue)
+    self.services.issue.TestAddIssue(next_issue)
+    self.services.issue.TestAddIssue(prev_issue)
+    hotlist = fake.Hotlist('name', 678, owner_ids=[111])
+
+    with self.work_env as we:
+      we.GetIssuePositionInHotlist = mock.Mock(
+          return_value=[78901, 1, 78903, 3])
+
+      actual_issue = issuedetailezt.GetAdjacentIssue(
+          self.mr, we, cur_issue, hotlist=hotlist, next_issue=True)
+      self.assertEqual(next_issue, actual_issue)
+      we.GetIssuePositionInHotlist.assert_called_once_with(
+          cur_issue, hotlist, self.mr.can, self.mr.sort_spec,
+          self.mr.group_by_spec)
+
+
+class FlipperRedirectTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        config=fake.ConfigService(),
+        features=fake.FeaturesService(),
+        issue=fake.IssueService(),
+        user=fake.UserService(),
+        project=fake.ProjectService())
+    self.project = self.services.project.TestAddProject(
+      'proj', project_id=987, committer_ids=[111])
+    self.next_servlet = issuedetailezt.FlipperNext(
+        'req', 'res', services=self.services)
+    self.prev_servlet = issuedetailezt.FlipperPrev(
+        'req', 'res', services=self.services)
+    self.list_servlet = issuedetailezt.FlipperList(
+        'req', 'res', services=self.services)
+    mr = testing_helpers.MakeMonorailRequest(project=self.project)
+    mr.local_id = 123
+    mr.me_user_id = 111
+
+    self.next_servlet.mr = mr
+    self.prev_servlet.mr = mr
+    self.list_servlet.mr = mr
+
+    self.fake_issue_1 = fake.MakeTestIssue(987, 123, 'summary', 'New', 111,
+        project_name='rutabaga')
+    self.services.issue.TestAddIssue(self.fake_issue_1)
+    self.fake_issue_2 = fake.MakeTestIssue(987, 456, 'summary', 'New', 111,
+        project_name='rutabaga')
+    self.services.issue.TestAddIssue(self.fake_issue_2)
+    self.fake_issue_3 = fake.MakeTestIssue(987, 789, 'summary', 'New', 111,
+        project_name='potato')
+    self.services.issue.TestAddIssue(self.fake_issue_3)
+
+    self.next_servlet.redirect = mock.Mock()
+    self.prev_servlet.redirect = mock.Mock()
+    self.list_servlet.redirect = mock.Mock()
+
+  @mock.patch('tracker.issuedetailezt.GetAdjacentIssue')
+  def testFlipperNext(self, patchGetAdjacentIssue):
+    patchGetAdjacentIssue.return_value = self.fake_issue_2
+    self.next_servlet.mr.GetIntParam = mock.Mock(return_value=None)
+
+    self.next_servlet.get(project_name='proj', viewed_username=None)
+    self.next_servlet.mr.GetIntParam.assert_called_once_with('hotlist_id')
+    patchGetAdjacentIssue.assert_called_once()
+    self.next_servlet.redirect.assert_called_once_with(
+      '/p/rutabaga/issues/detail?id=456')
+
+  @mock.patch('tracker.issuedetailezt.GetAdjacentIssue')
+  def testFlipperNext_Hotlist(self, patchGetAdjacentIssue):
+    patchGetAdjacentIssue.return_value = self.fake_issue_3
+    self.next_servlet.mr.GetIntParam = mock.Mock(return_value=123)
+    # TODO(jeffcarp): Mock hotlist_id param on path here.
+
+    self.next_servlet.get(project_name='proj', viewed_username=None)
+    self.next_servlet.mr.GetIntParam.assert_called_with('hotlist_id')
+    self.next_servlet.redirect.assert_called_once_with(
+      '/p/potato/issues/detail?id=789')
+
+  @mock.patch('tracker.issuedetailezt.GetAdjacentIssue')
+  def testFlipperPrev(self, patchGetAdjacentIssue):
+    patchGetAdjacentIssue.return_value = self.fake_issue_2
+    self.next_servlet.mr.GetIntParam = mock.Mock(return_value=None)
+
+    self.prev_servlet.get(project_name='proj', viewed_username=None)
+    self.prev_servlet.mr.GetIntParam.assert_called_with('hotlist_id')
+    patchGetAdjacentIssue.assert_called_once()
+    self.prev_servlet.redirect.assert_called_once_with(
+      '/p/rutabaga/issues/detail?id=456')
+
+  @mock.patch('tracker.issuedetailezt.GetAdjacentIssue')
+  def testFlipperPrev_Hotlist(self, patchGetAdjacentIssue):
+    patchGetAdjacentIssue.return_value = self.fake_issue_3
+    self.prev_servlet.mr.GetIntParam = mock.Mock(return_value=123)
+    # TODO(jeffcarp): Mock hotlist_id param on path here.
+
+    self.prev_servlet.get(project_name='proj', viewed_username=None)
+    self.prev_servlet.mr.GetIntParam.assert_called_with('hotlist_id')
+    self.prev_servlet.redirect.assert_called_once_with(
+      '/p/potato/issues/detail?id=789')
+
+  @mock.patch('tracker.issuedetailezt._ComputeBackToListURL')
+  def testFlipperList(self, patch_ComputeBackToListURL):
+    patch_ComputeBackToListURL.return_value = '/p/test/issues/list'
+    self.list_servlet.mr.GetIntParam = mock.Mock(return_value=None)
+
+    self.list_servlet.get()
+
+    self.list_servlet.mr.GetIntParam.assert_called_with('hotlist_id')
+    patch_ComputeBackToListURL.assert_called_once()
+    self.list_servlet.redirect.assert_called_once_with(
+      '/p/test/issues/list')
+
+  @mock.patch('tracker.issuedetailezt._ComputeBackToListURL')
+  def testFlipperList_Hotlist(self, patch_ComputeBackToListURL):
+    patch_ComputeBackToListURL.return_value = '/p/test/issues/list'
+    self.list_servlet.mr.GetIntParam = mock.Mock(return_value=123)
+
+    self.list_servlet.get()
+
+    self.list_servlet.mr.GetIntParam.assert_called_with('hotlist_id')
+    self.list_servlet.redirect.assert_called_once_with(
+      '/p/test/issues/list')
+
+
+class ShouldShowFlipperTest(unittest.TestCase):
+
+  def setUp(self):
+    self.cnxn = 'fake cnxn'
+
+  def VerifyShouldShowFlipper(
+      self, expected, query, sort_spec, can, create_issues=0):
+    """Instantiate a _Flipper and check if makes a pipeline or not."""
+    services = service_manager.Services(
+        config=fake.ConfigService(),
+        issue=fake.IssueService(),
+        project=fake.ProjectService(),
+        user=fake.UserService())
+    project = services.project.TestAddProject(
+      'proj', project_id=987, committer_ids=[111])
+    mr = testing_helpers.MakeMonorailRequest(project=project)
+    mr.query = query
+    mr.sort_spec = sort_spec
+    mr.can = can
+    mr.project_name = project.project_name
+    mr.project = project
+
+    for idx in range(create_issues):
+      _created_issue = fake.MakeTestIssue(
+          project.project_id,
+          idx,
+          'summary_%d' % idx,
+          'status',
+          111,
+          reporter_id=111)
+      services.issue.TestAddIssue(_created_issue)
+
+    self.assertEqual(expected, issuedetailezt._ShouldShowFlipper(mr, services))
+
+  def testShouldShowFlipper_RegularSizedProject(self):
+    # If the user is looking for a specific issue, no flipper.
+    self.VerifyShouldShowFlipper(
+        False, '123', '', tracker_constants.OPEN_ISSUES_CAN)
+    self.VerifyShouldShowFlipper(False, '123', '', 5)
+    self.VerifyShouldShowFlipper(
+        False, '123', 'priority', tracker_constants.OPEN_ISSUES_CAN)
+
+    # If the user did a search or sort or all in a small can, show flipper.
+    self.VerifyShouldShowFlipper(
+        True, 'memory leak', '', tracker_constants.OPEN_ISSUES_CAN)
+    self.VerifyShouldShowFlipper(
+        True, 'id=1,2,3', '', tracker_constants.OPEN_ISSUES_CAN)
+    # Any can other than 1 or 2 is doing a query and so it should have a
+    # failry narrow result set size.  5 is issues starred by me.
+    self.VerifyShouldShowFlipper(True, '', '', 5)
+    self.VerifyShouldShowFlipper(
+        True, '', 'status', tracker_constants.OPEN_ISSUES_CAN)
+
+    # In a project without a huge number of issues, still show the flipper even
+    # if there was no specific query.
+    self.VerifyShouldShowFlipper(
+        True, '', '', tracker_constants.OPEN_ISSUES_CAN)
+
+  def testShouldShowFlipper_LargeSizedProject(self):
+    settings.threshold_to_suppress_prev_next = 1
+
+    # In a project that has tons of issues, save time by not showing the
+    # flipper unless there was a specific query, sort, or can.
+    self.VerifyShouldShowFlipper(
+        False, '', '', tracker_constants.ALL_ISSUES_CAN, create_issues=3)
+    self.VerifyShouldShowFlipper(
+        False, '', '', tracker_constants.OPEN_ISSUES_CAN, create_issues=3)
diff --git a/tracker/test/issueentry_test.py b/tracker/test/issueentry_test.py
new file mode 100644
index 0000000..4a64d7c
--- /dev/null
+++ b/tracker/test/issueentry_test.py
@@ -0,0 +1,1045 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unittests for the issueentry servlet."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import mox
+import time
+import unittest
+
+import ezt
+
+from google.appengine.ext import testbed
+from mock import Mock, patch
+import webapp2
+
+from framework import framework_bizobj
+from framework import framework_views
+from framework import permissions
+from services import service_manager
+from services import template_svc
+from testing import fake
+from testing import testing_helpers
+from tracker import issueentry
+from tracker import tracker_bizobj
+from proto import tracker_pb2
+from proto import user_pb2
+
+
+class IssueEntryTest(unittest.TestCase):
+  def setUp(self):
+    self.testbed = testbed.Testbed()
+    self.testbed.activate()
+    self.testbed.init_memcache_stub()
+    self.testbed.init_datastore_v3_stub()
+    # Load queue.yaml.
+
+    self.services = service_manager.Services(
+        config=fake.ConfigService(),
+        issue=fake.IssueService(),
+        user=fake.UserService(),
+        usergroup=fake.UserGroupService(),
+        project=fake.ProjectService(),
+        template=Mock(spec=template_svc.TemplateService),
+        features=fake.FeaturesService())
+    self.project = self.services.project.TestAddProject('proj', project_id=987)
+    request = webapp2.Request.blank('/p/proj/issues/entry')
+    response = webapp2.Response()
+    self.servlet = issueentry.IssueEntry(
+        request, response, services=self.services)
+    self.user = self.services.user.TestAddUser('to_pass_tests', 0)
+    self.services.features.TestAddHotlist(
+        name='dontcare', summary='', owner_ids=[0])
+    self.template = testing_helpers.DefaultTemplates()[1]
+    self.services.template.GetTemplateByName = Mock(return_value=self.template)
+    self.services.template.GetTemplateSetForProject = Mock(
+        return_value=[(1, 'name', False)])
+
+    # Set-up for testing hotlist parsing.
+    # Scenario:
+    #   Users: U1, U2, and U3
+    #   Hotlists:
+    #     H1: owned by U1 (private)
+    #     H2: owned by U2, can be edited by U1 (private)
+    #     H2: owned by U3, can be edited by U1 and U2 (public)
+    self.cnxn = fake.MonorailConnection()
+    self.U1 = self.services.user.TestAddUser('U1', 111)
+    self.U2 = self.services.user.TestAddUser('U2', 222)
+    self.U3 = self.services.user.TestAddUser('U3', 333)
+
+    self.H1 = self.services.features.TestAddHotlist(
+        name='H1', summary='', owner_ids=[111], is_private=True)
+    self.H2 = self.services.features.TestAddHotlist(
+        name='H2', summary='', owner_ids=[222], editor_ids=[111],
+        is_private=True)
+    self.H2_U3 = self.services.features.TestAddHotlist(
+        name='H2', summary='', owner_ids=[333], editor_ids=[111, 222],
+        is_private=False)
+
+    self.mox = mox.Mox()
+
+  def tearDown(self):
+    self.testbed.deactivate()
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def testAssertBasePermission(self):
+    """Permit users with CREATE_ISSUE."""
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry', services=self.services,
+        perms=permissions.EMPTY_PERMISSIONSET)
+    self.assertRaises(permissions.PermissionException,
+                      self.servlet.AssertBasePermission, mr)
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry', services=self.services,
+        perms=permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET)
+    self.servlet.AssertBasePermission(mr)
+
+  def testDiscardUnusedTemplateLabelPrefixes(self):
+    labels = ['pre-val', 'other-value', 'oneword', 'x', '-y', '-w-z', '', '-']
+    self.assertEqual(labels,
+                     issueentry._DiscardUnusedTemplateLabelPrefixes(labels))
+
+    labels = ['prefix-value', 'other-?', 'third-', '', '-', '-?']
+    self.assertEqual(['prefix-value', 'third-', '', '-'],
+                     issueentry._DiscardUnusedTemplateLabelPrefixes(labels))
+
+  def testGatherPageData(self):
+    user = self.services.user.TestAddUser('user@invalid', 100)
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry', services=self.services)
+    mr.perms = permissions.PermissionSet(
+        [permissions.CREATE_ISSUE, permissions.EDIT_ISSUE])
+    mr.auth.user_view = framework_views.MakeUserView(
+        'cnxn', self.services.user, 100)
+    mr.auth.effective_ids = {100}
+    mr.template_name = 'rutabaga'
+
+    self.mox.StubOutWithMock(self.services.user, 'GetUser')
+    self.services.user.GetUser(
+        mox.IgnoreArg(), mox.IgnoreArg()).MultipleTimes().AndReturn(user)
+    self.mox.ReplayAll()
+    config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+    config.field_defs = [
+        tracker_bizobj.MakeFieldDef(
+            22, mr.project_id, 'NotEnum', tracker_pb2.FieldTypes.STR_TYPE, None,
+            '', False, False, False, None, None, '', False, '', '',
+            tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False),
+        tracker_bizobj.MakeFieldDef(
+            23, mr.project_id, 'Choices', tracker_pb2.FieldTypes.ENUM_TYPE,
+            None, '', False, False, False, None, None, '', False, '', '',
+            tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False),
+        tracker_bizobj.MakeFieldDef(
+            24,
+            mr.project_id,
+            'RestrictedField',
+            tracker_pb2.FieldTypes.STR_TYPE,
+            None,
+            '',
+            False,
+            False,
+            False,
+            None,
+            None,
+            '',
+            False,
+            '',
+            '',
+            tracker_pb2.NotifyTriggers.NEVER,
+            'no_action',
+            'doc',
+            False,
+            is_restricted_field=True)
+    ]
+    self.services.config.StoreConfig(mr.cnxn, config)
+    template = tracker_pb2.TemplateDef(
+        labels=['NotEnum-Not-Masked', 'Choices-Masked'])
+    self.services.template.GetTemplateByName.return_value = template
+
+    page_data = self.servlet.GatherPageData(mr)
+    self.mox.VerifyAll()
+    self.assertEqual(page_data['initial_owner'], 'user@invalid')
+    self.assertEqual(page_data['initial_status'], 'New')
+    self.assertTrue(page_data['clear_summary_on_click'])
+    self.assertTrue(page_data['must_edit_summary'])
+    self.assertEqual(page_data['labels'], ['NotEnum-Not-Masked'])
+    self.assertEqual(page_data['offer_templates'], ezt.boolean(False))
+    self.assertEqual(page_data['fields'][0].is_editable, ezt.boolean(True))
+    self.assertEqual(page_data['fields'][1].is_editable, ezt.boolean(True))
+    self.assertEqual(page_data['fields'][2].is_editable, ezt.boolean(False))
+    self.assertEqual(page_data['uneditable_fields'], ezt.boolean(True))
+
+  def testGatherPageData_Approvals(self):
+    user = self.services.user.TestAddUser('user@invalid', 100)
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry', services=self.services)
+    mr.auth.user_view = framework_views.MakeUserView(
+        'cnxn', self.services.user, 100)
+    mr.template_name = 'rutabaga'
+
+    self.mox.StubOutWithMock(self.services.user, 'GetUser')
+    self.services.user.GetUser(
+        mox.IgnoreArg(), mox.IgnoreArg()).MultipleTimes().AndReturn(user)
+    self.mox.ReplayAll()
+    config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+    config.field_defs = [
+    tracker_bizobj.MakeFieldDef(
+        24, mr.project_id, 'UXReview',
+        tracker_pb2.FieldTypes.APPROVAL_TYPE, None, '', False, False,
+        False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)]
+    self.services.config.StoreConfig(mr.cnxn, config)
+    template = tracker_pb2.TemplateDef()
+    template.phases = [tracker_pb2.Phase(
+        phase_id=1, rank=4, name='Stable')]
+    template.approval_values = [tracker_pb2.ApprovalValue(
+        approval_id=24, phase_id=1,
+        status=tracker_pb2.ApprovalStatus.NEEDS_REVIEW)]
+    self.services.template.GetTemplateByName.return_value = template
+
+    page_data = self.servlet.GatherPageData(mr)
+    self.mox.VerifyAll()
+    self.assertEqual(page_data['approvals'][0].field_name, 'UXReview')
+    self.assertEqual(page_data['initial_phases'][0],
+                          tracker_pb2.Phase(phase_id=1, name='Stable', rank=4))
+    self.assertEqual(page_data['prechecked_approvals'], ['24_phase_0'])
+    self.assertEqual(page_data['required_approval_ids'], [24])
+
+    # phase fields row shown when config contains phase fields.
+    config.field_defs.append(tracker_bizobj.MakeFieldDef(
+        26, mr.project_id, 'GateTarget',
+        tracker_pb2.FieldTypes.INT_TYPE, None, '', False, False, False,
+        None, None, '', False, '', '', tracker_pb2.NotifyTriggers.NEVER,
+        'no_action', 'doc', False, is_phase_field=True))
+    self.services.config.StoreConfig(mr.cnxn, config)
+    page_data = self.servlet.GatherPageData(mr)
+    self.assertEqual(page_data['issue_phase_names'], ['stable'])
+
+    # approval subfields in config hidden when chosen template does not contain
+    # its parent approval
+    template = tracker_pb2.TemplateDef()
+    self.services.template.GetTemplateByName.return_value = template
+    page_data = self.servlet.GatherPageData(mr)
+    self.assertEqual(page_data['approvals'], [])
+    # phase fields row hidden when template has no phases
+    self.assertEqual(page_data['issue_phase_names'], [])
+
+  # TODO(jojwang): monorail:6305, remove this test when Edit perms
+  # for field values are implemented.
+  def testGatherPageData_FLTSpecialFields(self):
+    user = self.services.user.TestAddUser('user@invalid', 100)
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry', services=self.services)
+    mr.auth.user_view = framework_views.MakeUserView(
+        'cnxn', self.services.user, 100)
+    mr.template_name = 'rutabaga'
+
+    self.mox.StubOutWithMock(self.services.user, 'GetUser')
+    self.services.user.GetUser(
+        mox.IgnoreArg(), mox.IgnoreArg()).MultipleTimes().AndReturn(user)
+    self.mox.ReplayAll()
+    config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+    config.field_defs = [
+        tracker_bizobj.MakeFieldDef(
+            25, mr.project_id, 'nOtice',
+            tracker_pb2.FieldTypes.STR_TYPE, None, '', False, False,
+            False, None, None, '', False, '', '',
+            tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False),
+        tracker_bizobj.MakeFieldDef(
+            24, mr.project_id, 'M-Target',
+            tracker_pb2.FieldTypes.STR_TYPE, None, '', False, False,
+            False, None, None, '', False, '', '',
+            tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False),
+        tracker_bizobj.MakeFieldDef(
+            25, mr.project_id, 'whitepaper',
+            tracker_pb2.FieldTypes.STR_TYPE, None, '', False, False,
+            False, None, None, '', False, '', '',
+            tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False),
+        tracker_bizobj.MakeFieldDef(
+            25, mr.project_id, 'm-approved',
+            tracker_pb2.FieldTypes.STR_TYPE, None, '', False, False,
+            False, None, None, '', False, '', '',
+            tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False),
+    ]
+
+    self.services.config.StoreConfig(mr.cnxn, config)
+    template = tracker_pb2.TemplateDef()
+    self.services.template.GetTemplateByName.return_value = template
+
+    page_data = self.servlet.GatherPageData(mr)
+    self.mox.VerifyAll()
+    self.assertEqual(page_data['fields'][0].field_name, 'M-Target')
+    self.assertEqual(len(page_data['fields']), 1)
+
+  def testGatherPageData_DefaultOwnerAvailability(self):
+    user = self.services.user.TestAddUser('user@invalid', 100)
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry', services=self.services)
+    mr.auth.user_view = framework_views.MakeUserView(
+        'cnxn', self.services.user, 100)
+    mr.template_name = 'rutabaga'
+
+    self.mox.StubOutWithMock(self.services.user, 'GetUser')
+    self.services.user.GetUser(
+        mox.IgnoreArg(), mox.IgnoreArg()).MultipleTimes().AndReturn(user)
+    self.mox.ReplayAll()
+
+    page_data = self.servlet.GatherPageData(mr)
+    self.mox.VerifyAll()
+    self.assertEqual(page_data['initial_owner'], 'user@invalid')
+    self.assertEqual(page_data['owner_avail_state'], 'never')
+    self.assertEqual(
+        page_data['owner_avail_message_short'],
+        'User never visited')
+
+    user.last_visit_timestamp = int(time.time())
+    mr.auth.user_view = framework_views.MakeUserView(
+        'cnxn', self.services.user, 100)
+    page_data = self.servlet.GatherPageData(mr)
+    self.mox.VerifyAll()
+    self.assertEqual(page_data['initial_owner'], 'user@invalid')
+    self.assertEqual(page_data['owner_avail_state'], None)
+    self.assertEqual(page_data['owner_avail_message_short'], '')
+
+  def testGatherPageData_TemplateAllowsKeepingSummary(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry', services=self.services)
+    mr.auth.user_view = framework_views.StuffUserView(100, 'user@invalid', True)
+    mr.template_name = 'rutabaga'
+    user = self.services.user.TestAddUser('user@invalid', 100)
+
+    self.mox.StubOutWithMock(self.services.user, 'GetUser')
+    self.services.user.GetUser(
+        mox.IgnoreArg(), mox.IgnoreArg()).MultipleTimes().AndReturn(user)
+    self.mox.ReplayAll()
+    config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+    self.services.config.StoreConfig(mr.cnxn, config)
+    self.template.summary_must_be_edited = False
+
+    page_data = self.servlet.GatherPageData(mr)
+    self.mox.VerifyAll()
+    self.assertEqual(page_data['initial_owner'], 'user@invalid')
+    self.assertEqual(page_data['initial_status'], 'New')
+    self.assertFalse(page_data['clear_summary_on_click'])
+    self.assertFalse(page_data['must_edit_summary'])
+
+  def testGatherPageData_DeepLinkSetsSummary(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry?summary=foo', services=self.services)
+    mr.auth.user_view = framework_views.StuffUserView(100, 'user@invalid', True)
+    user = self.services.user.TestAddUser('user@invalid', 100)
+    mr.template_name = 'rutabaga'
+
+    self.mox.StubOutWithMock(self.services.user, 'GetUser')
+    self.services.user.GetUser(
+        mox.IgnoreArg(), mox.IgnoreArg()).MultipleTimes().AndReturn(user)
+    self.mox.ReplayAll()
+
+    page_data = self.servlet.GatherPageData(mr)
+    self.mox.VerifyAll()
+    self.assertEqual(page_data['initial_owner'], 'user@invalid')
+    self.assertEqual(page_data['initial_status'], 'New')
+    self.assertFalse(page_data['clear_summary_on_click'])
+    self.assertTrue(page_data['must_edit_summary'])
+
+  @patch('framework.framework_bizobj.UserIsInProject')
+  def testGatherPageData_MembersOnlyTemplatesExcluded(self,
+        mockUserIsInProject):
+    """Templates with members_only=True are excluded from results
+    when the user is not a member of the project."""
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry', services=self.services)
+    mr.auth.user_view = framework_views.StuffUserView(100, 'user@invalid', True)
+    user = self.services.user.TestAddUser('user@invalid', 100)
+    mr.template_name = 'rutabaga'
+    self.services.template.GetTemplateSetForProject = Mock(
+        return_value=[(1, 'one', False), (2, 'two', True)])
+    mockUserIsInProject.return_value = False
+
+    self.mox.StubOutWithMock(self.services.user, 'GetUser')
+    self.services.user.GetUser(
+        mox.IgnoreArg(), mox.IgnoreArg()).MultipleTimes().AndReturn(user)
+    self.mox.ReplayAll()
+
+    page_data = self.servlet.GatherPageData(mr)
+    self.mox.VerifyAll()
+    self.assertEqual(page_data['config'].template_names, ['one'])
+
+  @patch('framework.framework_bizobj.UserIsInProject')
+  def testGatherPageData_DefaultTemplatesMember(self, mockUserIsInProject):
+    """If no template is specified, the default one is used based on
+    whether the user is a project member."""
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry', services=self.services)
+    mr.auth.user_view = framework_views.StuffUserView(100, 'user@invalid', True)
+    user = self.services.user.TestAddUser('user@invalid', 100)
+    self.services.template.GetTemplateSetForProject = Mock(
+        return_value=[(1, 'one', False), (2, 'two', True)])
+    config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+    config.default_template_for_users = 456
+    config.default_template_for_developers = 789
+    self.services.config.StoreConfig(mr.cnxn, config)
+
+    mockUserIsInProject.return_value = True
+    self.services.template.GetTemplateById = Mock(return_value=self.template)
+    self.mox.StubOutWithMock(self.services.user, 'GetUser')
+    self.services.user.GetUser(
+        mox.IgnoreArg(), mox.IgnoreArg()).MultipleTimes().AndReturn(user)
+
+    self.mox.ReplayAll()
+    self.servlet.GatherPageData(mr)
+    self.mox.VerifyAll()
+
+    call_args = self.services.template.GetTemplateById.call_args[0]
+    self.assertEqual(call_args[1], 789)
+
+  @patch('framework.framework_bizobj.UserIsInProject')
+  def testGatherPageData_DefaultTemplatesNonMember(self, mockUserIsInProject):
+    """If no template is specified, the default one is used based on
+    whether the user is not a project member."""
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry', services=self.services)
+    mr.auth.user_view = framework_views.StuffUserView(100, 'user@invalid', True)
+    user = self.services.user.TestAddUser('user@invalid', 100)
+    self.services.template.GetTemplateSetForProject = Mock(
+        return_value=[(1, 'one', False), (2, 'two', True)])
+    config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+    config.default_template_for_users = 456
+    config.default_template_for_developers = 789
+    self.services.config.StoreConfig(mr.cnxn, config)
+
+    mockUserIsInProject.return_value = False
+    self.services.template.GetTemplateById = Mock(return_value=self.template)
+    self.mox.StubOutWithMock(self.services.user, 'GetUser')
+    self.services.user.GetUser(
+        mox.IgnoreArg(), mox.IgnoreArg()).MultipleTimes().AndReturn(user)
+
+    self.mox.ReplayAll()
+    self.servlet.GatherPageData(mr)
+    self.mox.VerifyAll()
+
+    call_args = self.services.template.GetTemplateById.call_args[0]
+    self.assertEqual(call_args[1], 456)
+
+  def testGatherPageData_MissingDefaultTemplates(self):
+    """If the default templates were deleted, pick the first template."""
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry', services=self.services)
+    mr.auth.user_view = framework_views.StuffUserView(100, 'user@invalid', True)
+    user = self.services.user.TestAddUser('user@invalid', 100)
+    self.services.template.GetTemplateSetForProject = Mock(
+        return_value=[(1, 'one', False), (2, 'two', True)])
+
+    self.services.template.GetTemplateById.return_value = None
+    self.services.template.GetProjectTemplates.return_value = [
+        tracker_pb2.TemplateDef(members_only=True),
+        tracker_pb2.TemplateDef(members_only=False)]
+    self.mox.StubOutWithMock(self.services.user, 'GetUser')
+    self.services.user.GetUser(
+        mox.IgnoreArg(), mox.IgnoreArg()).MultipleTimes().AndReturn(user)
+
+    self.mox.ReplayAll()
+    page_data = self.servlet.GatherPageData(mr)
+    self.mox.VerifyAll()
+
+    self.assertTrue(self.services.template.GetProjectTemplates.called)
+    self.assertTrue(page_data['config'].template_view.members_only)
+
+  def testGatherPageData_IncorrectTemplate(self):
+    """The handler shouldn't error out if passed a non-existent template."""
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry', services=self.services)
+    mr.auth.user_view = framework_views.StuffUserView(100, 'user@invalid', True)
+    mr.template_name = 'rutabaga'
+
+    user = self.services.user.TestAddUser('user@invalid', 100)
+    config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+    config.default_template_for_users = 456
+    config.default_template_for_developers = 789
+    self.services.config.StoreConfig(mr.cnxn, config)
+
+    self.services.template.GetTemplateSetForProject.return_value = [
+        (1, 'one', False), (2, 'two', True)]
+    self.services.template.GetTemplateByName.return_value = None
+    self.services.template.GetTemplateById.return_value = \
+        tracker_pb2.TemplateDef(template_id=123, labels=['yo'])
+    self.services.template.GetProjectTemplates.return_value = [
+        tracker_pb2.TemplateDef(labels=['no']),
+        tracker_pb2.TemplateDef(labels=['maybe'])]
+    self.mox.StubOutWithMock(self.services.user, 'GetUser')
+    self.services.user.GetUser(
+        mox.IgnoreArg(), mox.IgnoreArg()).MultipleTimes().AndReturn(user)
+
+    self.mox.ReplayAll()
+    page_data = self.servlet.GatherPageData(mr)
+    self.mox.VerifyAll()
+
+    self.assertTrue(self.services.template.GetTemplateByName.called)
+    self.assertTrue(self.services.template.GetTemplateById.called)
+    self.assertFalse(self.services.template.GetProjectTemplates.called)
+    self.assertEqual(page_data['config'].template_view.label0, 'yo')
+
+  def testGatherPageData_RestrictNewIssues(self):
+    """Users with this pref set default to reporting issues with R-V-G."""
+    self.mox.ReplayAll()
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry', services=self.services)
+    mr.auth.user_view = framework_views.StuffUserView(100, 'user@invalid', True)
+    user = self.services.user.TestAddUser('user@invalid', 100)
+    self.services.user.GetUser = Mock(return_value=user)
+    self.services.template.GetTemplateById = Mock(return_value=self.template)
+
+    mr.auth.user_id = 100
+    page_data = self.servlet.GatherPageData(mr)
+    self.assertNotIn('Restrict-View-Google', page_data['labels'])
+
+    pref = user_pb2.UserPrefValue(name='restrict_new_issues', value='true')
+    self.services.user.SetUserPrefs(self.cnxn, 100, [pref])
+    page_data = self.servlet.GatherPageData(mr)
+    self.assertIn('Restrict-View-Google', page_data['labels'])
+
+  def testGatherHelpData_Anon(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry', project=self.project)
+    mr.auth.user_pb = user_pb2.User()
+    mr.auth.user_id = 0
+
+    help_data = self.servlet.GatherHelpData(mr, {})
+    self.assertEqual(
+        {'account_cue': None,
+         'cue': None,
+         'is_privileged_domain_user': None},
+        help_data)
+
+  def testGatherHelpData_NewUser(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry', project=self.project)
+    mr.auth.user_pb = user_pb2.User(user_id=111)
+    mr.auth.user_id = 111
+
+    help_data = self.servlet.GatherHelpData(mr, {})
+    self.assertEqual(
+        {'account_cue': None,
+         'cue': 'privacy_click_through',
+         'is_privileged_domain_user': None},
+        help_data)
+
+  def testGatherHelpData_AlreadyClickedThroughPrivacy(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry', project=self.project)
+    mr.auth.user_pb = user_pb2.User(user_id=111)
+    mr.auth.user_id = 111
+    self.services.user.SetUserPrefs(
+        self.cnxn, 111,
+        [user_pb2.UserPrefValue(name='privacy_click_through', value='true')])
+
+    help_data = self.servlet.GatherHelpData(mr, {})
+    self.assertEqual(
+        {'account_cue': None,
+         'cue': 'code_of_conduct',
+         'is_privileged_domain_user': None},
+        help_data)
+
+  def testGatherHelpData_DismissedEverything(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry', project=self.project)
+    mr.auth.user_pb = user_pb2.User(user_id=111)
+    mr.auth.user_id = 111
+    self.services.user.SetUserPrefs(
+        self.cnxn, 111,
+        [user_pb2.UserPrefValue(name='privacy_click_through', value='true'),
+         user_pb2.UserPrefValue(name='code_of_conduct', value='true')])
+
+    help_data = self.servlet.GatherHelpData(mr, {})
+    self.assertEqual(
+        {'account_cue': None,
+         'cue': None,
+         'is_privileged_domain_user': None},
+        help_data)
+
+  @patch('framework.cloud_tasks_helpers.create_task')
+  def testProcessFormData_RedirectToEnteredIssue(self, _create_task_mock):
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry', project=self.project)
+    mr.auth.user_view = framework_views.StuffUserView(100, 'user@invalid', True)
+    mr.template_name = 'rutabaga'
+    mr.auth.effective_ids = set([100])
+    post_data = fake.PostData(
+        template_name=['rutabaga'],
+        summary=['fake summary'],
+        comment=['fake comment'],
+        status=['New'])
+
+    self.mox.ReplayAll()
+    url = self.servlet.ProcessFormData(mr, post_data)
+
+    self.mox.VerifyAll()
+    self.assertTrue('/p/proj/issues/detail?id=' in url)
+
+  @patch('framework.cloud_tasks_helpers.create_task')
+  def testProcessFormData_AcceptWithFields(self, _create_task_mock):
+    """We can create new issues with custom fields (restricted or not)."""
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry', project=self.project)
+    mr.auth.user_view = framework_views.StuffUserView(100, 'admin@test', True)
+    mr.template_name = 'rutabaga'
+    mr.auth.effective_ids = set([100])
+    config = self.services.config.GetProjectConfig(
+        mr.cnxn, self.project.project_id)
+    config.field_defs = [
+        tracker_bizobj.MakeFieldDef(
+            1, 789, 'NonRestrictedField', tracker_pb2.FieldTypes.INT_TYPE, None,
+            '', False, False, False, None, None, '', False, '', '',
+            tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'NonRestrictedField',
+            False),
+        tracker_bizobj.MakeFieldDef(
+            2,
+            789,
+            'RestrictedField',
+            tracker_pb2.FieldTypes.INT_TYPE,
+            None,
+            '',
+            False,
+            False,
+            False,
+            None,
+            None,
+            '',
+            False,
+            '',
+            '',
+            tracker_pb2.NotifyTriggers.NEVER,
+            'no_action',
+            'RestrictedField',
+            False,
+            is_restricted_field=True),
+        tracker_bizobj.MakeFieldDef(
+            3,
+            789,
+            'RestrictedEnumField',
+            tracker_pb2.FieldTypes.ENUM_TYPE,
+            None,
+            '',
+            False,
+            False,
+            False,
+            None,
+            None,
+            '',
+            False,
+            '',
+            '',
+            tracker_pb2.NotifyTriggers.NEVER,
+            'no_action',
+            'RestrictedEnumField',
+            False,
+            is_restricted_field=True)
+    ]
+    self.services.config.StoreConfig(mr.cnxn, config)
+    post_data = fake.PostData(
+        template_name=['rutabaga'],
+        summary=['fake summary'],
+        comment=['fake comment'],
+        custom_1=['3'],
+        custom_2=['7'],
+        label=['RestrictedEnumField-7'],
+        status=['New'])
+
+    self.mox.ReplayAll()
+    url = self.servlet.ProcessFormData(mr, post_data)
+
+    self.mox.VerifyAll()
+    self.assertTrue('/p/proj/issues/detail?id=' in url)
+    field_values = self.services.issue.issues_by_project[987][1].field_values
+    self.assertEqual(
+        self.services.issue.issues_by_project[987][1].labels,
+        ['RestrictedEnumField-7'])
+    self.assertEqual(field_values[0].int_value, 3)
+    self.assertEqual(field_values[1].int_value, 7)
+
+  @patch('framework.cloud_tasks_helpers.create_task')
+  def testProcessFormData_AcceptEnforceTemplateRestrictedDefaultValues(
+      self, _create_task_mock):
+    """The template applies default vals on fields that the user cannot edit."""
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry', project=self.project)
+    mr.auth.user_view = framework_views.StuffUserView(100, 'admin@test', True)
+    mr.template_name = 'rutabaga'
+    mr.auth.effective_ids = set([100])
+    mr.perms = permissions.PermissionSet([])
+    config = self.services.config.GetProjectConfig(
+        mr.cnxn, self.project.project_id)
+    config.field_defs = [
+        tracker_bizobj.MakeFieldDef(
+            1, 789, 'NonRestrictedField', tracker_pb2.FieldTypes.INT_TYPE, None,
+            '', False, False, False, None, None, '', False, '', '',
+            tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'NonRestrictedField',
+            False),
+        tracker_bizobj.MakeFieldDef(
+            2,
+            789,
+            'RestrictedField',
+            tracker_pb2.FieldTypes.INT_TYPE,
+            None,
+            '',
+            False,
+            False,
+            False,
+            None,
+            None,
+            '',
+            False,
+            '',
+            '',
+            tracker_pb2.NotifyTriggers.NEVER,
+            'no_action',
+            'RestrictedField',
+            False,
+            is_restricted_field=True),
+        tracker_bizobj.MakeFieldDef(
+            3,
+            789,
+            'RestrictedEnumField',
+            tracker_pb2.FieldTypes.ENUM_TYPE,
+            None,
+            '',
+            False,
+            False,
+            False,
+            None,
+            None,
+            '',
+            False,
+            '',
+            '',
+            tracker_pb2.NotifyTriggers.NEVER,
+            'no_action',
+            'RestrictedEnumField',
+            False,
+            is_restricted_field=True)
+    ]
+    self.services.config.StoreConfig(mr.cnxn, config)
+    post_data = fake.PostData(
+        template_name=['rutabaga'],
+        summary=['fake summary'],
+        comment=['fake comment'],
+        custom_1=['3'],
+        label=['Hey'],
+        status=['New'])
+
+    temp_restricted_fv = tracker_bizobj.MakeFieldValue(
+        2, 3737, None, None, None, None, False)
+    self.template.field_values.append(temp_restricted_fv)
+    self.template.labels.append('RestrictedEnumField-b')
+
+    self.mox.ReplayAll()
+    url = self.servlet.ProcessFormData(mr, post_data)
+
+    self.mox.VerifyAll()
+    self.assertTrue('/p/proj/issues/detail?id=' in url)
+    field_values = self.services.issue.issues_by_project[987][1].field_values
+    self.assertEqual(
+        self.services.issue.issues_by_project[987][1].labels,
+        ['Hey', 'RestrictedEnumField-b'])
+    self.assertEqual(field_values[0].int_value, 3)
+    self.assertEqual(field_values[1].int_value, 3737)
+
+  def testProcessFormData_RejectRestrictedFields(self):
+    """We raise an AssertionError when restricted fields are set w/o perms."""
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry', project=self.project)
+    mr.auth.user_view = framework_views.StuffUserView(
+        100, 'non-admin@test', True)
+    mr.template_name = 'rutabaga'
+    mr.auth.effective_ids = set([100])
+    mr.perms = permissions.PermissionSet([])
+    config = self.services.config.GetProjectConfig(
+        mr.cnxn, self.project.project_id)
+    config.field_defs = [
+        tracker_bizobj.MakeFieldDef(
+            1, 789, 'NonRestrictedField', tracker_pb2.FieldTypes.INT_TYPE, None,
+            '', False, False, False, None, None, '', False, '', '',
+            tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'NonRestrictedField',
+            False),
+        tracker_bizobj.MakeFieldDef(
+            2,
+            789,
+            'RestrictedField',
+            tracker_pb2.FieldTypes.INT_TYPE,
+            None,
+            '',
+            False,
+            False,
+            False,
+            None,
+            None,
+            '',
+            False,
+            '',
+            '',
+            tracker_pb2.NotifyTriggers.NEVER,
+            'no_action',
+            'RestrictedField',
+            False,
+            is_restricted_field=True),
+        tracker_bizobj.MakeFieldDef(
+            3,
+            789,
+            'RestrictedEnumField',
+            tracker_pb2.FieldTypes.ENUM_TYPE,
+            None,
+            '',
+            False,
+            False,
+            False,
+            None,
+            None,
+            '',
+            False,
+            '',
+            '',
+            tracker_pb2.NotifyTriggers.NEVER,
+            'no_action',
+            'RestrictedEnumField',
+            False,
+            is_restricted_field=True)
+    ]
+    self.services.config.StoreConfig(mr.cnxn, config)
+    post_data_add_fv = fake.PostData(
+        template_name=['rutabaga'],
+        summary=['fake summary'],
+        comment=['fake comment'],
+        custom_1=['3'],
+        custom_2=['7'],
+        status=['New'])
+    post_data_label_edits_enum = fake.PostData(
+        template_name=['rutabaga'],
+        summary=['fake summary'],
+        comment=['fake comment'],
+        label=['RestrictedEnumField-7'],
+        status=['New'])
+
+    self.assertRaises(
+        AssertionError, self.servlet.ProcessFormData, mr, post_data_add_fv)
+    self.assertRaises(
+        AssertionError, self.servlet.ProcessFormData, mr,
+        post_data_label_edits_enum)
+
+  def testProcessFormData_RejectPlacedholderSummary(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry')
+    mr.auth.user_view = framework_views.StuffUserView(100, 'user@invalid', True)
+    mr.perms = permissions.USER_PERMISSIONSET
+    mr.template_name = 'rutabaga'
+    post_data = fake.PostData(
+        template_name=['rutabaga'],
+        summary=[issueentry.PLACEHOLDER_SUMMARY],
+        comment=['fake comment'],
+        status=['New'])
+
+    self.mox.StubOutWithMock(self.servlet, 'PleaseCorrect')
+    self.servlet.PleaseCorrect(
+        mr, component_required=None, fields=[], initial_blocked_on='',
+        initial_blocking='', initial_cc='', initial_comment='fake comment',
+        initial_components='', initial_owner='', initial_status='New',
+        initial_summary='Enter one-line summary', initial_hotlists='',
+        labels=[], template_name='rutabaga')
+    self.mox.ReplayAll()
+
+    url = self.servlet.ProcessFormData(mr, post_data)
+    self.mox.VerifyAll()
+    self.assertEqual('Summary is required', mr.errors.summary)
+    self.assertIsNone(url)
+
+  def testProcessFormData_RejectUnmodifiedTemplate(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry')
+    mr.perms = permissions.USER_PERMISSIONSET
+    mr.auth.user_view = framework_views.StuffUserView(100, 'user@invalid', True)
+    post_data = fake.PostData(
+        template_name=['rutabaga'],
+        summary=['Nya nya I modified the summary'],
+        comment=[self.template.content],
+        status=['New'])
+
+    self.mox.StubOutWithMock(self.servlet, 'PleaseCorrect')
+    self.servlet.PleaseCorrect(
+        mr, component_required=None, fields=[], initial_blocked_on='',
+        initial_blocking='', initial_cc='',
+        initial_comment=self.template.content, initial_components='',
+        initial_owner='', initial_status='New',
+        initial_summary='Nya nya I modified the summary', initial_hotlists='',
+        labels=[], template_name='rutabaga')
+    self.mox.ReplayAll()
+
+    url = self.servlet.ProcessFormData(mr, post_data)
+    self.mox.VerifyAll()
+    self.assertEqual('Template must be filled out.', mr.errors.comment)
+    self.assertIsNone(url)
+
+  def testProcessFormData_RejectNonexistentHotlist(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry', user_info={'user_id': 111})
+    entered_hotlists = 'H3'
+    post_data = fake.PostData(hotlists=[entered_hotlists],
+        template_name=['rutabaga'])
+    self.mox.StubOutWithMock(self.servlet, 'PleaseCorrect')
+    self.servlet.PleaseCorrect(
+        mr, component_required=None, fields=[], initial_blocked_on='',
+        initial_blocking='', initial_cc='', initial_comment='',
+        initial_components='', initial_owner='', initial_status='',
+        initial_summary='', initial_hotlists=entered_hotlists, labels=[],
+        template_name='rutabaga')
+    self.mox.ReplayAll()
+    url = self.servlet.ProcessFormData(mr, post_data)
+    self.mox.VerifyAll()
+    self.assertEqual('You have no hotlist(s) named: H3', mr.errors.hotlists)
+    self.assertIsNone(url)
+
+  def testProcessFormData_RejectNonexistentHotlistOwner(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry', user_info={'user_id': 111})
+    entered_hotlists = 'abc:H1'
+    post_data = fake.PostData(hotlists=[entered_hotlists],
+                              template_name=['rutabaga'])
+    self.mox.StubOutWithMock(self.servlet, 'PleaseCorrect')
+    self.servlet.PleaseCorrect(
+        mr, component_required=None, fields=[], initial_blocked_on='',
+        initial_blocking='', initial_cc='', initial_comment='',
+        initial_components='', initial_owner='', initial_status='',
+        initial_summary='', initial_hotlists=entered_hotlists, labels=[],
+        template_name='rutabaga')
+    self.mox.ReplayAll()
+    url = self.servlet.ProcessFormData(mr, post_data)
+    self.mox.VerifyAll()
+    self.assertEqual('You have no hotlist(s) owned by: abc', mr.errors.hotlists)
+    self.assertIsNone(url)
+
+  def testProcessFormData_RejectInvalidHotlistName(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry', user_info={'user_id': 111})
+    entered_hotlists = 'U1:H2'
+    post_data = fake.PostData(hotlists=[entered_hotlists],
+                              template_name=['rutabaga'])
+    self.mox.StubOutWithMock(self.servlet, 'PleaseCorrect')
+    self.servlet.PleaseCorrect(
+        mr, component_required=None, fields=[], initial_blocked_on='',
+        initial_blocking='', initial_cc='', initial_comment='',
+        initial_components='', initial_owner='', initial_status='',
+        initial_summary='', initial_hotlists=entered_hotlists, labels=[],
+        template_name='rutabaga')
+    self.mox.ReplayAll()
+    url = self.servlet.ProcessFormData(mr, post_data)
+    self.mox.VerifyAll()
+    self.assertEqual('Not in your hotlist(s): U1:H2', mr.errors.hotlists)
+    self.assertIsNone(url)
+
+  def testProcessFormData_RejectDeprecatedComponent(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry',
+        user_info={'user_id': 111},
+        project=self.project)
+    config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+    config.component_defs = [
+        tracker_bizobj.MakeComponentDef(
+            1, mr.project_id, 'active', '', False, [], [], 0, 0),
+        tracker_bizobj.MakeComponentDef(
+            2, mr.project_id, 'notactive', '', True, [], [], 0, 0),
+    ]
+    self.services.config.StoreConfig(mr.cnxn, config)
+    print(config)
+    post_data = fake.PostData(
+        template_name=['rutabaga'],
+        summary=['fake summary'],
+        comment=['fake comment'],
+        components=['notactive'])
+
+    self.mox.StubOutWithMock(self.servlet, 'PleaseCorrect')
+    self.servlet.PleaseCorrect(
+        mr,
+        component_required=None,
+        fields=[],
+        initial_blocked_on='',
+        initial_blocking='',
+        initial_cc='',
+        initial_comment='fake comment',
+        initial_components='notactive',
+        initial_owner='',
+        initial_status='',
+        initial_summary='fake summary',
+        initial_hotlists='',
+        labels=[],
+        template_name='rutabaga')
+    self.mox.ReplayAll()
+    url = self.servlet.ProcessFormData(mr, post_data)
+    self.mox.VerifyAll()
+    self.assertEqual(mr.errors.components, 'Undefined or deprecated component')
+    self.assertIsNone(url)
+
+  @patch('framework.cloud_tasks_helpers.create_task')
+  def testProcessFormData_TemplateNameMissing(self, _create_task_mock):
+    """POST doesn't fail if no template_name is passed."""
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry', project=self.project)
+    mr.auth.user_view = framework_views.StuffUserView(100, 'user@invalid', True)
+    mr.auth.effective_ids = set([100])
+
+    self.services.template.GetTemplateById.return_value = None
+    self.services.template.GetProjectTemplates.return_value = [
+        tracker_pb2.TemplateDef(members_only=True, content=''),
+        tracker_pb2.TemplateDef(members_only=False, content='')]
+    post_data = fake.PostData(
+        summary=['fake summary'],
+        comment=['fake comment'],
+        status=['New'])
+
+    self.mox.ReplayAll()
+    url = self.servlet.ProcessFormData(mr, post_data)
+
+    self.mox.VerifyAll()
+    self.assertTrue('/p/proj/issues/detail?id=' in url)
+
+  @patch('framework.cloud_tasks_helpers.create_task')
+  def testProcessFormData_AcceptsFederatedReferences(self, _create_task_mock):
+    """ProcessFormData accepts federated references."""
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry', project=self.project)
+    mr.auth.user_view = framework_views.StuffUserView(100, 'user@invalid', True)
+    mr.auth.effective_ids = set([100])
+
+    post_data = fake.PostData(
+        summary=['fake summary'],
+        comment=['fake comment'],
+        status=['New'],
+        template_name='rutabaga',
+        blocking=['b/123, b/987'],
+        blockedon=['b/456, b/654'])
+
+    self.mox.ReplayAll()
+    self.servlet.ProcessFormData(mr, post_data)
+
+    self.mox.VerifyAll()
+    self.assertIsNone(mr.errors.blockedon)
+    self.assertIsNone(mr.errors.blocking)
+
+  def testAttachDefaultApprovers(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    config.approval_defs = [
+        tracker_pb2.ApprovalDef(
+            approval_id=23, approver_ids=[222], survey='Question?'),
+        tracker_pb2.ApprovalDef(
+            approval_id=24, approver_ids=[111], survey='Question?')]
+    approval_values = [tracker_pb2.ApprovalValue(
+         approval_id=24, phase_id=1,
+         status=tracker_pb2.ApprovalStatus.NEEDS_REVIEW)]
+    issueentry._AttachDefaultApprovers(config, approval_values)
+    self.assertEqual(approval_values[0].approver_ids, [111])
+
+  # TODO(aneeshm): add a test for the ambiguous hotlist name case; it works
+  # correctly when tested locally, but for some reason doesn't in the test
+  # environment. Probably a result of some quirk in fake.py?
diff --git a/tracker/test/issueexport_test.py b/tracker/test/issueexport_test.py
new file mode 100644
index 0000000..4e70ab7
--- /dev/null
+++ b/tracker/test/issueexport_test.py
@@ -0,0 +1,163 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unittests for the issueexport servlet."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from mock import Mock, patch
+
+from framework import permissions
+from proto import tracker_pb2
+from services import service_manager
+from testing import testing_helpers
+from testing import fake
+from tracker import issueexport
+
+
+class IssueExportTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        config=fake.ConfigService(),
+        project=fake.ProjectService(),
+        issue=fake.IssueService(),
+        user=fake.UserService(),
+        issue_star=fake.IssueStarService(),
+    )
+    self.cnxn = 'fake connection'
+    self.project = self.services.project.TestAddProject('proj', project_id=789)
+    self.servlet = issueexport.IssueExport(
+        'req', 'res', services=self.services)
+    self.jsonfeed = issueexport.IssueExportJSON(
+        'req', 'res', services=self.services)
+    self.mr = testing_helpers.MakeMonorailRequest(
+        project=self.project, perms=permissions.OWNER_ACTIVE_PERMISSIONSET)
+    self.mr.can = 1
+
+  def testAssertBasePermission(self):
+    self.assertRaises(permissions.PermissionException,
+                      self.servlet.AssertBasePermission, self.mr)
+    self.mr.auth.user_pb.is_site_admin = True
+    self.servlet.AssertBasePermission(self.mr)
+
+  @patch('time.time')
+  def testHandleRequest(self, mockTime):
+    mockTime.return_value = 1234
+    self.services.issue.GetAllIssuesInProject = Mock(return_value=[])
+    self.services.issue.GetCommentsForIssues = Mock(return_value={})
+    self.services.issue_star.LookupItemsStarrers = Mock(return_value={})
+    self.services.user.LookupUserEmails = Mock(
+        return_value={111: 'user1@test.com', 222: 'user2@test.com'})
+
+    self.mr.project_name = self.project.project_name
+    json_data = self.jsonfeed.HandleRequest(self.mr)
+
+    self.assertEqual(json_data['metadata'],
+                     {'version': 1, 'who': None, 'when': 1234,
+                      'project': 'proj', 'start': 0, 'num': 100})
+    self.assertEqual(json_data['issues'], [])
+    self.assertItemsEqual(
+        json_data['emails'], ['user1@test.com', 'user2@test.com'])
+
+  # TODO(jojwang): test attachments, amendments, comment details
+  def testMakeIssueJSON(self):
+    config = self.services.config.GetProjectConfig(
+        self.cnxn, 789)
+    config.field_defs.extend(
+        [tracker_pb2.FieldDef(
+            field_id=1, field_name='UXReview',
+            field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE),
+         tracker_pb2.FieldDef(
+             field_id=2, field_name='approvalsubfield',
+             field_type=tracker_pb2.FieldTypes.STR_TYPE, approval_id=1),
+         tracker_pb2.FieldDef(
+             field_id=3, field_name='phasefield',
+             field_type=tracker_pb2.FieldTypes.INT_TYPE, is_phase_field=True),
+         tracker_pb2.FieldDef(
+             field_id=4, field_name='normalfield',
+             field_type=tracker_pb2.FieldTypes.STR_TYPE)
+        ])
+    self.services.config.StoreConfig(self.cnxn, config)
+
+    phases = [tracker_pb2.Phase(phase_id=1, name='Phase1', rank=1),
+              tracker_pb2.Phase(phase_id=2, name='Phase2', rank=2)]
+    avs = [tracker_pb2.ApprovalValue(
+        approval_id=1, status=tracker_pb2.ApprovalStatus.APPROVED,
+        setter_id=111, set_on=7, approver_ids=[333, 444], phase_id=1)]
+    fvs = [tracker_pb2.FieldValue(field_id=2, str_value='two'),
+           tracker_pb2.FieldValue(field_id=3, int_value=3, phase_id=2),
+           tracker_pb2.FieldValue(field_id=4, str_value='four')]
+    labels = ['test', 'Type-FLT-Launch']
+
+    issue = fake.MakeTestIssue(
+        self.project.project_id, 1, 'summary', 'Open', 111, labels=labels,
+        issue_id=78901, reporter_id=222, opened_timestamp=1,
+        closed_timestamp=2, modified_timestamp=3, project_name='project',
+        field_values=fvs, phases=phases, approval_values=avs)
+
+    email_dict = {111: 'user1@test.com', 222: 'user2@test.com',
+                  333: 'user3@test.com', 444: 'user4@test.com'}
+    comment_list = [
+        tracker_pb2.IssueComment(content='simple'),
+        tracker_pb2.IssueComment(
+            content='issue desc', is_description=True)]
+    starrer_id_list = [222, 333]
+
+    issue_JSON = self.jsonfeed._MakeIssueJSON(
+        self.mr, issue, email_dict, comment_list, starrer_id_list)
+    expected_JSON = {
+        'local_id': 1,
+        'reporter': 'user2@test.com',
+        'summary': 'summary',
+        'owner': 'user1@test.com',
+        'status': 'Open',
+        'cc': [],
+        'labels': labels,
+        'phases': [{'id': 1, 'name': 'Phase1', 'rank': 1},
+                   {'id': 2, 'name': 'Phase2', 'rank': 2}],
+        'fields': [
+            {'field': 'approvalsubfield',
+             'phase': None,
+             'approval': 'UXReview',
+             'str_value': 'two'},
+            {'field': 'phasefield',
+             'phase': 'Phase2',
+             'int_value': 3},
+            {'field': 'normalfield',
+             'phase': None,
+             'str_value': 'four'}],
+        'approvals': [
+            {'approval': 'UXReview',
+             'status': 'APPROVED',
+             'setter': 'user1@test.com',
+             'set_on': 7,
+             'approvers': ['user3@test.com', 'user4@test.com'],
+             'phase': 'Phase1'}
+        ],
+        'starrers': ['user2@test.com', 'user3@test.com'],
+        'comments': [
+            {'content': 'simple',
+             'timestamp': None,
+             'amendments': [],
+             'commenter': None,
+             'attachments': [],
+             'description_num': None},
+            {'content': 'issue desc',
+             'timestamp': None,
+             'amendments': [],
+             'commenter': None,
+             'attachments': [],
+             'description_num': '1'},
+            ],
+        'opened': 1,
+        'modified': 3,
+        'closed': 2,
+    }
+
+    self.assertEqual(expected_JSON, issue_JSON)
diff --git a/tracker/test/issueimport_test.py b/tracker/test/issueimport_test.py
new file mode 100644
index 0000000..c0e38af
--- /dev/null
+++ b/tracker/test/issueimport_test.py
@@ -0,0 +1,69 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unittests for the issueimport servlet."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from framework import permissions
+from services import service_manager
+from testing import testing_helpers
+from tracker import issueimport
+from proto import tracker_pb2
+
+
+class IssueExportTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services()
+    self.servlet = issueimport.IssueImport(
+        'req', 'res', services=self.services)
+    self.event_log = None
+
+  def testAssertBasePermission(self):
+    """Only site admins can import issues."""
+    mr = testing_helpers.MakeMonorailRequest(
+        perms=permissions.OWNER_ACTIVE_PERMISSIONSET)
+    self.assertRaises(permissions.PermissionException,
+                      self.servlet.AssertBasePermission, mr)
+    mr.auth.user_pb.is_site_admin = True
+    self.servlet.AssertBasePermission(mr)
+
+  def testParseComment(self):
+    """Test a Comment JSON is correctly parsed."""
+    users_id_dict = {'adam@test.com': 111}
+    json = {
+        'timestamp': 123,
+        'commenter': 'adam@test.com',
+        'content': 'so basically, what I was thinkig of',
+        'amendments': [],
+        'attachments': [],
+        'description_num': None,
+        }
+    comment = self.servlet._ParseComment(
+        12, users_id_dict, json, self.event_log)
+    self.assertEqual(
+        comment, tracker_pb2.IssueComment(
+            project_id=12, timestamp=123, user_id=111,
+            content='so basically, what I was thinkig of'))
+
+    json_desc = {
+        'timestamp': 223,
+        'commenter': 'adam@test.com',
+        'content': 'I cant believe youve done this',
+        'description_num': '2',
+        'amendments': [],
+        'attachments': [],
+    }
+    desc_comment = self.servlet._ParseComment(
+        12, users_id_dict, json_desc, self.event_log)
+    self.assertEqual(
+        desc_comment, tracker_pb2.IssueComment(
+            project_id=12, timestamp=223, user_id=111,
+            content='I cant believe youve done this',
+            is_description=True))
diff --git a/tracker/test/issueoriginal_test.py b/tracker/test/issueoriginal_test.py
new file mode 100644
index 0000000..1b2b7d6
--- /dev/null
+++ b/tracker/test/issueoriginal_test.py
@@ -0,0 +1,226 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Tests for the issueoriginal module."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import mock
+import unittest
+
+import webapp2
+
+from framework import exceptions
+from framework import framework_helpers
+from framework import monorailrequest
+from framework import permissions
+from proto import tracker_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+from tracker import issueoriginal
+
+
+STRIPPED_MSG = 'Are you sure that it is   plugged in?\n'
+ORIG_MSG = ('Are you sure that it is   plugged in?\n'
+            '\n'
+            '> Issue 1 entered by user foo:\n'
+            '> http://blah blah\n'
+            '> The screen is just dark when I press power on\n')
+XXX_GOOD_UNICODE_MSG = u'Thanks,\n\342\230\206*username*'.encode('utf-8')
+GOOD_UNICODE_MSG = u'Thanks,\n XXX *username*'
+XXX_BAD_UNICODE_MSG = ORIG_MSG + ('\xff' * 1000)
+BAD_UNICODE_MSG = ORIG_MSG + 'XXX'
+GMAIL_CRUFT_MSG = ORIG_MSG  # XXX .replace('   ', ' \xa0 ')
+
+
+class IssueOriginalTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        project=fake.ProjectService(),
+        config=fake.ConfigService(),
+        issue=fake.IssueService(),
+        user=fake.UserService())
+    self.servlet = issueoriginal.IssueOriginal(
+        'req', 'res', services=self.services)
+
+    self.proj = self.services.project.TestAddProject('proj', project_id=789)
+    summary = 'System wont boot'
+    status = 'New'
+    cnxn = 'fake connection'
+    self.services.user.TestAddUser('commenter@example.com', 222)
+
+    created_issue_1 = fake.MakeTestIssue(
+        789, 1, summary, status, 111, reporter_id=111)
+    self.services.issue.TestAddIssue(created_issue_1)
+    self.local_id_1 = created_issue_1.local_id
+    comment_0 = tracker_pb2.IssueComment(
+        issue_id=created_issue_1.issue_id,
+        user_id=222,
+        project_id=789,
+        content=STRIPPED_MSG,
+        inbound_message=ORIG_MSG)
+    self.services.issue.InsertComment(cnxn, comment_0)
+    comment_1 = tracker_pb2.IssueComment(
+        issue_id=created_issue_1.issue_id,
+        user_id=222,
+        project_id=789,
+        content=STRIPPED_MSG,
+        inbound_message=BAD_UNICODE_MSG)
+    self.services.issue.InsertComment(cnxn, comment_1)
+    comment_2 = tracker_pb2.IssueComment(
+        issue_id=created_issue_1.issue_id,
+        user_id=222,
+        project_id=789,
+        content=STRIPPED_MSG,
+        inbound_message=GMAIL_CRUFT_MSG)
+    self.services.issue.InsertComment(cnxn, comment_2)
+    comment_3 = tracker_pb2.IssueComment(
+        issue_id=created_issue_1.issue_id,
+        user_id=222,
+        project_id=789,
+        content=STRIPPED_MSG,
+        inbound_message=GOOD_UNICODE_MSG)
+    self.services.issue.InsertComment(cnxn, comment_3)
+    self.issue_1 = self.services.issue.GetIssueByLocalID(
+        cnxn, 789, self.local_id_1)
+    self.comments = [comment_0, comment_1, comment_2, comment_3]
+
+  @mock.patch('framework.permissions.GetPermissions')
+  def testAssertBasePermission(self, mock_getpermissions):
+    """Permit users who can view issue, view inbound message and delete."""
+    _request, mr = testing_helpers.GetRequestObjects(
+        path='/p/proj/issues/original?id=1&seq=1',
+        project=self.proj)
+
+    # Allow the user to view the issue itself.
+    mock_getpermissions.return_value = (
+        permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET)
+
+    # Someone without VIEW permission cannot view the inbound email.
+    mr.perms = permissions.EMPTY_PERMISSIONSET
+    self.assertRaises(permissions.PermissionException,
+                      self.servlet.AssertBasePermission, mr)
+
+    # Contributors don't have VIEW_INBOUND_MESSAGES.
+    mr.perms = permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET
+    self.assertRaises(permissions.PermissionException,
+                      self.servlet.AssertBasePermission, mr)
+
+    # Committers do have VIEW_INBOUND_MESSAGES.
+    mr.perms = permissions.COMMITTER_ACTIVE_PERMISSIONSET
+    self.servlet.AssertBasePermission(mr)
+
+    # But, a committer cannot use that if they cannot view the issue.
+    self.issue_1.labels.append('Restrict-View-Foo')
+    mr.perms = permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET
+    self.assertRaises(permissions.PermissionException,
+                      self.servlet.AssertBasePermission, mr)
+
+    # Project owners have VIEW_INBOUND_MESSAGES and bypass restrictions.
+    mock_getpermissions.return_value = (
+        permissions.OWNER_ACTIVE_PERMISSIONSET)
+    mr.perms = permissions.OWNER_ACTIVE_PERMISSIONSET
+    self.servlet.AssertBasePermission(mr)
+
+  def testGatherPageData_Normal(self):
+    _request, mr = testing_helpers.GetRequestObjects(
+        path='/p/proj/issues/original?id=1&seq=1',
+        project=self.proj)
+    page_data = self.servlet.GatherPageData(mr)
+    self.assertEqual(1, page_data['local_id'])
+    self.assertEqual(1, page_data['seq'])
+    self.assertFalse(page_data['is_binary'])
+    self.assertEqual(ORIG_MSG, page_data['message_body'])
+
+  def testGatherPageData_GoodUnicode(self):
+    _request, mr = testing_helpers.GetRequestObjects(
+        path='/p/proj/issues/original?id=1&seq=4',
+        project=self.proj)
+    page_data = self.servlet.GatherPageData(mr)
+    self.assertEqual(1, page_data['local_id'])
+    self.assertEqual(4, page_data['seq'])
+    self.assertEqual(GOOD_UNICODE_MSG, page_data['message_body'])
+    self.assertFalse(page_data['is_binary'])
+
+  def testGatherPageData_BadUnicode(self):
+    _request, mr = testing_helpers.GetRequestObjects(
+        path='/p/proj/issues/original?id=1&seq=2',
+        project=self.proj)
+    page_data = self.servlet.GatherPageData(mr)
+    self.assertEqual(1, page_data['local_id'])
+    self.assertEqual(2, page_data['seq'])
+    # xxx: should be true if cruft was there.
+    # self.assertTrue(page_data['is_binary'])
+
+  def testGatherPageData_GmailCruft(self):
+    _request, mr = testing_helpers.GetRequestObjects(
+        path='/p/proj/issues/original?id=1&seq=3',
+        project=self.proj)
+    page_data = self.servlet.GatherPageData(mr)
+    self.assertEqual(1, page_data['local_id'])
+    self.assertEqual(3, page_data['seq'])
+    self.assertFalse(page_data['is_binary'])
+    self.assertEqual(ORIG_MSG, page_data['message_body'])
+
+  def testGatherPageData_404(self):
+    _request, mr = testing_helpers.GetRequestObjects(
+        path='/p/proj/issues/original',
+        project=self.proj)
+    with self.assertRaises(webapp2.HTTPException) as cm:
+      self.servlet.GatherPageData(mr)
+    self.assertEqual(404, cm.exception.code)
+
+    _request, mr = testing_helpers.GetRequestObjects(
+        path='/p/proj/issues/original?id=1&seq=999',
+        project=self.proj)
+    with self.assertRaises(webapp2.HTTPException) as cm:
+      self.servlet.GatherPageData(mr)
+    self.assertEqual(404, cm.exception.code)
+
+    _request, mr = testing_helpers.GetRequestObjects(
+        path='/p/proj/issues/original?id=999&seq=1',
+        project=self.proj)
+    with self.assertRaises(exceptions.NoSuchIssueException) as cm:
+      self.servlet.GatherPageData(mr)
+
+  def testGetIssueAndComment_Normal(self):
+    _request, mr = testing_helpers.GetRequestObjects(
+        path='/p/proj/issues/original?id=1&seq=1',
+        project=self.proj)
+    issue, comment = self.servlet._GetIssueAndComment(mr)
+    self.assertEqual(self.issue_1, issue)
+    self.assertEqual(self.comments[1].content, comment.content)
+
+  def testGetIssueAndComment_NoSuchComment(self):
+    _request, mr = testing_helpers.GetRequestObjects(
+        path='/p/proj/issues/original?id=1&seq=99',
+        project=self.proj)
+    with self.assertRaises(webapp2.HTTPException) as cm:
+      self.servlet._GetIssueAndComment(mr)
+    self.assertEqual(404, cm.exception.code)
+
+  def testGetIssueAndComment_Malformed(self):
+    _request, mr = testing_helpers.GetRequestObjects(
+        path='/p/proj/issues/original',
+        project=self.proj)
+    with self.assertRaises(webapp2.HTTPException) as cm:
+      self.servlet._GetIssueAndComment(mr)
+    self.assertEqual(404, cm.exception.code)
+
+    _request, mr = testing_helpers.GetRequestObjects(
+        path='/p/proj/issues/original?id=1',
+        project=self.proj)
+    with self.assertRaises(webapp2.HTTPException) as cm:
+      self.servlet._GetIssueAndComment(mr)
+    self.assertEqual(404, cm.exception.code)
+
+    _request, mr = testing_helpers.GetRequestObjects(
+        path='/p/proj/issues/original?seq=1',
+        project=self.proj)
+    with self.assertRaises(exceptions.NoSuchIssueException) as cm:
+      self.servlet._GetIssueAndComment(mr)
diff --git a/tracker/test/issuereindex_test.py b/tracker/test/issuereindex_test.py
new file mode 100644
index 0000000..fe033b8
--- /dev/null
+++ b/tracker/test/issuereindex_test.py
@@ -0,0 +1,124 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unittests for monorail.tracker.issuereindex."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+import mox
+
+import settings
+from framework import permissions
+from framework import template_helpers
+from services import service_manager
+from services import tracker_fulltext
+from testing import fake
+from testing import testing_helpers
+from tracker import issuereindex
+
+
+class IssueReindexTest(unittest.TestCase):
+
+  def setUp(self):
+    self.cnxn = 'fake cnxn'
+    self.services = service_manager.Services(
+        config=fake.ConfigService(),
+        issue=fake.IssueService(),
+        user=fake.UserService(),
+        project=fake.ProjectService())
+    self.project = self.services.project.TestAddProject('proj', project_id=987)
+    self.mox = mox.Mox()
+
+  def tearDown(self):
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def testAssertBasePermission_NoAccess(self):
+    # Non-members and contributors do not have permission to view this page.
+    for permission in (permissions.USER_PERMISSIONSET,
+                       permissions.COMMITTER_ACTIVE_PERMISSIONSET):
+      request, mr = testing_helpers.GetRequestObjects(
+          project=self.project, perms=permission)
+      servlet = issuereindex.IssueReindex(
+          request, 'res', services=self.services)
+    with self.assertRaises(permissions.PermissionException) as cm:
+      servlet.AssertBasePermission(mr)
+    self.assertEqual('You are not allowed to administer this project',
+                     cm.exception.message)
+
+  def testAssertBasePermission_WithAccess(self):
+    # Owners and admins have permission to view this page.
+    for permission in (permissions.OWNER_ACTIVE_PERMISSIONSET,
+                       permissions.ADMIN_PERMISSIONSET):
+      request, mr = testing_helpers.GetRequestObjects(
+          project=self.project, perms=permission)
+      servlet = issuereindex.IssueReindex(
+          request, 'res', services=self.services)
+      servlet.AssertBasePermission(mr)
+
+  def testGatherPageData(self):
+    servlet = issuereindex.IssueReindex('req', 'res', services=self.services)
+
+    mr = testing_helpers.MakeMonorailRequest()
+    mr.auto_submit = True
+    ret = servlet.GatherPageData(mr)
+
+    self.assertTrue(ret['auto_submit'])
+    self.assertIsNone(ret['issue_tab_mode'])
+    self.assertTrue(ret['page_perms'].CreateIssue)
+
+  def _callProcessFormData(self, post_data, index_issue_1=True):
+    servlet = issuereindex.IssueReindex('req', 'res', services=self.services)
+
+    mr = testing_helpers.MakeMonorailRequest(project=self.project)
+    mr.cnxn = self.cnxn
+
+    issue1 = fake.MakeTestIssue(
+        project_id=self.project.project_id, local_id=1, summary='sum',
+        status='New', owner_id=111)
+    issue1.project_name = self.project.project_name
+    self.services.issue.TestAddIssue(issue1)
+
+    self.mox.StubOutWithMock(tracker_fulltext, 'IndexIssues')
+    if index_issue_1:
+      tracker_fulltext.IndexIssues(
+          self.cnxn, [issue1], self.services.user, self.services.issue,
+          self.services.config)
+
+    self.mox.ReplayAll()
+
+    ret = servlet.ProcessFormData(mr, post_data)
+    self.mox.VerifyAll()
+    return ret
+
+  def testProcessFormData_NormalInputs(self):
+    post_data = {'start': 1, 'num': 5}
+    ret = self._callProcessFormData(post_data)
+    self.assertEqual(
+        '/p/None/issues/reindex?start=6&auto_submit=False&num=5', ret)
+
+  def testProcessFormData_LargeInputs(self):
+    post_data = {'start': 0, 'num': 10000000}
+    ret = self._callProcessFormData(post_data)
+    self.assertEqual(
+        '/p/None/issues/reindex?start=%s&auto_submit=False&num=%s' % (
+            settings.max_artifact_search_results_per_page,
+            settings.max_artifact_search_results_per_page), ret)
+
+  def testProcessFormData_WithAutoSubmit(self):
+    post_data = {'start': 1, 'num': 5, 'auto_submit': 1}
+    ret = self._callProcessFormData(post_data)
+    self.assertEqual(
+        '/p/None/issues/reindex?start=6&auto_submit=True&num=5', ret)
+
+  def testProcessFormData_WithAutoSubmitButNoMoreIssues(self):
+    """This project has no issues 6-10, so stop autosubmitting."""
+    post_data = {'start': 6, 'num': 5, 'auto_submit': 1}
+    ret = self._callProcessFormData(post_data, index_issue_1=False)
+    self.assertEqual(
+        '/p/None/issues/reindex?start=11&auto_submit=False&num=5', ret)
diff --git a/tracker/test/issuetips_test.py b/tracker/test/issuetips_test.py
new file mode 100644
index 0000000..44f5f70
--- /dev/null
+++ b/tracker/test/issuetips_test.py
@@ -0,0 +1,33 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Tests for issuetips module."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+from tracker import issuetips
+
+
+class IssueTipsTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        config=fake.ConfigService(),
+        issue=fake.IssueService(),
+        user=fake.UserService(),
+        project=fake.ProjectService())
+    self.servlet = issuetips.IssueSearchTips(
+        'req', 'res', services=self.services)
+
+  def testGatherPageData(self):
+    mr = testing_helpers.MakeMonorailRequest(path='/p/proj/issues/tips')
+    page_data = self.servlet.GatherPageData(mr)
+    self.assertEqual('issueSearchTips', page_data['issue_tab_mode'])
diff --git a/tracker/test/rerank_helpers_test.py b/tracker/test/rerank_helpers_test.py
new file mode 100644
index 0000000..47ddd47
--- /dev/null
+++ b/tracker/test/rerank_helpers_test.py
@@ -0,0 +1,135 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unittests for monorail.tracker.rerank_helpers."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from framework import exceptions
+from testing import fake
+from tracker import rerank_helpers
+
+
+rerank_helpers.MAX_RANKING = 10
+
+
+class Rerank_HelpersTest(unittest.TestCase):
+
+  def setUp(self):
+    self.PAST_TIME = 12345
+    hotlist_item_fields = [
+        (78904, 31, 111, self.PAST_TIME, 'note'),
+        (78903, 21, 222, self.PAST_TIME, 'note'),
+        (78902, 11, 111, self.PAST_TIME, 'note'),
+        (78901, 1, 222, self.PAST_TIME, 'note')]
+    self.hotlist = fake.Hotlist(
+        'hotlist_name', 1234, hotlist_item_fields=hotlist_item_fields)
+
+  # Tested in tests for RerankHotlistItems.
+  def testGetHotlistRerankChanges_FirstPosition(self):
+    moved_issue_ids = [78903, 78902]
+    target_position = 0
+    changed_ranks = rerank_helpers.GetHotlistRerankChanges(
+        self.hotlist.items, moved_issue_ids, target_position)
+    self.assertEqual(changed_ranks, [(78903, 5), (78902, 15), (78901, 25)])
+
+  def testGetHotlistRerankChanges_LastPosition(self):
+    moved_issue_ids = [78903, 78902]
+    target_position = 2
+    changed_ranks = rerank_helpers.GetHotlistRerankChanges(
+        self.hotlist.items, moved_issue_ids, target_position)
+    self.assertEqual(changed_ranks, [(78904, 3), (78903, 6), (78902, 9)])
+
+  def testGetHotlistRerankChanges_Middle(self):
+    moved_issue_ids = [78903]
+    target_position = 1
+    changed_ranks = rerank_helpers.GetHotlistRerankChanges(
+        self.hotlist.items, moved_issue_ids, target_position)
+    self.assertEqual(changed_ranks, [(78903, 6)])
+
+
+  def testGetHotlistRerankChanges_NewMoveIds(self):
+    "We can handle reranking for inserting new issues."
+    moved_issue_ids = [78909, 78910, 78903]
+    target_position = 0
+    changed_ranks = rerank_helpers.GetHotlistRerankChanges(
+        self.hotlist.items, moved_issue_ids, target_position)
+    self.assertEqual(
+        changed_ranks, [(78909, 1), (78910, 3), (78903, 5), (78901, 7)])
+
+  def testGetHotlistRerankChanges_InvalidMovedIds(self):
+    moved_issue_ids = [78903]
+    target_position = -1
+    with self.assertRaises(exceptions.InputException):
+      rerank_helpers.GetHotlistRerankChanges(
+          self.hotlist.items, moved_issue_ids, target_position)
+
+  def testGetHotlistRerankChanges_InvalidPosition(self):
+    moved_issue_ids = [78909]
+    target_position = 8
+    with self.assertRaises(exceptions.InputException):
+      rerank_helpers.GetHotlistRerankChanges(
+          self.hotlist.items, moved_issue_ids, target_position)
+
+  def testGetInsertRankings(self):
+    lower = [(1, 0)]
+    higher = [(2, 10)]
+    moved_ids = [3]
+    ret = rerank_helpers.GetInsertRankings(lower, higher, moved_ids)
+    self.assertEqual(ret, [(3, 5)])
+
+  def testGetInsertRankings_Below(self):
+    lower = []
+    higher = [(1, 2)]
+    moved_ids = [2]
+    ret = rerank_helpers.GetInsertRankings(lower, higher, moved_ids)
+    self.assertEqual(ret, [(2, 1)])
+
+  def testGetInsertRankings_Above(self):
+    lower = [(1, 0)]
+    higher = []
+    moved_ids = [2]
+    ret = rerank_helpers.GetInsertRankings(lower, higher, moved_ids)
+    self.assertEqual(ret, [(2, 5)])
+
+  def testGetInsertRankings_Multiple(self):
+    lower = [(1, 0)]
+    higher = [(2, 10)]
+    moved_ids = [3,4,5]
+    ret = rerank_helpers.GetInsertRankings(lower, higher, moved_ids)
+    self.assertEqual(ret, [(3, 2), (4, 5), (5, 8)])
+
+  def testGetInsertRankings_SplitLow(self):
+    lower = [(1, 0), (2, 5)]
+    higher = [(3, 6), (4, 10)]
+    moved_ids = [5]
+    ret = rerank_helpers.GetInsertRankings(lower, higher, moved_ids)
+    self.assertEqual(ret, [(2, 2), (5, 5)])
+
+  def testGetInsertRankings_SplitHigh(self):
+    lower = [(1, 0), (2, 4)]
+    higher = [(3, 5), (4, 10)]
+    moved_ids = [5]
+    ret = rerank_helpers.GetInsertRankings(lower, higher, moved_ids)
+    self.assertEqual(ret, [(5, 6), (3, 9)])
+
+  def testGetInsertRankings_NoLower(self):
+    lower = []
+    higher = [(1, 1)]
+    moved_ids = [2]
+    ret = rerank_helpers.GetInsertRankings(lower, higher, moved_ids)
+    self.assertEqual(ret, [(2, 3), (1, 8)])
+
+  def testGetInsertRankings_NoRoom(self):
+    max_ranking, rerank_helpers.MAX_RANKING = rerank_helpers.MAX_RANKING, 1
+    lower = [(1, 0)]
+    higher = [(2, 1)]
+    moved_ids = [3]
+    ret = rerank_helpers.GetInsertRankings(lower, higher, moved_ids)
+    self.assertIsNone(ret)
+    rerank_helpers.MAX_RANKING = max_ranking
diff --git a/tracker/test/tablecell_test.py b/tracker/test/tablecell_test.py
new file mode 100644
index 0000000..c8b7292
--- /dev/null
+++ b/tracker/test/tablecell_test.py
@@ -0,0 +1,491 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unit tests for issuelist module."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import time
+import unittest
+
+from framework import framework_constants
+from framework import table_view_helpers
+from framework import template_helpers
+from proto import tracker_pb2
+from testing import fake
+from testing import testing_helpers
+from tracker import tablecell
+from tracker import tracker_bizobj
+
+
+class DisplayNameMock(object):
+
+  def __init__(self, name):
+    self.display_name = name
+    self.user = None
+
+
+def MakeTestIssue(local_id, issue_id, summary, status=None):
+  issue = tracker_pb2.Issue()
+  issue.local_id = local_id
+  issue.issue_id = issue_id
+  issue.summary = summary
+  if status:
+    issue.status = status
+  return issue
+
+
+class TableCellUnitTest(unittest.TestCase):
+
+  USERS_BY_ID = {
+      23456: DisplayNameMock('Jason'),
+      34567: DisplayNameMock('Nathan'),
+      }
+
+  def setUp(self):
+    self.issue1 = MakeTestIssue(
+        local_id=1, issue_id=100001, summary='One', status="New")
+    self.issue2 = MakeTestIssue(
+        local_id=2, issue_id=100002, summary='Two', status="Fixed")
+    self.issue3 = MakeTestIssue(
+        local_id=3, issue_id=100003, summary='Three', status="UndefinedString")
+    self.issue5 = MakeTestIssue(
+        local_id=5, issue_id=100005, summary='FiveUnviewable', status="Fixed")
+    self.table_cell_kws = {
+        'col': None,
+        'users_by_id': self.USERS_BY_ID,
+        'non_col_labels': [],
+        'label_values': {},
+        'related_issues': {},
+        'config': tracker_bizobj.MakeDefaultProjectIssueConfig(678),
+        'viewable_iids_set': {100001, 100002, 100003}
+        }
+
+  def testTableCellNote(self):
+    table_cell_kws = self.table_cell_kws.copy()
+    table_cell_kws.update({'note': ''})
+    cell = tablecell.TableCellNote(
+        self.issue1, **table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_NOTE)
+    self.assertEqual(cell.values, [])
+
+  def testTableCellNote_NoNote(self):
+    table_cell_kws = self.table_cell_kws.copy()
+    table_cell_kws.update({'note': 'some note'})
+    cell = tablecell.TableCellNote(
+        self.issue1, **table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_NOTE)
+    self.assertEqual(cell.values[0].item, 'some note')
+
+  def testTableCellDateAdded(self):
+    table_cell_kws = self.table_cell_kws.copy()
+    table_cell_kws.update({'date_added': 1234})
+    cell = tablecell.TableCellDateAdded(
+        self.issue1, **table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ATTR)
+    self.assertEqual(cell.values[0].item, 1234)
+
+  def testTableCellAdderID(self):
+    table_cell_kws = self.table_cell_kws.copy()
+    table_cell_kws.update({'adder_id': 23456})
+    cell = tablecell.TableCellAdderID(
+        self.issue1, **table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ATTR)
+    self.assertEqual(cell.values[0].item, 'Jason')
+
+  def testTableCellRank(self):
+    table_cell_kws = self.table_cell_kws.copy()
+    table_cell_kws.update({'issue_rank': 3})
+    cell = tablecell.TableCellRank(
+        self.issue1, **table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ATTR)
+    self.assertEqual(cell.values[0].item, 3)
+
+  def testTableCellID(self):
+    cell = tablecell.TableCellID(
+        MakeTestIssue(4, 4, 'Four'), **self.table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ID)
+    # Note that the ID itself is accessed from the row, not the cell.
+
+  def testTableCellOwner(self):
+    test_issue = MakeTestIssue(4, 4, 'Four')
+    test_issue.owner_id=23456
+
+    cell = tablecell.TableCellOwner(
+        test_issue, **self.table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ATTR)
+    self.assertEqual(cell.values[0].item, 'Jason')
+
+  def testTableCellOwnerNoOwner(self):
+    test_issue = MakeTestIssue(4, 4, 'Four')
+    test_issue.owner_id=framework_constants.NO_USER_SPECIFIED
+
+    cell = tablecell.TableCellOwner(
+        test_issue, **self.table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ATTR)
+    self.assertEqual(cell.values, [])
+
+  def testTableCellReporter(self):
+    test_issue = MakeTestIssue(4, 4, 'Four')
+    test_issue.reporter_id=34567
+
+    cell = tablecell.TableCellReporter(
+        test_issue, **self.table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ATTR)
+    self.assertEqual(cell.values[0].item, 'Nathan')
+
+  def testTableCellCc(self):
+    test_issue = MakeTestIssue(4, 4, 'Four')
+    test_issue.cc_ids = [23456, 34567]
+
+    cell = tablecell.TableCellCc(
+        test_issue, **self.table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ATTR)
+    self.assertEqual(cell.values[0].item, 'Jason')
+    self.assertEqual(cell.values[1].item, 'Nathan')
+
+  def testTableCellCcNoCcs(self):
+    cell = tablecell.TableCellCc(
+        MakeTestIssue(4, 4, 'Four'), **self.table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ATTR)
+    self.assertEqual(cell.values, [])
+
+  def testTableCellAttachmentsNone(self):
+    cell = tablecell.TableCellAttachments(
+        MakeTestIssue(4, 4, 'Four'), **self.table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ATTR)
+    self.assertEqual(cell.values[0].item, 0)
+
+  def testTableCellAttachments(self):
+    test_issue = MakeTestIssue(4, 4, 'Four')
+    test_issue.attachment_count = 2
+
+    cell = tablecell.TableCellAttachments(
+        test_issue, **self.table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ATTR)
+    self.assertEqual(cell.values[0].item, 2)
+
+  def testTableCellOpened(self):
+    test_issue = MakeTestIssue(4, 4, 'Four')
+    test_issue.opened_timestamp = 1200000000
+
+    cell = tablecell.TableCellOpened(
+        test_issue, **self.table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+    self.assertEqual(cell.values[0].item, 'Jan 2008')
+
+  def testTableCellClosed(self):
+    test_issue = MakeTestIssue(4, 4, 'Four')
+    test_issue.closed_timestamp = None
+
+    cell = tablecell.TableCellClosed(
+        test_issue, **self.table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+    self.assertEqual(cell.values, [])
+
+    test_issue.closed_timestamp = 1200000000
+    cell = tablecell.TableCellClosed(
+        test_issue, **self.table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+    self.assertEqual(cell.values[0].item, 'Jan 2008')
+
+  def testTableCellModified(self):
+    test_issue = MakeTestIssue(4, 4, 'Four')
+    test_issue.modified_timestamp = None
+
+    cell = tablecell.TableCellModified(
+        test_issue, **self.table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+    self.assertEqual(cell.values, [])
+
+    test_issue.modified_timestamp = 1200000000
+    cell = tablecell.TableCellModified(
+        test_issue, **self.table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+    self.assertEqual(cell.values[0].item, 'Jan 2008')
+
+  def testTableCellOwnerLastVisit(self):
+    test_issue = MakeTestIssue(4, 4, 'Four')
+    test_issue.owner_id = None
+
+    cell = tablecell.TableCellOwnerLastVisit(
+        test_issue, **self.table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+    self.assertEqual(cell.values, [])
+
+    test_issue.owner_id = 23456
+    self.USERS_BY_ID[23456].user = testing_helpers.Blank(last_visit_timestamp=0)
+    cell = tablecell.TableCellOwnerLastVisit(
+        test_issue, **self.table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+    self.assertEqual(cell.values, [])
+
+    self.USERS_BY_ID[23456].user.last_visit_timestamp = int(time.time())
+    cell = tablecell.TableCellOwnerLastVisit(
+        test_issue, **self.table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+    self.assertEqual(cell.values[0].item, 'Today')
+
+    self.USERS_BY_ID[23456].user.last_visit_timestamp = (
+        int(time.time()) - 25 * framework_constants.SECS_PER_HOUR)
+    cell = tablecell.TableCellOwnerLastVisit(
+        test_issue, **self.table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+    self.assertEqual(cell.values[0].item, 'Yesterday')
+
+  def testTableCellBlockedOn(self):
+    test_issue = MakeTestIssue(4, 4, 'Four')
+    test_issue.blocked_on_iids = [
+        self.issue1.issue_id, self.issue2.issue_id, self.issue3.issue_id,
+        self.issue5.issue_id]
+    table_cell_kws = self.table_cell_kws.copy()
+    table_cell_kws['related_issues'] = {
+        self.issue1.issue_id: self.issue1, self.issue2.issue_id: self.issue2,
+        self.issue3.issue_id: self.issue3, self.issue5.issue_id: self.issue5}
+
+    cell = tablecell.TableCellBlockedOn(
+        test_issue, **table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ISSUES)
+    self.assertEqual(
+        [x.item for x in cell.values], [
+            template_helpers.EZTItem(
+                href='/p/None/issues/detail?id=1',
+                id='1',
+                closed=None,
+                title='One'),
+            template_helpers.EZTItem(
+                href='/p/None/issues/detail?id=3',
+                id='3',
+                closed=None,
+                title='Three'),
+            template_helpers.EZTItem(
+                href='/p/None/issues/detail?id=5',
+                id='5',
+                closed=None,
+                title=''),
+            template_helpers.EZTItem(
+                href='/p/None/issues/detail?id=2',
+                id='2',
+                closed='yes',
+                title='Two')
+        ])
+
+  def testTableCellBlockedOnNone(self):
+    cell = tablecell.TableCellBlockedOn(
+        MakeTestIssue(4, 4, 'Four'), **self.table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ISSUES)
+    self.assertEqual(cell.values, [])
+
+  def testTableCellBlocking(self):
+    test_issue = MakeTestIssue(4, 4, 'Four')
+    test_issue.blocking_iids = [
+        self.issue1.issue_id, self.issue2.issue_id, self.issue3.issue_id,
+        self.issue5.issue_id]
+    table_cell_kws = self.table_cell_kws.copy()
+    table_cell_kws['related_issues'] = {
+        self.issue1.issue_id: self.issue1, self.issue2.issue_id: self.issue2,
+        self.issue3.issue_id: self.issue3, self.issue5.issue_id: self.issue5}
+
+    cell = tablecell.TableCellBlocking(
+        test_issue, **table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ISSUES)
+    self.assertEqual(
+        [x.item for x in cell.values], [
+            template_helpers.EZTItem(
+                href='/p/None/issues/detail?id=1',
+                id='1',
+                closed=None,
+                title='One'),
+            template_helpers.EZTItem(
+                href='/p/None/issues/detail?id=3',
+                id='3',
+                closed=None,
+                title='Three'),
+            template_helpers.EZTItem(
+                href='/p/None/issues/detail?id=5',
+                id='5',
+                closed=None,
+                title=''),
+            template_helpers.EZTItem(
+                href='/p/None/issues/detail?id=2',
+                id='2',
+                closed='yes',
+                title='Two')
+        ])
+
+  def testTableCellBlockingNone(self):
+    cell = tablecell.TableCellBlocking(
+        MakeTestIssue(4, 4, 'Four'),
+        **self.table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ISSUES)
+    self.assertEqual(cell.values, [])
+
+  def testTableCellBlocked(self):
+    test_issue = MakeTestIssue(4, 4, 'Four')
+    test_issue.blocked_on_iids = [1, 2, 3]
+
+    cell = tablecell.TableCellBlocked(
+        test_issue, **self.table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ATTR)
+    self.assertEqual(cell.values[0].item, 'Yes')
+
+  def testTableCellBlockedNotBlocked(self):
+    cell = tablecell.TableCellBlocked(
+        MakeTestIssue(4, 4, 'Four'), **self.table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ATTR)
+    self.assertEqual(cell.values[0].item, 'No')
+
+  def testTableCellMergedInto(self):
+    test_issue = MakeTestIssue(4, 4, 'Four')
+    test_issue.merged_into = self.issue2.issue_id
+    table_cell_kws = self.table_cell_kws.copy()
+    table_cell_kws['related_issues'] = {self.issue2.issue_id: self.issue2}
+
+    cell = tablecell.TableCellMergedInto(
+        test_issue, **table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ISSUES)
+    self.assertEqual(
+        cell.values[0].item,
+        template_helpers.EZTItem(
+            href='/p/None/issues/detail?id=2',
+            id='2',
+            closed='yes',
+            title='Two'))
+
+  def testTableCellMergedIntoUnviewable(self):
+    test_issue = MakeTestIssue(4, 4, 'Four')
+    test_issue.merged_into = self.issue5.issue_id
+    table_cell_kws = self.table_cell_kws.copy()
+    table_cell_kws['related_issues'] = {self.issue5.issue_id: self.issue5}
+
+    cell = tablecell.TableCellMergedInto(
+        test_issue, **table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ISSUES)
+    self.assertEqual(
+        cell.values[0].item,
+        template_helpers.EZTItem(
+            href='/p/None/issues/detail?id=5', id='5', closed=None, title=''))
+
+  def testTableCellMergedIntoNotMerged(self):
+    cell = tablecell.TableCellMergedInto(
+        MakeTestIssue(4, 4, 'Four'), **self.table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ISSUES)
+    self.assertEqual(cell.values, [])
+
+  def testTableCellAllLabels(self):
+    labels = ['A', 'B', 'C', 'D-E', 'F-G']
+    derived_labels = ['W', 'X', 'Y-Z']
+
+    test_issue = MakeTestIssue(4, 4, 'Four')
+    test_issue.labels = labels
+    test_issue.derived_labels = derived_labels
+
+    cell = tablecell.TableCellAllLabels(test_issue)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ATTR)
+    self.assertEqual([v.item for v in cell.values], labels + derived_labels)
+
+
+class TableCellCSVTest(unittest.TestCase):
+
+  USERS_BY_ID = {
+      23456: DisplayNameMock('Jason'),
+      }
+
+  def testTableCellOpenedTimestamp(self):
+    test_issue = MakeTestIssue(4, 4, 'Four')
+    test_issue.opened_timestamp = 1200000000
+
+    cell = tablecell.TableCellOpenedTimestamp(test_issue)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+    self.assertEqual(cell.values[0].item, 1200000000)
+
+  def testTableCellClosedTimestamp(self):
+    test_issue = MakeTestIssue(4, 4, 'Four')
+    test_issue.closed_timestamp = None
+
+    cell = tablecell.TableCellClosedTimestamp(test_issue)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+    self.assertEqual(cell.values[0].item, 0)
+
+    test_issue.closed_timestamp = 1200000000
+    cell = tablecell.TableCellClosedTimestamp(test_issue)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+    self.assertEqual(cell.values[0].item, 1200000000)
+
+  def testTableCellModifiedTimestamp(self):
+    test_issue = MakeTestIssue(4, 4, 'Four')
+    test_issue.modified_timestamp = 0
+
+    cell = tablecell.TableCellModifiedTimestamp(test_issue)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+    self.assertEqual(cell.values[0].item, 0)
+
+    test_issue.modified_timestamp = 1200000000
+    cell = tablecell.TableCellModifiedTimestamp(test_issue)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+    self.assertEqual(cell.values[0].item, 1200000000)
+
+  def testTableCellOwnerModifiedTimestamp(self):
+    test_issue = MakeTestIssue(4, 4, 'Four')
+    test_issue.owner_modified_timestamp = 0
+
+    cell = tablecell.TableCellOwnerModifiedTimestamp(test_issue)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+    self.assertEqual(cell.values[0].item, 0)
+
+    test_issue.owner_modified_timestamp = 1200000000
+    cell = tablecell.TableCellOwnerModifiedTimestamp(test_issue)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+    self.assertEqual(cell.values[0].item, 1200000000)
+
+  def testTableCellStatusModifiedTimestamp(self):
+    test_issue = MakeTestIssue(4, 4, 'Four')
+    test_issue.status_modified_timestamp = 0
+
+    cell = tablecell.TableCellStatusModifiedTimestamp(test_issue)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+    self.assertEqual(cell.values[0].item, 0)
+
+    test_issue.status_modified_timestamp = 1200000000
+    cell = tablecell.TableCellStatusModifiedTimestamp(test_issue)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+    self.assertEqual(cell.values[0].item, 1200000000)
+
+  def testTableCellComponentModifiedTimestamp(self):
+    test_issue = MakeTestIssue(4, 4, 'Four')
+    test_issue.component_modified_timestamp = 0
+
+    cell = tablecell.TableCellComponentModifiedTimestamp(test_issue)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+    self.assertEqual(cell.values[0].item, 0)
+
+    test_issue.component_modified_timestamp = 1200000000
+    cell = tablecell.TableCellComponentModifiedTimestamp(test_issue)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+    self.assertEqual(cell.values[0].item, 1200000000)
+
+  def testTableCellOwnerLastVisitDaysAgo(self):
+    test_issue = MakeTestIssue(4, 4, 'Four')
+    test_issue.owner_id = None
+
+    cell = tablecell.TableCellOwnerLastVisitDaysAgo(
+        test_issue, users_by_id=self.USERS_BY_ID)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+    self.assertEqual(None, cell.values[0].item)
+
+    test_issue.owner_id = 23456
+    self.USERS_BY_ID[23456].user = testing_helpers.Blank(last_visit_timestamp=0)
+    cell = tablecell.TableCellOwnerLastVisitDaysAgo(
+        test_issue, users_by_id=self.USERS_BY_ID)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+    self.assertEqual(None, cell.values[0].item)
+
+    self.USERS_BY_ID[23456].user.last_visit_timestamp = (
+        int(time.time()) - 25 * 60 * 60)
+    cell = tablecell.TableCellOwnerLastVisitDaysAgo(
+        test_issue, users_by_id=self.USERS_BY_ID)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+    self.assertEqual(1, cell.values[0].item)
diff --git a/tracker/test/template_helpers_test.py b/tracker/test/template_helpers_test.py
new file mode 100644
index 0000000..6c4a034
--- /dev/null
+++ b/tracker/test/template_helpers_test.py
@@ -0,0 +1,355 @@
+# Copyright 2018 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unittest for the template helpers module."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import unittest
+
+import settings
+
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+from tracker import template_helpers
+from tracker import tracker_bizobj
+from proto import tracker_pb2
+
+
+class TemplateHelpers(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        user=fake.UserService(),
+        config=fake.ConfigService(),
+        project=fake.ProjectService(),
+        usergroup=fake.UserGroupService())
+    self.project = self.services.project.TestAddProject('proj')
+    self.mr = testing_helpers.MakeMonorailRequest(
+        project=self.project)
+    self.config = self.services.config.GetProjectConfig(
+        'fake cnxn', self.project.project_id)
+    self.fd_1 =  tracker_bizobj.MakeFieldDef(
+        1, 789, 'UXReview', tracker_pb2.FieldTypes.STR_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action',
+        'Approval for UX review', False)
+    self.fd_2 =  tracker_bizobj.MakeFieldDef(
+        2, 789, 'UXReview', tracker_pb2.FieldTypes.STR_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action',
+        'Approval for UX review', False)
+    self.fd_3 = tracker_bizobj.MakeFieldDef(
+        3, 789, 'UXApproval', tracker_pb2.FieldTypes.APPROVAL_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action',
+        'Approval for UX review', False)
+    self.fd_4 = tracker_bizobj.MakeFieldDef(
+        4, 789, 'TestApproval', tracker_pb2.FieldTypes.APPROVAL_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action',
+        'Approval for Test review', False)
+    self.fd_5 = tracker_bizobj.MakeFieldDef(
+        5, 789, 'SomeApproval', tracker_pb2.FieldTypes.APPROVAL_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action',
+        'Approval for Test review', False)
+    self.ad_3 = tracker_pb2.ApprovalDef(approval_id=3)
+    self.ad_4 = tracker_pb2.ApprovalDef(approval_id=4)
+    self.ad_5 = tracker_pb2.ApprovalDef(approval_id=5)
+    self.cd_1 = tracker_bizobj.MakeComponentDef(
+        1, 789, 'BackEnd', 'doc', False, [111], [], 100000, 222)
+
+    self.services.user.TestAddUser('1@ex.com', 111)
+    self.services.user.TestAddUser('2@ex.com', 222)
+    self.services.user.TestAddUser('3@ex.com', 333)
+    self.services.project.TestAddProjectMembers(
+        [111], self.project, 'OWNER_ROLE')
+
+  def testParseTemplateRequest_Empty(self):
+    post_data = fake.PostData()
+    parsed = template_helpers.ParseTemplateRequest(post_data, self.config)
+    self.assertEqual(parsed.name, '')
+    self.assertFalse(parsed.members_only)
+    self.assertEqual(parsed.summary, '')
+    self.assertFalse(parsed.summary_must_be_edited)
+    self.assertEqual(parsed.content, '')
+    self.assertEqual(parsed.status, '')
+    self.assertEqual(parsed.owner_str, '')
+    self.assertEqual(parsed.labels, [])
+    self.assertEqual(parsed.field_val_strs, {})
+    self.assertEqual(parsed.component_paths, [])
+    self.assertFalse(parsed.component_required)
+    self.assertFalse(parsed.owner_defaults_to_member)
+    self.assertFalse(parsed.add_approvals)
+    self.assertItemsEqual(parsed.phase_names, ['', '', '', '', '', ''])
+    self.assertEqual(parsed.approvals_to_phase_idx, {})
+    self.assertEqual(parsed.required_approval_ids, [])
+
+  def testParseTemplateRequest_Normal(self):
+    self.config.field_defs.extend([self.fd_1, self.fd_2])
+    self.config.approval_defs.extend([self.ad_3, self.ad_4])
+    post_data = fake.PostData(
+        name=['sometemplate'],
+        members_only=['on'],
+        summary=['TLDR'],
+        summary_must_be_edited=['on'],
+        content=['HEY WHY'],
+        status=['Accepted'],
+        owner=['someone@world.com'],
+        label=['label-One', 'label-Two'],
+        custom_1=['NO'],
+        custom_2=['MOOD'],
+        components=['hey, hey2,he3'],
+        component_required=['on'],
+        owner_defaults_to_memeber=['no'],
+        admin_names=['jojwang@test.com, annajo@test.com'],
+        add_approvals=['on'],
+        phase_0=['Canary'],
+        phase_1=['Stable-Exp'],
+        phase_2=['Stable'],
+        phase_3=[''],
+        phase_4=[''],
+        phase_5=['Oops'],
+        approval_3=['phase_2'],
+        approval_4=['no_phase'],
+        approval_3_required=['on'],
+        approval_4_required=['on'],
+        # ignore required cb for omitted approvals
+        approval_5_required=['on']
+    )
+
+    parsed = template_helpers.ParseTemplateRequest(post_data, self.config)
+    self.assertEqual(parsed.name, 'sometemplate')
+    self.assertTrue(parsed.members_only)
+    self.assertEqual(parsed.summary, 'TLDR')
+    self.assertTrue(parsed.summary_must_be_edited)
+    self.assertEqual(parsed.content, 'HEY WHY')
+    self.assertEqual(parsed.status, 'Accepted')
+    self.assertEqual(parsed.owner_str, 'someone@world.com')
+    self.assertEqual(parsed.labels, ['label-One', 'label-Two'])
+    self.assertEqual(parsed.field_val_strs, {1: ['NO'], 2: ['MOOD']})
+    self.assertEqual(parsed.component_paths, ['hey', 'hey2', 'he3'])
+    self.assertTrue(parsed.component_required)
+    self.assertFalse(parsed.owner_defaults_to_member)
+    self.assertTrue(parsed.add_approvals)
+    self.assertEqual(parsed.admin_str, 'jojwang@test.com, annajo@test.com')
+    self.assertItemsEqual(parsed.phase_names,
+                          ['Canary', 'Stable-Exp', 'Stable', '', '', 'Oops'])
+    self.assertEqual(parsed.approvals_to_phase_idx, {3: 2, 4: None})
+    self.assertItemsEqual(parsed.required_approval_ids, [3, 4])
+
+  def testGetTemplateInfoFromParsed_Normal(self):
+    self.config.field_defs.extend([self.fd_1, self.fd_2])
+    self.config.component_defs.append(self.cd_1)
+    parsed = template_helpers.ParsedTemplate(
+        'template', True, 'summary', True, 'content', 'Available',
+        '1@ex.com', ['label1', 'label1'], {1: ['NO'], 2: ['MOOD']},
+        ['BackEnd'], True, True, '2@ex.com', False, [], {}, [])
+    (admin_ids, owner_id, component_ids,
+     field_values, phases,
+     approval_values) = template_helpers.GetTemplateInfoFromParsed(
+        self.mr, self.services, parsed, self.config)
+    self.assertEqual(admin_ids, [222])
+    self.assertEqual(owner_id, 111)
+    self.assertEqual(component_ids, [1])
+    self.assertEqual(field_values[0].str_value, 'NO')
+    self.assertEqual(field_values[1].str_value, 'MOOD')
+    self.assertEqual(phases, [])
+    self.assertEqual(approval_values, [])
+
+  def testGetTemplateInfoFromParsed_Errors(self):
+    self.config.field_defs.extend([self.fd_1, self.fd_2])
+    parsed = template_helpers.ParsedTemplate(
+        'template', True, 'summary', True, 'content', 'Available',
+        '4@ex.com', ['label1', 'label1'], {1: ['NO'], 2: ['MOOD']},
+        ['BackEnd'], True, True, '2@ex.com', False, [], {}, [])
+    (admin_ids, _owner_id, _component_ids,
+     field_values, phases,
+     approval_values) = template_helpers.GetTemplateInfoFromParsed(
+        self.mr, self.services, parsed, self.config)
+    self.assertEqual(admin_ids, [222])
+    self.assertEqual(field_values[0].str_value, 'NO')
+    self.assertEqual(field_values[1].str_value, 'MOOD')
+    self.assertEqual(self.mr.errors.owner, 'Owner not found.')
+    self.assertEqual(self.mr.errors.components, 'Unknown component BackEnd')
+    self.assertEqual(phases, [])
+    self.assertEqual(approval_values, [])
+
+  def testGetPhasesAndApprovalsFromParsed_Normal(self):
+    self.config.field_defs.extend([self.fd_1, self.fd_2])
+    self.config.approval_defs.extend([self.ad_3, self.ad_4, self.ad_5])
+
+    phase_names = ['Canary', '', 'Stable-Exp', '', '', '']
+    approvals_to_phase_idx = {3: 0, 4: None, 5: 2}
+    required_approval_ids = [3, 5]
+
+    phases, approval_values = template_helpers._GetPhasesAndApprovalsFromParsed(
+        self.mr, phase_names, approvals_to_phase_idx, required_approval_ids)
+    self.assertEqual(len(phases), 2)
+    self.assertEqual(len(approval_values), 3)
+
+    canary = tracker_bizobj.FindPhase('canary', phases)
+    self.assertEqual(canary.rank, 0)
+    av_3 = tracker_bizobj.FindApprovalValueByID(3, approval_values)
+    self.assertEqual(av_3.status, tracker_pb2.ApprovalStatus.NEEDS_REVIEW)
+    self.assertEqual(av_3.phase_id, canary.phase_id)
+
+    av_4 = tracker_bizobj.FindApprovalValueByID(4, approval_values)
+    self.assertEqual(av_4.status, tracker_pb2.ApprovalStatus.NOT_SET)
+    self.assertIsNone(av_4.phase_id)
+
+    stable_exp = tracker_bizobj.FindPhase('stable-exp', phases)
+    self.assertEqual(stable_exp.rank, 2)
+    av_5 = tracker_bizobj.FindApprovalValueByID(5, approval_values)
+    self.assertEqual(av_5.status, tracker_pb2.ApprovalStatus.NEEDS_REVIEW)
+    self.assertEqual(av_5.phase_id, stable_exp.phase_id)
+
+    self.assertIsNone(self.mr.errors.phase_approvals)
+
+  def testGetPhasesAndApprovalsFromParsed_Errors(self):
+    self.config.field_defs.extend([self.fd_1, self.fd_2])
+    self.config.approval_defs.extend([self.ad_3, self.ad_4, self.ad_5])
+    required_approval_ids = []
+
+    phase_names = ['Canary', 'Extra', 'Stable-Exp', '', '', '']
+    approvals_to_phase_idx = {3: 0, 4: None, 5: 2}
+
+    template_helpers._GetPhasesAndApprovalsFromParsed(
+        self.mr, phase_names, approvals_to_phase_idx, required_approval_ids)
+    self.assertEqual(self.mr.errors.phase_approvals,
+                     'Defined gates must have assigned approvals.')
+
+  def testGetPhasesAndApprovalsFromParsed_DupsErrors(self):
+    self.config.field_defs.extend([self.fd_1, self.fd_2])
+    self.config.approval_defs.extend([self.ad_3, self.ad_4, self.ad_5])
+    required_approval_ids = []
+
+    phase_names = ['Canary', 'canary', 'Stable-Exp', '', '', '']
+    approvals_to_phase_idx = {3: 0, 4: None, 5: 2}
+
+    template_helpers._GetPhasesAndApprovalsFromParsed(
+        self.mr, phase_names, approvals_to_phase_idx, required_approval_ids)
+    self.assertEqual(self.mr.errors.phase_approvals,
+                     'Duplicate gate names.')
+
+  def testGetPhasesAndApprovalsFromParsed_InvalidPhaseName(self):
+    self.config.field_defs.extend([self.fd_1, self.fd_2])
+    self.config.approval_defs.extend([self.ad_3, self.ad_4, self.ad_5])
+    required_approval_ids = []
+
+    phase_names = ['Canary', 'A B', 'Stable-Exp', '', '', '']
+    approvals_to_phase_idx = {3: 0, 4: None, 5: 2}
+
+    template_helpers._GetPhasesAndApprovalsFromParsed(
+        self.mr, phase_names, approvals_to_phase_idx, required_approval_ids)
+    self.assertEqual(self.mr.errors.phase_approvals,
+                     'Invalid gate name(s).')
+
+  def testGatherApprovalsPageData(self):
+    self.fd_3.is_deleted = True
+    self.config.field_defs = [self.fd_3, self.fd_4, self.fd_5]
+    approval_values = [
+        tracker_pb2.ApprovalValue(approval_id=3, phase_id=8),
+        tracker_pb2.ApprovalValue(
+            approval_id=4, phase_id=9,
+            status=tracker_pb2.ApprovalStatus.NEEDS_REVIEW),
+        tracker_pb2.ApprovalValue(approval_id=5)
+    ]
+    tmpl_phases = [
+        tracker_pb2.Phase(phase_id=8, rank=1, name='deletednoshow'),
+        tracker_pb2.Phase(phase_id=9, rank=2, name='notdeleted')
+    ]
+
+    (prechecked_approvals, required_approval_ids,
+     phases) = template_helpers.GatherApprovalsPageData(
+         approval_values, tmpl_phases, self.config)
+    self.assertItemsEqual(prechecked_approvals,
+                          ['4_phase_0', '5'])
+    self.assertEqual(required_approval_ids, [4])
+    self.assertEqual(phases[0], tmpl_phases[1])
+    self.assertIsNone(phases[1].name)
+    self.assertEqual(len(phases), 6)
+
+  def testGetCheckedApprovalsFromParsed(self):
+    approvals_to_phase_idx = {23: 0, 25: 1, 26: None}
+    checked = template_helpers.GetCheckedApprovalsFromParsed(
+        approvals_to_phase_idx)
+    self.assertItemsEqual(checked,
+                          ['23_phase_0', '25_phase_1', '26'])
+
+  def testGetIssueFromTemplate(self):
+    """Can fill and return the templated issue"""
+    expected_fvs = [
+        tracker_pb2.FieldValue(field_id=123, str_value='fv_1_value'),
+        tracker_pb2.FieldValue(field_id=124, str_value='fv_2_value'),
+    ]
+    expected_phases = [
+        tracker_pb2.Phase(phase_id=123, name='phase_1_name', rank=1)
+    ]
+    expected_avs = [
+        tracker_pb2.ApprovalValue(
+            approval_id=1,
+            setter_id=111,
+            set_on=1232352,
+            approver_ids=[111],
+            phase_id=123),
+    ]
+    input_template = tracker_pb2.TemplateDef(
+        summary='expected_summary',
+        owner_id=111,
+        status='expected_status',
+        labels=['expected-label_1, expected-label_2'],
+        field_values=expected_fvs,
+        component_ids=[987],
+        phases=expected_phases,
+        approval_values=expected_avs)
+    reporter_id = 321
+    project_id = 1
+
+    actual = template_helpers.GetIssueFromTemplate(
+        input_template, project_id, reporter_id)
+    expected = tracker_pb2.Issue(
+        project_id=project_id,
+        summary='expected_summary',
+        status='expected_status',
+        owner_id=111,
+        labels=['expected-label_1, expected-label_2'],
+        component_ids=[987],
+        reporter_id=reporter_id,
+        field_values=expected_fvs,
+        phases=expected_phases,
+        approval_values=expected_avs)
+    self.assertEqual(actual, expected)
+
+  def testGetIssueFromTemplate_NoOwner(self):
+    """Uses reporter as owner when owner_defaults_to_member"""
+    input_template = tracker_pb2.TemplateDef(owner_defaults_to_member=False)
+
+    actual = template_helpers.GetIssueFromTemplate(input_template, 1, 1)
+    self.assertEqual(actual.owner_id, None)
+
+  def testGetIssueFromTemplate_DefaultsOwnerToReporter(self):
+    """Uses reporter as owner when owner_defaults_to_member"""
+    input_template = tracker_pb2.TemplateDef(owner_defaults_to_member=True)
+    reporter_id = 321
+
+    actual = template_helpers.GetIssueFromTemplate(
+        input_template, 1, reporter_id)
+    self.assertEqual(actual.owner_id, reporter_id)
+
+  def testGetIssueFromTemplate_SpecifiedOwnerOverridesReporter(self):
+    """Specified owner overrides owner_defaults_to_member"""
+    expected_owner_id = 111
+    input_template = tracker_pb2.TemplateDef(
+        owner_id=expected_owner_id, owner_defaults_to_member=True)
+    reporter_id = 321
+
+    actual = template_helpers.GetIssueFromTemplate(
+        input_template, 1, reporter_id)
+    self.assertEqual(actual.owner_id, expected_owner_id)
diff --git a/tracker/test/templatecreate_test.py b/tracker/test/templatecreate_test.py
new file mode 100644
index 0000000..60db78b
--- /dev/null
+++ b/tracker/test/templatecreate_test.py
@@ -0,0 +1,374 @@
+# Copyright 2018 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unit test for Template creation servlet."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import mox
+import unittest
+import settings
+
+from mock import Mock
+
+import ezt
+
+from framework import permissions
+from services import service_manager
+from services import template_svc
+from testing import fake
+from testing import testing_helpers
+from tracker import templatecreate
+from tracker import tracker_bizobj
+from tracker import tracker_views
+from proto import tracker_pb2
+
+
+class TemplateCreateTest(unittest.TestCase):
+  """Tests for the TemplateCreate servlet."""
+
+  def setUp(self):
+    self.cnxn = 'fake cnxn'
+    self.services = service_manager.Services(
+        project=fake.ProjectService(),
+        config=fake.ConfigService(),
+        template=Mock(spec=template_svc.TemplateService),
+        user=fake.UserService())
+    self.servlet = templatecreate.TemplateCreate('req', 'res',
+        services=self.services)
+    self.project = self.services.project.TestAddProject('proj')
+
+    self.fd_1 = tracker_bizobj.MakeFieldDef(
+        1, self.project.project_id, 'StringFieldName',
+        tracker_pb2.FieldTypes.STR_TYPE, None, '', False,
+        False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action',
+        'some approval thing', False, approval_id=2)
+
+    self.fd_2 = tracker_bizobj.MakeFieldDef(
+        2, self.project.project_id, 'UXApproval',
+        tracker_pb2.FieldTypes.APPROVAL_TYPE, None, '', False, False, False,
+        None, None, '', False, '', '', tracker_pb2.NotifyTriggers.NEVER,
+        'no_action', 'Approval for UX review', False)
+    self.fd_3 = tracker_bizobj.MakeFieldDef(
+        3, self.project.project_id, 'TestApproval',
+        tracker_pb2.FieldTypes.APPROVAL_TYPE, None, '', False, False, False,
+        None, None, '', False, '', '', tracker_pb2.NotifyTriggers.NEVER,
+        'no_action', 'Approval for Test review', False)
+    self.fd_4 =  tracker_bizobj.MakeFieldDef(
+        4, self.project.project_id, 'Target',
+        tracker_pb2.FieldTypes.INT_TYPE, None, '', False, False, False, None,
+        None, '', False, '', '', tracker_pb2.NotifyTriggers.NEVER, 'no_action',
+        'milestone target', False, is_phase_field=True)
+    self.fd_5 = tracker_bizobj.MakeFieldDef(
+        5,
+        self.project.project_id,
+        'RestrictedField',
+        tracker_pb2.FieldTypes.INT_TYPE,
+        None,
+        '',
+        False,
+        False,
+        False,
+        None,
+        None,
+        '',
+        False,
+        '',
+        '',
+        tracker_pb2.NotifyTriggers.NEVER,
+        'no_action',
+        'RestrictedField',
+        False,
+        is_restricted_field=True)
+    self.fd_6 = tracker_bizobj.MakeFieldDef(
+        6,
+        self.project.project_id,
+        'RestrictedEnumField',
+        tracker_pb2.FieldTypes.ENUM_TYPE,
+        None,
+        '',
+        False,
+        False,
+        False,
+        None,
+        None,
+        '',
+        False,
+        '',
+        '',
+        tracker_pb2.NotifyTriggers.NEVER,
+        'no_action',
+        'RestrictedEnumField',
+        False,
+        is_restricted_field=True)
+    ad_2 = tracker_pb2.ApprovalDef(approval_id=2)
+    ad_3 = tracker_pb2.ApprovalDef(approval_id=3)
+
+    self.config = self.services.config.GetProjectConfig(
+        'fake cnxn', self.project.project_id)
+    self.config.approval_defs.extend([ad_2, ad_3])
+    self.config.field_defs.extend(
+        [self.fd_1, self.fd_2, self.fd_3, self.fd_4, self.fd_5, self.fd_6])
+
+    first_tmpl = tracker_bizobj.MakeIssueTemplate(
+        'sometemplate', 'summary', None, None, 'content', [], [], [],
+        [])
+    self.services.config.StoreConfig(None, self.config)
+
+    templates = testing_helpers.DefaultTemplates()
+    templates.append(first_tmpl)
+    self.services.template.GetProjectTemplates = Mock(
+        return_value=templates)
+
+    self.mr = testing_helpers.MakeMonorailRequest(
+        project=self.project, perms=permissions.OWNER_ACTIVE_PERMISSIONSET)
+    self.mox = mox.Mox()
+
+  def tearDown(self):
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def testAssertBasePermission(self):
+    # Anon users can never do it
+    self.mr.perms = permissions.READ_ONLY_PERMISSIONSET
+    self.assertRaises(
+        permissions.PermissionException,
+        self.servlet.AssertBasePermission, self.mr)
+
+    # Project owner can do it.
+    self.mr.perms = permissions.OWNER_ACTIVE_PERMISSIONSET
+    self.servlet.AssertBasePermission(self.mr)
+
+    # Project member cannot do it
+    self.mr.perms = permissions.COMMITTER_ACTIVE_PERMISSIONSET
+    self.assertRaises(
+        permissions.PermissionException,
+        self.servlet.AssertBasePermission, self.mr)
+    self.mr.perms = permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET
+    self.assertRaises(
+        permissions.PermissionException,
+        self.servlet.AssertBasePermission, self.mr)
+
+  def testGatherPageData(self):
+    precomp_view_info = tracker_views._PrecomputeInfoForValueViews(
+        [], [], [], self.config, [])
+    fv = tracker_views._MakeFieldValueView(
+        self.fd_1, self.config, precomp_view_info, {})
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.assertEqual(self.servlet.PROCESS_TAB_TEMPLATES,
+                     page_data['admin_tab_mode'])
+    self.assertTrue(page_data['allow_edit'])
+    self.assertEqual(page_data['uneditable_fields'], ezt.boolean(False))
+    self.assertTrue(page_data['new_template_form'])
+    self.assertFalse(page_data['initial_members_only'])
+    self.assertEqual(page_data['template_name'], '')
+    self.assertEqual(page_data['initial_summary'], '')
+    self.assertFalse(page_data['initial_must_edit_summary'])
+    self.assertEqual(page_data['initial_content'], '')
+    self.assertEqual(page_data['initial_status'], '')
+    self.assertEqual(page_data['initial_owner'], '')
+    self.assertFalse(page_data['initial_owner_defaults_to_member'])
+    self.assertEqual(page_data['initial_components'], '')
+    self.assertFalse(page_data['initial_component_required'])
+    self.assertEqual(page_data['fields'][2].field_name, fv.field_name)
+    self.assertEqual(page_data['initial_admins'], '')
+    self.assertEqual(page_data['approval_subfields_present'], ezt.boolean(True))
+    self.assertEqual(page_data['phase_fields_present'], ezt.boolean(False))
+
+  def testProcessFormData_Reject(self):
+    self.services.user.TestAddUser('user@example.com', 222)
+    self.mr.auth.effective_ids = {222}
+    post_data = fake.PostData(
+      name=['sometemplate'],
+      members_only=['on'],
+      summary=['TLDR'],
+      summary_must_be_edited=['on'],
+      content=['HEY WHY'],
+      status=['Accepted'],
+      owner=['someone@world.com'],
+      label=['label-One', 'label-Two'],
+      custom_1=['NO'],
+      custom_2=['MOOD'],
+      components=['hey, hey2,he3'],
+      component_required=['on'],
+      owner_defaults_to_member=['no'],
+      add_approvals = ['on'],
+      phase_0=['Canary'],
+      phase_1=['Stable-Exp'],
+      phase_2=['Stable'],
+      phase_3=[''],
+      phase_4=[''],
+      phase_5=[''],
+      approval_2=['phase_1'],
+      approval_3=['phase_2']
+    )
+
+    self.mox.StubOutWithMock(self.servlet, 'PleaseCorrect')
+    self.servlet.PleaseCorrect(
+        self.mr,
+        initial_members_only=ezt.boolean(True),
+        template_name='sometemplate',
+        initial_content='TLDR',
+        initial_must_edit_summary=ezt.boolean(True),
+        initial_description='HEY WHY',
+        initial_status='Accepted',
+        initial_owner='someone@world.com',
+        initial_owner_defaults_to_member=ezt.boolean(False),
+        initial_components='hey, hey2, he3',
+        initial_component_required=ezt.boolean(True),
+        initial_admins='',
+        labels=['label-One', 'label-Two'],
+        fields=mox.IgnoreArg(),
+        initial_add_approvals=ezt.boolean(True),
+        initial_phases=[tracker_pb2.Phase(name=name) for
+                        name in ['Canary', 'Stable-Exp', 'Stable', '', '', '']],
+        approvals=mox.IgnoreArg(),
+        prechecked_approvals=['2_phase_1', '3_phase_2'],
+        required_approval_ids=[]
+        )
+    self.mox.ReplayAll()
+    url = self.servlet.ProcessFormData(self.mr, post_data)
+    self.mox.VerifyAll()
+    self.assertEqual('Owner not found.', self.mr.errors.owner)
+    self.assertEqual('Unknown component he3', self.mr.errors.components)
+    self.assertEqual(
+        'Template with name sometemplate already exists', self.mr.errors.name)
+    self.assertEqual('Defined gates must have assigned approvals.',
+                     self.mr.errors.phase_approvals)
+    self.assertIsNone(url)
+
+  def testProcessFormData_RejectRestrictedFields(self):
+    self.services.template.GetTemplateByName = Mock(return_value=None)
+    self.mr.perms = permissions.PermissionSet([])
+    post_data_add_fv = fake.PostData(
+        name=['secondtemplate'],
+        members_only=['on'],
+        summary=['TLDR'],
+        summary_must_be_edited=['on'],
+        content=['HEY WHY'],
+        status=['Accepted'],
+        label=['label-One', 'label-Two'],
+        custom_1=['Hey'],
+        custom_5=['7'],
+        component_required=['on'],
+        owner_defaults_to_member=['no'],
+        add_approvals=['no'],
+        phase_0=[''],
+        phase_1=[''],
+        phase_2=[''],
+        phase_3=[''],
+        phase_4=[''],
+        phase_5=['OOPs'],
+        approval_2=['phase_0'],
+        approval_3=['phase_2'])
+    post_data_label_edits_enum = fake.PostData(
+        name=['secondtemplate'],
+        members_only=['on'],
+        summary=['TLDR'],
+        summary_must_be_edited=['on'],
+        content=['HEY WHY'],
+        status=['Accepted'],
+        label=['label-One', 'label-Two', 'RestrictedEnumField-7'],
+        component_required=['on'],
+        owner_defaults_to_member=['no'],
+        add_approvals=['no'],
+        phase_0=[''],
+        phase_1=[''],
+        phase_2=[''],
+        phase_3=[''],
+        phase_4=[''],
+        phase_5=['OOPs'],
+        approval_2=['phase_0'],
+        approval_3=['phase_2'])
+
+    self.assertRaises(
+        AssertionError, self.servlet.ProcessFormData, self.mr, post_data_add_fv)
+    self.assertRaises(
+        AssertionError, self.servlet.ProcessFormData, self.mr,
+        post_data_label_edits_enum)
+
+  def testProcessFormData_Accept(self):
+    self.services.user.TestAddUser('user@example.com', 222)
+    self.mr.auth.effective_ids = {222}
+    self.services.template.GetTemplateByName = Mock(return_value=None)
+    post_data = fake.PostData(
+        name=['secondtemplate'],
+        members_only=['on'],
+        summary=['TLDR'],
+        summary_must_be_edited=['on'],
+        content=['HEY WHY'],
+        status=['Accepted'],
+        label=['label-One', 'label-Two', 'RestrictedEnumField-7'],
+        custom_1=['NO'],
+        custom_5=['37'],
+        component_required=['on'],
+        owner_defaults_to_member=['no'],
+        add_approvals=['no'],
+        phase_0=[''],
+        phase_1=[''],
+        phase_2=[''],
+        phase_3=[''],
+        phase_4=[''],
+        phase_5=['OOPs'],
+        approval_2=['phase_0'],
+        approval_3=['phase_2'])
+
+    url = self.servlet.ProcessFormData(self.mr, post_data)
+
+    self.assertTrue('/adminTemplates?saved=1&ts' in url)
+
+    self.assertEqual(0,
+        self.services.template.UpdateIssueTemplateDef.call_count)
+
+    # errors in phases should not matter if add_approvals is not 'on'
+    self.assertIsNone(self.mr.errors.phase_approvals)
+
+  def testProcessFormData_AcceptPhases(self):
+    self.services.user.TestAddUser('user@example.com', 222)
+    self.mr.auth.effective_ids = {222}
+    self.services.template.GetTemplateByName = Mock(return_value=None)
+    post_data = fake.PostData(
+      name=['secondtemplate'],
+      members_only=['on'],
+      summary=['TLDR'],
+      summary_must_be_edited=['on'],
+      content=['HEY WHY'],
+      status=['Accepted'],
+      label=['label-One', 'label-Two'],
+      custom_1=['NO'],
+      component_required=['on'],
+      owner_defaults_to_member=['no'],
+      add_approvals = ['on'],
+      phase_0=['Canary'],
+      phase_1=['Stable'],
+      phase_2=[''],
+      phase_3=[''],
+      phase_4=[''],
+      phase_5=[''],
+      approval_2=['phase_0'],
+      approval_3=['phase_1'],
+      approval_3_required=['on']
+    )
+
+    url = self.servlet.ProcessFormData(self.mr, post_data)
+    self.assertTrue('/adminTemplates?saved=1&ts' in url)
+
+    fv = tracker_pb2.FieldValue(field_id=1, str_value='NO', derived=False)
+    phases = [
+        tracker_pb2.Phase(name='Canary', rank=0, phase_id=0),
+        tracker_pb2.Phase(name='Stable', rank=1, phase_id=1)
+    ]
+    approval_values = [
+        tracker_pb2.ApprovalValue(approval_id=2, phase_id=0),
+        tracker_pb2.ApprovalValue(
+            approval_id=3, status=tracker_pb2.ApprovalStatus(
+                tracker_pb2.ApprovalStatus.NEEDS_REVIEW), phase_id=1)
+        ]
+    self.services.template.CreateIssueTemplateDef.assert_called_once_with(
+        self.mr.cnxn, 47925, 'secondtemplate', 'HEY WHY', 'TLDR', True,
+        'Accepted', True, False, True, 0, ['label-One', 'label-Two'], [], [],
+        [fv], phases=phases, approval_values=approval_values)
diff --git a/tracker/test/templatedetail_test.py b/tracker/test/templatedetail_test.py
new file mode 100644
index 0000000..607996a
--- /dev/null
+++ b/tracker/test/templatedetail_test.py
@@ -0,0 +1,521 @@
+# Copyright 2018 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unit tests for Template editing/viewing servlet."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import mox
+import logging
+import unittest
+import settings
+
+from mock import Mock
+
+import ezt
+
+from framework import permissions
+from services import service_manager
+from services import template_svc
+from testing import fake
+from testing import testing_helpers
+from tracker import templatedetail
+from tracker import tracker_bizobj
+from proto import tracker_pb2
+
+
+class TemplateDetailTest(unittest.TestCase):
+  """Tests for the TemplateDetail servlet."""
+
+  def setUp(self):
+    self.cnxn = 'fake cnxn'
+    mock_template_service = Mock(spec=template_svc.TemplateService)
+    self.services = service_manager.Services(project=fake.ProjectService(),
+                                             config=fake.ConfigService(),
+                                             template=mock_template_service,
+                                             usergroup=fake.UserGroupService(),
+                                             user=fake.UserService())
+    self.servlet = templatedetail.TemplateDetail('req', 'res',
+                                               services=self.services)
+
+    self.services.user.TestAddUser('gatsby@example.com', 111)
+    self.services.user.TestAddUser('sport@example.com', 222)
+    self.services.user.TestAddUser('gatsby@example.com', 111)
+    self.services.user.TestAddUser('daisy@example.com', 333)
+
+    self.project = self.services.project.TestAddProject('proj')
+    self.services.project.TestAddProjectMembers(
+        [333], self.project, 'CONTRIBUTOR_ROLE')
+
+    self.template = self.test_template = tracker_bizobj.MakeIssueTemplate(
+        'TestTemplate', 'sum', 'New', 111, 'content', ['label1', 'label2'],
+        [], [222], [], summary_must_be_edited=True,
+        owner_defaults_to_member=True, component_required=False,
+        members_only=False)
+    self.template.template_id = 12345
+    self.services.template.GetTemplateByName = Mock(
+        return_value=self.template)
+
+    self.mr = testing_helpers.MakeMonorailRequest(project=self.project)
+    self.mr.template_name = 'TestTemplate'
+
+    self.mox = mox.Mox()
+
+    self.fd_1 =  tracker_bizobj.MakeFieldDef(
+        1, 789, 'UXReview', tracker_pb2.FieldTypes.STR_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action',
+        'Approval for UX review', False, approval_id=2)
+    self.fd_2 =  tracker_bizobj.MakeFieldDef(
+        2, 789, 'UXReview', tracker_pb2.FieldTypes.STR_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action',
+        'Approval for UX review', False)
+    self.fd_3 = tracker_bizobj.MakeFieldDef(
+        3, 789, 'TestApproval', tracker_pb2.FieldTypes.APPROVAL_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'Approval for Test',
+        False)
+    self.fd_4 = tracker_bizobj.MakeFieldDef(
+        4, 789, 'SecurityApproval', tracker_pb2.FieldTypes.APPROVAL_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'Approval for Security',
+        False)
+    self.fd_5 = tracker_bizobj.MakeFieldDef(
+        5, 789, 'GateTarget', tracker_pb2.FieldTypes.INT_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action',
+        'milestone target', False, is_phase_field=True)
+    self.fd_6 = tracker_bizobj.MakeFieldDef(
+        6, 789, 'Choices', tracker_pb2.FieldTypes.ENUM_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action',
+        'milestone target', False, is_phase_field=True)
+    self.fd_7 = tracker_bizobj.MakeFieldDef(
+        7,
+        789,
+        'RestrictedField',
+        tracker_pb2.FieldTypes.INT_TYPE,
+        None,
+        '',
+        False,
+        False,
+        False,
+        None,
+        None,
+        '',
+        False,
+        '',
+        '',
+        tracker_pb2.NotifyTriggers.NEVER,
+        'no_action',
+        'RestrictedField',
+        False,
+        is_restricted_field=True)
+    self.fd_8 = tracker_bizobj.MakeFieldDef(
+        8,
+        789,
+        'RestrictedEnumField',
+        tracker_pb2.FieldTypes.ENUM_TYPE,
+        None,
+        '',
+        False,
+        False,
+        False,
+        None,
+        None,
+        '',
+        False,
+        '',
+        '',
+        tracker_pb2.NotifyTriggers.NEVER,
+        'no_action',
+        'RestrictedEnumField',
+        False,
+        is_restricted_field=True)
+    self.fd_9 = tracker_bizobj.MakeFieldDef(
+        9,
+        789,
+        'RestrictedField_2',
+        tracker_pb2.FieldTypes.INT_TYPE,
+        None,
+        '',
+        False,
+        False,
+        False,
+        None,
+        None,
+        '',
+        False,
+        '',
+        '',
+        tracker_pb2.NotifyTriggers.NEVER,
+        'no_action',
+        'RestrictedField_2',
+        False,
+        is_restricted_field=True)
+    self.fd_10 = tracker_bizobj.MakeFieldDef(
+        10,
+        789,
+        'RestrictedEnumField_2',
+        tracker_pb2.FieldTypes.ENUM_TYPE,
+        None,
+        '',
+        False,
+        False,
+        False,
+        None,
+        None,
+        '',
+        False,
+        '',
+        '',
+        tracker_pb2.NotifyTriggers.NEVER,
+        'no_action',
+        'RestrictedEnumField_2',
+        False,
+        is_restricted_field=True)
+
+    self.ad_3 = tracker_pb2.ApprovalDef(approval_id=3)
+    self.ad_4 = tracker_pb2.ApprovalDef(approval_id=4)
+
+    self.cd_1 = tracker_bizobj.MakeComponentDef(
+        1, 789, 'BackEnd', 'doc', False, [111], [], 100000, 222)
+    self.template.component_ids.append(1)
+
+    self.canary_phase = tracker_pb2.Phase(
+        name='Canary', phase_id=1, rank=1)
+    self.av_3 = tracker_pb2.ApprovalValue(approval_id=3, phase_id=1)
+    self.stable_phase = tracker_pb2.Phase(
+        name='Stable', phase_id=2, rank=3)
+    self.av_4 = tracker_pb2.ApprovalValue(approval_id=4, phase_id=2)
+    self.template.phases.extend([self.stable_phase, self.canary_phase])
+    self.template.approval_values.extend([self.av_3, self.av_4])
+
+    self.config = self.services.config.GetProjectConfig(
+        'fake cnxn', self.project.project_id)
+    self.templates = testing_helpers.DefaultTemplates()
+    self.template.labels.extend(
+        ['GateTarget-Should-Not', 'GateTarget-Be-Masked',
+         'Choices-Wrapped', 'Choices-Burritod'])
+    self.templates.append(self.template)
+    self.services.template.GetProjectTemplates = Mock(
+        return_value=self.templates)
+    self.services.template.FindTemplateByName = Mock(return_value=self.template)
+    self.config.component_defs.append(self.cd_1)
+    self.config.field_defs.extend(
+        [
+            self.fd_1, self.fd_2, self.fd_3, self.fd_4, self.fd_5, self.fd_6,
+            self.fd_7, self.fd_8, self.fd_9, self.fd_10
+        ])
+    self.config.approval_defs.extend([self.ad_3, self.ad_4])
+    self.services.config.StoreConfig(None, self.config)
+
+  def tearDown(self):
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def testAssertBasePermission_Anyone(self):
+    self.mr.auth.effective_ids = {222}
+    self.servlet.AssertBasePermission(self.mr)
+
+    self.mr.auth.effective_ids = {333}
+    self.servlet.AssertBasePermission(self.mr)
+
+    self.mr.perms = permissions.OWNER_ACTIVE_PERMISSIONSET
+    self.servlet.AssertBasePermission(self.mr)
+
+    self.mr.perms = permissions.READ_ONLY_PERMISSIONSET
+    self.servlet.AssertBasePermission(self.mr)
+
+  def testAssertBasePermision_MembersOnly(self):
+    self.template.members_only = True
+    self.mr.auth.effective_ids = {222}
+    self.servlet.AssertBasePermission(self.mr)
+
+    self.mr.auth.effective_ids = {333}
+    self.servlet.AssertBasePermission(self.mr)
+
+    self.mr.auth.effective_ids = {444}
+    self.assertRaises(
+        permissions.PermissionException,
+        self.servlet.AssertBasePermission, self.mr)
+
+    self.mr.perms = permissions.READ_ONLY_PERMISSIONSET
+    self.assertRaises(
+        permissions.PermissionException,
+        self.servlet.AssertBasePermission, self.mr)
+
+  def testGatherPageData(self):
+    self.mr.perms = permissions.PermissionSet([])
+    self.mr.auth.effective_ids = {222}  # template admin
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.assertEqual(self.servlet.PROCESS_TAB_TEMPLATES,
+                     page_data['admin_tab_mode'])
+    self.assertTrue(page_data['allow_edit'])
+    self.assertEqual(page_data['uneditable_fields'], ezt.boolean(True))
+    self.assertFalse(page_data['new_template_form'])
+    self.assertFalse(page_data['initial_members_only'])
+    self.assertEqual(page_data['template_name'], 'TestTemplate')
+    self.assertEqual(page_data['initial_summary'], 'sum')
+    self.assertTrue(page_data['initial_must_edit_summary'])
+    self.assertEqual(page_data['initial_content'], 'content')
+    self.assertEqual(page_data['initial_status'], 'New')
+    self.assertEqual(page_data['initial_owner'], 'gatsby@example.com')
+    self.assertTrue(page_data['initial_owner_defaults_to_member'])
+    self.assertEqual(page_data['initial_components'], 'BackEnd')
+    self.assertFalse(page_data['initial_component_required'])
+    self.assertItemsEqual(
+        page_data['labels'],
+        ['label1', 'label2', 'GateTarget-Should-Not', 'GateTarget-Be-Masked'])
+    self.assertEqual(page_data['initial_admins'], 'sport@example.com')
+    self.assertTrue(page_data['initial_add_approvals'])
+    self.assertEqual(len(page_data['initial_phases']), 6)
+    phases = [phase for phase in page_data['initial_phases'] if phase.name]
+    self.assertEqual(len(phases), 2)
+    self.assertEqual(len(page_data['approvals']), 2)
+    self.assertItemsEqual(page_data['prechecked_approvals'],
+                          ['3_phase_0', '4_phase_1'])
+    self.assertTrue(page_data['fields'][3].is_editable)  #nonRestrictedField
+    self.assertIsNone(page_data['fields'][4].is_editable)  #restrictedField
+
+  def testProcessFormData_Reject(self):
+    self.mr.auth.effective_ids = {222}
+    post_data = fake.PostData(
+      name=['TestTemplate'],
+      members_only=['on'],
+      summary=['TLDR'],
+      summary_must_be_edited=['on'],
+      content=['HEY WHY'],
+      status=['Accepted'],
+      owner=['someone@world.com'],
+      label=['label-One', 'label-Two'],
+      custom_1=['NO'],
+      custom_2=['MOOD'],
+      components=['hey, hey2,he3'],
+      component_required=['on'],
+      owner_defaults_to_member=['no'],
+      add_approvals = ['on'],
+      phase_0=['Canary'],
+      phase_1=['Stable-Exp'],
+      phase_2=['Stable'],
+      phase_3=[''],
+      phase_4=[''],
+      phase_5=[''],
+      approval_3=['phase_0'],
+      approval_4=['phase_2']
+    )
+
+    self.mox.StubOutWithMock(self.servlet, 'PleaseCorrect')
+    self.servlet.PleaseCorrect(
+        self.mr,
+        initial_members_only=ezt.boolean(True),
+        template_name='TestTemplate',
+        initial_summary='TLDR',
+        initial_must_edit_summary=ezt.boolean(True),
+        initial_content='HEY WHY',
+        initial_status='Accepted',
+        initial_owner='someone@world.com',
+        initial_owner_defaults_to_member=ezt.boolean(False),
+        initial_components='hey, hey2, he3',
+        initial_component_required=ezt.boolean(True),
+        initial_admins='',
+        labels=['label-One', 'label-Two'],
+        fields=mox.IgnoreArg(),
+        initial_add_approvals=ezt.boolean(True),
+        initial_phases=[tracker_pb2.Phase(name=name) for
+                        name in ['Canary', 'Stable-Exp', 'Stable', '', '', '']],
+        approvals=mox.IgnoreArg(),
+        prechecked_approvals=['3_phase_0', '4_phase_2'],
+        required_approval_ids=[]
+        )
+    self.mox.ReplayAll()
+    url = self.servlet.ProcessFormData(self.mr, post_data)
+    self.mox.VerifyAll()
+    self.assertEqual('Owner not found.', self.mr.errors.owner)
+    self.assertEqual('Unknown component he3', self.mr.errors.components)
+    self.assertIsNone(url)
+    self.assertEqual('Defined gates must have assigned approvals.',
+                     self.mr.errors.phase_approvals)
+
+  def testProcessFormData_RejectRestrictedFields(self):
+    """Template admins cannot set restricted fields by default."""
+    self.mr.perms = permissions.PermissionSet([])
+    self.mr.auth.effective_ids = {222}  # template admin
+    post_data_add_fv = fake.PostData(
+        name=['TestTemplate'],
+        members_only=['on'],
+        summary=['TLDR'],
+        summary_must_be_edited=[''],
+        content=['HEY WHY'],
+        status=['Accepted'],
+        owner=['daisy@example.com'],
+        label=['label-One', 'label-Two'],
+        custom_1=['NO'],
+        custom_2=['MOOD'],
+        custom_7=['37'],
+        components=['BackEnd'],
+        component_required=['on'],
+        owner_defaults_to_member=['on'],
+        add_approvals=['no'],
+        phase_0=[''],
+        phase_1=[''],
+        phase_2=[''],
+        phase_3=[''],
+        phase_4=[''],
+        phase_5=['OOPs'],
+        approval_3=['phase_0'],
+        approval_4=['phase_2'])
+    post_data_label_edits_enum = fake.PostData(
+        name=['TestTemplate'],
+        members_only=['on'],
+        summary=['TLDR'],
+        summary_must_be_edited=[''],
+        content=['HEY WHY'],
+        status=['Accepted'],
+        owner=['daisy@example.com'],
+        label=['label-One', 'label-Two', 'RestrictedEnumField-7'],
+        components=['BackEnd'],
+        component_required=['on'],
+        owner_defaults_to_member=['on'],
+        add_approvals=['no'],
+        phase_0=[''],
+        phase_1=[''],
+        phase_2=[''],
+        phase_3=[''],
+        phase_4=[''],
+        phase_5=['OOPs'],
+        approval_3=['phase_0'],
+        approval_4=['phase_2'])
+
+    self.assertRaises(
+        AssertionError, self.servlet.ProcessFormData, self.mr, post_data_add_fv)
+    self.assertRaises(
+        AssertionError, self.servlet.ProcessFormData, self.mr,
+        post_data_label_edits_enum)
+
+  def testProcessFormData_Accept(self):
+    self.fd_7.editor_ids = [222]
+    self.fd_8.editor_ids = [222]
+    self.mr.perms = permissions.PermissionSet([])
+    self.mr.auth.effective_ids = {222}  # template admin
+    temp_restricted_fv = tracker_bizobj.MakeFieldValue(
+        9, 3737, None, None, None, None, False)
+    self.template.field_values.append(temp_restricted_fv)
+    self.template.labels.append('RestrictedEnumField_2-b')
+    post_data = fake.PostData(
+        name=['TestTemplate'],
+        members_only=['on'],
+        summary=['TLDR'],
+        summary_must_be_edited=[''],
+        content=['HEY WHY'],
+        status=['Accepted'],
+        owner=['daisy@example.com'],
+        label=['label-One', 'label-Two', 'RestrictedEnumField-7'],
+        custom_1=['NO'],
+        custom_2=['MOOD'],
+        custom_7=['37'],
+        components=['BackEnd'],
+        component_required=['on'],
+        owner_defaults_to_member=['on'],
+        add_approvals=['no'],
+        phase_0=[''],
+        phase_1=[''],
+        phase_2=[''],
+        phase_3=[''],
+        phase_4=[''],
+        phase_5=['OOPs'],
+        approval_3=['phase_0'],
+        approval_4=['phase_2'])
+    url = self.servlet.ProcessFormData(self.mr, post_data)
+
+    self.assertTrue('/templates/detail?saved=1&template=TestTemplate&' in url)
+
+    self.services.template.UpdateIssueTemplateDef.assert_called_once_with(
+        self.mr.cnxn,
+        47925,
+        12345,
+        status='Accepted',
+        component_required=True,
+        phases=[],
+        approval_values=[],
+        name='TestTemplate',
+        field_values=[
+            tracker_pb2.FieldValue(field_id=1, str_value='NO', derived=False),
+            tracker_pb2.FieldValue(field_id=2, str_value='MOOD', derived=False),
+            tracker_pb2.FieldValue(field_id=7, int_value=37, derived=False),
+            tracker_pb2.FieldValue(field_id=9, int_value=3737, derived=False)
+        ],
+        labels=[
+            'label-One', 'label-Two', 'RestrictedEnumField-7',
+            'RestrictedEnumField_2-b'
+        ],
+        owner_defaults_to_member=True,
+        admin_ids=[],
+        content='HEY WHY',
+        component_ids=[1],
+        summary_must_be_edited=False,
+        summary='TLDR',
+        members_only=True,
+        owner_id=333)
+
+  def testProcessFormData_AcceptPhases(self):
+    self.mr.auth.effective_ids = {222}
+    post_data = fake.PostData(
+      name=['TestTemplate'],
+      members_only=['on'],
+      summary=['TLDR'],
+      summary_must_be_edited=[''],
+      content=['HEY WHY'],
+      status=['Accepted'],
+      owner=['daisy@example.com'],
+      label=['label-One', 'label-Two'],
+      custom_1=['NO'],
+      custom_2=['MOOD'],
+      components=['BackEnd'],
+      component_required=['on'],
+      owner_defaults_to_member=['on'],
+      add_approvals = ['on'],
+      phase_0=['Canary'],
+      phase_1=['Stable'],
+      phase_2=[''],
+      phase_3=[''],
+      phase_4=[''],
+      phase_5=[''],
+      approval_3=['phase_0'],
+      approval_4=['phase_1']
+    )
+    url = self.servlet.ProcessFormData(self.mr, post_data)
+
+    self.assertTrue('/templates/detail?saved=1&template=TestTemplate&' in url)
+
+    self.services.template.UpdateIssueTemplateDef.assert_called_once_with(
+        self.mr.cnxn, 47925, 12345, status='Accepted', component_required=True,
+        phases=[
+            tracker_pb2.Phase(name='Canary', rank=0, phase_id=0),
+            tracker_pb2.Phase(name='Stable', rank=1, phase_id=1)],
+        approval_values=[tracker_pb2.ApprovalValue(approval_id=3, phase_id=0),
+                         tracker_pb2.ApprovalValue(approval_id=4, phase_id=1)],
+        name='TestTemplate', field_values=[
+            tracker_pb2.FieldValue(field_id=1, str_value='NO', derived=False),
+            tracker_pb2.FieldValue(
+                field_id=2, str_value='MOOD', derived=False)],
+        labels=['label-One', 'label-Two'], owner_defaults_to_member=True,
+        admin_ids=[], content='HEY WHY', component_ids=[1],
+        summary_must_be_edited=False, summary='TLDR', members_only=True,
+        owner_id=333)
+
+  def testProcessFormData_Delete(self):
+    post_data = fake.PostData(
+      deletetemplate=['Submit'],
+      name=['TestTemplate'],
+      members_only=['on'],
+    )
+    url = self.servlet.ProcessFormData(self.mr, post_data)
+
+    self.assertTrue('/p/None/adminTemplates?deleted=1' in url)
+    self.services.template.DeleteIssueTemplateDef\
+        .assert_called_once_with(self.mr.cnxn, 47925, 12345)
diff --git a/tracker/test/tracker_bizobj_test.py b/tracker/test/tracker_bizobj_test.py
new file mode 100644
index 0000000..29351b0
--- /dev/null
+++ b/tracker/test/tracker_bizobj_test.py
@@ -0,0 +1,2456 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Tests for issue  bizobj functions."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+import logging
+
+from framework import framework_constants
+from framework import framework_views
+from proto import tracker_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+
+
+class BizobjTest(unittest.TestCase):
+
+  def setUp(self):
+    self.cnxn = 'fake cnxn'
+    self.services = service_manager.Services(
+        issue=fake.IssueService())
+    self.config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    self.config.field_defs = [
+        tracker_pb2.FieldDef(
+            field_id=1, project_id=789, field_name='EstDays',
+            field_type=tracker_pb2.FieldTypes.INT_TYPE)
+        ]
+    self.config.component_defs = [
+        tracker_pb2.ComponentDef(component_id=1, project_id=789, path='UI'),
+        tracker_pb2.ComponentDef(component_id=2, project_id=789, path='DB'),
+        ]
+
+  def testGetOwnerId(self):
+    issue = tracker_pb2.Issue()
+    self.assertEqual(
+        tracker_bizobj.GetOwnerId(issue), framework_constants.NO_USER_SPECIFIED)
+
+    issue.derived_owner_id = 123
+    self.assertEqual(tracker_bizobj.GetOwnerId(issue), 123)
+
+    issue.owner_id = 456
+    self.assertEqual(tracker_bizobj.GetOwnerId(issue), 456)
+
+  def testGetStatus(self):
+    issue = tracker_pb2.Issue()
+    self.assertEqual(tracker_bizobj.GetStatus(issue), '')
+
+    issue.derived_status = 'InReview'
+    self.assertEqual(tracker_bizobj.GetStatus(issue), 'InReview')
+
+    issue.status = 'Forgotten'
+    self.assertEqual(tracker_bizobj.GetStatus(issue), 'Forgotten')
+
+  def testGetCcIds(self):
+    issue = tracker_pb2.Issue()
+    self.assertEqual(tracker_bizobj.GetCcIds(issue), [])
+
+    issue.derived_cc_ids.extend([1, 2, 3])
+    self.assertEqual(tracker_bizobj.GetCcIds(issue), [1, 2, 3])
+
+    issue.cc_ids.extend([4, 5, 6])
+    self.assertEqual(tracker_bizobj.GetCcIds(issue), [4, 5, 6, 1, 2, 3])
+
+  def testGetApproverIds(self):
+    issue = tracker_pb2.Issue()
+    self.assertEqual(tracker_bizobj.GetApproverIds(issue), [])
+
+    av_1 = tracker_pb2.ApprovalValue(approver_ids=[111, 222])
+    av_2 = tracker_pb2.ApprovalValue()
+    av_3 = tracker_pb2.ApprovalValue(approver_ids=[222, 333])
+    issue.approval_values = [av_1, av_2, av_3]
+    self.assertItemsEqual(
+        tracker_bizobj.GetApproverIds(issue), [111, 222, 333])
+
+  def testGetLabels(self):
+    issue = tracker_pb2.Issue()
+    self.assertEqual(tracker_bizobj.GetLabels(issue), [])
+
+    issue.derived_labels.extend(['a', 'b', 'c'])
+    self.assertEqual(tracker_bizobj.GetLabels(issue), ['a', 'b', 'c'])
+
+    issue.labels.extend(['d', 'e', 'f'])
+    self.assertEqual(
+        tracker_bizobj.GetLabels(issue), ['d', 'e', 'f', 'a', 'b', 'c'])
+
+  def testFindFieldDef_None(self):
+    config = tracker_pb2.ProjectIssueConfig()
+    self.assertIsNone(tracker_bizobj.FindFieldDef(None, config))
+
+  def testFindFieldDef_Empty(self):
+    config = tracker_pb2.ProjectIssueConfig()
+    self.assertIsNone(tracker_bizobj.FindFieldDef('EstDays', config))
+
+  def testFindFieldDef_Default(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    self.assertIsNone(tracker_bizobj.FindFieldDef('EstDays', config))
+
+  def testFindFieldDef_Normal(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    fd = tracker_pb2.FieldDef(field_name='EstDays')
+    config.field_defs = [fd]
+    self.assertEqual(fd, tracker_bizobj.FindFieldDef('EstDays', config))
+    self.assertEqual(fd, tracker_bizobj.FindFieldDef('ESTDAYS', config))
+    self.assertIsNone(tracker_bizobj.FindFieldDef('Unknown', config))
+
+  def testFindFieldDefByID_Empty(self):
+    config = tracker_pb2.ProjectIssueConfig()
+    self.assertIsNone(tracker_bizobj.FindFieldDefByID(1, config))
+
+  def testFindFieldDefByID_Default(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    self.assertIsNone(tracker_bizobj.FindFieldDefByID(1, config))
+
+  def testFindFieldDefByID_Normal(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    fd = tracker_pb2.FieldDef(field_id=1)
+    config.field_defs = [fd]
+    self.assertEqual(fd, tracker_bizobj.FindFieldDefByID(1, config))
+    self.assertIsNone(tracker_bizobj.FindFieldDefByID(99, config))
+
+  def testFindApprovalDef_Empty(self):
+    config = tracker_pb2.ProjectIssueConfig()
+    self.assertEqual(None, tracker_bizobj.FindApprovalDef(
+        'Nonexistent', config))
+
+  def testFindApprovalDef_Normal(self):
+    config = tracker_pb2.ProjectIssueConfig()
+    approval_fd = tracker_pb2.FieldDef(field_id=1, field_name='UIApproval')
+    approval_def = tracker_pb2.ApprovalDef(
+        approval_id=1, approver_ids=[111], survey='')
+    config.field_defs = [approval_fd]
+    config.approval_defs = [approval_def]
+    self.assertEqual(approval_def, tracker_bizobj.FindApprovalDef(
+        'UIApproval', config))
+
+  def testFindApprovalDef_NotApproval(self):
+    config = tracker_pb2.ProjectIssueConfig()
+    field_def = tracker_pb2.FieldDef(field_id=1, field_name='DesignDoc')
+    config.field_defs = [field_def]
+    self.assertEqual(None, tracker_bizobj.FindApprovalDef('DesignDoc', config))
+
+  def testFindApprovalDefByID_Empty(self):
+    config = tracker_pb2.ProjectIssueConfig()
+    self.assertEqual(None, tracker_bizobj.FindApprovalDefByID(1, config))
+
+  def testFindApprovalDefByID_Normal(self):
+    config = tracker_pb2.ProjectIssueConfig()
+    approval_def = tracker_pb2.ApprovalDef(
+        approval_id=1, approver_ids=[111, 222], survey='')
+    config.approval_defs = [approval_def]
+    self.assertEqual(approval_def, tracker_bizobj.FindApprovalDefByID(
+        1, config))
+    self.assertEqual(None, tracker_bizobj.FindApprovalDefByID(99, config))
+
+  def testFindApprovalValueByID_Normal(self):
+    av_24 = tracker_pb2.ApprovalValue(approval_id=24)
+    av_22 = tracker_pb2.ApprovalValue()
+    self.assertEqual(
+        av_24, tracker_bizobj.FindApprovalValueByID(24, [av_22, av_24]))
+
+  def testFindApprovalValueByID_None(self):
+    av_no_id = tracker_pb2.ApprovalValue()
+    self.assertIsNone(tracker_bizobj.FindApprovalValueByID(24, [av_no_id]))
+
+  def testFindApprovalsSubfields(self):
+    config = tracker_pb2.ProjectIssueConfig()
+    subfd_1 = tracker_pb2.FieldDef(approval_id=1)
+    subfd_2 = tracker_pb2.FieldDef(approval_id=2)
+    subfd_3 = tracker_pb2.FieldDef(approval_id=1)
+    subfd_4 = tracker_pb2.FieldDef()
+    config.field_defs = [subfd_1, subfd_2, subfd_3, subfd_4]
+
+    subfields_dict = tracker_bizobj.FindApprovalsSubfields([1, 2], config)
+    self.assertItemsEqual(subfields_dict[1], [subfd_1, subfd_3])
+    self.assertItemsEqual(subfields_dict[2], [subfd_2])
+    self.assertItemsEqual(subfields_dict[3], [])
+
+  def testFindPhaseByID_Normal(self):
+    canary_phase = tracker_pb2.Phase(phase_id=2, name='Canary')
+    stable_phase = tracker_pb2.Phase(name='Stable')
+    self.assertEqual(
+        canary_phase,
+        tracker_bizobj.FindPhaseByID(2, [stable_phase, canary_phase]))
+
+  def testFindPhaseByID_None(self):
+    stable_phase = tracker_pb2.Phase(name='Stable')
+    self.assertIsNone(tracker_bizobj.FindPhaseByID(42, [stable_phase]))
+
+  def testFindPhase_Normal(self):
+    canary_phase = tracker_pb2.Phase(phase_id=2)
+    stable_phase = tracker_pb2.Phase(name='Stable')
+    self.assertEqual(stable_phase, tracker_bizobj.FindPhase(
+        'Stable', [stable_phase, canary_phase]))
+
+  def testFindPhase_None(self):
+    self.assertIsNone(tracker_bizobj.FindPhase('ghost_phase', []))
+
+  def testGetGrantedPerms_Empty(self):
+    config = tracker_pb2.ProjectIssueConfig()
+    issue = tracker_pb2.Issue()
+    self.assertEqual(
+        set(), tracker_bizobj.GetGrantedPerms(issue, {111}, config))
+
+  def testGetGrantedPerms_Default(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    issue = tracker_pb2.Issue()
+    self.assertEqual(
+        set(), tracker_bizobj.GetGrantedPerms(issue, {111}, config))
+
+  def testGetGrantedPerms_NothingGranted(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    fd = tracker_pb2.FieldDef(field_id=1)  # Nothing granted
+    config.field_defs = [fd]
+    fv = tracker_pb2.FieldValue(field_id=1, user_id=222)
+    issue = tracker_pb2.Issue(field_values=[fv])
+    self.assertEqual(
+        set(),
+        tracker_bizobj.GetGrantedPerms(issue, {111, 222}, config))
+
+  def testGetGrantedPerms_Normal(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    fd = tracker_pb2.FieldDef(field_id=1, grants_perm='Highlight')
+    config.field_defs = [fd]
+    fv = tracker_pb2.FieldValue(field_id=1, user_id=222)
+    issue = tracker_pb2.Issue(field_values=[fv])
+    self.assertEqual(
+        set(),
+        tracker_bizobj.GetGrantedPerms(issue, {111}, config))
+    self.assertEqual(
+        set(['highlight']),
+        tracker_bizobj.GetGrantedPerms(issue, {111, 222}, config))
+
+  def testLabelsByPrefix(self):
+    expected = tracker_bizobj.LabelsByPrefix(
+      ['OneWordLabel', 'Key-Value1', 'Key-Value2', 'Launch-X-Y-Z'],
+      ['launch-x'])
+    self.assertEqual(
+      {'key': ['Value1', 'Value2'],
+       'launch-x': ['Y-Z']},
+      expected)
+
+  def testLabelIsMaskedByField(self):
+    self.assertIsNone(tracker_bizobj.LabelIsMaskedByField('UI', []))
+    self.assertIsNone(tracker_bizobj.LabelIsMaskedByField('P-1', []))
+    field_names = ['priority', 'size']
+    self.assertIsNone(tracker_bizobj.LabelIsMaskedByField(
+        'UI', field_names))
+    self.assertIsNone(tracker_bizobj.LabelIsMaskedByField(
+        'OS-All', field_names))
+    self.assertEqual(
+        'size', tracker_bizobj.LabelIsMaskedByField('size-xl', field_names))
+    self.assertEqual(
+        'size', tracker_bizobj.LabelIsMaskedByField('Size-XL', field_names))
+
+  def testNonMaskedLabels(self):
+    self.assertEqual([], tracker_bizobj.NonMaskedLabels([], []))
+    field_names = ['priority', 'size']
+    self.assertEqual([], tracker_bizobj.NonMaskedLabels([], field_names))
+    self.assertEqual(
+        [], tracker_bizobj.NonMaskedLabels(['Size-XL'], field_names))
+    self.assertEqual(
+        ['Hot'], tracker_bizobj.NonMaskedLabels(['Hot'], field_names))
+    self.assertEqual(
+        ['Hot'],
+        tracker_bizobj.NonMaskedLabels(['Hot', 'Size-XL'], field_names))
+
+  def testMakeApprovalValue_Basic(self):
+    av = tracker_bizobj.MakeApprovalValue(2)
+    expected = tracker_pb2.ApprovalValue(approval_id=2)
+    self.assertEqual(av, expected)
+
+  def testMakeApprovalValue_Full(self):
+    av = tracker_bizobj.MakeApprovalValue(
+        2, approver_ids=[], status=tracker_pb2.ApprovalStatus.APPROVED,
+        setter_id=3, set_on=123, phase_id=3)
+    expected = tracker_pb2.ApprovalValue(
+        approval_id=2, approver_ids=[],
+        status=tracker_pb2.ApprovalStatus.APPROVED,
+        setter_id=3, set_on=123, phase_id=3)
+    self.assertEqual(av, expected)
+
+  def testMakeFieldDef_Basic(self):
+    fd = tracker_bizobj.MakeFieldDef(
+        1, 789, 'Size', tracker_pb2.FieldTypes.USER_TYPE, None, None,
+        False, False, False, None, None, None, False,
+        None, None, None, 'no_action', 'Some field', False)
+    self.assertEqual(1, fd.field_id)
+    self.assertEqual(None, fd.approval_id)
+    self.assertFalse(fd.is_phase_field)
+    self.assertFalse(fd.is_restricted_field)
+
+  def testMakeFieldDef_Full(self):
+    fd = tracker_bizobj.MakeFieldDef(
+        1,
+        789,
+        'Size',
+        tracker_pb2.FieldTypes.INT_TYPE,
+        None,
+        None,
+        False,
+        False,
+        False,
+        1,
+        100,
+        None,
+        False,
+        None,
+        None,
+        None,
+        'no_action',
+        'Some field',
+        False,
+        approval_id=4,
+        is_phase_field=True,
+        is_restricted_field=True)
+    self.assertEqual(1, fd.min_value)
+    self.assertEqual(100, fd.max_value)
+    self.assertEqual(4, fd.approval_id)
+    self.assertTrue(fd.is_phase_field)
+    self.assertTrue(fd.is_restricted_field)
+
+    fd = tracker_bizobj.MakeFieldDef(
+        1,
+        789,
+        'Size',
+        tracker_pb2.FieldTypes.STR_TYPE,
+        None,
+        None,
+        False,
+        False,
+        False,
+        None,
+        None,
+        'A.*Z',
+        False,
+        'EditIssue',
+        None,
+        None,
+        'no_action',
+        'Some field',
+        False,
+        4,
+        is_restricted_field=False)
+    self.assertEqual('A.*Z', fd.regex)
+    self.assertEqual('EditIssue', fd.needs_perm)
+    self.assertEqual(4, fd.approval_id)
+    self.assertFalse(fd.is_restricted_field)
+
+  def testMakeFieldDef_IntBools(self):
+    fd = tracker_bizobj.MakeFieldDef(
+        1,
+        789,
+        'Size',
+        tracker_pb2.FieldTypes.INT_TYPE,
+        None,
+        None,
+        0,
+        0,
+        0,
+        1,
+        100,
+        None,
+        0,
+        None,
+        None,
+        None,
+        'no_action',
+        'Some field',
+        0,
+        approval_id=4,
+        is_phase_field=1,
+        is_restricted_field=1)
+    self.assertFalse(fd.is_required)
+    self.assertFalse(fd.is_niche)
+    self.assertFalse(fd.is_multivalued)
+    self.assertFalse(fd.needs_member)
+    self.assertFalse(fd.is_deleted)
+    self.assertTrue(fd.is_phase_field)
+    self.assertTrue(fd.is_restricted_field)
+
+  def testMakeFieldValue(self):
+    # Only the first value counts.
+    fv = tracker_bizobj.MakeFieldValue(1, 42, 'yay', 111, None, None, True)
+    self.assertEqual(1, fv.field_id)
+    self.assertEqual(42, fv.int_value)
+    self.assertIsNone(fv.str_value)
+    self.assertEqual(None, fv.user_id)
+    self.assertEqual(None, fv.phase_id)
+
+    fv = tracker_bizobj.MakeFieldValue(1, None, 'yay', 111, None, None, True)
+    self.assertEqual('yay', fv.str_value)
+    self.assertEqual(None, fv.user_id)
+
+    fv = tracker_bizobj.MakeFieldValue(1, None, None, 111, None, None, True)
+    self.assertEqual(111, fv.user_id)
+    self.assertEqual(True, fv.derived)
+
+    fv = tracker_bizobj.MakeFieldValue(
+        1, None, None, None, 1234567890, None, True)
+    self.assertEqual(1234567890, fv.date_value)
+    self.assertEqual(True, fv.derived)
+
+    fv = tracker_bizobj.MakeFieldValue(
+        1, None, None, None, None, 'www.google.com', True, phase_id=1)
+    self.assertEqual('www.google.com', fv.url_value)
+    self.assertEqual(True, fv.derived)
+    self.assertEqual(1, fv.phase_id)
+
+    with self.assertRaises(ValueError):
+      tracker_bizobj.MakeFieldValue(1, None, None, None, None, None, True)
+
+  def testGetFieldValueWithRawValue(self):
+    class MockUser(object):
+      def __init__(self):
+        self.email = 'test@example.com'
+    users_by_id = {111: MockUser()}
+
+    class MockFieldValue(object):
+      def __init__(
+          self, int_value=None, str_value=None, user_id=None,
+          date_value=None, url_value=None):
+        self.int_value = int_value
+        self.str_value = str_value
+        self.user_id = user_id
+        self.date_value = date_value
+        self.url_value = url_value
+
+    # Test user types.
+    # Use user_id from the field_value and get user from users_by_id.
+    val = tracker_bizobj.GetFieldValueWithRawValue(
+        field_type=tracker_pb2.FieldTypes.USER_TYPE,
+        users_by_id=users_by_id,
+        field_value=MockFieldValue(user_id=111),
+        raw_value=113,
+    )
+    self.assertEqual('test@example.com', val)
+    # Specify user_id that does not exist in users_by_id.
+    val = tracker_bizobj.GetFieldValueWithRawValue(
+        field_type=tracker_pb2.FieldTypes.USER_TYPE,
+        users_by_id=users_by_id,
+        field_value=MockFieldValue(user_id=112),
+        raw_value=113,
+    )
+    self.assertEqual(112, val)
+    # Pass in empty users_by_id.
+    val = tracker_bizobj.GetFieldValueWithRawValue(
+        field_type=tracker_pb2.FieldTypes.USER_TYPE,
+        users_by_id={},
+        field_value=MockFieldValue(user_id=111),
+        raw_value=113,
+    )
+    self.assertEqual(111, val)
+    # Test different raw_values.
+    raw_value_tests = (
+        (111, 'test@example.com'),
+        (112, 112),
+        (framework_constants.NO_USER_NAME, framework_constants.NO_USER_NAME))
+    for (raw_value, expected_output) in raw_value_tests:
+      val = tracker_bizobj.GetFieldValueWithRawValue(
+          field_type=tracker_pb2.FieldTypes.USER_TYPE,
+          users_by_id=users_by_id,
+          field_value=None,
+          raw_value=raw_value,
+      )
+      self.assertEqual(expected_output, val)
+
+    # Test enum types.
+    # The returned value should be the raw_value regardless of field_value being
+    # specified.
+    for field_value in (MockFieldValue(), None):
+      val = tracker_bizobj.GetFieldValueWithRawValue(
+          field_type=tracker_pb2.FieldTypes.ENUM_TYPE,
+          users_by_id=users_by_id,
+          field_value=field_value,
+          raw_value='abc',
+      )
+      self.assertEqual('abc', val)
+
+    # Test int type.
+    # Use int_value from the field_value.
+    val = tracker_bizobj.GetFieldValueWithRawValue(
+        field_type=tracker_pb2.FieldTypes.INT_TYPE,
+        users_by_id=users_by_id,
+        field_value=MockFieldValue(int_value=100),
+        raw_value=101,
+    )
+    self.assertEqual(100, val)
+    # Use the raw_value when field_value is not specified.
+    val = tracker_bizobj.GetFieldValueWithRawValue(
+        field_type=tracker_pb2.FieldTypes.INT_TYPE,
+        users_by_id=users_by_id,
+        field_value=None,
+        raw_value=101,
+    )
+    self.assertEqual(101, val)
+
+    # Test str type.
+    # Use str_value from the field_value.
+    val = tracker_bizobj.GetFieldValueWithRawValue(
+        field_type=tracker_pb2.FieldTypes.STR_TYPE,
+        users_by_id=users_by_id,
+        field_value=MockFieldValue(str_value='testing'),
+        raw_value='test',
+    )
+    self.assertEqual('testing', val)
+    # Use the raw_value when field_value is not specified.
+    val = tracker_bizobj.GetFieldValueWithRawValue(
+        field_type=tracker_pb2.FieldTypes.STR_TYPE,
+        users_by_id=users_by_id,
+        field_value=None,
+        raw_value='test',
+    )
+    self.assertEqual('test', val)
+
+    # Test date type.
+    # Use date_value from the field_value.
+    val = tracker_bizobj.GetFieldValueWithRawValue(
+        field_type=tracker_pb2.FieldTypes.DATE_TYPE,
+        users_by_id=users_by_id,
+        field_value=MockFieldValue(date_value=1234567890),
+        raw_value=2345678901,
+    )
+    self.assertEqual('2009-02-13', val)
+    # Use the raw_value when field_value is not specified.
+    val = tracker_bizobj.GetFieldValueWithRawValue(
+        field_type=tracker_pb2.FieldTypes.DATE_TYPE,
+        users_by_id=users_by_id,
+        field_value=None,
+        raw_value='2016-10-30',
+    )
+    self.assertEqual('2016-10-30', val)
+
+  def testFindComponentDef_Empty(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    actual = tracker_bizobj.FindComponentDef('DB', config)
+    self.assertIsNone(actual)
+
+  def testFindComponentDef_NoMatch(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    cd = tracker_pb2.ComponentDef(path='UI>Splash')
+    config.component_defs.append(cd)
+    actual = tracker_bizobj.FindComponentDef('DB', config)
+    self.assertIsNone(actual)
+
+  def testFindComponentDef_MatchFound(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    cd = tracker_pb2.ComponentDef(path='UI>Splash')
+    config.component_defs.append(cd)
+    actual = tracker_bizobj.FindComponentDef('UI>Splash', config)
+    self.assertEqual(cd, actual)
+
+  def testFindMatchingComponentIDs_Empty(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    actual = tracker_bizobj.FindMatchingComponentIDs('DB', config)
+    self.assertEqual([], actual)
+    actual = tracker_bizobj.FindMatchingComponentIDs('DB', config, exact=False)
+    self.assertEqual([], actual)
+
+  def testFindMatchingComponentIDs_NoMatch(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    config.component_defs.append(tracker_pb2.ComponentDef(
+        component_id=1, path='UI>Splash'))
+    config.component_defs.append(tracker_pb2.ComponentDef(
+        component_id=2, path='UI>AboutBox'))
+    actual = tracker_bizobj.FindMatchingComponentIDs('DB', config)
+    self.assertEqual([], actual)
+    actual = tracker_bizobj.FindMatchingComponentIDs('DB', config, exact=False)
+    self.assertEqual([], actual)
+
+  def testFindMatchingComponentIDs_Match(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    config.component_defs.append(tracker_pb2.ComponentDef(
+        component_id=1, path='UI>Splash'))
+    config.component_defs.append(tracker_pb2.ComponentDef(
+        component_id=2, path='UI>AboutBox'))
+    config.component_defs.append(tracker_pb2.ComponentDef(
+        component_id=3, path='DB>Attachments'))
+    actual = tracker_bizobj.FindMatchingComponentIDs('DB', config)
+    self.assertEqual([], actual)
+    actual = tracker_bizobj.FindMatchingComponentIDs('DB', config, exact=False)
+    self.assertEqual([3], actual)
+
+  def testFindMatchingComponentIDs_MatchMultiple(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    config.component_defs.append(tracker_pb2.ComponentDef(
+        component_id=1, path='UI>Splash'))
+    config.component_defs.append(tracker_pb2.ComponentDef(
+        component_id=2, path='UI>AboutBox'))
+    config.component_defs.append(tracker_pb2.ComponentDef(
+        component_id=22, path='UI>AboutBox'))
+    config.component_defs.append(tracker_pb2.ComponentDef(
+        component_id=3, path='DB>Attachments'))
+    actual = tracker_bizobj.FindMatchingComponentIDs('UI>AboutBox', config)
+    self.assertEqual([2, 22], actual)
+    actual = tracker_bizobj.FindMatchingComponentIDs('UI', config, exact=False)
+    self.assertEqual([1, 2, 22], actual)
+
+  def testFindComponentDefByID_Empty(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    actual = tracker_bizobj.FindComponentDefByID(999, config)
+    self.assertIsNone(actual)
+
+  def testFindComponentDefByID_NoMatch(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    config.component_defs.append(tracker_pb2.ComponentDef(
+        component_id=1, path='UI>Splash'))
+    config.component_defs.append(tracker_pb2.ComponentDef(
+        component_id=2, path='UI>AboutBox'))
+    actual = tracker_bizobj.FindComponentDefByID(999, config)
+    self.assertIsNone(actual)
+
+  def testFindComponentDefByID_MatchFound(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    cd = tracker_pb2.ComponentDef(component_id=1, path='UI>Splash')
+    config.component_defs.append(cd)
+    config.component_defs.append(tracker_pb2.ComponentDef(
+        component_id=2, path='UI>AboutBox'))
+    actual = tracker_bizobj.FindComponentDefByID(1, config)
+    self.assertEqual(cd, actual)
+
+  def testFindAncestorComponents_Empty(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    cd = tracker_pb2.ComponentDef(component_id=1, path='UI>Splash')
+    actual = tracker_bizobj.FindAncestorComponents(config, cd)
+    self.assertEqual([], actual)
+
+  def testFindAncestorComponents_NoMatch(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    cd = tracker_pb2.ComponentDef(component_id=1, path='UI>Splash')
+    config.component_defs.append(tracker_pb2.ComponentDef(
+        component_id=2, path='UI>AboutBox'))
+    actual = tracker_bizobj.FindAncestorComponents(config, cd)
+    self.assertEqual([], actual)
+
+  def testFindAncestorComponents_NoComponents(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    cd = tracker_pb2.ComponentDef(component_id=1, path='UI')
+    config.component_defs.append(cd)
+    cd2 = tracker_pb2.ComponentDef(component_id=2, path='UI>Splash')
+    config.component_defs.append(cd2)
+    actual = tracker_bizobj.FindAncestorComponents(config, cd2)
+    self.assertEqual([cd], actual)
+
+  def testGetIssueComponentsAndAncestors_NoSuchComponent(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    cd = tracker_pb2.ComponentDef(component_id=1, path='UI')
+    config.component_defs.append(cd)
+    cd2 = tracker_pb2.ComponentDef(component_id=2, path='UI>Splash')
+    config.component_defs.append(cd2)
+    issue = tracker_pb2.Issue(component_ids=[999])
+    actual = tracker_bizobj.GetIssueComponentsAndAncestors(issue, config)
+    self.assertEqual([], actual)
+
+  def testGetIssueComponentsAndAncestors_AffectsNoComponents(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    cd = tracker_pb2.ComponentDef(component_id=1, path='UI')
+    config.component_defs.append(cd)
+    cd2 = tracker_pb2.ComponentDef(component_id=2, path='UI>Splash')
+    config.component_defs.append(cd2)
+    issue = tracker_pb2.Issue(component_ids=[])
+    actual = tracker_bizobj.GetIssueComponentsAndAncestors(issue, config)
+    self.assertEqual([], actual)
+
+  def testGetIssueComponentsAndAncestors_AffectsSomeComponents(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    cd = tracker_pb2.ComponentDef(component_id=1, path='UI')
+    config.component_defs.append(cd)
+    cd2 = tracker_pb2.ComponentDef(component_id=2, path='UI>Splash')
+    config.component_defs.append(cd2)
+    issue = tracker_pb2.Issue(component_ids=[2])
+    actual = tracker_bizobj.GetIssueComponentsAndAncestors(issue, config)
+    self.assertEqual([cd, cd2], actual)
+
+  def testFindDescendantComponents_Empty(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    cd = tracker_pb2.ComponentDef(component_id=1, path='UI')
+    actual = tracker_bizobj.FindDescendantComponents(config, cd)
+    self.assertEqual([], actual)
+
+  def testFindDescendantComponents_NoMatch(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    cd = tracker_pb2.ComponentDef(component_id=1, path='UI')
+    config.component_defs.append(cd)
+    actual = tracker_bizobj.FindDescendantComponents(config, cd)
+    self.assertEqual([], actual)
+
+  def testFindDescendantComponents_SomeMatch(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    cd = tracker_pb2.ComponentDef(component_id=1, path='UI')
+    config.component_defs.append(cd)
+    cd2 = tracker_pb2.ComponentDef(component_id=2, path='UI>Splash')
+    config.component_defs.append(cd2)
+    actual = tracker_bizobj.FindDescendantComponents(config, cd)
+    self.assertEqual([cd2], actual)
+
+  def testMakeComponentDef(self):
+    cd = tracker_bizobj.MakeComponentDef(
+      1, 789, 'UI', 'doc', False, [111], [222], 1234567890,
+      111)
+    self.assertEqual(1, cd.component_id)
+    self.assertEqual([111], cd.admin_ids)
+    self.assertEqual([], cd.label_ids)
+
+  def testMakeSavedQuery_WithNone(self):
+    sq = tracker_bizobj.MakeSavedQuery(
+      None, 'my query', 2, 'priority:high')
+    self.assertEqual(None, sq.query_id)
+    self.assertEqual(None, sq.subscription_mode)
+    self.assertEqual([], sq.executes_in_project_ids)
+
+  def testMakeSavedQuery(self):
+    sq = tracker_bizobj.MakeSavedQuery(
+      100, 'my query', 2, 'priority:high',
+      subscription_mode='immediate', executes_in_project_ids=[789])
+    self.assertEqual(100, sq.query_id)
+    self.assertEqual('immediate', sq.subscription_mode)
+    self.assertEqual([789], sq.executes_in_project_ids)
+
+  def testConvertDictToTemplate(self):
+    template = tracker_bizobj.ConvertDictToTemplate(
+        dict(name='name', content='content', summary='summary',
+             status='status', owner_id=111))
+    self.assertEqual('name', template.name)
+    self.assertEqual('content', template.content)
+    self.assertEqual('summary', template.summary)
+    self.assertEqual('status', template.status)
+    self.assertEqual(111, template.owner_id)
+    self.assertFalse(template.summary_must_be_edited)
+    self.assertTrue(template.owner_defaults_to_member)
+    self.assertFalse(template.component_required)
+
+    template = tracker_bizobj.ConvertDictToTemplate(
+        dict(name='name', content='content', labels=['a', 'b', 'c']))
+    self.assertListEqual(
+        ['a', 'b', 'c'], list(template.labels))
+
+    template = tracker_bizobj.ConvertDictToTemplate(
+        dict(name='name', content='content', summary_must_be_edited=True,
+             owner_defaults_to_member=True, component_required=True))
+    self.assertTrue(template.summary_must_be_edited)
+    self.assertTrue(template.owner_defaults_to_member)
+    self.assertTrue(template.component_required)
+
+    template = tracker_bizobj.ConvertDictToTemplate(
+        dict(name='name', content='content', summary_must_be_edited=False,
+             owner_defaults_to_member=False, component_required=False))
+    self.assertFalse(template.summary_must_be_edited)
+    self.assertFalse(template.owner_defaults_to_member)
+    self.assertFalse(template.component_required)
+
+  def CheckDefaultConfig(self, config):
+    self.assertTrue(len(config.well_known_statuses) > 0)
+    self.assertTrue(config.statuses_offer_merge > 0)
+    self.assertTrue(len(config.well_known_labels) > 0)
+    self.assertTrue(len(config.exclusive_label_prefixes) > 0)
+    # TODO(jrobbins): test actual values from default config
+
+  def testMakeDefaultProjectIssueConfig(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    config.default_template_for_developers = 1
+    config.default_template_for_users = 2
+    self.CheckDefaultConfig(config)
+
+  def testHarmonizeConfigs_Empty(self):
+    harmonized = tracker_bizobj.HarmonizeConfigs([])
+    self.CheckDefaultConfig(harmonized)
+
+  def testHarmonizeConfigs(self):
+    c1 = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    harmonized = tracker_bizobj.HarmonizeConfigs([c1])
+    self.assertListEqual(
+        [stat.status for stat in c1.well_known_statuses],
+        [stat.status for stat in harmonized.well_known_statuses])
+    self.assertListEqual(
+        [lab.label for lab in c1.well_known_labels],
+        [lab.label for lab in harmonized.well_known_labels])
+    self.assertEqual('', harmonized.default_sort_spec)
+
+    c2 = tracker_bizobj.MakeDefaultProjectIssueConfig(678)
+    tracker_bizobj.SetConfigStatuses(c2, [
+        ('Unconfirmed', '', True, False),
+        ('New', '', True, True),
+        ('Accepted', '', True, False),
+        ('Begun', '', True, False),
+        ('Fixed', '', False, False),
+        ('Obsolete', '', False, False)])
+    tracker_bizobj.SetConfigLabels(c2, [
+        ('Pri-0', '', False),
+        ('Priority-High', '', True),
+        ('Pri-1', '', False),
+        ('Priority-Medium', '', True),
+        ('Pri-2', '', False),
+        ('Priority-Low', '', True),
+        ('Pri-3', '', False),
+        ('Pri-4', '', False)])
+    c2.default_sort_spec = 'Pri -status'
+
+    c1.approval_defs = [
+        tracker_pb2.ApprovalDef(approval_id=1),
+        tracker_pb2.ApprovalDef(approval_id=3),
+    ]
+    c1.field_defs = [
+      tracker_pb2.FieldDef(
+          field_id=1, project_id=789, field_name='CowApproval',
+          field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE),
+      tracker_pb2.FieldDef(
+          field_id=3, project_id=789, field_name='MooApproval',
+          field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE)
+    ]
+    c2.approval_defs = [
+        tracker_pb2.ApprovalDef(approval_id=2),
+    ]
+    c2.field_defs = [
+        tracker_pb2.FieldDef(
+            field_id=2, project_id=788, field_name='CowApproval',
+            field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE),
+    ]
+    harmonized = tracker_bizobj.HarmonizeConfigs([c1, c2])
+    result_statuses = [stat.status
+                       for stat in harmonized.well_known_statuses]
+    result_labels = [lab.label
+                     for lab in harmonized.well_known_labels]
+    self.assertListEqual(
+        ['Unconfirmed', 'New', 'Accepted', 'Begun', 'Started', 'Fixed',
+         'Obsolete', 'Verified', 'Invalid', 'Duplicate', 'WontFix', 'Done'],
+        result_statuses)
+    self.assertListEqual(
+        ['Pri-0', 'Type-Defect', 'Type-Enhancement', 'Type-Task',
+         'Type-Other', 'Priority-Critical', 'Priority-High',
+         'Pri-1', 'Priority-Medium', 'Pri-2', 'Priority-Low', 'Pri-3',
+         'Pri-4'],
+        result_labels[:result_labels.index('OpSys-All')])
+    self.assertEqual('Pri -status', harmonized.default_sort_spec.strip())
+    self.assertItemsEqual(c1.field_defs + c2.field_defs,
+                          harmonized.field_defs)
+    self.assertItemsEqual(c1.approval_defs + c2.approval_defs,
+                          harmonized.approval_defs)
+
+  def testHarmonizeConfigsMeansOpen(self):
+    c1 = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    c2 = tracker_bizobj.MakeDefaultProjectIssueConfig(678)
+    means_open = [("TT", True, True),
+                  ("TF", True, False),
+                  ("FT", False, True),
+                  ("FF", False, False)]
+    tracker_bizobj.SetConfigStatuses(c1, [
+        (x[0], x[0], x[1], False)
+         for x in means_open])
+    tracker_bizobj.SetConfigStatuses(c2, [
+        (x[0], x[0], x[2], False)
+         for x in means_open])
+
+    harmonized = tracker_bizobj.HarmonizeConfigs([c1, c2])
+    for stat in harmonized.well_known_statuses:
+      self.assertEqual(stat.means_open, stat.status != "FF")
+
+  def testHarmonizeConfigs_DeletedCustomField(self):
+    """Only non-deleted custom fields in configs are included."""
+    harmonized = tracker_bizobj.HarmonizeConfigs([self.config])
+    self.assertEqual(1, len(harmonized.field_defs))
+
+    self.config.field_defs[0].is_deleted = True
+    harmonized = tracker_bizobj.HarmonizeConfigs([self.config])
+    self.assertEqual(0, len(harmonized.field_defs))
+
+  def testHarmonizeLabelOrStatusRows_Empty(self):
+    def_rows = []
+    actual = tracker_bizobj.HarmonizeLabelOrStatusRows(def_rows)
+    self.assertEqual([], actual)
+
+  def testHarmonizeLabelOrStatusRows_Normal(self):
+    def_rows = [
+        (100, 789, 1, 'Priority-High'),
+        (101, 789, 2, 'Priority-Normal'),
+        (103, 789, 3, 'Priority-Low'),
+        (199, 789, None, 'Monday'),
+        (200, 678, 1, 'Priority-High'),
+        (201, 678, 2, 'Priority-Medium'),
+        (202, 678, 3, 'Priority-Low'),
+        (299, 678, None, 'Hot'),
+        ]
+    actual = tracker_bizobj.HarmonizeLabelOrStatusRows(def_rows)
+    self.assertEqual(
+        [(199, None, 'Monday'),
+         (299, None, 'Hot'),
+         (200, 1, 'Priority-High'),
+         (100, 1, 'Priority-High'),
+         (101, 2, 'Priority-Normal'),
+         (201, 2, 'Priority-Medium'),
+         (202, 3, 'Priority-Low'),
+         (103, 3, 'Priority-Low')
+         ],
+        actual)
+
+  def testCombineOrderedLists_Empty(self):
+    self.assertEqual([], tracker_bizobj._CombineOrderedLists([]))
+
+  def testCombineOrderedLists_Normal(self):
+    a = ['Mon', 'Wed', 'Fri']
+    b = ['Mon', 'Tue']
+    c = ['Wed', 'Thu']
+    self.assertEqual(['Mon', 'Tue', 'Wed', 'Thu', 'Fri'],
+                     tracker_bizobj._CombineOrderedLists([a, b, c]))
+
+    d = ['Mon', 'StartOfWeek', 'Wed', 'MidWeek', 'Fri', 'EndOfWeek']
+    self.assertEqual(['Mon', 'StartOfWeek', 'Tue', 'Wed', 'MidWeek', 'Thu',
+                      'Fri', 'EndOfWeek'],
+                     tracker_bizobj._CombineOrderedLists([a, b, c, d]))
+
+  def testAccumulateCombinedList_Empty(self):
+    combined_items = []
+    combined_keys = []
+    seen_keys_set = set()
+    tracker_bizobj._AccumulateCombinedList(
+        [], combined_items, combined_keys, seen_keys_set)
+    self.assertEqual([], combined_items)
+    self.assertEqual([], combined_keys)
+    self.assertEqual(set(), seen_keys_set)
+
+  def testAccumulateCombinedList_Normal(self):
+    combined_items = ['a', 'b', 'C']
+    combined_keys = ['a', 'b', 'c']  # Keys are always lowercased
+    seen_keys_set = set(['a', 'b', 'c'])
+    tracker_bizobj._AccumulateCombinedList(
+        ['b', 'x', 'C', 'd', 'a'], combined_items, combined_keys, seen_keys_set)
+    self.assertEqual(['a', 'b', 'x', 'C', 'd'], combined_items)
+    self.assertEqual(['a', 'b', 'x', 'c', 'd'], combined_keys)
+    self.assertEqual(set(['a', 'b', 'x', 'c', 'd']), seen_keys_set)
+
+  def testAccumulateCombinedList_NormalWithKeyFunction(self):
+    combined_items = ['A', 'B', 'C']
+    combined_keys = ['@a', '@b', '@c']
+    seen_keys_set = set(['@a', '@b', '@c'])
+    tracker_bizobj._AccumulateCombinedList(
+        ['B', 'X', 'c', 'D', 'A'], combined_items, combined_keys, seen_keys_set,
+        key=lambda s: '@' + s)
+    self.assertEqual(['A', 'B', 'X', 'C', 'D'], combined_items)
+    self.assertEqual(['@a', '@b', '@x', '@c', '@d'], combined_keys)
+    self.assertEqual(set(['@a', '@b', '@x', '@c', '@d']), seen_keys_set)
+
+  def testGetBuiltInQuery(self):
+    self.assertEqual(
+        'is:open', tracker_bizobj.GetBuiltInQuery(2))
+    self.assertEqual(
+        '', tracker_bizobj.GetBuiltInQuery(101))
+
+  def testUsersInvolvedInComment(self):
+    comment = tracker_pb2.IssueComment()
+    self.assertEqual({0}, tracker_bizobj.UsersInvolvedInComment(comment))
+
+    comment.user_id = 111
+    self.assertEqual(
+        {111}, tracker_bizobj.UsersInvolvedInComment(comment))
+
+    amendment = tracker_pb2.Amendment(newvalue='foo')
+    comment.amendments.append(amendment)
+    self.assertEqual(
+        {111}, tracker_bizobj.UsersInvolvedInComment(comment))
+
+    amendment.added_user_ids.append(222)
+    amendment.removed_user_ids.append(333)
+    self.assertEqual({111, 222, 333},
+                     tracker_bizobj.UsersInvolvedInComment(comment))
+
+  def testUsersInvolvedInCommentList(self):
+    self.assertEqual(set(), tracker_bizobj.UsersInvolvedInCommentList([]))
+
+    c1 = tracker_pb2.IssueComment()
+    c1.user_id = 111
+    c1.amendments.append(tracker_pb2.Amendment(newvalue='foo'))
+
+    c2 = tracker_pb2.IssueComment()
+    c2.user_id = 111
+    c2.amendments.append(tracker_pb2.Amendment(
+        added_user_ids=[222], removed_user_ids=[333]))
+
+    self.assertEqual({111},
+                     tracker_bizobj.UsersInvolvedInCommentList([c1]))
+
+    self.assertEqual({111, 222, 333},
+                     tracker_bizobj.UsersInvolvedInCommentList([c2]))
+
+    self.assertEqual({111, 222, 333},
+                     tracker_bizobj.UsersInvolvedInCommentList([c1, c2]))
+
+  def testUsersInvolvedInIssues_Empty(self):
+    self.assertEqual(set(), tracker_bizobj.UsersInvolvedInIssues([]))
+
+  def testUsersInvolvedInIssues_Normal(self):
+    av_1 = tracker_pb2.ApprovalValue(approver_ids=[666, 222, 444])
+    av_2 = tracker_pb2.ApprovalValue(approver_ids=[777], setter_id=888)
+    issue1 = tracker_pb2.Issue(
+        reporter_id=111, owner_id=222, cc_ids=[222, 333],
+        approval_values=[av_1, av_2])
+    issue2 = tracker_pb2.Issue(
+        reporter_id=333, owner_id=444, derived_cc_ids=[222, 444])
+    issue2.field_values = [tracker_pb2.FieldValue(user_id=555)]
+    self.assertEqual(
+        set([0, 111, 222, 333, 444, 555, 666, 777, 888]),
+        tracker_bizobj.UsersInvolvedInIssues([issue1, issue2]))
+
+  def testUsersInvolvedInTemplate_Empty(self):
+    template = tracker_bizobj.MakeIssueTemplate(
+        'A report', 'Something went wrong', 'New', None, 'Look out!',
+        ['Priority-High'], [], [], [])
+    self.assertEqual(set(), tracker_bizobj.UsersInvolvedInTemplate(template))
+
+  def testUsersInvolvedInTemplate_Normal(self):
+    template = tracker_bizobj.MakeIssueTemplate(
+        'A report', 'Something went wrong', 'New', 111, 'Look out!',
+        ['Priority-High'], [], [333, 444], [])
+    template.field_values = [
+        tracker_bizobj.MakeFieldValue(22, None, None, 222, None, None, False),
+        tracker_bizobj.MakeFieldValue(23, None, None, 333, None, None, False),
+        tracker_bizobj.MakeFieldValue(24, None, None, 222, None, None, False),
+        tracker_bizobj.MakeFieldValue(25, None, 'pop', None, None, None, False)]
+    template.approval_values = [
+        tracker_pb2.ApprovalValue(
+            approval_id=30, setter_id=666, approver_ids=[444, 555]),
+        tracker_pb2.ApprovalValue(approval_id=31),
+    ]
+    self.assertEqual(
+        {111, 333, 444, 222, 555, 666},
+        tracker_bizobj.UsersInvolvedInTemplate(template))
+
+  def testUsersInvolvedInTemplates_NoTemplates(self):
+    self.assertEqual(set(), tracker_bizobj.UsersInvolvedInTemplates([]))
+
+  def testUsersInvolvedInTemplates_Normal(self):
+    template1 = tracker_bizobj.MakeIssueTemplate(
+        'A report', 'Something went wrong', 'New', 111, 'Look out!',
+        ['Priority-High'], [], [333, 444], [])
+    template1.field_values = [
+        tracker_bizobj.MakeFieldValue(22, None, None, 222, None, None, False)]
+
+    template2 = tracker_bizobj.MakeIssueTemplate(
+        'dude', 'wheres my', 'New', 222, 'car', [], [], [999, 888], [])
+    template2.field_values = [
+        tracker_bizobj.MakeFieldValue(23, None, None, 333, None, None, False)]
+    template2.approval_values = [
+        tracker_pb2.ApprovalValue(
+            approval_id=30, setter_id=666, approver_ids=[444, 555]),
+        tracker_pb2.ApprovalValue(approval_id=31)]
+
+    self.assertEqual(
+        {111, 333, 444, 222, 555, 666, 888, 999},
+        tracker_bizobj.UsersInvolvedInTemplates([template1, template2]))
+
+  def testUsersInvolvedInApprovalDefs_Empty(self):
+    """There are no user IDs given empty inputs"""
+    actual = tracker_bizobj.UsersInvolvedInApprovalDefs([], [])
+    self.assertEqual(set(), actual)
+
+  def testsersInvolvedInApprovalDefs_Normal(self):
+    """We find user IDs mentioned in approval approvers and field admins"""
+    self.config.field_defs[0].admin_ids = [111, 222]
+    approval_def = tracker_pb2.ApprovalDef(
+        approval_id=1, approver_ids=[111, 333], survey='')
+    self.config.approval_defs = [approval_def]
+    actual = tracker_bizobj.UsersInvolvedInApprovalDefs(
+        [approval_def], [self.config.field_defs[0]])
+    self.assertEqual({111, 222, 333}, actual)
+
+  def testUsersInvolvedInConfig_Empty(self):
+    """There are no user IDs mentioned in a default config."""
+    actual = tracker_bizobj.UsersInvolvedInConfig(self.config)
+    self.assertEqual(set(), actual)
+
+  def testUsersInvolvedInConfig_Normal(self):
+    """We find user IDs mentioned components, fields, and approvals."""
+    self.config.component_defs[0].admin_ids = [111]
+    self.config.component_defs[0].cc_ids = [444]
+    self.config.field_defs[0].admin_ids = [111, 222]
+    approval_def = tracker_pb2.ApprovalDef(
+        approval_id=1, approver_ids=[111, 333], survey='')
+    self.config.approval_defs = [approval_def]
+    actual = tracker_bizobj.UsersInvolvedInConfig(self.config)
+    self.assertEqual({111, 222, 333, 444}, actual)
+
+  def testLabelIDsInvolvedInConfig_Empty(self):
+    """There are no label IDs mentioned in a default config."""
+    actual = tracker_bizobj.LabelIDsInvolvedInConfig(self.config)
+    self.assertEqual(set(), actual)
+
+  def testLabelIDsInvolvedInConfig_Normal(self):
+    """We find label IDs added by components."""
+    self.config.component_defs[0].label_ids = [1, 2, 3]
+    actual = tracker_bizobj.LabelIDsInvolvedInConfig(self.config)
+    self.assertEqual({1, 2, 3}, actual)
+
+  def testMakeApprovalDelta_AllSpecified(self):
+    added_fv = tracker_bizobj.MakeFieldValue(
+      1, None, 'added str', None, None, None, False)
+    removed_fv = tracker_bizobj.MakeFieldValue(
+      1, None, 'removed str', None, None, None, False)
+    clear_fvs = [24]
+    labels_add = ['ittly-bittly', 'piggly-wiggly']
+    labels_remove = ['golly-goops', 'whoopsie']
+    actual = tracker_bizobj.MakeApprovalDelta(
+        tracker_pb2.ApprovalStatus.APPROVED, 111, [222], [],
+        [added_fv], [removed_fv], clear_fvs, labels_add, labels_remove,
+        set_on=1234)
+    self.assertEqual(actual.status, tracker_pb2.ApprovalStatus.APPROVED)
+    self.assertEqual(actual.setter_id, 111)
+    self.assertEqual(actual.set_on, 1234)
+    self.assertEqual(actual.subfield_vals_add, [added_fv])
+    self.assertEqual(actual.subfield_vals_remove, [removed_fv])
+    self.assertEqual(actual.subfields_clear, clear_fvs)
+    self.assertEqual(actual.labels_add, labels_add)
+    self.assertEqual(actual.labels_remove, labels_remove)
+
+  def testMakeApprovalDelta_WithNones(self):
+    added_fv = tracker_bizobj.MakeFieldValue(
+      1, None, 'added str', None, None, None, False)
+    removed_fv = tracker_bizobj.MakeFieldValue(
+      1, None, 'removed str', None, None, None, False)
+    clear_fields = [2]
+    labels_add = ['ittly-bittly', 'piggly-wiggly']
+    labels_remove = ['golly-goops', 'whoopsie']
+    actual = tracker_bizobj.MakeApprovalDelta(
+        None, 111, [222], [],
+        [added_fv], [removed_fv], clear_fields,
+        labels_add, labels_remove)
+    self.assertIsNone(actual.status)
+    self.assertIsNone(actual.setter_id)
+    self.assertIsNone(actual.set_on)
+
+  def testMakeIssueDelta_AllSpecified(self):
+    added_fv = tracker_bizobj.MakeFieldValue(
+      1, None, 'added str', None, None, None, False)
+    removed_fv = tracker_bizobj.MakeFieldValue(
+      1, None, 'removed str', None, None, None, False)
+    actual = tracker_bizobj.MakeIssueDelta(
+      'New', 111, [222], [333], [1], [2],
+      ['AddedLabel'], ['RemovedLabel'], [added_fv], [removed_fv],
+      [3], [78901], [78902], [78903], [78904], 78905,
+      'New summary',
+      ext_blocked_on_add=['b/123', 'b/234'],
+      ext_blocked_on_remove=['b/345', 'b/456'],
+      ext_blocking_add=['b/567', 'b/678'],
+      ext_blocking_remove=['b/789', 'b/890'])
+    self.assertEqual('New', actual.status)
+    self.assertEqual(111, actual.owner_id)
+    self.assertEqual([222], actual.cc_ids_add)
+    self.assertEqual([333], actual.cc_ids_remove)
+    self.assertEqual([1], actual.comp_ids_add)
+    self.assertEqual([2], actual.comp_ids_remove)
+    self.assertEqual(['AddedLabel'], actual.labels_add)
+    self.assertEqual(['RemovedLabel'], actual.labels_remove)
+    self.assertEqual([added_fv], actual.field_vals_add)
+    self.assertEqual([removed_fv], actual.field_vals_remove)
+    self.assertEqual([3], actual.fields_clear)
+    self.assertEqual([78901], actual.blocked_on_add)
+    self.assertEqual([78902], actual.blocked_on_remove)
+    self.assertEqual([78903], actual.blocking_add)
+    self.assertEqual([78904], actual.blocking_remove)
+    self.assertEqual(78905, actual.merged_into)
+    self.assertEqual('New summary', actual.summary)
+    self.assertEqual(['b/123', 'b/234'], actual.ext_blocked_on_add)
+    self.assertEqual(['b/345', 'b/456'], actual.ext_blocked_on_remove)
+    self.assertEqual(['b/567', 'b/678'], actual.ext_blocking_add)
+    self.assertEqual(['b/789', 'b/890'], actual.ext_blocking_remove)
+
+  def testMakeIssueDelta_WithNones(self):
+    """None for status, owner_id, or summary does not set a value."""
+    actual = tracker_bizobj.MakeIssueDelta(
+      None, None, [], [], [], [],
+      [], [], [], [],
+      [], [], [], [], [], None,
+      None)
+    self.assertIsNone(actual.status)
+    self.assertIsNone(actual.owner_id)
+    self.assertIsNone(actual.merged_into)
+    self.assertIsNone(actual.summary)
+
+  def testApplyLabelChanges_RemoveAndAdd(self):
+    issue = tracker_pb2.Issue(
+        labels=['tobe-removed', 'tobe-notremoved', 'tobe-removed-2'])
+    amendment = tracker_bizobj.ApplyLabelChanges(
+        issue, self.config,
+        [u'tobe-added', 'to:be-added-2'],
+        [u'tobe-removed', u'to:be-removed-2'])
+    self.assertEqual(amendment, tracker_bizobj.MakeLabelsAmendment(
+        ['tobe-added', 'tobe-added-2'], ['tobe-removed', 'tobe-removed-2']))
+
+  def testApplyLabelChanges_RemoveInvalidLabel(self):
+    issue = tracker_pb2.Issue(labels=[])
+    amendment = tracker_bizobj.ApplyLabelChanges(
+        issue, self.config, [], [u'lost-car'])
+    self.assertIsNone(amendment)
+
+  def testApplyLabelChanges_NoChangesAfterMerge(self):
+    issue = tracker_pb2.Issue(labels=['lost-car'])
+    amendment = tracker_bizobj.ApplyLabelChanges(
+        issue, self.config, [u'lost-car'], [])
+    self.assertIsNone(amendment)
+
+  def testApplyLabelChanges_Empty(self):
+    issue = tracker_pb2.Issue(labels=[])
+    amendment = tracker_bizobj.ApplyLabelChanges(issue, self.config, [], [])
+    self.assertIsNone(amendment)
+
+  def testApplyFieldValueChanges(self):
+    self.config.field_defs = [
+        tracker_pb2.FieldDef(
+            field_id=1, project_id=789, field_name='EstDays',
+            field_type=tracker_pb2.FieldTypes.INT_TYPE),
+        tracker_pb2.FieldDef(
+            field_id=2, project_id=789, field_name='SleepHrs',
+            field_type=tracker_pb2.FieldTypes.INT_TYPE, is_phase_field=True),
+        tracker_pb2.FieldDef(
+            field_id=3, project_id=789, field_name='Chickens',
+            field_type=tracker_pb2.FieldTypes.STR_TYPE, is_phase_field=True,
+            is_multivalued=True),
+    ]
+    original_keep = [
+        tracker_pb2.FieldValue(field_id=3, str_value='bok', phase_id=45)]
+    original_replace = [
+        tracker_pb2.FieldValue(field_id=1, int_value=72),
+        tracker_pb2.FieldValue(field_id=2, int_value=88, phase_id=44)]
+    original_remove = [
+        tracker_pb2.FieldValue(field_id=3, str_value='removedbok', phase_id=45),
+    ]
+    issue = tracker_pb2.Issue(
+        phases=[
+            tracker_pb2.Phase(phase_id=45, name='high-school'),
+            tracker_pb2.Phase(phase_id=44, name='college')])
+    issue.field_values = original_keep + original_replace + original_remove
+
+    fvs_add_ignore = [
+        tracker_pb2.FieldValue(field_id=3, str_value='egg', phase_id=42)]
+    fvs_add = [
+        tracker_pb2.FieldValue(field_id=1, int_value=73),  # replace
+        tracker_pb2.FieldValue(field_id=2, int_value=99, phase_id=44),  #replace
+        tracker_pb2.FieldValue(field_id=2, int_value=100, phase_id=45),  # added
+        # added
+        tracker_pb2.FieldValue(field_id=3, str_value='rooster', phase_id=45),
+    ]
+    fvs_remove = original_remove
+    fields_clear = []
+    amendments = tracker_bizobj.ApplyFieldValueChanges(
+        issue, self.config, fvs_add+fvs_add_ignore, fvs_remove, fields_clear)
+
+    self.assertEqual(
+        amendments,
+        [tracker_bizobj.MakeFieldAmendment(1, self.config, [73]),
+         tracker_bizobj.MakeFieldAmendment(
+             2, self.config, [99], phase_name='college'),
+         tracker_bizobj.MakeFieldAmendment(
+             2, self.config, [100], phase_name='high-school'),
+         tracker_bizobj.MakeFieldAmendment(
+             3, self.config, ['rooster'], old_values=['removedbok'],
+             phase_name='high-school')])
+    self.assertEqual(issue.field_values, original_keep + fvs_add)
+
+  def testApplyIssueDelta_NoChange(self):
+    """A delta with no change should change nothing."""
+    issue = tracker_pb2.Issue(
+        status='New', owner_id=111, cc_ids=[222], labels=['a', 'b'],
+        component_ids=[1], blocked_on_iids=[78902], blocking_iids=[78903],
+        merged_into=78904, summary='Sum')
+    delta = tracker_pb2.IssueDelta()
+
+    amendments, impacted_iids = tracker_bizobj.ApplyIssueDelta(
+        self.cnxn, self.services.issue, issue, delta, self.config)
+
+    self.assertEqual('New', issue.status)
+    self.assertEqual(111, issue.owner_id)
+    self.assertEqual([222], issue.cc_ids)
+    self.assertEqual(['a', 'b'], issue.labels)
+    self.assertEqual([1], issue.component_ids)
+    self.assertEqual([78902], issue.blocked_on_iids)
+    self.assertEqual([78903], issue.blocking_iids)
+    self.assertEqual(78904, issue.merged_into)
+    self.assertEqual('Sum', issue.summary)
+
+    self.assertEqual(0, len(amendments))
+    self.assertEqual(0, len(impacted_iids))
+
+  def testApplyIssueDelta_BuiltInFields(self):
+    """A delta can change built-in fields."""
+    ref_issue_70 = fake.MakeTestIssue(
+        789, 70, 'Something that must be done before', 'New', 111)
+    self.services.issue.TestAddIssue(ref_issue_70)
+    ref_issue_71 = fake.MakeTestIssue(
+        789, 71, 'Something that can only be done after', 'New', 111)
+    self.services.issue.TestAddIssue(ref_issue_71)
+    ref_issue_72 = fake.MakeTestIssue(
+        789, 72, 'Something that seems the same', 'New', 111)
+    self.services.issue.TestAddIssue(ref_issue_72)
+    ref_issue_73 = fake.MakeTestIssue(
+        789, 73, 'Something that used to seem the same', 'New', 111)
+    self.services.issue.TestAddIssue(ref_issue_73)
+    issue = tracker_pb2.Issue(
+        status='New', owner_id=111, cc_ids=[222], labels=['a', 'b'],
+        component_ids=[1], blocked_on_iids=[78902], blocking_iids=[78903],
+        merged_into=ref_issue_73.issue_id, summary='Sum')
+    delta = tracker_pb2.IssueDelta(
+      status='Duplicate', owner_id=999, cc_ids_add=[333, 444],
+      comp_ids_add=[2], labels_add=['c', 'd'],
+      blocked_on_add=[ref_issue_70.issue_id],
+      blocking_add=[ref_issue_71.issue_id],
+      merged_into=ref_issue_72.issue_id, summary='New summary')
+
+    actual_amendments, actual_impacted_iids = tracker_bizobj.ApplyIssueDelta(
+        self.cnxn, self.services.issue, issue, delta, self.config)
+
+    self.assertEqual('Duplicate', issue.status)
+    self.assertEqual(999, issue.owner_id)
+    self.assertEqual([222, 333, 444], issue.cc_ids)
+    self.assertEqual([1, 2], issue.component_ids)
+    self.assertEqual(['a', 'b', 'c', 'd'], issue.labels)
+    self.assertEqual([78902, ref_issue_70.issue_id], issue.blocked_on_iids)
+    self.assertEqual([78903, ref_issue_71.issue_id], issue.blocking_iids)
+    self.assertEqual(ref_issue_72.issue_id, issue.merged_into)
+    self.assertEqual('New summary', issue.summary)
+
+    self.assertEqual(
+      [tracker_bizobj.MakeStatusAmendment('Duplicate', 'New'),
+       tracker_bizobj.MakeOwnerAmendment(999, 111),
+       tracker_bizobj.MakeCcAmendment([333, 444], []),
+       tracker_bizobj.MakeComponentsAmendment([2], [], self.config),
+       tracker_bizobj.MakeLabelsAmendment(['c', 'd'], []),
+       tracker_bizobj.MakeBlockedOnAmendment([(None, 70)], []),
+       tracker_bizobj.MakeBlockingAmendment([(None, 71)], []),
+       tracker_bizobj.MakeMergedIntoAmendment([(None, 72)], [(None, 73)]),
+       tracker_bizobj.MakeSummaryAmendment('New summary', 'Sum'),
+       ],
+      actual_amendments)
+    self.assertEqual(
+      set([ref_issue_70.issue_id, ref_issue_71.issue_id,
+           ref_issue_72.issue_id, ref_issue_73.issue_id]),
+      actual_impacted_iids)
+
+  def testApplyIssueDelta_ReferrencedIssueNotFound(self):
+    """This part of the code copes with missing issues."""
+    issue = tracker_pb2.Issue(
+        status='New', owner_id=111, cc_ids=[222], labels=['a', 'b'],
+        component_ids=[1], blocked_on_iids=[78902], blocking_iids=[78903],
+        merged_into=78904, summary='Sum')
+    delta = tracker_pb2.IssueDelta(
+      blocked_on_add=[78905], blocked_on_remove=[78902],
+      blocking_add=[78906], blocking_remove=[78903],
+      merged_into=78907)
+
+    actual_amendments, actual_impacted_iids = tracker_bizobj.ApplyIssueDelta(
+        self.cnxn, self.services.issue, issue, delta, self.config)
+
+    self.assertEqual([78905], issue.blocked_on_iids)
+    self.assertEqual([78906], issue.blocking_iids)
+    self.assertEqual(78907, issue.merged_into)
+
+    self.assertEqual(
+      [tracker_bizobj.MakeBlockedOnAmendment([], []),
+       tracker_bizobj.MakeBlockingAmendment([], []),
+       tracker_bizobj.MakeMergedIntoAmendment([], []),
+       ],
+      actual_amendments)
+    self.assertEqual(
+      set([78902, 78903, 78905, 78906]),
+      actual_impacted_iids)
+
+  def testApplyIssueDelta_CustomPhaseFields(self):
+    """A delta can add, remove, or clear custom phase fields."""
+    fd_a = tracker_pb2.FieldDef(
+        field_id=1, project_id=789, field_name='a',
+        field_type=tracker_pb2.FieldTypes.INT_TYPE,
+        is_multivalued=True, is_phase_field=True)
+    fd_b = tracker_pb2.FieldDef(
+        field_id=2, project_id=789, field_name='b',
+        field_type=tracker_pb2.FieldTypes.INT_TYPE,
+        is_phase_field=True)
+    fd_c = tracker_pb2.FieldDef(
+        field_id=3, project_id=789, field_name='c',
+        field_type=tracker_pb2.FieldTypes.INT_TYPE, is_phase_field=True)
+    self.config.field_defs = [fd_a, fd_b, fd_c]
+    fv_a1_p1 = tracker_pb2.FieldValue(
+        field_id=1, int_value=1, phase_id=1)  # fv
+    fv_a2_p1 = tracker_pb2.FieldValue(
+        field_id=1, int_value=2, phase_id=1)  # add
+    fv_a3_p1 = tracker_pb2.FieldValue(
+        field_id=1, int_value=3, phase_id=1)  # add
+    fv_b1_p1 = tracker_pb2.FieldValue(
+        field_id=2, int_value=1, phase_id=1)  # add
+    fv_c2_p1 = tracker_pb2.FieldValue(
+        field_id=3, int_value=2, phase_id=1)  # clear
+
+    fv_a2_p2 = tracker_pb2.FieldValue(
+        field_id=1, int_value=2, phase_id=2)  # add
+    fv_b1_p2 = tracker_pb2.FieldValue(
+        field_id=2, int_value=1, phase_id=2)  # fv remove
+    fv_c1_p2 = tracker_pb2.FieldValue(
+        field_id=3, int_value=1, phase_id=2)  # clear
+
+    issue = tracker_pb2.Issue(
+        status='New', owner_id=111, summary='Sum',
+        field_values=[fv_a1_p1, fv_c2_p1, fv_b1_p2, fv_c1_p2])
+    issue.phases = [
+        tracker_pb2.Phase(phase_id=1, name='Phase-1'),
+        tracker_pb2.Phase(phase_id=2, name='Phase-2')]
+
+    delta = tracker_pb2.IssueDelta(
+        field_vals_add=[fv_a2_p1, fv_a3_p1, fv_b1_p1, fv_a2_p2],
+        field_vals_remove=[fv_b1_p2], fields_clear=[3])
+
+    actual_amendments, actual_impacted_iids = tracker_bizobj.ApplyIssueDelta(
+        self.cnxn, self.services.issue, issue, delta, self.config)
+    self.assertEqual(
+      [tracker_bizobj.MakeFieldAmendment(
+          1, self.config, ['2', '3'], [], phase_name='Phase-1'),
+       tracker_bizobj.MakeFieldAmendment(
+           1, self.config, ['2'], [], phase_name='Phase-2'),
+       tracker_bizobj.MakeFieldAmendment(
+           2, self.config, ['1'], [], phase_name='Phase-1'),
+       tracker_bizobj.MakeFieldAmendment(
+           2, self.config, [], ['1'], phase_name='Phase-2'),
+       tracker_bizobj.MakeFieldClearedAmendment(3, self.config)],
+      actual_amendments)
+    self.assertEqual(set(), actual_impacted_iids)
+
+  def testApplyIssueDelta_CustomFields(self):
+    """A delta can add, remove, or clear custom fields."""
+    fd_a = tracker_pb2.FieldDef(
+        field_id=1, project_id=789, field_name='a',
+        field_type=tracker_pb2.FieldTypes.INT_TYPE,
+        is_multivalued=True)
+    fd_b = tracker_pb2.FieldDef(
+        field_id=2, project_id=789, field_name='b',
+        field_type=tracker_pb2.FieldTypes.INT_TYPE)
+    fd_c = tracker_pb2.FieldDef(
+        field_id=3, project_id=789, field_name='c',
+        field_type=tracker_pb2.FieldTypes.INT_TYPE)
+    fd_d = tracker_pb2.FieldDef(
+        field_id=4, project_id=789, field_name='d',
+        field_type=tracker_pb2.FieldTypes.ENUM_TYPE)
+    self.config.field_defs = [fd_a, fd_b, fd_c, fd_d]
+    fv_a1 = tracker_pb2.FieldValue(field_id=1, int_value=1)
+    fv_a2 = tracker_pb2.FieldValue(field_id=1, int_value=2)
+    fv_b1 = tracker_pb2.FieldValue(field_id=2, int_value=1)
+    fv_c1 = tracker_pb2.FieldValue(field_id=3, int_value=1)
+    issue = tracker_pb2.Issue(
+        status='New', owner_id=111, labels=['d-val', 'Hot'], summary='Sum',
+        field_values=[fv_a1, fv_b1, fv_c1])
+    delta = tracker_pb2.IssueDelta(
+      field_vals_add=[fv_a2], field_vals_remove=[fv_b1], fields_clear=[3, 4])
+
+    actual_amendments, actual_impacted_iids = tracker_bizobj.ApplyIssueDelta(
+        self.cnxn, self.services.issue, issue, delta, self.config)
+
+    self.assertEqual([fv_a1, fv_a2], issue.field_values)
+    self.assertEqual(['Hot'], issue.labels)
+
+    self.assertEqual(
+      [tracker_bizobj.MakeFieldAmendment(1, self.config, ['2'], []),
+       tracker_bizobj.MakeFieldAmendment(2, self.config, [], ['1']),
+       tracker_bizobj.MakeFieldClearedAmendment(3, self.config),
+       tracker_bizobj.MakeFieldClearedAmendment(4, self.config),
+       ],
+      actual_amendments)
+    self.assertEqual(set(), actual_impacted_iids)
+
+  def testApplyIssueDelta_ExternalRefs(self):
+    """Only applies valid issue refs from a delta."""
+    issue = tracker_pb2.Issue(
+        status='New', owner_id=111, cc_ids=[222], labels=['a', 'b'],
+        component_ids=[1], blocked_on_iids=[78902], blocking_iids=[78903],
+        merged_into=78904, summary='Sum',
+        dangling_blocked_on_refs=[
+          tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/345'),
+          tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/111')],
+        dangling_blocking_refs=[
+          tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/789'),
+          tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/222')])
+    delta = tracker_pb2.IssueDelta(
+        # Add one valid, one invalid, and another valid.
+        ext_blocked_on_add=['b/123', 'b123', 'b/234'],
+        # Remove one valid, one invalid, and one that does not exist.
+        ext_blocked_on_remove=['b/345', 'b', 'b/456'],
+        # Add one valid, one invalid, and another valid.
+        ext_blocking_add=['b/567', 'b//123', 'b/678'],
+        # Remove one valid, one invalid, and one that does not exist.
+        ext_blocking_remove=['b/789', 'b/123/123', 'b/890'])
+
+    amendments, impacted_iids = tracker_bizobj.ApplyIssueDelta(
+        self.cnxn, self.services.issue, issue, delta, self.config)
+
+    # Test amendments.
+    self.assertEqual(2, len(amendments))
+    self.assertEqual(tracker_pb2.FieldID.BLOCKEDON, amendments[0].field)
+    self.assertEqual('-b/345 b/123 b/234', amendments[0].newvalue)
+    self.assertEqual(tracker_pb2.FieldID.BLOCKING, amendments[1].field)
+    self.assertEqual('-b/789 b/567 b/678', amendments[1].newvalue)
+
+    self.assertEqual(0, len(impacted_iids))
+
+    # Issue refs are applied correctly and alphabetized.
+    self.assertEqual([
+          tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/111'),
+          tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/123'),
+          tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/234'),
+        ], issue.dangling_blocked_on_refs)
+    self.assertEqual([
+          tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/222'),
+          tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/567'),
+          tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/678'),
+        ], issue.dangling_blocking_refs)
+
+  def testApplyIssueDelta_AddAndRemoveExtRef(self):
+    """Only applies valid issue refs from a delta."""
+    issue = tracker_pb2.Issue(
+        status='New',
+        summary='Sum',
+        dangling_blocked_on_refs=[
+            tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/111')
+        ],
+        dangling_blocking_refs=[
+            tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/222')
+        ])
+    delta = tracker_pb2.IssueDelta(
+        ext_blocked_on_add=['b/123'],
+        ext_blocked_on_remove=['b/123'],
+        ext_blocking_add=['b/456'],
+        ext_blocking_remove=['b/456'])
+
+    amendments, impacted_iids = tracker_bizobj.ApplyIssueDelta(
+        self.cnxn, self.services.issue, issue, delta, self.config)
+
+    # Nothing changed.
+    self.assertEqual(0, len(amendments))
+    self.assertEqual(0, len(impacted_iids))
+
+    self.assertEqual(
+        [tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/111')],
+        issue.dangling_blocked_on_refs)
+    self.assertEqual(
+        [tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/222')],
+        issue.dangling_blocking_refs)
+
+  def testApplyIssueDelta_OnlyInvalidExternalRefs(self):
+    """Only applies valid issue refs from a delta."""
+    issue = tracker_pb2.Issue(
+        status='New',
+        summary='Sum',
+        dangling_blocked_on_refs=[
+            tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/111')
+        ],
+        dangling_blocking_refs=[
+            tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/222')
+        ])
+    delta = tracker_pb2.IssueDelta(
+        # Add one invalid and one that already exists.
+        ext_blocked_on_add=['b123', 'b/111'],
+        # Remove one invalid, and one that does not exist.
+        ext_blocked_on_remove=['b', 'b/456'],
+        # Add one invalid and one that already exists.
+        ext_blocking_add=['b//123', 'b/222'],
+        # Remove one invalid, and one that does not exist.
+        ext_blocking_remove=['b/123/123', 'b/890'])
+
+    amendments, impacted_iids = tracker_bizobj.ApplyIssueDelta(
+        self.cnxn, self.services.issue, issue, delta, self.config)
+
+    # Nothing changed.
+    self.assertEqual(0, len(amendments))
+    self.assertEqual(0, len(impacted_iids))
+
+    self.assertEqual(
+        [tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/111')],
+        issue.dangling_blocked_on_refs)
+    self.assertEqual(
+        [tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/222')],
+        issue.dangling_blocking_refs)
+
+  def testApplyIssueDelta_MergedIntoExternal(self):
+    """ApplyIssueDelta applies valid mergedinto refs."""
+    issue = tracker_pb2.Issue(status='New', owner_id=111)
+    delta = tracker_pb2.IssueDelta(merged_into_external='b/5678')
+    amendments, impacted_iids = tracker_bizobj.ApplyIssueDelta(
+        self.cnxn, self.services.issue, issue, delta, self.config)
+
+    # Test amendments.
+    self.assertEqual(1, len(amendments))
+    self.assertEqual(tracker_pb2.FieldID.MERGEDINTO, amendments[0].field)
+    self.assertEqual('b/5678', amendments[0].newvalue)
+
+    self.assertEqual(0, len(impacted_iids))
+
+    # Issue refs are applied correctly and alphabetized.
+    self.assertEqual('b/5678', issue.merged_into_external)
+
+  def testApplyIssueDelta_MergedIntoExternalInvalid(self):
+    """ApplyIssueDelta does not accept invalid mergedinto refs."""
+    issue = tracker_pb2.Issue(status='New', owner_id=111)
+    delta = tracker_pb2.IssueDelta(merged_into_external='a/5678')
+    amendments, impacted_iids = tracker_bizobj.ApplyIssueDelta(
+        self.cnxn, self.services.issue, issue, delta, self.config)
+
+    # No change.
+    self.assertEqual(0, len(amendments))
+    self.assertEqual(0, len(impacted_iids))
+    self.assertEqual(None, issue.merged_into_external)
+
+  def testApplyIssueDelta_MergedIntoFromInternalToExternal(self):
+    """ApplyIssueDelta updates from an internal to an external ref."""
+    self.services.issue.TestAddIssue(fake.MakeTestIssue(1, 2, 'Summary',
+        'New', 111, issue_id=6789))
+    issue = tracker_pb2.Issue(status='New', owner_id=111, merged_into=6789)
+    delta = tracker_pb2.IssueDelta(merged_into_external='b/5678')
+    amendments, impacted_iids = tracker_bizobj.ApplyIssueDelta(
+        self.cnxn, self.services.issue, issue, delta, self.config)
+
+    # Test amendments.
+    self.assertEqual(1, len(amendments))
+    self.assertEqual(tracker_pb2.FieldID.MERGEDINTO, amendments[0].field)
+    self.assertEqual('-2 b/5678', amendments[0].newvalue)
+    self.assertEqual(set([6789]), impacted_iids)
+    self.assertEqual(0, issue.merged_into)
+    self.assertEqual('b/5678', issue.merged_into_external)
+
+  def testApplyIssueDelta_MergedIntoFromExternalToInternal(self):
+    """ApplyIssueDelta updates from an external to an internalref."""
+    self.services.issue.TestAddIssue(fake.MakeTestIssue(1, 2, 'Summary',
+        'New', 111, issue_id=6789))
+    issue = tracker_pb2.Issue(status='New', owner_id=111,
+        merged_into_external='b/5678')
+    delta = tracker_pb2.IssueDelta(merged_into=6789)
+    amendments, impacted_iids = tracker_bizobj.ApplyIssueDelta(
+        self.cnxn, self.services.issue, issue, delta, self.config)
+
+    # Test amendments.
+    self.assertEqual(1, len(amendments))
+    self.assertEqual(tracker_pb2.FieldID.MERGEDINTO, amendments[0].field)
+    self.assertEqual('-b/5678 2', amendments[0].newvalue)
+    self.assertEqual(set([6789]), impacted_iids)
+    self.assertEqual(6789, issue.merged_into)
+    self.assertEqual(None, issue.merged_into_external)
+
+  def testApplyIssueDelta_MergedIntoFromExternalToExternal(self):
+    """ApplyIssueDelta updates from an external to another external ref."""
+    issue = tracker_pb2.Issue(
+        status='New', owner_id=111, merged_into_external='b/1')
+    delta = tracker_pb2.IssueDelta(merged_into_external='b/5678')
+    amendments, impacted_iids = tracker_bizobj.ApplyIssueDelta(
+        self.cnxn, self.services.issue, issue, delta, self.config)
+
+    # Test amendments.
+    self.assertEqual(1, len(amendments))
+    self.assertEqual(tracker_pb2.FieldID.MERGEDINTO, amendments[0].field)
+    self.assertEqual('-b/1 b/5678', amendments[0].newvalue)
+    self.assertEqual(set(), impacted_iids)
+    self.assertEqual(0, issue.merged_into)
+    self.assertEqual('b/5678', issue.merged_into_external)
+
+  def testApplyIssueDelta_NoMergedIntoInternalAndExternal(self):
+    """ApplyIssueDelta does not allow updating the internal and external
+    merged_into fields at the same time.
+    """
+    issue = tracker_pb2.Issue(status='New', owner_id=111, merged_into=321)
+    delta = tracker_pb2.IssueDelta(merged_into=543,
+        merged_into_external='b/5678')
+    with self.assertRaises(ValueError):
+      tracker_bizobj.ApplyIssueDelta(self.cnxn, self.services.issue, issue,
+          delta, self.config)
+
+  def testApplyIssueDelta_RemoveExistingMergedInto(self):
+    self.services.issue.TestAddIssue(
+        fake.MakeTestIssue(1, 2, 'Summary', 'New', 111, issue_id=6789))
+    issue = tracker_pb2.Issue(status='New', owner_id=111, merged_into=6789)
+    delta = tracker_pb2.IssueDelta(merged_into=0)
+    amendments, impacted_iids = tracker_bizobj.ApplyIssueDelta(
+        self.cnxn, self.services.issue, issue, delta, self.config)
+    self.assertEqual(impacted_iids, {6789})
+    self.assertEqual(1, len(amendments))
+    self.assertEqual(
+        amendments[0],
+        tracker_bizobj.MakeMergedIntoAmendment(
+            [], [(issue.project_name, 2)],
+            default_project_name=issue.project_name))
+    self.assertEqual(issue.merged_into, 0)
+
+  def testApplyIssueDelta_RemoveExternalMergedInto(self):
+    issue = tracker_pb2.Issue(
+        status='New', owner_id=111, merged_into_external='b/123')
+    delta = tracker_pb2.IssueDelta(merged_into_external='')
+    amendments, impacted_iids = tracker_bizobj.ApplyIssueDelta(
+        self.cnxn, self.services.issue, issue, delta, self.config)
+    self.assertEqual(impacted_iids, set())
+    self.assertEqual(1, len(amendments))
+    self.assertEqual(
+        amendments[0],
+        tracker_bizobj.MakeMergedIntoAmendment(
+            [], [tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/123')]))
+    self.assertEqual(issue.merged_into_external, '')
+
+  def testApplyIssueDelta_RemoveMergedIntoNoop(self):
+    issue = tracker_pb2.Issue(
+        status='New', owner_id=111, merged_into_external='b/123')
+    delta = tracker_pb2.IssueDelta(merged_into=0)
+    amendments, impacted_iids = tracker_bizobj.ApplyIssueDelta(
+        self.cnxn, self.services.issue, issue, delta, self.config)
+    self.assertEqual(impacted_iids, set())
+    self.assertEqual(0, len(amendments))
+    # A noop request to remove merged_into, should not affect the existing
+    # external value.
+    self.assertIsNone(issue.merged_into)
+    self.assertEqual(issue.merged_into_external, 'b/123')
+
+  def testApplyIssueDelta_RemoveExternalMergedIntoNoop(self):
+    self.services.issue.TestAddIssue(
+        fake.MakeTestIssue(1, 2, 'Summary', 'New', 111, issue_id=6789))
+    issue = tracker_pb2.Issue(status='New', owner_id=111, merged_into=6789)
+    delta = tracker_pb2.IssueDelta(merged_into_external='')
+    amendments, impacted_iids = tracker_bizobj.ApplyIssueDelta(
+        self.cnxn, self.services.issue, issue, delta, self.config)
+    self.assertEqual(impacted_iids, set())
+    self.assertEqual(len(amendments), 0)
+    # A noop request to remove merged_into_external, should not affect the
+    # existing internal merged_into value.
+    self.assertIsNone(issue.merged_into_external)
+    self.assertEqual(issue.merged_into, 6789)
+
+  def testApplyIssueBlockRelationChanges(self):
+    """We can apply blocking and blocked_on relation changes to an issue."""
+
+    blocked_on = fake.MakeTestIssue(
+        789, 2, 'Something that must be done before', 'New', 111,
+        project_name='proj')
+    self.services.issue.TestAddIssue(blocked_on)
+    blocking = fake.MakeTestIssue(
+        789, 3, 'Something that must be done after', 'New', 111,
+        project_name='proj')
+    self.services.issue.TestAddIssue(blocking)
+
+    issue = tracker_pb2.Issue(
+        project_name='chicken',
+        blocked_on_iids=[blocked_on.issue_id, 78904],
+        blocking_iids=[blocking.issue_id, 78905])
+    blocked_on_add = fake.MakeTestIssue(
+        789, 6, 'Something that must be done before', 'New', 111,
+        project_name='chicken')
+    self.services.issue.TestAddIssue(blocked_on_add)
+    blocking_add = fake.MakeTestIssue(
+        789, 7, 'Something that must be done after', 'New', 111,
+        project_name='chicken')
+    self.services.issue.TestAddIssue(blocking_add)
+
+    (actual_amendments, actual_impacted_iids
+    ) = tracker_bizobj.ApplyIssueBlockRelationChanges(
+        self.cnxn,
+        issue,
+        # 78904 ref already exists can't be added, shuold ignore.
+        # 78404 ref does not exist, can't be removed, should ignore.
+        # blocked_on is ignored in the add list, but honored in the remove.
+        [blocked_on_add.issue_id, 78904, blocked_on.issue_id],
+        [78404, blocked_on.issue_id],
+        # 78905 ref already exists, can't be added, should ignore.
+        # 79404 ref does not exist in issue, can't be removed, should ignore.
+        # blocking_add is ignored in the remove list, but honored in the add.
+        [blocking_add.issue_id, 78905],
+        [79404, blocking.issue_id, blocking_add.issue_id],
+        self.services.issue)
+
+    expected_amendments = [
+        tracker_bizobj.MakeBlockedOnAmendment(
+            [('chicken', blocked_on_add.local_id)],
+            [('proj', blocked_on.local_id)],
+            default_project_name=issue.project_name),
+        tracker_bizobj.MakeBlockingAmendment(
+            [('chicken', blocking_add.local_id)], [('proj', blocking.local_id)],
+            default_project_name=issue.project_name)
+    ]
+    self.assertEqual(actual_amendments, expected_amendments)
+    self.assertItemsEqual(
+        actual_impacted_iids, [
+            blocked_on_add.issue_id, blocking_add.issue_id, blocked_on.issue_id,
+            blocking.issue_id
+        ])
+    self.assertEqual(issue.blocked_on_iids, [78904, blocked_on_add.issue_id])
+    self.assertEqual(issue.blocking_iids, [78905, blocking_add.issue_id])
+
+  def testApplyIssueBlockRelationChanges_Empty(self):
+    """We can handle empty blocking and blocked_on relation changes."""
+    issue = tracker_pb2.Issue(blocked_on_iids=[78901], blocking_iids=[78902])
+    (actual_amendments,
+     actual_impacted_iids) = tracker_bizobj.ApplyIssueBlockRelationChanges(
+         self.cnxn, issue, [], [], [], [], self.services.issue)
+
+    self.assertEqual(actual_amendments, [])
+    self.assertEqual(actual_impacted_iids, set())
+    self.assertEqual(issue.blocked_on_iids, [78901])
+    self.assertEqual(issue.blocking_iids, [78902])
+
+  def testMakeAmendment(self):
+    amendment = tracker_bizobj.MakeAmendment(
+        tracker_pb2.FieldID.STATUS, 'new', [111], [222])
+    self.assertEqual(tracker_pb2.FieldID.STATUS, amendment.field)
+    self.assertEqual('new', amendment.newvalue)
+    self.assertEqual([111], amendment.added_user_ids)
+    self.assertEqual([222], amendment.removed_user_ids)
+
+  def testPlusMinusString(self):
+    self.assertEqual('', tracker_bizobj._PlusMinusString([], []))
+    self.assertEqual('-a -b c d',
+                     tracker_bizobj._PlusMinusString(['c', 'd'], ['a', 'b']))
+
+  def testPlusMinusAmendment(self):
+    amendment = tracker_bizobj._PlusMinusAmendment(
+        tracker_pb2.FieldID.STATUS, ['add1', 'add2'], ['remove1'])
+    self.assertEqual(tracker_pb2.FieldID.STATUS, amendment.field)
+    self.assertEqual('-remove1 add1 add2', amendment.newvalue)
+
+  def testPlusMinusRefsAmendment(self):
+    ref1 = (None, 1)
+    ref2 = ('other-proj', 2)
+    amendment = tracker_bizobj._PlusMinusRefsAmendment(
+        tracker_pb2.FieldID.STATUS, [ref1], [ref2])
+    self.assertEqual(tracker_pb2.FieldID.STATUS, amendment.field)
+    self.assertEqual('-other-proj:2 1', amendment.newvalue)
+
+  def testMakeSummaryAmendment(self):
+    amendment = tracker_bizobj.MakeSummaryAmendment('', None)
+    self.assertEqual(tracker_pb2.FieldID.SUMMARY, amendment.field)
+    self.assertEqual('', amendment.newvalue)
+    self.assertEqual(None, amendment.oldvalue)
+
+    amendment = tracker_bizobj.MakeSummaryAmendment('new summary', '')
+    self.assertEqual(tracker_pb2.FieldID.SUMMARY, amendment.field)
+    self.assertEqual('new summary', amendment.newvalue)
+    self.assertEqual('', amendment.oldvalue)
+
+  def testMakeStatusAmendment(self):
+    amendment = tracker_bizobj.MakeStatusAmendment('', None)
+    self.assertEqual(tracker_pb2.FieldID.STATUS, amendment.field)
+    self.assertEqual('', amendment.newvalue)
+    self.assertEqual(None, amendment.oldvalue)
+
+    amendment = tracker_bizobj.MakeStatusAmendment('New', '')
+    self.assertEqual(tracker_pb2.FieldID.STATUS, amendment.field)
+    self.assertEqual('New', amendment.newvalue)
+    self.assertEqual('', amendment.oldvalue)
+
+  def testMakeOwnerAmendment(self):
+    amendment = tracker_bizobj.MakeOwnerAmendment(111, 0)
+    self.assertEqual(tracker_pb2.FieldID.OWNER, amendment.field)
+    self.assertEqual('', amendment.newvalue)
+    self.assertEqual([111], amendment.added_user_ids)
+    self.assertEqual([0], amendment.removed_user_ids)
+
+  def testMakeCcAmendment(self):
+    amendment = tracker_bizobj.MakeCcAmendment([111], [222])
+    self.assertEqual(tracker_pb2.FieldID.CC, amendment.field)
+    self.assertEqual('', amendment.newvalue)
+    self.assertEqual([111], amendment.added_user_ids)
+    self.assertEqual([222], amendment.removed_user_ids)
+
+  def testMakeLabelsAmendment(self):
+    amendment = tracker_bizobj.MakeLabelsAmendment(['added1'], ['removed1'])
+    self.assertEqual(tracker_pb2.FieldID.LABELS, amendment.field)
+    self.assertEqual('-removed1 added1', amendment.newvalue)
+
+  def testDiffValueLists(self):
+    added, removed = tracker_bizobj.DiffValueLists([], [])
+    self.assertItemsEqual([], added)
+    self.assertItemsEqual([], removed)
+
+    added, removed = tracker_bizobj.DiffValueLists([], None)
+    self.assertItemsEqual([], added)
+    self.assertItemsEqual([], removed)
+
+    added, removed = tracker_bizobj.DiffValueLists([1, 2], [])
+    self.assertItemsEqual([1, 2], added)
+    self.assertItemsEqual([], removed)
+
+    added, removed = tracker_bizobj.DiffValueLists([], [8, 9])
+    self.assertItemsEqual([], added)
+    self.assertItemsEqual([8, 9], removed)
+
+    added, removed = tracker_bizobj.DiffValueLists([1, 2], [8, 9])
+    self.assertItemsEqual([1, 2], added)
+    self.assertItemsEqual([8, 9], removed)
+
+    added, removed = tracker_bizobj.DiffValueLists([1, 2, 5, 6], [5, 6, 8, 9])
+    self.assertItemsEqual([1, 2], added)
+    self.assertItemsEqual([8, 9], removed)
+
+    added, removed = tracker_bizobj.DiffValueLists([5, 6], [5, 6, 8, 9])
+    self.assertItemsEqual([], added)
+    self.assertItemsEqual([8, 9], removed)
+
+    added, removed = tracker_bizobj.DiffValueLists([1, 2, 5, 6], [5, 6])
+    self.assertItemsEqual([1, 2], added)
+    self.assertItemsEqual([], removed)
+
+    added, removed = tracker_bizobj.DiffValueLists(
+        [1, 2, 2, 5, 6], [5, 6, 8, 9])
+    self.assertItemsEqual([1, 2, 2], added)
+    self.assertItemsEqual([8, 9], removed)
+
+    added, removed = tracker_bizobj.DiffValueLists(
+        [1, 2, 5, 6], [5, 6, 8, 8, 9])
+    self.assertItemsEqual([1, 2], added)
+    self.assertItemsEqual([8, 8, 9], removed)
+
+  def testMakeFieldAmendment_NoSuchFieldDef(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    with self.assertRaises(ValueError):
+      tracker_bizobj.MakeFieldAmendment(1, config, ['Large'], ['Small'])
+
+  def testMakeFieldAmendment_MultiValued(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    fd = tracker_pb2.FieldDef(
+        field_id=1, field_name='Days', is_multivalued=True)
+    config.field_defs.append(fd)
+    self.assertEqual(
+        tracker_bizobj.MakeAmendment(
+            tracker_pb2.FieldID.CUSTOM, '-Mon Tue Wed', [], [], 'Days'),
+        tracker_bizobj.MakeFieldAmendment(1, config, ['Tue', 'Wed'], ['Mon']))
+    self.assertEqual(
+        tracker_bizobj.MakeAmendment(
+            tracker_pb2.FieldID.CUSTOM, '-Mon', [], [], 'Days'),
+        tracker_bizobj.MakeFieldAmendment(1, config, [], ['Mon']))
+
+  def testMakeFieldAmendment_MultiValuedUser(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    fd = tracker_pb2.FieldDef(
+        field_id=1, field_name='Friends', is_multivalued=True,
+        field_type=tracker_pb2.FieldTypes.USER_TYPE)
+    config.field_defs.append(fd)
+    self.assertEqual(
+        tracker_bizobj.MakeAmendment(
+            tracker_pb2.FieldID.CUSTOM, '', [111], [222], 'Friends'),
+        tracker_bizobj.MakeFieldAmendment(1, config, [111], [222]))
+    self.assertEqual(
+        tracker_bizobj.MakeAmendment(
+            tracker_pb2.FieldID.CUSTOM, '', [], [222], 'Friends'),
+        tracker_bizobj.MakeFieldAmendment(1, config, [], [222]))
+
+  def testMakeFieldAmendment_SingleValued(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    fd = tracker_pb2.FieldDef(field_id=1, field_name='Size')
+    config.field_defs.append(fd)
+    self.assertEqual(
+        tracker_bizobj.MakeAmendment(
+            tracker_pb2.FieldID.CUSTOM, 'Large', [], [], 'Size'),
+        tracker_bizobj.MakeFieldAmendment(1, config, ['Large'], ['Small']))
+    self.assertEqual(
+        tracker_bizobj.MakeAmendment(
+            tracker_pb2.FieldID.CUSTOM, '----', [], [], 'Size'),
+        tracker_bizobj.MakeFieldAmendment(1, config, [], ['Small']))
+
+  def testMakeFieldAmendment_SingleValuedUser(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    fd = tracker_pb2.FieldDef(
+        field_id=1, field_name='Friend',
+        field_type=tracker_pb2.FieldTypes.USER_TYPE)
+    config.field_defs.append(fd)
+    self.assertEqual(
+        tracker_bizobj.MakeAmendment(
+            tracker_pb2.FieldID.CUSTOM, '', [111], [], 'Friend'),
+        tracker_bizobj.MakeFieldAmendment(1, config, [111], [222]))
+    self.assertEqual(
+        tracker_bizobj.MakeAmendment(
+            tracker_pb2.FieldID.CUSTOM, '', [], [], 'Friend'),
+        tracker_bizobj.MakeFieldAmendment(1, config, [], [222]))
+
+  def testMakeFieldAmendment_PhaseField(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    fd = tracker_pb2.FieldDef(
+        field_id=1, field_name='Friend',
+        field_type=tracker_pb2.FieldTypes.USER_TYPE, is_phase_field=True)
+    config.field_defs.append(fd)
+    self.assertEqual(
+        tracker_bizobj.MakeAmendment(
+            tracker_pb2.FieldID.CUSTOM, '', [111], [], 'PhaseName-Friend'),
+        tracker_bizobj.MakeFieldAmendment(
+            1, config, [111], [222], phase_name='PhaseName'))
+    self.assertEqual(
+        tracker_bizobj.MakeAmendment(
+            tracker_pb2.FieldID.CUSTOM, '', [], [], 'PhaseName-3-Friend'),
+        tracker_bizobj.MakeFieldAmendment(
+            1, config, [], [222], phase_name='PhaseName-3'))
+
+  def testMakeFieldClearedAmendment_FieldNotFound(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    with self.assertRaises(ValueError):
+      tracker_bizobj.MakeFieldClearedAmendment(1, config)
+
+  def testMakeFieldClearedAmendment_Normal(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    fd = tracker_pb2.FieldDef(field_id=1, field_name='Rabbit')
+    config.field_defs.append(fd)
+    self.assertEqual(
+        tracker_bizobj.MakeAmendment(
+            tracker_pb2.FieldID.CUSTOM, '----', [], [], 'Rabbit'),
+        tracker_bizobj.MakeFieldClearedAmendment(1, config))
+
+  def testMakeApprovalStructureAmendment(self):
+    actual_amendment = tracker_bizobj.MakeApprovalStructureAmendment(
+        ['Chicken1', 'Chicken', 'Llama'], ['Cow', 'Chicken2', 'Llama'])
+    amendment = tracker_bizobj.MakeAmendment(
+        tracker_pb2.FieldID.CUSTOM, '-Cow -Chicken2 Chicken1 Chicken',
+        [], [], 'Approvals')
+    self.assertEqual(amendment, actual_amendment)
+
+  def testMakeApprovalStatusAmendment(self):
+    actual_amendment = tracker_bizobj.MakeApprovalStatusAmendment(
+        tracker_pb2.ApprovalStatus.APPROVED)
+    amendment = tracker_pb2.Amendment(
+        field=tracker_pb2.FieldID.CUSTOM, newvalue='approved',
+        custom_field_name='Status')
+    self.assertEqual(amendment, actual_amendment)
+
+  def testMakeApprovalApproversAmendment(self):
+    actual_amendment = tracker_bizobj.MakeApprovalApproversAmendment(
+        [222], [333])
+    amendment = tracker_pb2.Amendment(
+        field=tracker_pb2.FieldID.CUSTOM, newvalue='', added_user_ids=[222],
+        removed_user_ids=[333], custom_field_name='Approvers')
+    self.assertEqual(actual_amendment, amendment)
+
+  def testMakeComponentsAmendment_NoChange(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    config.component_defs = [
+        tracker_pb2.ComponentDef(component_id=1, path='UI'),
+        tracker_pb2.ComponentDef(component_id=2, path='DB')]
+    self.assertEqual(
+        tracker_bizobj.MakeAmendment(
+            tracker_pb2.FieldID.COMPONENTS, '', [], []),
+        tracker_bizobj.MakeComponentsAmendment([], [], config))
+
+  def testMakeComponentsAmendment_NotFound(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    config.component_defs = [
+        tracker_pb2.ComponentDef(component_id=1, path='UI'),
+        tracker_pb2.ComponentDef(component_id=2, path='DB')]
+    self.assertEqual(
+        tracker_bizobj.MakeAmendment(
+            tracker_pb2.FieldID.COMPONENTS, '', [], []),
+        tracker_bizobj.MakeComponentsAmendment([99], [999], config))
+
+  def testMakeComponentsAmendment_Normal(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    config.component_defs = [
+        tracker_pb2.ComponentDef(component_id=1, path='UI'),
+        tracker_pb2.ComponentDef(component_id=2, path='DB')]
+    self.assertEqual(
+        tracker_bizobj.MakeAmendment(
+            tracker_pb2.FieldID.COMPONENTS, '-UI DB', [], []),
+        tracker_bizobj.MakeComponentsAmendment([2], [1], config))
+
+  def testMakeBlockedOnAmendment(self):
+    ref1 = (None, 1)
+    ref2 = ('other-proj', 2)
+    amendment = tracker_bizobj.MakeBlockedOnAmendment([ref1], [ref2])
+    self.assertEqual(tracker_pb2.FieldID.BLOCKEDON, amendment.field)
+    self.assertEqual('-other-proj:2 1', amendment.newvalue)
+
+    amendment = tracker_bizobj.MakeBlockedOnAmendment([ref2], [ref1])
+    self.assertEqual(tracker_pb2.FieldID.BLOCKEDON, amendment.field)
+    self.assertEqual('-1 other-proj:2', amendment.newvalue)
+
+  def testMakeBlockingAmendment(self):
+    ref1 = (None, 1)
+    ref2 = ('other-proj', 2)
+    amendment = tracker_bizobj.MakeBlockingAmendment([ref1], [ref2])
+    self.assertEqual(tracker_pb2.FieldID.BLOCKING, amendment.field)
+    self.assertEqual('-other-proj:2 1', amendment.newvalue)
+
+  def testMakeMergedIntoAmendment(self):
+    ref1 = (None, 1)
+    ref2 = ('other-proj', 2)
+    ref3 = ('chicken-proj', 3)
+    amendment = tracker_bizobj.MakeMergedIntoAmendment(
+        [ref1, None], [ref2, ref3])
+    self.assertEqual(tracker_pb2.FieldID.MERGEDINTO, amendment.field)
+    self.assertEqual('-other-proj:2 -chicken-proj:3 1', amendment.newvalue)
+
+  def testMakeProjectAmendment(self):
+    self.assertEqual(
+        tracker_bizobj.MakeAmendment(
+            tracker_pb2.FieldID.PROJECT, 'moonshot', [], []),
+        tracker_bizobj.MakeProjectAmendment('moonshot'))
+
+  def testAmendmentString(self):
+    users_by_id = {
+        111: framework_views.StuffUserView(111, 'username@gmail.com', True),
+        framework_constants.DELETED_USER_ID: framework_views.StuffUserView(
+            framework_constants.DELETED_USER_ID, '', True),
+    }
+    summary_amendment = tracker_bizobj.MakeSummaryAmendment('new summary', None)
+    self.assertEqual(
+        'new summary',
+        tracker_bizobj.AmendmentString(summary_amendment, users_by_id))
+
+    status_amendment = tracker_bizobj.MakeStatusAmendment('', None)
+    self.assertEqual(
+        '', tracker_bizobj.AmendmentString(status_amendment, users_by_id))
+    status_amendment = tracker_bizobj.MakeStatusAmendment('Assigned', 'New')
+    self.assertEqual(
+        'Assigned',
+        tracker_bizobj.AmendmentString(status_amendment, users_by_id))
+
+    owner_amendment = tracker_bizobj.MakeOwnerAmendment(0, 0)
+    self.assertEqual(
+        '----', tracker_bizobj.AmendmentString(owner_amendment, users_by_id))
+    owner_amendment = tracker_bizobj.MakeOwnerAmendment(111, 0)
+    self.assertEqual(
+        'usern...@gmail.com',
+        tracker_bizobj.AmendmentString(owner_amendment, users_by_id))
+
+    owner_amendment_deleted = tracker_bizobj.MakeOwnerAmendment(1, 0)
+    self.assertEqual(
+        framework_constants.DELETED_USER_NAME,
+        tracker_bizobj.AmendmentString(owner_amendment_deleted, users_by_id))
+
+  def testAmendmentString_New(self):
+    """AmendmentString_New behaves equivalently to the old version."""
+    # TODO(crbug.com/monorail/7571): Delete this test.
+    users_by_id = {
+        111:
+            framework_views.StuffUserView(111, 'username@gmail.com', True),
+        framework_constants.DELETED_USER_ID:
+            framework_views.StuffUserView(
+                framework_constants.DELETED_USER_ID, '', True),
+    }
+    user_display_names = {
+        111:
+            'usern...@gmail.com',
+        framework_constants.DELETED_USER_ID:
+            framework_constants.DELETED_USER_NAME
+    }
+
+    summary_amendment = tracker_bizobj.MakeSummaryAmendment('new summary', None)
+    new_str_summary = tracker_bizobj.AmendmentString_New(
+        summary_amendment, user_display_names)
+    self.assertEqual(
+        tracker_bizobj.AmendmentString(summary_amendment, users_by_id),
+        new_str_summary)
+
+    status_amendment = tracker_bizobj.MakeStatusAmendment('', None)
+    new_str_status = tracker_bizobj.AmendmentString_New(
+        status_amendment, user_display_names)
+    self.assertEqual(
+        tracker_bizobj.AmendmentString(status_amendment, users_by_id),
+        new_str_status)
+
+    status_amendment_2 = tracker_bizobj.MakeStatusAmendment('Assigned', 'New')
+    new_str_status_2 = tracker_bizobj.AmendmentString_New(
+        status_amendment_2, user_display_names)
+    self.assertEqual(
+        tracker_bizobj.AmendmentString(status_amendment_2, users_by_id),
+        new_str_status_2)
+
+    owner_amendment = tracker_bizobj.MakeOwnerAmendment(0, 0)
+    new_str_owner = tracker_bizobj.AmendmentString_New(
+        owner_amendment, user_display_names)
+    self.assertEqual(
+        tracker_bizobj.AmendmentString(owner_amendment, users_by_id),
+        new_str_owner)
+
+    owner_amendment_2 = tracker_bizobj.MakeOwnerAmendment(111, 0)
+    new_str_owner_2 = tracker_bizobj.AmendmentString_New(
+        owner_amendment_2, user_display_names)
+    self.assertEqual(
+        tracker_bizobj.AmendmentString(owner_amendment_2, users_by_id),
+        new_str_owner_2)
+
+    owner_amendment_deleted = tracker_bizobj.MakeOwnerAmendment(1, 0)
+    new_str_owner_deleted = tracker_bizobj.AmendmentString_New(
+        owner_amendment_deleted, user_display_names)
+    self.assertEqual(
+        tracker_bizobj.AmendmentString(owner_amendment_deleted, users_by_id),
+        new_str_owner_deleted)
+
+
+  def testAmendmentLinks(self):
+    users_by_id = {
+        111: framework_views.StuffUserView(111, 'foo@gmail.com', False),
+        222: framework_views.StuffUserView(222, 'bar@gmail.com', False),
+        333: framework_views.StuffUserView(333, 'baz@gmail.com', False),
+        framework_constants.DELETED_USER_ID: framework_views.StuffUserView(
+            framework_constants.DELETED_USER_ID, '', True),
+        }
+    # SUMMARY
+    summary_amendment = tracker_bizobj.MakeSummaryAmendment('new summary', None)
+    self.assertEqual(
+        [{'value': 'new summary', 'url': None}],
+        tracker_bizobj.AmendmentLinks(summary_amendment, users_by_id, 'proj'))
+
+    summary_amendment = tracker_bizobj.MakeSummaryAmendment(
+        'new summary', 'NULL')
+    self.assertEqual(
+        [{'value': 'new summary', 'url': None}],
+        tracker_bizobj.AmendmentLinks(summary_amendment, users_by_id, 'proj'))
+
+    summary_amendment = tracker_bizobj.MakeSummaryAmendment(
+        'new summary', 'old info')
+    self.assertEqual(
+        [{'value': 'new summary (was: old info)', 'url': None}],
+        tracker_bizobj.AmendmentLinks(summary_amendment, users_by_id, 'proj'))
+
+    # STATUS
+    status_amendment = tracker_bizobj.MakeStatusAmendment('New', None)
+    self.assertEqual(
+        [{'value': 'New', 'url': None}],
+        tracker_bizobj.AmendmentLinks(status_amendment, users_by_id, 'proj'))
+
+    status_amendment = tracker_bizobj.MakeStatusAmendment('New', 'NULL')
+    self.assertEqual(
+        [{'value': 'New', 'url': None}],
+        tracker_bizobj.AmendmentLinks(status_amendment, users_by_id, 'proj'))
+
+    status_amendment = tracker_bizobj.MakeStatusAmendment(
+        'Assigned', 'New')
+    self.assertEqual(
+        [{'value': 'Assigned (was: New)', 'url': None}],
+        tracker_bizobj.AmendmentLinks(status_amendment, users_by_id, 'proj'))
+
+    # OWNER
+    owner_amendment = tracker_bizobj.MakeOwnerAmendment(0, 0)
+    self.assertEqual(
+        [{'value': '----', 'url': None}],
+        tracker_bizobj.AmendmentLinks(owner_amendment, users_by_id, 'proj'))
+    owner_amendment = tracker_bizobj.MakeOwnerAmendment(111, 0)
+    self.assertEqual(
+        [{'value': 'foo@gmail.com', 'url': None}],
+        tracker_bizobj.AmendmentLinks(owner_amendment, users_by_id, 'proj'))
+
+    # BLOCKEDON, BLOCKING, MERGEDINTO
+    blocking_amendment = tracker_bizobj.MakeBlockingAmendment(
+        [(None, 123), ('blah', 234)], [(None, 345), ('blah', 456)])
+    self.assertEqual([
+        {'value': '-345', 'url': '/p/proj/issues/detail?id=345'},
+        {'value': '-blah:456', 'url': '/p/blah/issues/detail?id=456'},
+        {'value': '123', 'url': '/p/proj/issues/detail?id=123'},
+        {'value': 'blah:234', 'url': '/p/blah/issues/detail?id=234'}],
+        tracker_bizobj.AmendmentLinks(blocking_amendment, users_by_id, 'proj'))
+
+    # newvalue catchall
+    label_amendment = tracker_bizobj.MakeLabelsAmendment(
+        ['My-Label', 'Your-Label'], ['Their-Label'])
+    self.assertEqual([
+        {'value': '-Their-Label', 'url': None},
+        {'value': 'My-Label', 'url': None},
+        {'value': 'Your-Label', 'url': None}],
+        tracker_bizobj.AmendmentLinks(label_amendment, users_by_id, 'proj'))
+
+    # CC, or CUSTOM with user type
+    cc_amendment = tracker_bizobj.MakeCcAmendment([222, 333], [111])
+    self.assertEqual([
+        {'value': '-foo@gmail.com', 'url': None},
+        {'value': 'bar@gmail.com', 'url': None},
+        {'value': 'baz@gmail.com', 'url': None}],
+        tracker_bizobj.AmendmentLinks(cc_amendment, users_by_id, 'proj'))
+    user_amendment = tracker_bizobj.MakeAmendment(
+        tracker_pb2.FieldID.CUSTOM, None, [222, 333], [111], 'ultracc')
+    self.assertEqual([
+        {'value': '-foo@gmail.com', 'url': None},
+        {'value': 'bar@gmail.com', 'url': None},
+        {'value': 'baz@gmail.com', 'url': None}],
+        tracker_bizobj.AmendmentLinks(user_amendment, users_by_id, 'proj'))
+
+    # deleted users
+    cc_amendment_deleted = tracker_bizobj.MakeCcAmendment(
+        [framework_constants.DELETED_USER_ID], [])
+    self.assertEqual(
+        [{'value': framework_constants.DELETED_USER_NAME, 'url': None}],
+        tracker_bizobj.AmendmentLinks(
+            cc_amendment_deleted, users_by_id, 'proj'))
+
+  def testGetAmendmentFieldName_Custom(self):
+    amendment = tracker_bizobj.MakeAmendment(
+        tracker_pb2.FieldID.CUSTOM, None, [222, 333], [111], 'Rabbit')
+    self.assertEqual('Rabbit', tracker_bizobj.GetAmendmentFieldName(amendment))
+
+  def testGetAmendmentFieldName_Builtin(self):
+    amendment = tracker_bizobj.MakeAmendment(
+        tracker_pb2.FieldID.SUMMARY, 'It broke', [], [])
+    self.assertEqual('Summary', tracker_bizobj.GetAmendmentFieldName(amendment))
+
+  def testMakeDanglingIssueRef(self):
+    di_ref = tracker_bizobj.MakeDanglingIssueRef('proj', 123)
+    self.assertEqual('proj', di_ref.project)
+    self.assertEqual(123, di_ref.issue_id)
+
+  def testFormatIssueURL_NoRef(self):
+    self.assertEqual('', tracker_bizobj.FormatIssueURL(None))
+
+  def testFormatIssueRef(self):
+    self.assertEqual('', tracker_bizobj.FormatIssueRef(None))
+
+    self.assertEqual(
+        'p:1', tracker_bizobj.FormatIssueRef(('p', 1)))
+
+    self.assertEqual(
+        '1', tracker_bizobj.FormatIssueRef((None, 1)))
+
+  def testFormatIssueRef_External(self):
+    """Outputs shortlink as-is."""
+    ref = tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/1234')
+    self.assertEqual('b/1234', tracker_bizobj.FormatIssueRef(ref))
+
+  def testFormatIssueRef_ExternalInvalid(self):
+    """Does not validate external IDs."""
+    ref = tracker_pb2.DanglingIssueRef(ext_issue_identifier='invalid')
+    self.assertEqual('invalid', tracker_bizobj.FormatIssueRef(ref))
+
+  def testFormatIssueRef_Empty(self):
+    """Passes on empty values."""
+    ref = tracker_pb2.DanglingIssueRef(ext_issue_identifier='')
+    self.assertEqual('', tracker_bizobj.FormatIssueRef(ref))
+
+  def testParseIssueRef(self):
+    self.assertEqual(None, tracker_bizobj.ParseIssueRef(''))
+    self.assertEqual(None, tracker_bizobj.ParseIssueRef('  \t '))
+
+    ref_pn, ref_id = tracker_bizobj.ParseIssueRef('1')
+    self.assertEqual(None, ref_pn)
+    self.assertEqual(1, ref_id)
+
+    ref_pn, ref_id = tracker_bizobj.ParseIssueRef('-1')
+    self.assertEqual(None, ref_pn)
+    self.assertEqual(1, ref_id)
+
+    ref_pn, ref_id = tracker_bizobj.ParseIssueRef('p:2')
+    self.assertEqual('p', ref_pn)
+    self.assertEqual(2, ref_id)
+
+    ref_pn, ref_id = tracker_bizobj.ParseIssueRef('-p:2')
+    self.assertEqual('p', ref_pn)
+    self.assertEqual(2, ref_id)
+
+  def testSafeParseIssueRef(self):
+    self.assertEqual(None, tracker_bizobj._SafeParseIssueRef('-'))
+    self.assertEqual(None, tracker_bizobj._SafeParseIssueRef('test:'))
+    ref_pn, ref_id = tracker_bizobj.ParseIssueRef('p:2')
+    self.assertEqual('p', ref_pn)
+    self.assertEqual(2, ref_id)
+
+  def testMergeFields_NoChange(self):
+    fv1 = tracker_bizobj.MakeFieldValue(1, 42, None, None, None, None, False)
+    merged_fvs, fvs_added_dict, fvs_removed_dict = tracker_bizobj._MergeFields(
+        [fv1], [], [], [])
+    self.assertEqual([fv1], merged_fvs)
+    self.assertEqual({}, fvs_added_dict)
+    self.assertEqual({}, fvs_removed_dict)
+
+  def testMergeFields_SingleValued(self):
+    fd = tracker_pb2.FieldDef(field_id=1, field_name='foo')
+    fv1 = tracker_bizobj.MakeFieldValue(1, 42, None, None, None, None, False)
+    fv2 = tracker_bizobj.MakeFieldValue(1, 43, None, None, None, None, False)
+    fv3 = tracker_bizobj.MakeFieldValue(1, 44, None, None, None, None, False)
+
+    # Adding one replaces all values since the field is single-valued.
+    merged_fvs, fvs_added_dict, fvs_removed_dict = tracker_bizobj._MergeFields(
+        [fv1, fv2], [fv3], [], [fd])
+    self.assertEqual([fv3], merged_fvs)
+    self.assertEqual({fv3.field_id: [fv3]}, fvs_added_dict)
+    self.assertEqual({}, fvs_removed_dict)
+
+    # Removing one just removes it, does not reset.
+    merged_fvs, fvs_added_dict, fvs_removed_dict = tracker_bizobj._MergeFields(
+        [fv1, fv2], [], [fv2], [fd])
+    self.assertEqual([fv1], merged_fvs)
+    self.assertEqual({}, fvs_added_dict)
+    self.assertEqual({fv2.field_id: [fv2]}, fvs_removed_dict)
+
+  def testMergeFields_SingleValuedPhase(self):
+    fd = tracker_pb2.FieldDef(
+        field_id=1, field_name='phase-foo', is_phase_field=True)
+    fv1 = tracker_bizobj.MakeFieldValue(
+        1, 45, None, None, None, None, False, phase_id=1)
+    fv2 = tracker_bizobj.MakeFieldValue(
+        1, 46, None, None, None, None, False, phase_id=2)
+    fv3 = tracker_bizobj.MakeFieldValue(
+        1, 47, None, None, None, None, False, phase_id=1) # should replace fv4
+
+    # Adding one replaces all values since the field is single-valued.
+    merged_fvs, fvs_added_dict, fvs_removed_dict = tracker_bizobj._MergeFields(
+        [fv1, fv2], [fv3], [], [fd])
+    self.assertEqual([fv2, fv3], merged_fvs)
+    self.assertEqual({fv3.field_id: [fv3]}, fvs_added_dict)
+    self.assertEqual({}, fvs_removed_dict)
+
+    # Removing one just removes it, does not reset.
+    merged_fvs, fvs_added_dict, fvs_removed_dict = tracker_bizobj._MergeFields(
+        [fv1, fv2], [], [fv2], [fd])
+    self.assertEqual([fv1], merged_fvs)
+    self.assertEqual({}, fvs_added_dict)
+    self.assertEqual({fv2.field_id: [fv2]}, fvs_removed_dict)
+
+  def testMergeFields_MultiValued(self):
+    fd = tracker_pb2.FieldDef(
+        field_id=1, field_name='foo', is_multivalued=True)
+    fv1 = tracker_bizobj.MakeFieldValue(1, 42, None, None, None, None, False)
+    fv2 = tracker_bizobj.MakeFieldValue(1, 43, None, None, None, None, False)
+    fv3 = tracker_bizobj.MakeFieldValue(1, 44, None, None, None, None, False)
+    fv4 = tracker_bizobj.MakeFieldValue(1, 42, None, None, None, None, False)
+    fv5 = tracker_bizobj.MakeFieldValue(1, 99, None, None, None, None, False)
+    fv6 = tracker_bizobj.MakeFieldValue(1, 100, None, None, None, None, False)
+
+    merged_fvs, fvs_added_dict, fvs_removed_dict = tracker_bizobj._MergeFields(
+        [fv1, fv2], [fv2, fv3, fv6], [fv4, fv5], [fd])
+    self.assertEqual([fv2, fv3, fv6], merged_fvs)
+    self.assertEqual({fv3.field_id: [fv3, fv6]}, fvs_added_dict)
+    self.assertEqual({fv4.field_id: [fv4]}, fvs_removed_dict)
+
+  def testMergeFields_MultiValuedPhase(self):
+    fd = tracker_pb2.FieldDef(
+        field_id=1, field_name='foo', is_multivalued=True, is_phase_field=True)
+    fd2 = tracker_pb2.FieldDef(
+        field_id=2, field_name='cow', is_multivalued=True, is_phase_field=True)
+    fv1 = tracker_bizobj.MakeFieldValue(
+        1, 42, None, None, None, None, False, phase_id=1)
+    fv2 = tracker_bizobj.MakeFieldValue(
+        1, 43, None, None, None, None, False, phase_id=2)
+    fv3 = tracker_bizobj.MakeFieldValue(
+        1, 44, None, None, None, None, False, phase_id=1)
+    fv4 = tracker_bizobj.MakeFieldValue(
+        1, 99, None, None, None, None, False, phase_id=2)
+    fv5 = tracker_bizobj.MakeFieldValue(
+        2, 22, None, None, None, None, False, phase_id=2)
+
+    merged_fvs, fvs_added_dict, fvs_removed_dict = tracker_bizobj._MergeFields(
+        [fv1, fv2], [fv3, fv1, fv5], [fv2, fv4], [fd, fd2])
+    self.assertEqual([fv1, fv3, fv5], merged_fvs)
+    self.assertEqual({fv3.field_id: [fv3], fv5.field_id: [fv5]}, fvs_added_dict)
+    self.assertEqual({fv2.field_id: [fv2]}, fvs_removed_dict)
+
+  def testSplitBlockedOnRanks_Normal(self):
+    issue = tracker_pb2.Issue()
+    issue.blocked_on_iids = [78902, 78903, 78904]
+    issue.blocked_on_ranks = [10, 20, 30]
+    rank_rows = list(zip(issue.blocked_on_iids, issue.blocked_on_ranks))
+    rank_rows.reverse()
+    ret = tracker_bizobj.SplitBlockedOnRanks(
+        issue, 78903, False, issue.blocked_on_iids)
+    self.assertEqual(ret, (rank_rows[:1], rank_rows[1:]))
+
+  def testSplitBlockedOnRanks_BadTarget(self):
+    issue = tracker_pb2.Issue()
+    issue.blocked_on_iids = [78902, 78903, 78904]
+    issue.blocked_on_ranks = [10, 20, 30]
+    rank_rows = list(zip(issue.blocked_on_iids, issue.blocked_on_ranks))
+    rank_rows.reverse()
+    ret = tracker_bizobj.SplitBlockedOnRanks(
+        issue, 78999, False, issue.blocked_on_iids)
+    self.assertEqual(ret, (rank_rows, []))
diff --git a/tracker/test/tracker_helpers_test.py b/tracker/test/tracker_helpers_test.py
new file mode 100644
index 0000000..4f89cc9
--- /dev/null
+++ b/tracker/test/tracker_helpers_test.py
@@ -0,0 +1,2775 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unittest for the tracker helpers module."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import copy
+import mock
+import unittest
+
+import settings
+
+from businesslogic import work_env
+from framework import exceptions
+from framework import framework_constants
+from framework import framework_helpers
+from framework import permissions
+from framework import template_helpers
+from framework import urls
+from proto import project_pb2
+from proto import tracker_pb2
+from proto import user_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+from tracker import tracker_helpers
+
+TEST_ID_MAP = {
+    'a@example.com': 1,
+    'b@example.com': 2,
+    'c@example.com': 3,
+    'd@example.com': 4,
+    }
+
+
+def _Issue(project_name, local_id, summary='', status='', project_id=789):
+  issue = tracker_pb2.Issue()
+  issue.project_name = project_name
+  issue.project_id = project_id
+  issue.local_id = local_id
+  issue.issue_id = 100000 + local_id
+  issue.summary = summary
+  issue.status = status
+  return issue
+
+
+def _MakeConfig():
+  config = tracker_pb2.ProjectIssueConfig()
+  config.well_known_statuses.append(tracker_pb2.StatusDef(
+      means_open=True, status='New', deprecated=False))
+  config.well_known_statuses.append(tracker_pb2.StatusDef(
+      status='Old', means_open=False, deprecated=False))
+  config.well_known_statuses.append(tracker_pb2.StatusDef(
+      status='StatusThatWeDontUseAnymore', means_open=False, deprecated=True))
+
+  return config
+
+
+class HelpersTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        project=fake.ProjectService(),
+        config=fake.ConfigService(),
+        issue=fake.IssueService(),
+        user=fake.UserService(),
+        usergroup=fake.UserGroupService())
+
+    for email, user_id in TEST_ID_MAP.items():
+      self.services.user.TestAddUser(email, user_id)
+
+    self.services.project.TestAddProject('testproj', project_id=789)
+    self.issue1 = fake.MakeTestIssue(789, 1, 'one', 'New', 111)
+    self.issue1.project_name = 'testproj'
+    self.services.issue.TestAddIssue(self.issue1)
+    self.issue2 = fake.MakeTestIssue(789, 2, 'two', 'New', 111)
+    self.issue2.project_name = 'testproj'
+    self.services.issue.TestAddIssue(self.issue2)
+    self.issue3 = fake.MakeTestIssue(789, 3, 'three', 'New', 111)
+    self.issue3.project_name = 'testproj'
+    self.services.issue.TestAddIssue(self.issue3)
+    self.cnxn = 'fake connextion'
+    self.errors = template_helpers.EZTError()
+    self.default_colspec_param = 'colspec=%s' % (
+        tracker_constants.DEFAULT_COL_SPEC.replace(' ', '%20'))
+    self.services.usergroup.TestAddGroupSettings(999, 'group@example.com')
+
+  def testParseIssueRequest_Empty(self):
+    post_data = fake.PostData()
+    errors = template_helpers.EZTError()
+    parsed = tracker_helpers.ParseIssueRequest(
+        'fake cnxn', post_data, self.services, errors, 'proj')
+    self.assertEqual('', parsed.summary)
+    self.assertEqual('', parsed.comment)
+    self.assertEqual('', parsed.status)
+    self.assertEqual('', parsed.users.owner_username)
+    self.assertEqual(0, parsed.users.owner_id)
+    self.assertEqual([], parsed.users.cc_usernames)
+    self.assertEqual([], parsed.users.cc_usernames_remove)
+    self.assertEqual([], parsed.users.cc_ids)
+    self.assertEqual([], parsed.users.cc_ids_remove)
+    self.assertEqual('', parsed.template_name)
+    self.assertEqual([], parsed.labels)
+    self.assertEqual([], parsed.labels_remove)
+    self.assertEqual({}, parsed.fields.vals)
+    self.assertEqual({}, parsed.fields.vals_remove)
+    self.assertEqual([], parsed.fields.fields_clear)
+    self.assertEqual('', parsed.blocked_on.entered_str)
+    self.assertEqual([], parsed.blocked_on.iids)
+
+  def testParseIssueRequest_Normal(self):
+    post_data = fake.PostData({
+        'summary': ['some summary'],
+        'comment': ['some comment'],
+        'status': ['SomeStatus'],
+        'template_name': ['some template'],
+        'label': ['lab1', '-lab2'],
+        'custom_123': ['field1123a', 'field1123b'],
+        })
+    errors = template_helpers.EZTError()
+    parsed = tracker_helpers.ParseIssueRequest(
+        'fake cnxn', post_data, self.services, errors, 'proj')
+    self.assertEqual('some summary', parsed.summary)
+    self.assertEqual('some comment', parsed.comment)
+    self.assertEqual('SomeStatus', parsed.status)
+    self.assertEqual('', parsed.users.owner_username)
+    self.assertEqual(0, parsed.users.owner_id)
+    self.assertEqual([], parsed.users.cc_usernames)
+    self.assertEqual([], parsed.users.cc_usernames_remove)
+    self.assertEqual([], parsed.users.cc_ids)
+    self.assertEqual([], parsed.users.cc_ids_remove)
+    self.assertEqual('some template', parsed.template_name)
+    self.assertEqual(['lab1'], parsed.labels)
+    self.assertEqual(['lab2'], parsed.labels_remove)
+    self.assertEqual({123: ['field1123a', 'field1123b']}, parsed.fields.vals)
+    self.assertEqual({}, parsed.fields.vals_remove)
+    self.assertEqual([], parsed.fields.fields_clear)
+
+  def testMarkupDescriptionOnInput(self):
+    content = 'What?\nthat\nWhy?\nidk\nWhere?\n'
+    tmpl_txt = 'What?\nWhy?\nWhere?\nWhen?'
+    desc = '<b>What?</b>\nthat\n<b>Why?</b>\nidk\n<b>Where?</b>\n'
+    self.assertEqual(tracker_helpers.MarkupDescriptionOnInput(
+        content, tmpl_txt), desc)
+
+  def testMarkupDescriptionLineOnInput(self):
+    line = 'What happened??'
+    tmpl_lines = ['What happened??','Why?']
+    self.assertEqual(tracker_helpers._MarkupDescriptionLineOnInput(
+        line, tmpl_lines), '<b>What happened??</b>')
+
+    line = 'Something terrible!!!'
+    self.assertEqual(tracker_helpers._MarkupDescriptionLineOnInput(
+        line, tmpl_lines), 'Something terrible!!!')
+
+  def testClassifyPlusMinusItems(self):
+    add, remove = tracker_helpers._ClassifyPlusMinusItems([])
+    self.assertEqual([], add)
+    self.assertEqual([], remove)
+
+    add, remove = tracker_helpers._ClassifyPlusMinusItems(
+        ['', ' ', '  \t', '-'])
+    self.assertItemsEqual([], add)
+    self.assertItemsEqual([], remove)
+
+    add, remove = tracker_helpers._ClassifyPlusMinusItems(
+        ['a', 'b', 'c'])
+    self.assertItemsEqual(['a', 'b', 'c'], add)
+    self.assertItemsEqual([], remove)
+
+    add, remove = tracker_helpers._ClassifyPlusMinusItems(
+        ['a-a-a', 'b-b', 'c-'])
+    self.assertItemsEqual(['a-a-a', 'b-b', 'c-'], add)
+    self.assertItemsEqual([], remove)
+
+    add, remove = tracker_helpers._ClassifyPlusMinusItems(
+        ['-a'])
+    self.assertItemsEqual([], add)
+    self.assertItemsEqual(['a'], remove)
+
+    add, remove = tracker_helpers._ClassifyPlusMinusItems(
+        ['-a', 'b', 'c-c'])
+    self.assertItemsEqual(['b', 'c-c'], add)
+    self.assertItemsEqual(['a'], remove)
+
+    add, remove = tracker_helpers._ClassifyPlusMinusItems(
+        ['-a', '-b-b', '-c-'])
+    self.assertItemsEqual([], add)
+    self.assertItemsEqual(['a', 'b-b', 'c-'], remove)
+
+    # We dedup, but we don't cancel out items that are both added and removed.
+    add, remove = tracker_helpers._ClassifyPlusMinusItems(
+        ['a', 'a', '-a'])
+    self.assertItemsEqual(['a'], add)
+    self.assertItemsEqual(['a'], remove)
+
+  def testParseIssueRequestFields(self):
+    parsed_fields = tracker_helpers._ParseIssueRequestFields(fake.PostData({
+        'custom_1': ['https://hello.com'],
+        'custom_12': ['https://blah.com'],
+        'custom_14': ['https://remove.com'],
+        'custom_15_goats': ['2', '3'],
+        'custom_15_sheep': ['3', '5'],
+        'custom_16_sheep': ['yarn'],
+        'op_custom_14': ['remove'],
+        'op_custom_12': ['clear'],
+        'op_custom_16_sheep': ['remove'],
+        'ignore': 'no matter',}))
+    self.assertEqual(
+        parsed_fields,
+        tracker_helpers.ParsedFields(
+            {
+                1: ['https://hello.com'],
+                12: ['https://blah.com']
+            }, {14: ['https://remove.com']}, [12],
+            {15: {
+                'goats': ['2', '3'],
+                'sheep': ['3', '5']
+            }}, {16: {
+                'sheep': ['yarn']
+            }}))
+
+  def testParseIssueRequestAttachments(self):
+    file1 = testing_helpers.Blank(
+        filename='hello.c',
+        value='hello world')
+
+    file2 = testing_helpers.Blank(
+        filename='README',
+        value='Welcome to our project')
+
+    file3 = testing_helpers.Blank(
+        filename='c:\\dir\\subdir\\FILENAME.EXT',
+        value='Abort, Retry, or Fail?')
+
+    # Browsers send this if FILE field was not filled in.
+    file4 = testing_helpers.Blank(
+        filename='',
+        value='')
+
+    attachments = tracker_helpers._ParseIssueRequestAttachments({})
+    self.assertEqual([], attachments)
+
+    attachments = tracker_helpers._ParseIssueRequestAttachments(fake.PostData({
+        'file1': [file1],
+        }))
+    self.assertEqual(
+        [('hello.c', 'hello world', 'text/plain')],
+        attachments)
+
+    attachments = tracker_helpers._ParseIssueRequestAttachments(fake.PostData({
+        'file1': [file1],
+        'file2': [file2],
+        }))
+    self.assertEqual(
+        [('hello.c', 'hello world', 'text/plain'),
+         ('README', 'Welcome to our project', 'text/plain')],
+        attachments)
+
+    attachments = tracker_helpers._ParseIssueRequestAttachments(fake.PostData({
+        'file3': [file3],
+        }))
+    self.assertEqual(
+        [('FILENAME.EXT', 'Abort, Retry, or Fail?',
+          'application/octet-stream')],
+        attachments)
+
+    attachments = tracker_helpers._ParseIssueRequestAttachments(fake.PostData({
+        'file1': [file4],  # Does not appear in result
+        'file3': [file3],
+        'file4': [file4],  # Does not appear in result
+        }))
+    self.assertEqual(
+        [('FILENAME.EXT', 'Abort, Retry, or Fail?',
+          'application/octet-stream')],
+        attachments)
+
+  def testParseIssueRequestKeptAttachments(self):
+    pass  # TODO(jrobbins): Write this test.
+
+  def testParseIssueRequestUsers(self):
+    post_data = {}
+    parsed_users = tracker_helpers._ParseIssueRequestUsers(
+        'fake connection', post_data, self.services)
+    self.assertEqual('', parsed_users.owner_username)
+    self.assertEqual(
+        framework_constants.NO_USER_SPECIFIED, parsed_users.owner_id)
+    self.assertEqual([], parsed_users.cc_usernames)
+    self.assertEqual([], parsed_users.cc_usernames_remove)
+    self.assertEqual([], parsed_users.cc_ids)
+    self.assertEqual([], parsed_users.cc_ids_remove)
+
+    post_data = fake.PostData({
+        'owner': [''],
+        })
+    parsed_users = tracker_helpers._ParseIssueRequestUsers(
+        'fake connection', post_data, self.services)
+    self.assertEqual('', parsed_users.owner_username)
+    self.assertEqual(
+        framework_constants.NO_USER_SPECIFIED, parsed_users.owner_id)
+    self.assertEqual([], parsed_users.cc_usernames)
+    self.assertEqual([], parsed_users.cc_usernames_remove)
+    self.assertEqual([], parsed_users.cc_ids)
+    self.assertEqual([], parsed_users.cc_ids_remove)
+
+    post_data = fake.PostData({
+        'owner': [' \t'],
+        })
+    parsed_users = tracker_helpers._ParseIssueRequestUsers(
+        'fake connection', post_data, self.services)
+    self.assertEqual('', parsed_users.owner_username)
+    self.assertEqual(
+        framework_constants.NO_USER_SPECIFIED, parsed_users.owner_id)
+    self.assertEqual([], parsed_users.cc_usernames)
+    self.assertEqual([], parsed_users.cc_usernames_remove)
+    self.assertEqual([], parsed_users.cc_ids)
+    self.assertEqual([], parsed_users.cc_ids_remove)
+
+    post_data = fake.PostData({
+        'owner': ['b@example.com'],
+        })
+    parsed_users = tracker_helpers._ParseIssueRequestUsers(
+        'fake connection', post_data, self.services)
+    self.assertEqual('b@example.com', parsed_users.owner_username)
+    self.assertEqual(TEST_ID_MAP['b@example.com'], parsed_users.owner_id)
+    self.assertEqual([], parsed_users.cc_usernames)
+    self.assertEqual([], parsed_users.cc_usernames_remove)
+    self.assertEqual([], parsed_users.cc_ids)
+    self.assertEqual([], parsed_users.cc_ids_remove)
+
+    post_data = fake.PostData({
+        'owner': ['b@example.com'],
+        })
+    parsed_users = tracker_helpers._ParseIssueRequestUsers(
+        'fake connection', post_data, self.services)
+    self.assertEqual('b@example.com', parsed_users.owner_username)
+    self.assertEqual(TEST_ID_MAP['b@example.com'], parsed_users.owner_id)
+    self.assertEqual([], parsed_users.cc_usernames)
+    self.assertEqual([], parsed_users.cc_usernames_remove)
+    self.assertEqual([], parsed_users.cc_ids)
+    self.assertEqual([], parsed_users.cc_ids_remove)
+
+    post_data = fake.PostData({
+        'cc': ['b@example.com'],
+        })
+    parsed_users = tracker_helpers._ParseIssueRequestUsers(
+        'fake connection', post_data, self.services)
+    self.assertEqual('', parsed_users.owner_username)
+    self.assertEqual(
+        framework_constants.NO_USER_SPECIFIED, parsed_users.owner_id)
+    self.assertEqual(['b@example.com'], parsed_users.cc_usernames)
+    self.assertEqual([], parsed_users.cc_usernames_remove)
+    self.assertEqual([TEST_ID_MAP['b@example.com']], parsed_users.cc_ids)
+    self.assertEqual([], parsed_users.cc_ids_remove)
+
+    post_data = fake.PostData({
+        'cc': ['-b@example.com, c@example.com,,'
+               'a@example.com,'],
+        })
+    parsed_users = tracker_helpers._ParseIssueRequestUsers(
+        'fake connection', post_data, self.services)
+    self.assertEqual('', parsed_users.owner_username)
+    self.assertEqual(
+        framework_constants.NO_USER_SPECIFIED, parsed_users.owner_id)
+    self.assertItemsEqual(['c@example.com', 'a@example.com'],
+                          parsed_users.cc_usernames)
+    self.assertEqual(['b@example.com'], parsed_users.cc_usernames_remove)
+    self.assertItemsEqual([TEST_ID_MAP['c@example.com'],
+                           TEST_ID_MAP['a@example.com']],
+                          parsed_users.cc_ids)
+    self.assertEqual([TEST_ID_MAP['b@example.com']],
+                      parsed_users.cc_ids_remove)
+
+    post_data = fake.PostData({
+        'owner': ['fuhqwhgads@example.com'],
+        'cc': ['c@example.com, fuhqwhgads@example.com'],
+        })
+    parsed_users = tracker_helpers._ParseIssueRequestUsers(
+        'fake connection', post_data, self.services)
+    self.assertEqual('fuhqwhgads@example.com', parsed_users.owner_username)
+    gen_uid = framework_helpers.MurmurHash3_x86_32(parsed_users.owner_username)
+    self.assertEqual(gen_uid, parsed_users.owner_id)  # autocreated user
+    self.assertItemsEqual(
+        ['c@example.com', 'fuhqwhgads@example.com'], parsed_users.cc_usernames)
+    self.assertEqual([], parsed_users.cc_usernames_remove)
+    self.assertItemsEqual(
+       [TEST_ID_MAP['c@example.com'], gen_uid], parsed_users.cc_ids)
+    self.assertEqual([], parsed_users.cc_ids_remove)
+
+    post_data = fake.PostData({
+        'cc': ['C@example.com, b@exAmple.cOm'],
+        })
+    parsed_users = tracker_helpers._ParseIssueRequestUsers(
+        'fake connection', post_data, self.services)
+    self.assertItemsEqual(
+        ['c@example.com', 'b@example.com'], parsed_users.cc_usernames)
+    self.assertEqual([], parsed_users.cc_usernames_remove)
+    self.assertItemsEqual(
+       [TEST_ID_MAP['c@example.com'], TEST_ID_MAP['b@example.com']],
+       parsed_users.cc_ids)
+    self.assertEqual([], parsed_users.cc_ids_remove)
+
+  def testParseBlockers_BlockedOnNothing(self):
+    """Was blocked on nothing, still nothing."""
+    post_data = {tracker_helpers.BLOCKED_ON: ''}
+    parsed_blockers = tracker_helpers._ParseBlockers(
+        self.cnxn, post_data, self.services, self.errors, 'testproj',
+        tracker_helpers.BLOCKED_ON)
+
+    self.assertEqual('', parsed_blockers.entered_str)
+    self.assertEqual([], parsed_blockers.iids)
+    self.assertIsNone(getattr(self.errors, tracker_helpers.BLOCKED_ON))
+    self.assertIsNone(getattr(self.errors, tracker_helpers.BLOCKING))
+
+  def testParseBlockers_BlockedOnAdded(self):
+    """Was blocked on nothing; now 1, 2, 3."""
+    post_data = {tracker_helpers.BLOCKED_ON: '1, 2, 3'}
+    parsed_blockers = tracker_helpers._ParseBlockers(
+        self.cnxn, post_data, self.services, self.errors, 'testproj',
+        tracker_helpers.BLOCKED_ON)
+
+    self.assertEqual('1, 2, 3', parsed_blockers.entered_str)
+    self.assertEqual([100001, 100002, 100003], parsed_blockers.iids)
+    self.assertIsNone(getattr(self.errors, tracker_helpers.BLOCKED_ON))
+    self.assertIsNone(getattr(self.errors, tracker_helpers.BLOCKING))
+
+  def testParseBlockers_BlockedOnDuplicateRef(self):
+    """Was blocked on nothing; now just 2, but repeated in input."""
+    post_data = {tracker_helpers.BLOCKED_ON: '2, 2, 2'}
+    parsed_blockers = tracker_helpers._ParseBlockers(
+        self.cnxn, post_data, self.services, self.errors, 'testproj',
+        tracker_helpers.BLOCKED_ON)
+
+    self.assertEqual('2, 2, 2', parsed_blockers.entered_str)
+    self.assertEqual([100002], parsed_blockers.iids)
+    self.assertIsNone(getattr(self.errors, tracker_helpers.BLOCKED_ON))
+    self.assertIsNone(getattr(self.errors, tracker_helpers.BLOCKING))
+
+  def testParseBlockers_Missing(self):
+    """Parsing an input field that was not in the POST."""
+    post_data = {}
+    parsed_blockers = tracker_helpers._ParseBlockers(
+        self.cnxn, post_data, self.services, self.errors, 'testproj',
+        tracker_helpers.BLOCKED_ON)
+
+    self.assertEqual('', parsed_blockers.entered_str)
+    self.assertEqual([], parsed_blockers.iids)
+    self.assertIsNone(getattr(self.errors, tracker_helpers.BLOCKED_ON))
+    self.assertIsNone(getattr(self.errors, tracker_helpers.BLOCKING))
+
+  def testParseBlockers_SameIssueNoProject(self):
+    """Adding same issue as blocker should modify the errors object."""
+    post_data = {'id': '2', tracker_helpers.BLOCKING: '2, 3'}
+
+    parsed_blockers = tracker_helpers._ParseBlockers(
+        self.cnxn, post_data, self.services, self.errors, 'testproj',
+        tracker_helpers.BLOCKING)
+    self.assertEqual('2, 3', parsed_blockers.entered_str)
+    self.assertEqual([], parsed_blockers.iids)
+    self.assertEqual(
+        getattr(self.errors, tracker_helpers.BLOCKING),
+        'Cannot be blocking the same issue')
+    self.assertIsNone(getattr(self.errors, tracker_helpers.BLOCKED_ON))
+
+  def testParseBlockers_SameIssueSameProject(self):
+    """Adding same issue as blocker should modify the errors object."""
+    post_data = {'id': '2', tracker_helpers.BLOCKING: 'testproj:2, 3'}
+
+    parsed_blockers = tracker_helpers._ParseBlockers(
+        self.cnxn, post_data, self.services, self.errors, 'testproj',
+        tracker_helpers.BLOCKING)
+    self.assertEqual('testproj:2, 3', parsed_blockers.entered_str)
+    self.assertEqual([], parsed_blockers.iids)
+    self.assertEqual(
+        getattr(self.errors, tracker_helpers.BLOCKING),
+        'Cannot be blocking the same issue')
+    self.assertIsNone(getattr(self.errors, tracker_helpers.BLOCKED_ON))
+
+  def testParseBlockers_SameIssueDifferentProject(self):
+    """Adding different blocker issue should not modify the errors object."""
+    post_data = {'id': '2', tracker_helpers.BLOCKING: 'testproj:2'}
+
+    parsed_blockers = tracker_helpers._ParseBlockers(
+        self.cnxn, post_data, self.services, self.errors, 'testprojB',
+        tracker_helpers.BLOCKING)
+    self.assertEqual('testproj:2', parsed_blockers.entered_str)
+    self.assertEqual([100002], parsed_blockers.iids)
+    self.assertIsNone(getattr(self.errors, tracker_helpers.BLOCKING))
+    self.assertIsNone(getattr(self.errors, tracker_helpers.BLOCKED_ON))
+
+  def testParseBlockers_Invalid(self):
+    """Input fields with invalid values should modify the errors object."""
+    post_data = {tracker_helpers.BLOCKING: '2, foo',
+                 tracker_helpers.BLOCKED_ON: '3, bar'}
+
+    parsed_blockers = tracker_helpers._ParseBlockers(
+        self.cnxn, post_data, self.services, self.errors, 'testproj',
+        tracker_helpers.BLOCKING)
+    self.assertEqual('2, foo', parsed_blockers.entered_str)
+    self.assertEqual([100002], parsed_blockers.iids)
+    self.assertEqual(
+        getattr(self.errors, tracker_helpers.BLOCKING), 'Invalid issue ID foo')
+    self.assertIsNone(getattr(self.errors, tracker_helpers.BLOCKED_ON))
+
+    parsed_blockers = tracker_helpers._ParseBlockers(
+        self.cnxn, post_data, self.services, self.errors, 'testproj',
+        tracker_helpers.BLOCKED_ON)
+    self.assertEqual('3, bar', parsed_blockers.entered_str)
+    self.assertEqual([100003], parsed_blockers.iids)
+    self.assertEqual(
+        getattr(self.errors, tracker_helpers.BLOCKED_ON),
+        'Invalid issue ID bar')
+
+  def testParseBlockers_Dangling(self):
+    """A ref to a sanctioned projected should be allowed."""
+    post_data = {'id': '2', tracker_helpers.BLOCKING: 'otherproj:2'}
+    real_codesite_projects = settings.recognized_codesite_projects
+    settings.recognized_codesite_projects = ['otherproj']
+    parsed_blockers = tracker_helpers._ParseBlockers(
+        self.cnxn, post_data, self.services, self.errors, 'testproj',
+        tracker_helpers.BLOCKING)
+    self.assertEqual('otherproj:2', parsed_blockers.entered_str)
+    self.assertEqual([('otherproj', 2)], parsed_blockers.dangling_refs)
+    settings.recognized_codesite_projects = real_codesite_projects
+
+  def testParseBlockers_FederatedReferences(self):
+    """Should parse and return FedRefs."""
+    post_data = {'id': '9', tracker_helpers.BLOCKING: '2, b/123, 3, b/789'}
+    parsed_blockers = tracker_helpers._ParseBlockers(
+        self.cnxn, post_data, self.services, self.errors, 'testproj',
+        tracker_helpers.BLOCKING)
+    self.assertEqual('2, b/123, 3, b/789', parsed_blockers.entered_str)
+    self.assertEqual([100002, 100003], parsed_blockers.iids)
+    self.assertEqual(['b/123', 'b/789'], parsed_blockers.federated_ref_strings)
+
+  def testIsValidIssueOwner(self):
+    project = project_pb2.Project()
+    project.owner_ids.extend([1, 2])
+    project.committer_ids.extend([3])
+    project.contributor_ids.extend([4, 999])
+
+    valid, _ = tracker_helpers.IsValidIssueOwner(
+        'fake cnxn', project, framework_constants.NO_USER_SPECIFIED,
+        self.services)
+    self.assertTrue(valid)
+
+    valid, _ = tracker_helpers.IsValidIssueOwner(
+        'fake cnxn', project, 1,
+        self.services)
+    self.assertTrue(valid)
+    valid, _ = tracker_helpers.IsValidIssueOwner(
+        'fake cnxn', project, 2,
+        self.services)
+    self.assertTrue(valid)
+    valid, _ = tracker_helpers.IsValidIssueOwner(
+        'fake cnxn', project, 3,
+        self.services)
+    self.assertTrue(valid)
+    valid, _ = tracker_helpers.IsValidIssueOwner(
+        'fake cnxn', project, 4,
+        self.services)
+    self.assertTrue(valid)
+
+    valid, _ = tracker_helpers.IsValidIssueOwner(
+        'fake cnxn', project, 7,
+        self.services)
+    self.assertFalse(valid)
+
+    valid, _ = tracker_helpers.IsValidIssueOwner(
+        'fake cnxn', project, 999,
+        self.services)
+    self.assertFalse(valid)
+
+  # MakeViewsForUsersInIssuesTest is tested in MakeViewsForUsersInIssuesTest.
+
+  def testGetAllowedOpenedAndClosedIssues(self):
+    pass  # TOOD(jrobbins): Write this test.
+
+  def testFormatIssueListURL_JumpedToIssue(self):
+    """If we jumped to issue 123, the list is can=1&q=id-123."""
+    config = tracker_pb2.ProjectIssueConfig()
+    path = '/p/proj/issues/detail?id=123&q=123'
+    mr = testing_helpers.MakeMonorailRequest(
+        path=path, headers={'Host': 'code.google.com'})
+    mr.ComputeColSpec(config)
+
+    absolute_base_url = 'http://code.google.com'
+
+    url_1 = tracker_helpers.FormatIssueListURL(mr, config)
+    self.assertEqual(
+        '%s/p/proj/issues/list?can=1&%s&q=id%%3D123' % (
+            absolute_base_url, self.default_colspec_param),
+        url_1)
+
+  def testFormatIssueListURL_NoCurrentState(self):
+    config = tracker_pb2.ProjectIssueConfig()
+    path = '/p/proj/issues/detail?id=123'
+    mr = testing_helpers.MakeMonorailRequest(
+        path=path, headers={'Host': 'code.google.com'})
+    mr.ComputeColSpec(config)
+
+    absolute_base_url = 'http://code.google.com'
+
+    url_1 = tracker_helpers.FormatIssueListURL(mr, config)
+    self.assertEqual(
+        '%s/p/proj/issues/list?%s&q=' % (
+            absolute_base_url, self.default_colspec_param),
+        url_1)
+
+    url_2 = tracker_helpers.FormatIssueListURL(
+        mr, config, foo=123)
+    self.assertEqual(
+        '%s/p/proj/issues/list?%s&foo=123&q=' % (
+            absolute_base_url, self.default_colspec_param),
+        url_2)
+
+    url_3 = tracker_helpers.FormatIssueListURL(
+        mr, config, foo=123, bar='abc')
+    self.assertEqual(
+        '%s/p/proj/issues/list?bar=abc&%s&foo=123&q=' % (
+            absolute_base_url, self.default_colspec_param),
+        url_3)
+
+    url_4 = tracker_helpers.FormatIssueListURL(
+        mr, config, baz='escaped+encoded&and100% "safe"')
+    self.assertEqual(
+        '%s/p/proj/issues/list?'
+        'baz=escaped%%2Bencoded%%26and100%%25%%20%%22safe%%22&%s&q=' % (
+            absolute_base_url, self.default_colspec_param),
+        url_4)
+
+  def testFormatIssueListURL_KeepCurrentState(self):
+    config = tracker_pb2.ProjectIssueConfig()
+    path = '/p/proj/issues/detail?id=123&sort=aa&colspec=a b c&groupby=d'
+    mr = testing_helpers.MakeMonorailRequest(
+        path=path, headers={'Host': 'localhost:8080'})
+    mr.ComputeColSpec(config)
+
+    absolute_base_url = 'http://localhost:8080'
+
+    url_1 = tracker_helpers.FormatIssueListURL(mr, config)
+    self.assertEqual(
+        '%s/p/proj/issues/list?colspec=a%%20b%%20c'
+        '&groupby=d&q=&sort=aa' % absolute_base_url,
+        url_1)
+
+    url_2 = tracker_helpers.FormatIssueListURL(
+        mr, config, foo=123)
+    self.assertEqual(
+        '%s/p/proj/issues/list?'
+        'colspec=a%%20b%%20c&foo=123&groupby=d&q=&sort=aa' % absolute_base_url,
+        url_2)
+
+    url_3 = tracker_helpers.FormatIssueListURL(
+        mr, config, colspec='X Y Z')
+    self.assertEqual(
+        '%s/p/proj/issues/list?colspec=a%%20b%%20c'
+        '&groupby=d&q=&sort=aa' % absolute_base_url,
+        url_3)
+
+  def testFormatRelativeIssueURL(self):
+    self.assertEqual(
+        '/p/proj/issues/attachment',
+        tracker_helpers.FormatRelativeIssueURL(
+            'proj', urls.ISSUE_ATTACHMENT))
+
+    self.assertEqual(
+        '/p/proj/issues/detail?id=123',
+        tracker_helpers.FormatRelativeIssueURL(
+            'proj', urls.ISSUE_DETAIL, id=123))
+
+  @mock.patch('google.appengine.api.app_identity.get_application_id')
+  def testFormatCrBugURL_Prod(self, mock_get_app_id):
+    mock_get_app_id.return_value = 'monorail-prod'
+    self.assertEqual(
+        'https://crbug.com/proj/123',
+        tracker_helpers.FormatCrBugURL('proj', 123))
+    self.assertEqual(
+        'https://crbug.com/123456',
+        tracker_helpers.FormatCrBugURL('chromium', 123456))
+
+  @mock.patch('google.appengine.api.app_identity.get_application_id')
+  def testFormatCrBugURL_NonProd(self, mock_get_app_id):
+    mock_get_app_id.return_value = 'monorail-staging'
+    self.assertEqual(
+        '/p/proj/issues/detail?id=123',
+        tracker_helpers.FormatCrBugURL('proj', 123))
+    self.assertEqual(
+        '/p/chromium/issues/detail?id=123456',
+        tracker_helpers.FormatCrBugURL('chromium', 123456))
+
+  @mock.patch('tracker.tracker_constants.ISSUE_ATTACHMENTS_QUOTA_HARD', 1)
+  def testComputeNewQuotaBytesUsed_ProjectQuota(self):
+    upload_1 = framework_helpers.AttachmentUpload(
+        'matter not', 'three men make a tiger', 'matter not')
+    upload_2 = framework_helpers.AttachmentUpload(
+        'matter not', 'chicken', 'matter not')
+    attachments = [upload_1, upload_2]
+
+    project = fake.Project()
+    project.attachment_bytes_used = 10
+    project.attachment_quota = project.attachment_bytes_used + len(
+        upload_1.contents + upload_2.contents) + 1
+
+    actual_new = tracker_helpers.ComputeNewQuotaBytesUsed(project, attachments)
+    expected_new = project.attachment_quota - 1
+    self.assertEqual(actual_new, expected_new)
+
+    upload_3 = framework_helpers.AttachmentUpload(
+        'matter not', 'donut', 'matter not')
+    attachments.append(upload_3)
+    with self.assertRaises(exceptions.OverAttachmentQuota):
+      tracker_helpers.ComputeNewQuotaBytesUsed(project, attachments)
+
+  @mock.patch(
+      'tracker.tracker_constants.ISSUE_ATTACHMENTS_QUOTA_HARD', len('tiger'))
+  def testComputeNewQuotaBytesUsed_GeneralQuota(self):
+    upload_1 = framework_helpers.AttachmentUpload(
+        'matter not', 'tiger', 'matter not')
+    attachments = [upload_1]
+
+    project = fake.Project()
+
+    actual_new = tracker_helpers.ComputeNewQuotaBytesUsed(project, attachments)
+    expected_new = len(upload_1.contents)
+    self.assertEqual(actual_new, expected_new)
+
+    upload_2 = framework_helpers.AttachmentUpload(
+        'matter not', 'donut', 'matter not')
+    attachments.append(upload_2)
+    with self.assertRaises(exceptions.OverAttachmentQuota):
+      tracker_helpers.ComputeNewQuotaBytesUsed(project, attachments)
+
+    upload_3 = framework_helpers.AttachmentUpload(
+        'matter not', 'donut', 'matter not')
+    attachments.append(upload_3)
+    with self.assertRaises(exceptions.OverAttachmentQuota):
+      tracker_helpers.ComputeNewQuotaBytesUsed(project, attachments)
+
+  def testIsUnderSoftAttachmentQuota(self):
+    pass  # TODO(jrobbins): Write this test.
+
+  # GetAllIssueProjects is tested in GetAllIssueProjectsTest.
+
+  def testGetPermissionsInAllProjects(self):
+    pass  # TODO(jrobbins): Write this test.
+
+  # FilterOutNonViewableIssues is tested in FilterOutNonViewableIssuesTest.
+
+  def testMeansOpenInProject(self):
+    config = _MakeConfig()
+
+    # ensure open means open
+    self.assertTrue(tracker_helpers.MeansOpenInProject('New', config))
+    self.assertTrue(tracker_helpers.MeansOpenInProject('new', config))
+
+    # ensure an unrecognized status means open
+    self.assertTrue(tracker_helpers.MeansOpenInProject(
+        '_undefined_status_', config))
+
+    # ensure closed means closed
+    self.assertFalse(tracker_helpers.MeansOpenInProject('Old', config))
+    self.assertFalse(tracker_helpers.MeansOpenInProject('old', config))
+    self.assertFalse(tracker_helpers.MeansOpenInProject(
+        'StatusThatWeDontUseAnymore', config))
+
+  def testIsNoisy(self):
+    self.assertTrue(tracker_helpers.IsNoisy(778, 320))
+    self.assertFalse(tracker_helpers.IsNoisy(20, 500))
+    self.assertFalse(tracker_helpers.IsNoisy(500, 20))
+    self.assertFalse(tracker_helpers.IsNoisy(1, 1))
+
+  def testMergeCCsAndAddComment(self):
+    target_issue = fake.MakeTestIssue(
+        789, 10, 'Target issue', 'New', 111)
+    source_issue = fake.MakeTestIssue(
+        789, 100, 'Source issue', 'New', 222)
+    source_issue.cc_ids.append(111)
+    # Issue without owner
+    source_issue_2 = fake.MakeTestIssue(
+        789, 101, 'Source issue 2', 'New', 0)
+
+    self.services.issue.TestAddIssue(target_issue)
+    self.services.issue.TestAddIssue(source_issue)
+    self.services.issue.TestAddIssue(source_issue_2)
+
+    # We copy this list so that it isn't updated by the test framework
+    initial_issue_comments = (
+        self.services.issue.GetCommentsForIssue(
+            'fake cnxn', target_issue.issue_id)[:])
+    mr = testing_helpers.MakeMonorailRequest(user_info={'user_id': 111})
+
+    # Merging source into target should create a comment.
+    self.assertIsNotNone(
+        tracker_helpers.MergeCCsAndAddComment(
+            self.services, mr, source_issue, target_issue))
+    updated_issue_comments = self.services.issue.GetCommentsForIssue(
+        'fake cnxn', target_issue.issue_id)
+    for comment in initial_issue_comments:
+      self.assertIn(comment, updated_issue_comments)
+      self.assertEqual(
+          len(initial_issue_comments) + 1, len(updated_issue_comments))
+
+    # Merging source into target should add source's owner to target's CCs.
+    updated_target_issue = self.services.issue.GetIssueByLocalID(
+        'fake cnxn', 789, 10)
+    self.assertIn(111, updated_target_issue.cc_ids)
+    self.assertIn(222, updated_target_issue.cc_ids)
+
+    # Merging source 2 into target should make a comment, but not update CCs.
+    self.assertIsNotNone(
+        tracker_helpers.MergeCCsAndAddComment(
+            self.services, mr, source_issue_2, updated_target_issue))
+    updated_target_issue = self.services.issue.GetIssueByLocalID(
+        'fake cnxn', 789, 10)
+    self.assertNotIn(0, updated_target_issue.cc_ids)
+
+  def testMergeCCsAndAddComment_RestrictedSourceIssue(self):
+    target_issue = fake.MakeTestIssue(
+        789, 10, 'Target issue', 'New', 222)
+    target_issue_2 = fake.MakeTestIssue(
+        789, 11, 'Target issue 2', 'New', 222)
+    source_issue = fake.MakeTestIssue(
+        789, 100, 'Source issue', 'New', 111)
+    source_issue.cc_ids.append(111)
+    source_issue.labels.append('Restrict-View-Commit')
+    target_issue_2.labels.append('Restrict-View-Commit')
+
+    self.services.issue.TestAddIssue(source_issue)
+    self.services.issue.TestAddIssue(target_issue)
+    self.services.issue.TestAddIssue(target_issue_2)
+
+    # We copy this list so that it isn't updated by the test framework
+    initial_issue_comments = self.services.issue.GetCommentsForIssue(
+        'fake cnxn', target_issue.issue_id)[:]
+    mr = testing_helpers.MakeMonorailRequest(user_info={'user_id': 111})
+    self.assertIsNotNone(
+        tracker_helpers.MergeCCsAndAddComment(
+            self.services, mr, source_issue, target_issue))
+
+    # When the source is restricted, we update the target comments...
+    updated_issue_comments = self.services.issue.GetCommentsForIssue(
+        'fake cnxn', target_issue.issue_id)
+    for comment in initial_issue_comments:
+      self.assertIn(comment, updated_issue_comments)
+      self.assertEqual(
+          len(initial_issue_comments) + 1, len(updated_issue_comments))
+    # ...but not the target CCs...
+    updated_target_issue = self.services.issue.GetIssueByLocalID(
+        'fake cnxn', 789, 10)
+    self.assertNotIn(111, updated_target_issue.cc_ids)
+    # ...unless both issues have the same restrictions.
+    self.assertIsNotNone(
+        tracker_helpers.MergeCCsAndAddComment(
+            self.services, mr, source_issue, target_issue_2))
+    updated_target_issue_2 = self.services.issue.GetIssueByLocalID(
+        'fake cnxn', 789, 11)
+    self.assertIn(111, updated_target_issue_2.cc_ids)
+
+  def testMergeCCsAndAddCommentMultipleIssues(self):
+    pass  # TODO(jrobbins): Write this test.
+
+  def testGetAttachmentIfAllowed(self):
+    pass  # TODO(jrobbins): Write this test.
+
+  def testLabelsMaskedByFields(self):
+    pass  # TODO(jrobbins): Write this test.
+
+  def testLabelsNotMaskedByFields(self):
+    pass  # TODO(jrobbins): Write this test.
+
+  def testLookupComponentIDs(self):
+    pass  # TODO(jrobbins): Write this test.
+
+  def testParsePostDataUsers(self):
+    pd_users = 'a@example.com, b@example.com'
+
+    pd_users_ids, pd_users_str = tracker_helpers.ParsePostDataUsers(
+        self.cnxn, pd_users, self.services.user)
+
+    self.assertEqual([1, 2], sorted(pd_users_ids))
+    self.assertEqual('a@example.com, b@example.com', pd_users_str)
+
+  def testParsePostDataUsers_Empty(self):
+    pd_users = ''
+
+    pd_users_ids, pd_users_str = tracker_helpers.ParsePostDataUsers(
+        self.cnxn, pd_users, self.services.user)
+
+    self.assertEqual([], sorted(pd_users_ids))
+    self.assertEqual('', pd_users_str)
+
+  def testFilterIssueTypes(self):
+    pass  # TODO(jrobbins): Write this test.
+
+  # ParseMergeFields is tested in IssueMergeTest.
+  # AddIssueStarrers is tested in IssueMergeTest.testMergeIssueStars().
+  # IsMergeAllowed is tested in IssueMergeTest.
+
+  def testPairDerivedValuesWithRuleExplanations_Nothing(self):
+    """Test we return nothing for an issue with no derived values."""
+    proposed_issue = tracker_pb2.Issue()  # No derived values.
+    traces = {}
+    derived_users_by_id = {}
+    actual = tracker_helpers.PairDerivedValuesWithRuleExplanations(
+        proposed_issue, traces, derived_users_by_id)
+    (derived_labels_and_why, derived_owner_and_why,
+     derived_cc_and_why, warnings_and_why, errors_and_why) = actual
+    self.assertEqual([], derived_labels_and_why)
+    self.assertEqual([], derived_owner_and_why)
+    self.assertEqual([], derived_cc_and_why)
+    self.assertEqual([], warnings_and_why)
+    self.assertEqual([], errors_and_why)
+
+  def testPairDerivedValuesWithRuleExplanations_SomeValues(self):
+    """Test we return derived values and explanations for an issue."""
+    proposed_issue = tracker_pb2.Issue(
+        derived_owner_id=111, derived_cc_ids=[222, 333],
+        derived_labels=['aaa', 'zzz'],
+        derived_warnings=['Watch out'],
+        derived_errors=['Status Assigned requires an owner'])
+    traces = {
+        (tracker_pb2.FieldID.OWNER, 111): 'explain 1',
+        (tracker_pb2.FieldID.CC, 222): 'explain 2',
+        (tracker_pb2.FieldID.CC, 333): 'explain 3',
+        (tracker_pb2.FieldID.LABELS, 'aaa'): 'explain 4',
+        (tracker_pb2.FieldID.WARNING, 'Watch out'): 'explain 6',
+        (tracker_pb2.FieldID.ERROR,
+         'Status Assigned requires an owner'): 'explain 7',
+        # There can be extra traces that are not used.
+        (tracker_pb2.FieldID.LABELS, 'bbb'): 'explain 5',
+        # If there is no trace for some derived value, why is None.
+        }
+    derived_users_by_id = {
+      111: testing_helpers.Blank(display_name='one@example.com'),
+      222: testing_helpers.Blank(display_name='two@example.com'),
+      333: testing_helpers.Blank(display_name='three@example.com'),
+      }
+    actual = tracker_helpers.PairDerivedValuesWithRuleExplanations(
+        proposed_issue, traces, derived_users_by_id)
+    (derived_labels_and_why, derived_owner_and_why,
+     derived_cc_and_why, warnings_and_why, errors_and_why) = actual
+    self.assertEqual([
+        {'value': 'aaa', 'why': 'explain 4'},
+        {'value': 'zzz', 'why': None},
+        ], derived_labels_and_why)
+    self.assertEqual([
+        {'value': 'one@example.com', 'why': 'explain 1'},
+        ], derived_owner_and_why)
+    self.assertEqual([
+        {'value': 'two@example.com', 'why': 'explain 2'},
+        {'value': 'three@example.com', 'why': 'explain 3'},
+        ], derived_cc_and_why)
+    self.assertEqual([
+        {'value': 'Watch out', 'why': 'explain 6'},
+        ], warnings_and_why)
+    self.assertEqual([
+        {'value': 'Status Assigned requires an owner', 'why': 'explain 7'},
+        ], errors_and_why)
+
+
+class MakeViewsForUsersInIssuesTest(unittest.TestCase):
+
+  def setUp(self):
+    self.issue1 = _Issue('proj', 1)
+    self.issue1.owner_id = 1001
+    self.issue1.reporter_id = 1002
+
+    self.issue2 = _Issue('proj', 2)
+    self.issue2.owner_id = 2001
+    self.issue2.reporter_id = 2002
+    self.issue2.cc_ids.extend([1, 1001, 1002, 1003])
+
+    self.issue3 = _Issue('proj', 3)
+    self.issue3.owner_id = 1001
+    self.issue3.reporter_id = 3002
+
+    self.user = fake.UserService()
+    for user_id in [1, 1001, 1002, 1003, 2001, 2002, 3002]:
+      self.user.TestAddUser(
+          'test%d' % user_id, user_id, add_user=True)
+
+  def testMakeViewsForUsersInIssues(self):
+    issue_list = [self.issue1, self.issue2, self.issue3]
+    users_by_id = tracker_helpers.MakeViewsForUsersInIssues(
+        'fake cnxn', issue_list, self.user)
+    self.assertItemsEqual([0, 1, 1001, 1002, 1003, 2001, 2002, 3002],
+                          list(users_by_id.keys()))
+    for user_id in [1001, 1002, 1003, 2001]:
+      self.assertEqual(users_by_id[user_id].user_id, user_id)
+
+  def testMakeViewsForUsersInIssuesOmittingSome(self):
+    issue_list = [self.issue1, self.issue2, self.issue3]
+    users_by_id = tracker_helpers.MakeViewsForUsersInIssues(
+        'fake cnxn', issue_list, self.user, omit_ids=[1001, 1003])
+    self.assertItemsEqual([0, 1, 1002, 2001, 2002, 3002],
+        list(users_by_id.keys()))
+    for user_id in [1002, 2001, 2002, 3002]:
+      self.assertEqual(users_by_id[user_id].user_id, user_id)
+
+  def testMakeViewsForUsersInIssuesEmpty(self):
+    issue_list = []
+    users_by_id = tracker_helpers.MakeViewsForUsersInIssues(
+        'fake cnxn', issue_list, self.user)
+    self.assertItemsEqual([], list(users_by_id.keys()))
+
+
+class GetAllIssueProjectsTest(unittest.TestCase):
+  issue_x_1 = tracker_pb2.Issue()
+  issue_x_1.project_id = 789
+  issue_x_1.local_id = 1
+  issue_x_1.reporter_id = 1002
+
+  issue_x_2 = tracker_pb2.Issue()
+  issue_x_2.project_id = 789
+  issue_x_2.local_id = 2
+  issue_x_2.reporter_id = 2002
+
+  issue_y_1 = tracker_pb2.Issue()
+  issue_y_1.project_id = 678
+  issue_y_1.local_id = 1
+  issue_y_1.reporter_id = 2002
+
+  def setUp(self):
+    self.project_service = fake.ProjectService()
+    self.project_service.TestAddProject('proj-x', project_id=789)
+    self.project_service.TestAddProject('proj-y', project_id=678)
+    self.cnxn = 'fake connection'
+
+  def testGetAllIssueProjects_Empty(self):
+    self.assertEqual(
+        {}, tracker_helpers.GetAllIssueProjects(
+            self.cnxn, [], self.project_service))
+
+  def testGetAllIssueProjects_Normal(self):
+    self.assertEqual(
+        {789: self.project_service.GetProjectByName(self.cnxn, 'proj-x')},
+        tracker_helpers.GetAllIssueProjects(
+            self.cnxn, [self.issue_x_1, self.issue_x_2], self.project_service))
+    self.assertEqual(
+        {789: self.project_service.GetProjectByName(self.cnxn, 'proj-x'),
+         678: self.project_service.GetProjectByName(self.cnxn, 'proj-y')},
+        tracker_helpers.GetAllIssueProjects(
+            self.cnxn, [self.issue_x_1, self.issue_x_2, self.issue_y_1],
+            self.project_service))
+
+
+class FilterOutNonViewableIssuesTest(unittest.TestCase):
+  owner_id = 111
+  committer_id = 222
+  nonmember_1_id = 1002
+  nonmember_2_id = 2002
+  nonmember_3_id = 3002
+
+  issue1 = tracker_pb2.Issue()
+  issue1.project_name = 'proj'
+  issue1.project_id = 789
+  issue1.local_id = 1
+  issue1.reporter_id = nonmember_1_id
+
+  issue2 = tracker_pb2.Issue()
+  issue2.project_name = 'proj'
+  issue2.project_id = 789
+  issue2.local_id = 2
+  issue2.reporter_id = nonmember_2_id
+  issue2.labels.extend(['foo', 'bar'])
+
+  issue3 = tracker_pb2.Issue()
+  issue3.project_name = 'proj'
+  issue3.project_id = 789
+  issue3.local_id = 3
+  issue3.reporter_id = nonmember_3_id
+  issue3.labels.extend(['restrict-view-commit'])
+
+  issue4 = tracker_pb2.Issue()
+  issue4.project_name = 'proj'
+  issue4.project_id = 789
+  issue4.local_id = 4
+  issue4.reporter_id = nonmember_3_id
+  issue4.labels.extend(['Foo', 'Restrict-View-Commit'])
+
+  def setUp(self):
+    self.user = user_pb2.User()
+    self.project = self.MakeProject(project_pb2.ProjectState.LIVE)
+    self.config = tracker_bizobj.MakeDefaultProjectIssueConfig(
+        self.project.project_id)
+    self.project_dict = {self.project.project_id: self.project}
+    self.config_dict = {self.config.project_id: self.config}
+
+  def MakeProject(self, state):
+    p = project_pb2.Project(
+        project_id=789, project_name='proj', state=state,
+        owner_ids=[self.owner_id], committer_ids=[self.committer_id])
+    return p
+
+  def testFilterOutNonViewableIssues_Member(self):
+    # perms will be permissions.COMMITTER_ACTIVE_PERMISSIONSET
+    filtered_issues = tracker_helpers.FilterOutNonViewableIssues(
+        {self.committer_id}, self.user, self.project_dict,
+        self.config_dict,
+        [self.issue1, self.issue2, self.issue3, self.issue4])
+    self.assertListEqual([1, 2, 3, 4],
+                         [issue.local_id for issue in filtered_issues])
+
+  def testFilterOutNonViewableIssues_Owner(self):
+    # perms will be permissions.OWNER_ACTIVE_PERMISSIONSET
+    filtered_issues = tracker_helpers.FilterOutNonViewableIssues(
+        {self.owner_id}, self.user, self.project_dict, self.config_dict,
+        [self.issue1, self.issue2, self.issue3, self.issue4])
+    self.assertListEqual([1, 2, 3, 4],
+                         [issue.local_id for issue in filtered_issues])
+
+  def testFilterOutNonViewableIssues_Empty(self):
+    # perms will be permissions.COMMITTER_ACTIVE_PERMISSIONSET
+    filtered_issues = tracker_helpers.FilterOutNonViewableIssues(
+        {self.committer_id}, self.user, self.project_dict,
+        self.config_dict, [])
+    self.assertListEqual([], filtered_issues)
+
+  def testFilterOutNonViewableIssues_NonMember(self):
+    # perms will be permissions.READ_ONLY_PERMISSIONSET
+    filtered_issues = tracker_helpers.FilterOutNonViewableIssues(
+        {self.nonmember_1_id}, self.user, self.project_dict,
+        self.config_dict, [self.issue1, self.issue2, self.issue3, self.issue4])
+    self.assertListEqual([1, 2],
+                         [issue.local_id for issue in filtered_issues])
+
+  def testFilterOutNonViewableIssues_Reporter(self):
+    # perms will be permissions.READ_ONLY_PERMISSIONSET
+    filtered_issues = tracker_helpers.FilterOutNonViewableIssues(
+        {self.nonmember_3_id}, self.user, self.project_dict,
+        self.config_dict, [self.issue1, self.issue2, self.issue3, self.issue4])
+    self.assertListEqual([1, 2, 3, 4],
+                         [issue.local_id for issue in filtered_issues])
+
+
+class IssueMergeTest(unittest.TestCase):
+
+  def setUp(self):
+    self.cnxn = 'fake cnxn'
+    self.services = service_manager.Services(
+        config=fake.ConfigService(),
+        issue=fake.IssueService(),
+        user=fake.UserService(),
+        project=fake.ProjectService(),
+        issue_star=fake.IssueStarService(),
+        spam=fake.SpamService()
+    )
+    self.project = self.services.project.TestAddProject('proj', project_id=987)
+    self.config = tracker_bizobj.MakeDefaultProjectIssueConfig(
+        self.project.project_id)
+    self.project_dict = {self.project.project_id: self.project}
+    self.config_dict = {self.config.project_id: self.config}
+
+  def testParseMergeFields_NotSpecified(self):
+    issue = fake.MakeTestIssue(987, 1, 'summary', 'New', 111)
+    errors = template_helpers.EZTError()
+    post_data = {}
+
+    text, merge_into_issue = tracker_helpers.ParseMergeFields(
+        self.cnxn, None, 'proj', post_data, 'New', self.config, issue, errors)
+    self.assertEqual('', text)
+    self.assertEqual(None, merge_into_issue)
+
+    text, merge_into_issue = tracker_helpers.ParseMergeFields(
+        self.cnxn, None, 'proj', post_data, 'Duplicate', self.config, issue,
+        errors)
+    self.assertEqual('', text)
+    self.assertTrue(errors.merge_into_id)
+    self.assertEqual(None, merge_into_issue)
+
+  def testParseMergeFields_WrongStatus(self):
+    issue = fake.MakeTestIssue(987, 1, 'summary', 'New', 111)
+    errors = template_helpers.EZTError()
+    post_data = {'merge_into': '12'}
+
+    text, merge_into_issue = tracker_helpers.ParseMergeFields(
+        self.cnxn, None, 'proj', post_data, 'New', self.config, issue, errors)
+    self.assertEqual('', text)
+    self.assertEqual(None, merge_into_issue)
+
+  def testParseMergeFields_NoSuchIssue(self):
+    issue = fake.MakeTestIssue(987, 1, 'summary', 'New', 111)
+    issue.merged_into = 12
+    errors = template_helpers.EZTError()
+    post_data = {'merge_into': '12'}
+
+    text, merge_into_issue = tracker_helpers.ParseMergeFields(
+        self.cnxn, self.services, 'proj', post_data, 'Duplicate',
+        self.config, issue, errors)
+    self.assertEqual('12', text)
+    self.assertEqual(None, merge_into_issue)
+
+  def testParseMergeFields_DontSelfMerge(self):
+    issue = fake.MakeTestIssue(987, 1, 'summary', 'New', 111)
+    errors = template_helpers.EZTError()
+    post_data = {'merge_into': '1'}
+
+    text, merge_into_issue = tracker_helpers.ParseMergeFields(
+        self.cnxn, self.services, 'proj', post_data, 'Duplicate', self.config,
+        issue, errors)
+    self.assertEqual('1', text)
+    self.assertEqual(None, merge_into_issue)
+    self.assertEqual('Cannot merge issue into itself', errors.merge_into_id)
+
+  def testParseMergeFields_NewIssueToMerge(self):
+    merged_issue = fake.MakeTestIssue(
+        self.project.project_id,
+        1,
+        'unused_summary',
+        'unused_status',
+        111,
+        reporter_id=111)
+    self.services.issue.TestAddIssue(merged_issue)
+    mergee_issue = fake.MakeTestIssue(
+        self.project.project_id,
+        2,
+        'unused_summary',
+        'unused_status',
+        111,
+        reporter_id=111)
+    self.services.issue.TestAddIssue(mergee_issue)
+
+    errors = template_helpers.EZTError()
+    post_data = {'merge_into': str(mergee_issue.local_id)}
+
+    text, merge_into_issue = tracker_helpers.ParseMergeFields(
+        self.cnxn, self.services, 'proj', post_data, 'Duplicate', self.config,
+        merged_issue, errors)
+    self.assertEqual(str(mergee_issue.local_id), text)
+    self.assertEqual(mergee_issue, merge_into_issue)
+
+  def testIsMergeAllowed(self):
+    mr = testing_helpers.MakeMonorailRequest()
+    issue = fake.MakeTestIssue(987, 1, 'summary', 'New', 111)
+    issue.project_name = self.project.project_name
+
+    for (perm_set, expected_merge_allowed) in (
+            (permissions.READ_ONLY_PERMISSIONSET, False),
+            (permissions.COMMITTER_INACTIVE_PERMISSIONSET, False),
+            (permissions.COMMITTER_ACTIVE_PERMISSIONSET, True),
+            (permissions.OWNER_ACTIVE_PERMISSIONSET, True)):
+      mr.perms = perm_set
+      merge_allowed = tracker_helpers.IsMergeAllowed(issue, mr, self.services)
+      self.assertEqual(expected_merge_allowed, merge_allowed)
+
+  def testMergeIssueStars(self):
+    mr = testing_helpers.MakeMonorailRequest()
+    mr.project_name = self.project.project_name
+    mr.project = self.project
+
+    config = self.services.config.GetProjectConfig(
+        self.cnxn, self.project.project_id)
+    self.services.issue_star.SetStar(
+        self.cnxn, self.services, config, 1, 1, True)
+    self.services.issue_star.SetStar(
+        self.cnxn, self.services, config, 1, 2, True)
+    self.services.issue_star.SetStar(
+        self.cnxn, self.services, config, 1, 3, True)
+    self.services.issue_star.SetStar(
+        self.cnxn, self.services, config, 3, 3, True)
+    self.services.issue_star.SetStar(
+        self.cnxn, self.services, config, 3, 6, True)
+    self.services.issue_star.SetStar(
+        self.cnxn, self.services, config, 2, 3, True)
+    self.services.issue_star.SetStar(
+        self.cnxn, self.services, config, 2, 4, True)
+    self.services.issue_star.SetStar(
+        self.cnxn, self.services, config, 2, 5, True)
+
+    new_starrers = tracker_helpers.GetNewIssueStarrers(
+        self.cnxn, self.services, [1, 3], 2)
+    self.assertItemsEqual(new_starrers, [1, 2, 6])
+    tracker_helpers.AddIssueStarrers(
+        self.cnxn, self.services, mr, 2, self.project, new_starrers)
+    issue_2_starrers = self.services.issue_star.LookupItemStarrers(
+        self.cnxn, 2)
+    # XXX(jrobbins): these tests incorrectly mix local IDs with IIDs.
+    self.assertItemsEqual([1, 2, 3, 4, 5, 6], issue_2_starrers)
+
+
+class MergeLinkedMembersTest(unittest.TestCase):
+
+  def setUp(self):
+    self.cnxn = 'fake cnxn'
+    self.services = service_manager.Services(
+        user=fake.UserService())
+    self.user1 = self.services.user.TestAddUser('one@example.com', 111)
+    self.user2 = self.services.user.TestAddUser('two@example.com', 222)
+
+  def testNoLinkedAccounts(self):
+    """When no candidate accounts are linked, they are all returned."""
+    actual = tracker_helpers._MergeLinkedMembers(
+        self.cnxn, self.services.user, [111, 222])
+    self.assertEqual([111, 222], actual)
+
+  def testSomeLinkedButNoMasking(self):
+    """If an account has linked accounts, but they are not here, keep it."""
+    self.user1.linked_child_ids = [999]
+    self.user2.linked_parent_id = 999
+    actual = tracker_helpers._MergeLinkedMembers(
+        self.cnxn, self.services.user, [111, 222])
+    self.assertEqual([111, 222], actual)
+
+  def testParentMasksChild(self):
+    """When two accounts linked, only the parent is returned."""
+    self.user2.linked_parent_id = 111
+    actual = tracker_helpers._MergeLinkedMembers(
+        self.cnxn, self.services.user, [111, 222])
+    self.assertEqual([111], actual)
+
+
+class FilterMemberDataTest(unittest.TestCase):
+
+  def setUp(self):
+    services = service_manager.Services(
+        project=fake.ProjectService(),
+        config=fake.ConfigService(),
+        issue=fake.IssueService(),
+        user=fake.UserService())
+    self.owner_email = 'owner@dom.com'
+    self.committer_email = 'commit@dom.com'
+    self.contributor_email = 'contrib@dom.com'
+    self.indirect_member_email = 'ind@dom.com'
+    self.all_emails = [self.owner_email, self.committer_email,
+                       self.contributor_email, self.indirect_member_email]
+    self.project = services.project.TestAddProject('proj')
+
+  def DoFiltering(self, perms, unsigned_user=False):
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project, perms=perms)
+    if not unsigned_user:
+      mr.auth.user_id = 111
+      mr.auth.user_view = testing_helpers.Blank(domain='jrobbins.org')
+    return tracker_helpers._FilterMemberData(
+        mr, [self.owner_email], [self.committer_email],
+        [self.contributor_email], [self.indirect_member_email], mr.project)
+
+  def testUnsignedUser_NormalProject(self):
+    visible_members = self.DoFiltering(
+        permissions.READ_ONLY_PERMISSIONSET, unsigned_user=True)
+    self.assertItemsEqual(
+        [self.owner_email, self.committer_email, self.contributor_email,
+         self.indirect_member_email],
+        visible_members)
+
+  def testUnsignedUser_RestrictedProject(self):
+    self.project.only_owners_see_contributors = True
+    visible_members = self.DoFiltering(
+        permissions.READ_ONLY_PERMISSIONSET, unsigned_user=True)
+    self.assertItemsEqual(
+        [self.owner_email, self.committer_email, self.indirect_member_email],
+        visible_members)
+
+  def testOwnersAndAdminsCanSeeAll_NormalProject(self):
+    visible_members = self.DoFiltering(
+        permissions.OWNER_ACTIVE_PERMISSIONSET)
+    self.assertItemsEqual(self.all_emails, visible_members)
+
+    visible_members = self.DoFiltering(
+        permissions.ADMIN_PERMISSIONSET)
+    self.assertItemsEqual(self.all_emails, visible_members)
+
+  def testOwnersAndAdminsCanSeeAll_HubAndSpoke(self):
+    self.project.only_owners_see_contributors = True
+
+    visible_members = self.DoFiltering(
+        permissions.OWNER_ACTIVE_PERMISSIONSET)
+    self.assertItemsEqual(self.all_emails, visible_members)
+
+    visible_members = self.DoFiltering(
+        permissions.ADMIN_PERMISSIONSET)
+    self.assertItemsEqual(self.all_emails, visible_members)
+
+    visible_members = self.DoFiltering(
+        permissions.COMMITTER_ACTIVE_PERMISSIONSET)
+    self.assertItemsEqual(self.all_emails, visible_members)
+
+  def testNonOwnersCanSeeAll_NormalProject(self):
+    visible_members = self.DoFiltering(
+        permissions.COMMITTER_ACTIVE_PERMISSIONSET)
+    self.assertItemsEqual(self.all_emails, visible_members)
+
+    visible_members = self.DoFiltering(
+        permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET)
+    self.assertItemsEqual(self.all_emails, visible_members)
+
+  def testCommittersSeeOnlySameDomain_HubAndSpoke(self):
+    self.project.only_owners_see_contributors = True
+
+    visible_members = self.DoFiltering(
+        permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET)
+    self.assertItemsEqual(
+        [self.owner_email, self.committer_email, self.indirect_member_email],
+        visible_members)
+
+
+class GetLabelOptionsTest(unittest.TestCase):
+
+  @mock.patch('tracker.tracker_helpers.LabelsNotMaskedByFields')
+  def testGetLabelOptions(self, mockLabelsNotMaskedByFields):
+    mockLabelsNotMaskedByFields.return_value = []
+    config = tracker_pb2.ProjectIssueConfig()
+    custom_perms = []
+    actual = tracker_helpers.GetLabelOptions(config, custom_perms)
+    expected = [
+      {'doc': 'Only users who can edit the issue may access it',
+       'name': 'Restrict-View-EditIssue'},
+      {'doc': 'Only users who can edit the issue may add comments',
+       'name': 'Restrict-AddIssueComment-EditIssue'},
+      {'doc': 'Custom permission CoreTeam is needed to access',
+       'name': 'Restrict-View-CoreTeam'}
+    ]
+    self.assertEqual(expected, actual)
+
+  def testBuildRestrictionChoices(self):
+    choices = tracker_helpers._BuildRestrictionChoices([], [], [])
+    self.assertEqual([], choices)
+
+    choices = tracker_helpers._BuildRestrictionChoices(
+        [], ['Hop', 'Jump'], [])
+    self.assertEqual([], choices)
+
+    freq = [('View', 'B', 'You need permission B to do anything'),
+            ('A', 'B', 'You need B to use A')]
+    choices = tracker_helpers._BuildRestrictionChoices(freq, [], [])
+    expected = [dict(name='Restrict-View-B',
+                     doc='You need permission B to do anything'),
+                dict(name='Restrict-A-B',
+                     doc='You need B to use A')]
+    self.assertListEqual(expected, choices)
+
+    extra_perms = ['Over18', 'Over21']
+    choices = tracker_helpers._BuildRestrictionChoices(
+        [], ['Drink', 'Smoke'], extra_perms)
+    expected = [dict(name='Restrict-Drink-Over18',
+                     doc='Permission Over18 needed to use Drink'),
+                dict(name='Restrict-Drink-Over21',
+                     doc='Permission Over21 needed to use Drink'),
+                dict(name='Restrict-Smoke-Over18',
+                     doc='Permission Over18 needed to use Smoke'),
+                dict(name='Restrict-Smoke-Over21',
+                     doc='Permission Over21 needed to use Smoke')]
+    self.assertListEqual(expected, choices)
+
+
+class FilterKeptAttachmentsTest(unittest.TestCase):
+  def testFilterKeptAttachments(self):
+    comments = [
+        tracker_pb2.IssueComment(
+            is_description=True,
+            attachments=[tracker_pb2.Attachment(attachment_id=1)]),
+        tracker_pb2.IssueComment(),
+        tracker_pb2.IssueComment(
+            is_description=True,
+            attachments=[
+                tracker_pb2.Attachment(attachment_id=2),
+                tracker_pb2.Attachment(attachment_id=3)]),
+        tracker_pb2.IssueComment(),
+        tracker_pb2.IssueComment(
+            approval_id=24,
+            is_description=True,
+            attachments=[tracker_pb2.Attachment(attachment_id=4)])]
+
+    filtered = tracker_helpers.FilterKeptAttachments(
+        True, [1, 2, 3, 4], comments, None)
+    self.assertEqual([2, 3], filtered)
+
+  def testApprovalDescription(self):
+    comments = [
+        tracker_pb2.IssueComment(
+            is_description=True,
+            attachments=[tracker_pb2.Attachment(attachment_id=1)]),
+        tracker_pb2.IssueComment(),
+        tracker_pb2.IssueComment(
+            is_description=True,
+            attachments=[
+                tracker_pb2.Attachment(attachment_id=2),
+                tracker_pb2.Attachment(attachment_id=3)]),
+        tracker_pb2.IssueComment(),
+        tracker_pb2.IssueComment(
+            approval_id=24,
+            is_description=True,
+            attachments=[tracker_pb2.Attachment(attachment_id=4)])]
+
+    filtered = tracker_helpers.FilterKeptAttachments(
+        True, [1, 2, 3, 4], comments, 24)
+    self.assertEqual([4], filtered)
+
+  def testNotAnIssueDescription(self):
+    comments = [
+        tracker_pb2.IssueComment(
+            is_description=True,
+            attachments=[tracker_pb2.Attachment(attachment_id=1)]),
+        tracker_pb2.IssueComment(),
+        tracker_pb2.IssueComment(
+            is_description=True,
+            attachments=[
+                tracker_pb2.Attachment(attachment_id=2),
+                tracker_pb2.Attachment(attachment_id=3)]),
+        tracker_pb2.IssueComment(),
+        tracker_pb2.IssueComment(
+            approval_id=24,
+            is_description=True,
+            attachments=[tracker_pb2.Attachment(attachment_id=4)])]
+
+    filtered = tracker_helpers.FilterKeptAttachments(
+        False, [1, 2, 3, 4], comments, None)
+    self.assertIsNone(filtered)
+
+  def testNoDescriptionsInComments(self):
+    comments = [
+        tracker_pb2.IssueComment(),
+        tracker_pb2.IssueComment()]
+
+    filtered = tracker_helpers.FilterKeptAttachments(
+        True, [1, 2, 3, 4], comments, None)
+    self.assertEqual([], filtered)
+
+  def testNoComments(self):
+    filtered = tracker_helpers.FilterKeptAttachments(
+        True, [1, 2, 3, 4], [], None)
+    self.assertEqual([], filtered)
+
+
+class EnumFieldHelpersTest(unittest.TestCase):
+
+  def test_GetEnumFieldValuesAndDocstrings(self):
+    """We can get all choices for an enum field"""
+    fd = tracker_pb2.FieldDef(
+        field_id=123,
+        project_id=1,
+        field_name='yellow',
+        field_type=tracker_pb2.FieldTypes.ENUM_TYPE)
+    ld_1 = tracker_pb2.LabelDef(
+        label='yellow-submarine', label_docstring='ld_1_docstring')
+    ld_2 = tracker_pb2.LabelDef(
+        label='yellow-tisket', label_docstring='ld_2_docstring')
+    ld_3 = tracker_pb2.LabelDef(
+        label='yellow-basket', label_docstring='ld_3_docstring')
+    ld_4 = tracker_pb2.LabelDef(
+        label='yellow', label_docstring='ld_4_docstring')
+    ld_5 = tracker_pb2.LabelDef(
+        label='not-yellow', label_docstring='ld_5_docstring')
+    ld_6 = tracker_pb2.LabelDef(
+        label='yellow-tasket',
+        label_docstring='ld_6_docstring',
+        deprecated=True)
+    config = tracker_pb2.ProjectIssueConfig(
+        default_template_for_developers=1,
+        default_template_for_users=2,
+        well_known_labels=[ld_1, ld_2, ld_3, ld_4, ld_5, ld_6])
+    actual = tracker_helpers._GetEnumFieldValuesAndDocstrings(fd, config)
+    # Expect to omit labels `yellow` and `not-yellow` due to prefix mismatch
+    # Also expect to omit label `yellow-tasket` because it's deprecated
+    expected = [
+        ('submarine', 'ld_1_docstring'), ('tisket', 'ld_2_docstring'),
+        ('basket', 'ld_3_docstring')
+    ]
+    self.assertEqual(expected, actual)
+
+
+class CreateIssueHelpersTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        project=fake.ProjectService(),
+        config=fake.ConfigService(),
+        issue=fake.IssueService(),
+        user=fake.UserService(),
+        usergroup=fake.UserGroupService())
+    self.cnxn = 'fake cnxn'
+
+    self.project_member = self.services.user.TestAddUser(
+        'user_1@example.com', 111)
+    self.project_group_member = self.services.user.TestAddUser(
+        'group@example.com', 999)
+    self.project = self.services.project.TestAddProject(
+        'proj',
+        project_id=789,
+        committer_ids=[
+            self.project_member.user_id, self.project_group_member.user_id
+        ])
+    self.no_project_user = self.services.user.TestAddUser(
+        'user_2@example.com', 222)
+    self.config = fake.MakeTestConfig(self.project.project_id, [], [])
+    self.int_fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'CPU', tracker_pb2.FieldTypes.INT_TYPE, None, '', False,
+        False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    self.int_fd.max_value = 999
+    self.config.field_defs = [self.int_fd]
+    self.status_1 = tracker_pb2.StatusDef(
+        status='New', means_open=True, status_docstring='status_1 docstring')
+    self.config.well_known_statuses = [self.status_1]
+    self.component_def_1 = tracker_pb2.ComponentDef(
+        component_id=1, path='compFOO')
+    self.component_def_2 = tracker_pb2.ComponentDef(
+        component_id=2, path='deprecated', deprecated=True)
+    self.config.component_defs = [self.component_def_1, self.component_def_2]
+    self.services.config.StoreConfig('cnxn', self.config)
+    self.services.usergroup.TestAddGroupSettings(999, 'group@example.com')
+
+  def testAssertValidIssueForCreate_Valid(self):
+    input_issue = tracker_pb2.Issue(
+        summary='sum',
+        status='New',
+        owner_id=111,
+        project_id=789,
+        component_ids=[1],
+        cc_ids=[999])
+    tracker_helpers.AssertValidIssueForCreate(
+        self.cnxn, self.services, input_issue, 'nonempty description')
+
+  def testAssertValidIssueForCreate_ValidatesOwner(self):
+    input_issue = tracker_pb2.Issue(
+        summary='sum', status='New', owner_id=222, project_id=789)
+    with self.assertRaisesRegexp(exceptions.InputException,
+                                 'Issue owner must be a project member'):
+      tracker_helpers.AssertValidIssueForCreate(
+          self.cnxn, self.services, input_issue, 'nonempty description')
+    input_issue.owner_id = 333
+    with self.assertRaisesRegexp(exceptions.InputException,
+                                 'Issue owner user ID not found'):
+      tracker_helpers.AssertValidIssueForCreate(
+          self.cnxn, self.services, input_issue, 'nonempty description')
+    input_issue.owner_id = 999
+    with self.assertRaisesRegexp(exceptions.InputException,
+                                 'Issue owner cannot be a user group'):
+      tracker_helpers.AssertValidIssueForCreate(
+          self.cnxn, self.services, input_issue, 'nonempty description')
+
+  def testAssertValidIssueForCreate_ValidatesSummary(self):
+    input_issue = tracker_pb2.Issue(
+        summary='', status='New', owner_id=111, project_id=789)
+    with self.assertRaisesRegexp(exceptions.InputException,
+                                 'Summary is required'):
+      tracker_helpers.AssertValidIssueForCreate(
+          self.cnxn, self.services, input_issue, 'nonempty description')
+      input_issue.summary = '   '
+      tracker_helpers.AssertValidIssueForCreate(
+          self.cnxn, self.services, input_issue, 'nonempty description')
+
+  def testAssertValidIssueForCreate_ValidatesDescription(self):
+    input_issue = tracker_pb2.Issue(
+        summary='sum', status='New', owner_id=111, project_id=789)
+    with self.assertRaisesRegexp(exceptions.InputException,
+                                 'Description is required'):
+      tracker_helpers.AssertValidIssueForCreate(
+          self.cnxn, self.services, input_issue, '')
+      tracker_helpers.AssertValidIssueForCreate(
+          self.cnxn, self.services, input_issue, '    ')
+
+  def testAssertValidIssueForCreate_ValidatesFieldDef(self):
+    fv = tracker_bizobj.MakeFieldValue(
+        self.int_fd.field_id, 1000, None, None, None, None, False)
+    input_issue = tracker_pb2.Issue(
+        summary='sum',
+        status='New',
+        owner_id=111,
+        project_id=789,
+        field_values=[fv])
+    with self.assertRaises(exceptions.InputException):
+      tracker_helpers.AssertValidIssueForCreate(
+          self.cnxn, self.services, input_issue, 'nonempty description')
+
+  def testAssertValidIssueForCreate_ValidatesStatus(self):
+    input_issue = tracker_pb2.Issue(
+        summary='sum', status='DNE_status', owner_id=111, project_id=789)
+
+    def mock_status_lookup(*_args, **_kwargs):
+      return None
+
+    self.services.config.LookupStatusID = mock_status_lookup
+    with self.assertRaisesRegexp(exceptions.InputException,
+                                 'Undefined status: DNE_status'):
+      tracker_helpers.AssertValidIssueForCreate(
+          self.cnxn, self.services, input_issue, 'nonempty description')
+
+  def testAssertValidIssueForCreate_ValidatesComponents(self):
+    # Tests an undefined component.
+    input_issue = tracker_pb2.Issue(
+        summary='',
+        status='New',
+        owner_id=111,
+        project_id=789,
+        component_ids=[3])
+    with self.assertRaisesRegexp(
+        exceptions.InputException,
+        'Undefined or deprecated component with id: 3'):
+      tracker_helpers.AssertValidIssueForCreate(
+          self.cnxn, self.services, input_issue, 'nonempty description')
+
+    # Tests a deprecated component.
+    input_issue = tracker_pb2.Issue(
+        summary='',
+        status='New',
+        owner_id=111,
+        project_id=789,
+        component_ids=[self.component_def_2.component_id])
+    with self.assertRaisesRegexp(
+        exceptions.InputException,
+        'Undefined or deprecated component with id: 2'):
+      tracker_helpers.AssertValidIssueForCreate(
+          self.cnxn, self.services, input_issue, 'nonempty description')
+
+  def testAssertValidIssueForCreate_ValidatesUsers(self):
+    user_fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'CPU', tracker_pb2.FieldTypes.INT_TYPE, None, '', False,
+        False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    self.services.config.TestAddFieldDef(user_fd)
+
+    input_issue = tracker_pb2.Issue(
+        summary='sum',
+        status='New',
+        owner_id=111,
+        project_id=789,
+        cc_ids=[123],
+        field_values=[
+            tracker_bizobj.MakeFieldValue(
+                user_fd.field_id, None, None, 124, None, None, False)
+        ])
+    copied_issue = copy.deepcopy(input_issue)
+    with self.assertRaisesRegexp(exceptions.InputException,
+                                 r'users/123: .+\nusers/124: .+'):
+      tracker_helpers.AssertValidIssueForCreate(
+          self.cnxn, self.services, input_issue, 'nonempty description')
+    self.assertEqual(input_issue, copied_issue)
+
+    self.services.user.TestAddUser('a@test.com', 123)
+    self.services.user.TestAddUser('a@test.com', 124)
+    tracker_helpers.AssertValidIssueForCreate(
+        self.cnxn, self.services, input_issue, 'nonempty description')
+    self.assertEqual(input_issue, copied_issue)
+
+
+class ModifyIssuesHelpersTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        project=fake.ProjectService(),
+        config=fake.ConfigService(),
+        issue=fake.IssueService(),
+        issue_star=fake.IssueStarService(),
+        user=fake.UserService(),
+        usergroup=fake.UserGroupService())
+    self.cnxn = 'fake cnxn'
+
+    self.project_member = self.services.user.TestAddUser(
+        'user_1@example.com', 111)
+    self.project = self.services.project.TestAddProject(
+        'proj', project_id=789, committer_ids=[self.project_member.user_id])
+    self.no_project_user = self.services.user.TestAddUser(
+        'user_2@example.com', 222)
+
+    self.config = fake.MakeTestConfig(self.project.project_id, [], [])
+    self.int_fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'CPU', tracker_pb2.FieldTypes.INT_TYPE, None, '', False,
+        False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    self.int_fd.max_value = 999
+    self.config.field_defs = [self.int_fd]
+    self.services.config.StoreConfig('cnxn', self.config)
+
+  def testApplyAllIssueChanges(self):
+    issue_delta_pairs = []
+    no_change_iid = 78942
+
+    expected_issues_to_update = {}
+    expected_amendments = {}
+    expected_imp_amendments = {}
+    expected_old_owners = {}
+    expected_old_statuses = {}
+    expected_old_components = {}
+    expected_merged_from_add = {}
+    expected_new_starrers = {}
+
+    issue_main = _Issue('proj', 100)
+    issue_main_ref = ('proj', issue_main.local_id)
+    issue_main.owner_id = 999
+    issue_main.cc_ids = [111, 222]
+    issue_main.labels = ['dont_touch', 'remove_me']
+
+    expected_main = copy.deepcopy(issue_main)
+    expected_main.owner_id = 888
+    expected_main.cc_ids = [111, 333]
+    expected_main.labels = ['dont_touch', 'add_me']
+    expected_amendments[issue_main.issue_id] = [
+        tracker_bizobj.MakeOwnerAmendment(888, 999),
+        tracker_bizobj.MakeCcAmendment([333], [222]),
+        tracker_bizobj.MakeLabelsAmendment(['add_me'], ['remove_me'])
+    ]
+    expected_old_owners[issue_main.issue_id] = 999
+
+    # blocked_on issues changes setup.
+    bo_add = _Issue('proj', 1)
+    self.services.issue.TestAddIssue(bo_add)
+    expected_bo_add = copy.deepcopy(bo_add)
+    # All impacted issues should be fetched within ApplyAllIssueChanges
+    # directly from the DB, skipping cache with `use_cache=False` in GetIssue().
+    # So we expect these issues to have assume_stale=False.
+    expected_bo_add.assume_stale = False
+    expected_bo_add.blocking_iids = [issue_main.issue_id]
+    expected_issues_to_update[expected_bo_add.issue_id] = expected_bo_add
+    expected_imp_amendments[bo_add.issue_id] = [
+        tracker_bizobj.MakeBlockingAmendment(
+            [issue_main_ref], [], default_project_name='proj')
+    ]
+
+    bo_remove = _Issue('proj', 2)
+    bo_remove.blocking_iids = [issue_main.issue_id]
+    self.services.issue.TestAddIssue(bo_remove)
+    expected_bo_remove = copy.deepcopy(bo_remove)
+    expected_bo_remove.assume_stale = False
+    expected_bo_remove.blocking_iids = []
+    expected_issues_to_update[expected_bo_remove.issue_id] = expected_bo_remove
+    expected_imp_amendments[bo_remove.issue_id] = [
+        tracker_bizobj.MakeBlockingAmendment(
+            [], [issue_main_ref], default_project_name='proj')
+    ]
+
+    issue_main.blocked_on_iids = [no_change_iid, bo_remove.issue_id]
+    # By default new blocked_on issues that appear in blocked_on_iids
+    # with no prior rank associated with it are un-ranked and assigned rank 0.
+    # See SortBlockedOn in issue_svc.py.
+    issue_main.blocked_on_ranks = [0, 0]
+    expected_main.blocked_on_iids = [no_change_iid, bo_add.issue_id]
+    expected_main.blocked_on_ranks = [0, 0]
+    expected_amendments[issue_main.issue_id].append(
+        tracker_bizobj.MakeBlockedOnAmendment(
+            [('proj', bo_add.local_id)], [('proj', bo_remove.local_id)],
+            default_project_name='proj'))
+
+    # blocking_issues changes setup.
+    b_add = _Issue('proj', 3)
+    self.services.issue.TestAddIssue(b_add)
+    expected_b_add = copy.deepcopy(b_add)
+    expected_b_add.assume_stale = False
+    expected_b_add.blocked_on_iids = [issue_main.issue_id]
+    expected_b_add.blocked_on_ranks = [0]
+    expected_issues_to_update[expected_b_add.issue_id] = expected_b_add
+    expected_imp_amendments[b_add.issue_id] = [
+        tracker_bizobj.MakeBlockedOnAmendment(
+            [issue_main_ref], [], default_project_name='proj')
+    ]
+
+    b_remove = _Issue('proj', 4)
+    b_remove.blocked_on_iids = [issue_main.issue_id]
+    self.services.issue.TestAddIssue(b_remove)
+    expected_b_remove = copy.deepcopy(b_remove)
+    expected_b_remove.assume_stale = False
+    expected_b_remove.blocked_on_iids = []
+    # Test we can process delta changes and impact changes.
+    delta_b_remove = tracker_pb2.IssueDelta(labels_add=['more_chickens'])
+    expected_b_remove.labels = ['more_chickens']
+    issue_delta_pairs.append((b_remove, delta_b_remove))
+    expected_issues_to_update[expected_b_remove.issue_id] = expected_b_remove
+    expected_imp_amendments[b_remove.issue_id] = [
+        tracker_bizobj.MakeBlockedOnAmendment(
+            [], [issue_main_ref], default_project_name='proj')
+    ]
+    expected_amendments[b_remove.issue_id] = [
+        tracker_bizobj.MakeLabelsAmendment(['more_chickens'], [])
+    ]
+
+    issue_main.blocking_iids = [no_change_iid, b_remove.issue_id]
+    expected_main.blocking_iids = [no_change_iid, b_add.issue_id]
+    expected_amendments[issue_main.issue_id].append(
+        tracker_bizobj.MakeBlockingAmendment(
+            [('proj', b_add.local_id)], [('proj', b_remove.local_id)],
+            default_project_name='proj'))
+
+    # Merged issues changes setup.
+    merge_remove = _Issue('proj', 5)
+    self.services.issue.TestAddIssue(merge_remove)
+    expected_merge_remove = copy.deepcopy(merge_remove)
+    expected_merge_remove.assume_stale = False
+    expected_issues_to_update[
+        expected_merge_remove.issue_id] = expected_merge_remove
+    expected_imp_amendments[merge_remove.issue_id] = [
+        tracker_bizobj.MakeMergedIntoAmendment(
+            [], [issue_main_ref], default_project_name='proj')
+    ]
+
+    merge_add = _Issue('proj', 6)
+    self.services.issue.TestAddIssue(merge_add)
+    expected_merge_add = copy.deepcopy(merge_add)
+    expected_merge_add.assume_stale = False
+    # We are adding 333 and removing 222 in issue_main with delta_main.
+    expected_merge_add.cc_ids = [expected_main.owner_id, 333, 111]
+    expected_merged_from_add[expected_merge_add.issue_id] = [
+        issue_main.issue_id
+    ]
+
+    expected_imp_amendments[merge_add.issue_id] = [
+        tracker_bizobj.MakeCcAmendment(expected_merge_add.cc_ids, []),
+        tracker_bizobj.MakeMergedIntoAmendment(
+            [issue_main_ref], [], default_project_name='proj')
+    ]
+    # We are merging issue_main into merge_add, so issue_main's starrers
+    # should be merged into merge_add's starrers.
+    self.services.issue_star.SetStar(
+        self.cnxn, self.services, None, issue_main.issue_id, 111, True)
+    self.services.issue_star.SetStar(
+        self.cnxn, self.services, None, issue_main.issue_id, 222, True)
+    expected_merge_add.star_count = 2
+    expected_new_starrers[merge_add.issue_id] = [222, 111]
+
+    expected_issues_to_update[expected_merge_add.issue_id] = expected_merge_add
+
+
+    issue_main.merged_into = merge_remove.issue_id
+    expected_main.merged_into = merge_add.issue_id
+    expected_amendments[issue_main.issue_id].append(
+        tracker_bizobj.MakeMergedIntoAmendment(
+            [('proj', merge_add.local_id)], [('proj', merge_remove.local_id)],
+            default_project_name='proj'))
+
+    self.services.issue.TestAddIssue(issue_main)
+    expected_issues_to_update[expected_main.issue_id] = expected_main
+
+
+    # Issues we'll put in delta_main.*_remove fields that aren't in issue_main.
+    # These issues should not show up in issues_to_update.
+    missing_1 = _Issue('proj', 404)
+    expected_missing_1 = copy.deepcopy(missing_1)
+    expected_missing_1.assume_stale = False
+    self.services.issue.TestAddIssue(missing_1)
+    missing_2 = _Issue('proj', 405)
+    self.services.issue.TestAddIssue(missing_2)
+    expected_missing_2 = copy.deepcopy(missing_2)
+    expected_missing_2.assume_stale = False
+
+    delta_main = tracker_pb2.IssueDelta(
+        owner_id=888,
+        cc_ids_remove=[222, 404], cc_ids_add=[333],
+        labels_remove=['remove_me', 'remove_404'], labels_add=['add_me'],
+        merged_into=merge_add.issue_id,
+        blocked_on_add=[bo_add.issue_id],
+        blocked_on_remove=[bo_remove.issue_id, missing_1.issue_id],
+        blocking_add=[b_add.issue_id],
+        blocking_remove=[b_remove.issue_id, missing_2.issue_id])
+    issue_delta_pairs.append((issue_main, delta_main))
+
+    actual_tuple = tracker_helpers.ApplyAllIssueChanges(
+        self.cnxn, issue_delta_pairs, self.services)
+
+    expected_tuple = tracker_helpers._IssueChangesTuple(
+        expected_issues_to_update, expected_merged_from_add,
+        expected_amendments, expected_imp_amendments, expected_old_owners,
+        expected_old_statuses, expected_old_components, expected_new_starrers)
+    self.assertEqual(actual_tuple, expected_tuple)
+
+    self.assertEqual(missing_1, expected_missing_1)
+    self.assertEqual(missing_2, expected_missing_2)
+
+  def testApplyAllIssueChanges_NOOP(self):
+    """Check we can ignore issue-delta pairs that are NOOP."""
+    noop_issue = _Issue('proj', 1)
+    bo_add_noop = _Issue('proj', 2)
+    bo_remove_noop = _Issue('proj', 3)
+
+    noop_issue.owner_id = 111
+    noop_issue.cc_ids = [222]
+    noop_issue.blocked_on_iids = [bo_add_noop.issue_id]
+    bo_add_noop.blocking_iids = [noop_issue.issue_id]
+
+    self.services.issue.TestAddIssue(noop_issue)
+    self.services.issue.TestAddIssue(bo_add_noop)
+    self.services.issue.TestAddIssue(bo_remove_noop)
+    expected_noop_issue = copy.deepcopy(noop_issue)
+    noop_delta = tracker_pb2.IssueDelta(
+        owner_id=noop_issue.owner_id,
+        cc_ids_add=noop_issue.cc_ids, cc_ids_remove=[333],
+        blocked_on_add=noop_issue.blocked_on_iids,
+        blocked_on_remove=[bo_remove_noop.issue_id])
+    issue_delta_pairs = [(noop_issue, noop_delta)]
+
+    actual_tuple = tracker_helpers.ApplyAllIssueChanges(
+        self.cnxn, issue_delta_pairs, self.services)
+    expected_tuple = tracker_helpers._IssueChangesTuple(
+        {}, {}, {}, {}, {}, {}, {}, {})
+    self.assertEqual(actual_tuple, expected_tuple)
+
+    self.assertEqual(noop_issue, expected_noop_issue)
+
+  def testApplyAllIssueChanges_Empty(self):
+    issue_delta_pairs = []
+    actual_tuple = tracker_helpers.ApplyAllIssueChanges(
+        self.cnxn, issue_delta_pairs, self.services)
+    expected_tuple = tracker_helpers._IssueChangesTuple(
+        {}, {}, {}, {}, {}, {}, {}, {})
+    self.assertEqual(actual_tuple, expected_tuple)
+
+  def testUpdateClosedTimestamp(self):
+    config = tracker_pb2.ProjectIssueConfig()
+    config.well_known_statuses.append(
+        tracker_pb2.StatusDef(status='New', means_open=True))
+    config.well_known_statuses.append(
+        tracker_pb2.StatusDef(status='Accepted', means_open=True))
+    config.well_known_statuses.append(
+        tracker_pb2.StatusDef(status='Old', means_open=False))
+    config.well_known_statuses.append(
+        tracker_pb2.StatusDef(status='Closed', means_open=False))
+
+    issue = tracker_pb2.Issue()
+    issue.local_id = 1234
+    issue.status = 'New'
+
+    # ensure the default value is undef
+    self.assertTrue(not issue.closed_timestamp)
+
+    # ensure transitioning to the same and other open states
+    # doesn't set the timestamp
+    issue.status = 'New'
+    tracker_helpers.UpdateClosedTimestamp(config, issue, 'New')
+    self.assertTrue(not issue.closed_timestamp)
+
+    issue.status = 'Accepted'
+    tracker_helpers.UpdateClosedTimestamp(config, issue, 'New')
+    self.assertTrue(not issue.closed_timestamp)
+
+    # ensure transitioning from open to closed sets the timestamp
+    issue.status = 'Closed'
+    tracker_helpers.UpdateClosedTimestamp(config, issue, 'Accepted')
+    self.assertTrue(issue.closed_timestamp)
+
+    # ensure that the timestamp is cleared when transitioning from
+    # closed to open
+    issue.status = 'New'
+    tracker_helpers.UpdateClosedTimestamp(config, issue, 'Closed')
+    self.assertTrue(not issue.closed_timestamp)
+
+  def testGroupUniqueDeltaIssues(self):
+    """We can identify unique IssueDeltas and group Issues by their deltas."""
+    issue_1 = _Issue('proj', 1)
+    delta_1 = tracker_pb2.IssueDelta(cc_ids_add=[111])
+
+    issue_2 = _Issue('proj', 2)
+    delta_2 = tracker_pb2.IssueDelta(cc_ids_add=[111], cc_ids_remove=[222])
+
+    issue_3 = _Issue('proj', 3)
+    delta_3 = tracker_pb2.IssueDelta(cc_ids_add=[111])
+
+    issue_4 = _Issue('proj', 4)
+    delta_4 = tracker_pb2.IssueDelta()
+
+    issue_5 = _Issue('proj', 5)
+    delta_5 = tracker_pb2.IssueDelta()
+
+    issue_delta_pairs = [
+        (issue_1, delta_1), (issue_2, delta_2), (issue_3, delta_3),
+        (issue_4, delta_4), (issue_5, delta_5)
+    ]
+    unique_deltas, issues_for_deltas = tracker_helpers.GroupUniqueDeltaIssues(
+        issue_delta_pairs)
+
+    expected_unique_deltas = [delta_1, delta_2, delta_4]
+    self.assertEqual(unique_deltas, expected_unique_deltas)
+    expected_issues_for_deltas = [
+        [issue_1, issue_3], [issue_2], [issue_4, issue_5]
+    ]
+    self.assertEqual(issues_for_deltas, expected_issues_for_deltas)
+
+  def testEnforceAttachmentQuotaLimits(self):
+    self.services.project.TestAddProject('Circe', project_id=798)
+    issue_a1 = _Issue('Circe', 1, project_id=798)
+    delta_a1 = tracker_pb2.IssueDelta()
+
+    issue_a2 = _Issue('Circe', 2, project_id=798)
+    delta_a2 = tracker_pb2.IssueDelta()
+
+    self.services.project.TestAddProject('Patroclus', project_id=788)
+    issue_b1 = _Issue('Patroclus', 1, project_id=788)
+    delta_b1 = tracker_pb2.IssueDelta()
+
+    issue_delta_pairs = [
+        (issue_a1, delta_a1), (issue_a2, delta_a2), (issue_b1, delta_b1)
+    ]
+
+    upload_1 = framework_helpers.AttachmentUpload(
+        'dragon', 'OOOOOO\n', 'text/plain')
+    upload_2 = framework_helpers.AttachmentUpload(
+        'snake', 'ooooo\n', 'text/plain')
+    attachment_uploads = [upload_1, upload_2]
+
+    actual = tracker_helpers._EnforceAttachmentQuotaLimits(
+        self.cnxn, issue_delta_pairs, self.services, attachment_uploads)
+
+    expected = {
+        798: len(upload_1.contents + upload_2.contents) * 2,
+        788: len(upload_1.contents + upload_2.contents)
+    }
+    self.assertEqual(actual, expected)
+
+  @mock.patch('tracker.tracker_constants.ISSUE_ATTACHMENTS_QUOTA_HARD', 1)
+  def testEnforceAttachmentQuotaLimits_Exceeded(self):
+    self.services.project.TestAddProject('Circe', project_id=798)
+    issue_a1 = _Issue('Circe', 1, project_id=798)
+    delta_a1 = tracker_pb2.IssueDelta()
+
+    issue_a2 = _Issue('Circe', 2, project_id=798)
+    delta_a2 = tracker_pb2.IssueDelta()
+
+    self.services.project.TestAddProject('Patroclus', project_id=788)
+    issue_b1 = _Issue('Patroclus', 1, project_id=788)
+    delta_b1 = tracker_pb2.IssueDelta()
+
+    issue_delta_pairs = [
+        (issue_a1, delta_a1), (issue_a2, delta_a2), (issue_b1, delta_b1)
+    ]
+
+    upload_1 = framework_helpers.AttachmentUpload(
+        'dragon', 'OOOOOO\n', 'text/plain')
+    upload_2 = framework_helpers.AttachmentUpload(
+        'snake', 'ooooo\n', 'text/plain')
+    attachment_uploads = [upload_1, upload_2]
+
+    with self.assertRaisesRegexp(exceptions.OverAttachmentQuota,
+                                 r'.+ project Patroclus\n.+ project Circe'):
+      tracker_helpers._EnforceAttachmentQuotaLimits(
+          self.cnxn, issue_delta_pairs, self.services, attachment_uploads)
+
+  def testAssertIssueChangesValid_Valid(self):
+    """We can assert when deltas are valid for issues."""
+    impacted_issue = _Issue('chicken', 101)
+    self.services.issue.TestAddIssue(impacted_issue)
+
+    issue_1 = _Issue('chicken', 1)
+    self.services.issue.TestAddIssue(issue_1)
+    delta_1 = tracker_pb2.IssueDelta(
+        merged_into=impacted_issue.issue_id, status='Duplicate')
+    exp_d1 = copy.deepcopy(delta_1)
+
+    issue_2 = _Issue('chicken', 2)
+    self.services.issue.TestAddIssue(issue_2)
+    delta_2 = tracker_pb2.IssueDelta(blocked_on_add=[impacted_issue.issue_id])
+    exp_d2 = copy.deepcopy(delta_2)
+
+    issue_3 = _Issue('chicken', 3)
+    self.services.issue.TestAddIssue(issue_3)
+    delta_3 = tracker_pb2.IssueDelta()
+    exp_d3 = copy.deepcopy(delta_3)
+
+    issue_4 = _Issue('chicken', 4)
+    self.services.issue.TestAddIssue(issue_4)
+    delta_4 = tracker_pb2.IssueDelta(owner_id=self.project_member.user_id)
+    exp_d4 = copy.deepcopy(delta_4)
+
+    issue_5 = _Issue('chicken', 5)
+    self.services.issue.TestAddIssue(issue_5)
+    fv = tracker_bizobj.MakeFieldValue(
+        self.int_fd.field_id, 998, None, None, None, None, False)
+    delta_5 = tracker_pb2.IssueDelta(field_vals_add=[fv])
+    exp_d5 = copy.deepcopy(delta_5)
+
+    issue_6 = _Issue('chicken', 6)
+    self.services.issue.TestAddIssue(issue_6)
+    delta_6 = tracker_pb2.IssueDelta(
+        summary='  ' + 's' * tracker_constants.MAX_SUMMARY_CHARS + '  ')
+    exp_d6 = copy.deepcopy(delta_6)
+
+    issue_7 = _Issue('chicken', 7)
+    self.services.issue.TestAddIssue(issue_7)
+    issue_8 = _Issue('chicken', 8)
+    self.services.issue.TestAddIssue(issue_8)
+
+    # We are fine with duplicate/consistent deltas.
+    delta_7 = tracker_pb2.IssueDelta(blocked_on_add=[issue_8.issue_id])
+    exp_d7 = copy.deepcopy(delta_7)
+    delta_8 = tracker_pb2.IssueDelta(blocking_add=[issue_7.issue_id])
+    exp_d8 = copy.deepcopy(delta_8)
+
+    issue_9 = _Issue('chicken', 9)
+    self.services.issue.TestAddIssue(issue_9)
+    issue_10 = _Issue('chicken', 10)
+    self.services.issue.TestAddIssue(issue_10)
+
+    delta_9 = tracker_pb2.IssueDelta(blocked_on_remove=[issue_10.issue_id])
+    exp_d9 = copy.deepcopy(delta_9)
+    delta_10 = tracker_pb2.IssueDelta(blocking_remove=[issue_9.issue_id])
+    exp_d10 = copy.deepcopy(delta_10)
+
+    issue_11 = _Issue('chicken', 11)
+    user_fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'CPU', tracker_pb2.FieldTypes.USER_TYPE, None, '', False,
+        False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    self.services.config.TestAddFieldDef(user_fd)
+    a_user = self.services.user.TestAddUser('a_user@test.com', 123)
+    delta_11 = tracker_pb2.IssueDelta(
+        cc_ids_add=[222],
+        field_vals_add=[
+            tracker_bizobj.MakeFieldValue(
+                user_fd.field_id, None, None, a_user.user_id, None, None, False)
+        ])
+    exp_d11 = copy.deepcopy(delta_11)
+
+    issue_delta_pairs = [
+        (issue_1, delta_1), (issue_2, delta_2), (issue_3, delta_3),
+        (issue_4, delta_4), (issue_5, delta_5), (issue_6, delta_6),
+        (issue_7, delta_7), (issue_8, delta_8), (issue_9, delta_9),
+        (issue_10, delta_10), (issue_11, delta_11)
+    ]
+    comment = '   ' + 'c' * tracker_constants.MAX_COMMENT_CHARS + '  '
+    tracker_helpers._AssertIssueChangesValid(
+        self.cnxn, issue_delta_pairs, self.services, comment_content=comment)
+
+    # Check we can handle None `comment_content`.
+    tracker_helpers._AssertIssueChangesValid(
+        self.cnxn, issue_delta_pairs, self.services)
+    self.assertEqual(
+        [
+            exp_d1, exp_d2, exp_d3, exp_d4, exp_d5, exp_d6, exp_d7, exp_d8,
+            exp_d9, exp_d10, exp_d11
+        ], [
+            delta_1, delta_2, delta_3, delta_4, delta_5, delta_6, delta_7,
+            delta_8, delta_9, delta_10, delta_11
+        ])
+
+  def testAssertIssueChangesValid_RequiredField(self):
+    """Asserts fields and requried fields.."""
+    issue_1 = _Issue('chicken', 1)
+    self.services.issue.TestAddIssue(issue_1)
+    delta_1 = tracker_pb2.IssueDelta()
+    exp_d1 = copy.deepcopy(delta_1)
+
+    required_fd = tracker_bizobj.MakeFieldDef(
+        124, 789, 'StrField', tracker_pb2.FieldTypes.STR_TYPE, None, '', True,
+        False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    self.services.config.TestAddFieldDef(required_fd)
+
+    issue_delta_pairs = [(issue_1, delta_1)]
+    comment = 'just a plain comment'
+    tracker_helpers._AssertIssueChangesValid(
+        self.cnxn, issue_delta_pairs, self.services, comment_content=comment)
+
+    # Check we can handle adding a field value when issue is in invalid state.
+    fv = tracker_bizobj.MakeFieldValue(
+        self.int_fd.field_id, 998, None, None, None, None, False)
+    delta_2 = tracker_pb2.IssueDelta(field_vals_add=[fv])
+    exp_d2 = copy.deepcopy(delta_2)
+    tracker_helpers._AssertIssueChangesValid(
+        self.cnxn, issue_delta_pairs, self.services)
+    self.assertEqual([exp_d1, exp_d2], [delta_1, delta_2])
+
+  def testAssertIssueChangesValid_Invalid(self):
+    """We can raise exceptions when deltas are not valid for issues. """
+
+    def getRef(issue):
+      return '%s:%d' % (issue.project_name, issue.local_id)
+
+    issue_delta_pairs = []
+    expected_err_msgs = []
+
+    comment = 'c' * (tracker_constants.MAX_COMMENT_CHARS + 1)
+    expected_err_msgs.append('Comment is too long.')
+
+    issue_1 = _Issue('chicken', 1)
+    self.services.issue.TestAddIssue(issue_1)
+    issue_1_ref = getRef(issue_1)
+
+    delta_1 = tracker_pb2.IssueDelta(
+        merged_into=issue_1.issue_id,
+        blocked_on_add=[issue_1.issue_id],
+        summary='',
+        status='',
+        cc_ids_add=[9876])
+
+    issue_delta_pairs.append((issue_1, delta_1))
+    expected_err_msgs.extend(
+        [
+            ('%s: MERGED type statuses must accompany mergedInto values.') %
+            issue_1_ref,
+            '%s: Cannot merge an issue into itself.' % issue_1_ref,
+            '%s: Cannot block an issue on itself.' % issue_1_ref,
+            'users/9876: User does not exist.',
+            '%s: Summary required.' % issue_1_ref,
+            '%s: Status is required.' % issue_1_ref
+        ])
+
+    issue_2 = _Issue('chicken', 2)
+    self.services.issue.TestAddIssue(issue_2)
+    issue_2_ref = getRef(issue_2)
+
+    fv = tracker_bizobj.MakeFieldValue(
+        self.int_fd.field_id, 1000, None, None, None, None, False)
+    delta_2 = tracker_pb2.IssueDelta(
+        status='Duplicate',
+        blocking_add=[issue_2.issue_id],
+        summary='s' * (tracker_constants.MAX_SUMMARY_CHARS + 1),
+        owner_id=self.no_project_user.user_id,
+        field_vals_add=[fv])
+    issue_delta_pairs.append((issue_2, delta_2))
+
+    expected_err_msgs.extend(
+        [
+            ('%s: MERGED type statuses must accompany mergedInto values.') %
+            issue_2_ref,
+            '%s: Cannot block an issue on itself.' % issue_2_ref,
+            '%s: Issue owner must be a project member.' % issue_2_ref,
+            '%s: Summary is too long.' % issue_2_ref,
+            '%s: Error for %r: Value must be <= 999.' % (issue_2_ref, fv)
+        ])
+
+    issue_3 = _Issue('chicken', 3)
+    issue_3.status = 'Duplicate'
+    issue_3.merged_into = 78911
+    self.services.issue.TestAddIssue(issue_3)
+    issue_3_ref = getRef(issue_3)
+    delta_3 = tracker_pb2.IssueDelta(
+        status='Available', merged_into_external='b/123')
+    issue_delta_pairs.append((issue_3, delta_3))
+    expected_err_msgs.append(
+        '%s: MERGED type statuses must accompany mergedInto values.' %
+        issue_3_ref)
+
+    with self.assertRaisesRegexp(exceptions.InputException,
+                                 '\n'.join(expected_err_msgs)):
+      tracker_helpers._AssertIssueChangesValid(
+          self.cnxn, issue_delta_pairs, self.services, comment_content=comment)
+
+  def testAssertIssueChangesValid_ConflictingDeltas(self):
+
+    def getRef(issue):
+      return '%s:%d' % (issue.project_name, issue.local_id)
+
+    expected_err_msgs = []
+    issue_3 = _Issue('chicken', 3)
+    self.services.issue.TestAddIssue(issue_3)
+    issue_3_ref = getRef(issue_3)
+    issue_4 = _Issue('chicken', 4)
+    self.services.issue.TestAddIssue(issue_4)
+    issue_4_ref = getRef(issue_4)
+    issue_5 = _Issue('chicken', 5)
+    self.services.issue.TestAddIssue(issue_5)
+    issue_5_ref = getRef(issue_5)
+    issue_6 = _Issue('chicken', 6)
+    self.services.issue.TestAddIssue(issue_6)
+    issue_6_ref = getRef(issue_6)
+    issue_7 = _Issue('chicken', 7)
+    self.services.issue.TestAddIssue(issue_7)
+    issue_7_ref = getRef(issue_7)
+
+    delta_3 = tracker_pb2.IssueDelta(
+        blocking_add=[issue_4.issue_id],
+        blocked_on_add=[issue_5.issue_id, issue_6.issue_id])
+
+    delta_4 = tracker_pb2.IssueDelta(
+        blocked_on_remove=[issue_3.issue_id], blocking_add=[issue_5.issue_id])
+    expected_err_msgs.append(
+        'Changes for %s conflict for %s' % (issue_4_ref, issue_3_ref))
+
+    delta_5 = tracker_pb2.IssueDelta(
+        blocking_remove=[issue_3.issue_id],
+        blocked_on_remove=[issue_4.issue_id])
+    expected_err_msgs.append(
+        'Changes for %s conflict for %s, %s' %
+        (issue_5_ref, issue_3_ref, issue_4_ref))
+
+    delta_6 = tracker_pb2.IssueDelta(blocking_remove=[issue_3.issue_id])
+    expected_err_msgs.append(
+        'Changes for %s conflict for %s' % (issue_6_ref, issue_3_ref))
+
+    impacted_issue = _Issue('chicken', 11)
+    self.services.issue.TestAddIssue(impacted_issue)
+    impacted_issue_ref = getRef(impacted_issue)
+    delta_7 = tracker_pb2.IssueDelta(
+        blocking_remove=[issue_3.issue_id],
+        blocking_add=[issue_3.issue_id],
+        blocked_on_remove=[impacted_issue.issue_id],
+        blocked_on_add=[impacted_issue.issue_id])
+    expected_err_msgs.append(
+        'Changes for %s conflict for %s, %s' %
+        (issue_7_ref, issue_3_ref, impacted_issue_ref))
+
+    issue_delta_pairs = [
+        (issue_3, delta_3),
+        (issue_4, delta_4),
+        (issue_5, delta_5),
+        (issue_6, delta_6),
+        (issue_7, delta_7),
+    ]
+
+    with self.assertRaisesRegexp(exceptions.InputException,
+                                 '\n'.join(expected_err_msgs)):
+      tracker_helpers._AssertIssueChangesValid(
+          self.cnxn, issue_delta_pairs, self.services)
+
+  def testComputeNewCcsFromIssueMerge(self):
+    """We can compute the new ccs to add to a merge-into issue."""
+    target_issue = fake.MakeTestIssue(789, 10, 'Target issue', 'New', 111)
+    source_issue_1 = fake.MakeTestIssue(
+        789, 11, 'Source issue', 'New', 111)  # different restrictions
+    source_issue_2 = fake.MakeTestIssue(
+        789, 12, 'Source issue', 'New', 222)  # same restrictions
+    source_issue_3 = fake.MakeTestIssue(
+        789, 13, 'Source issue', 'New', 222)  # no restrictions
+    source_issue_4 = fake.MakeTestIssue(
+        789, 14, 'Source issue', 'New', 666)  # empty ccs
+    source_issue_5 = fake.MakeTestIssue(
+        788, 15, 'Source issue', 'New', 666)  # different project
+    source_issue_1.cc_ids.append(333)
+    source_issue_2.cc_ids.append(444)
+    source_issue_3.cc_ids.append(555)
+    source_issue_5.cc_ids.append(999)
+
+    target_issue.labels.append('Restrict-View-Chicken')
+    source_issue_1.labels.append('Restrict-View-Cow')
+    source_issue_2.labels.append('Restrict-View-Chicken')
+
+    self.services.issue.TestAddIssue(target_issue)
+    self.services.issue.TestAddIssue(source_issue_1)
+    self.services.issue.TestAddIssue(source_issue_2)
+    self.services.issue.TestAddIssue(source_issue_3)
+    self.services.issue.TestAddIssue(source_issue_4)
+    self.services.issue.TestAddIssue(source_issue_5)
+
+    new_cc_ids = tracker_helpers._ComputeNewCcsFromIssueMerge(
+        target_issue, [source_issue_1, source_issue_2, source_issue_3])
+    self.assertItemsEqual(new_cc_ids, [444, 555, 222])
+
+  def testComputeNewCcsFromIssueMerge_Empty(self):
+    target_issue = fake.MakeTestIssue(789, 10, 'Target issue', 'New', 111)
+    self.services.issue.TestAddIssue(target_issue)
+    new_cc_ids = tracker_helpers._ComputeNewCcsFromIssueMerge(target_issue, [])
+    self.assertItemsEqual(new_cc_ids, [])
+
+  def testEnforceNonMergeStatusDeltas(self):
+    # No updates: user is setting to a non-MERGED status with no
+    # existing merged_into values.
+    issue_1 = _Issue('chicken', 1)
+    self.services.issue.TestAddIssue(issue_1)
+    delta_1 = tracker_pb2.IssueDelta(status='Available')
+    exp_delta_1 = copy.deepcopy(delta_1)
+
+    # No updates: user is setting to a MERGED status. Whether this request
+    # goes through will be handled by _AssertIssueChangesValid().
+    issue_2 = _Issue('chicken', 2)
+    self.services.issue.TestAddIssue(issue_2)
+    delta_2 = tracker_pb2.IssueDelta(status='Duplicate')
+    exp_delta_2 = copy.deepcopy(delta_2)
+
+    # No updates: user is setting to a MERGED status. (This test issue starts
+    # out with a merged_into value but a non-MERGED status. We don't expect
+    # real data to ever be in this state)
+    issue_3 = _Issue('chicken', 3)
+    issue_3.merged_into = 7011
+    self.services.issue.TestAddIssue(issue_3)
+    delta_3 = tracker_pb2.IssueDelta(status='Duplicate')
+    exp_delta_3 = copy.deepcopy(delta_3)
+
+    # No updates: same situation as above.
+    issue_4 = _Issue('chicken', 4)
+    issue_4.merged_into_external = 'b/123'
+    self.services.issue.TestAddIssue(issue_4)
+    delta_4 = tracker_pb2.IssueDelta(status='Duplicate')
+    exp_delta_4 = copy.deepcopy(delta_4)
+
+    # Update delta: user is setting status AWAY from a MERGED status, so we
+    # auto-remove any existing merged_into values.
+    issue_5 = _Issue('chicken', 5)
+    issue_5.merged_into = 7011
+    self.services.issue.TestAddIssue(issue_5)
+    delta_5 = tracker_pb2.IssueDelta(status='Available')
+    exp_delta_5 = copy.deepcopy(delta_5)
+    exp_delta_5.merged_into = 0
+
+    # Update delta: user is setting status AWAY from a MERGED status, so we
+    # auto-remove any existing merged_into values.
+    issue_6 = _Issue('chicken', 6)
+    issue_6.merged_into_external = 'b/123'
+    self.services.issue.TestAddIssue(issue_6)
+    delta_6 = tracker_pb2.IssueDelta(status='Available')
+    exp_delta_6 = copy.deepcopy(delta_6)
+    exp_delta_6.merged_into_external = ''
+
+    # No updates: user is setting to a non-MERGED status while also setting
+    # a merged_into value. This will be rejected down the line by
+    # _AssertIssueChangesValid()
+    issue_7 = _Issue('chicken', 7)
+    issue_7.merged_into = 7011
+    self.services.issue.TestAddIssue(issue_7)
+    delta_7 = tracker_pb2.IssueDelta(
+        merged_into_external='b/123', status='Available')
+    exp_delta_7 = copy.deepcopy(delta_7)
+
+    # No updates: user is setting to a non-MERGED status while also setting
+    # a merged_into value. This will be rejected down the line by
+    # _AssertIssueChangesValid()
+    issue_8 = _Issue('chicken', 8)
+    issue_8.merged_into_external = 'b/123'
+    self.services.issue.TestAddIssue(issue_8)
+    delta_8 = tracker_pb2.IssueDelta(merged_into=8011, status='Available')
+    exp_delta_8 = copy.deepcopy(delta_8)
+
+    pairs = [
+        (issue_1, delta_1), (issue_2, delta_2), (issue_3, delta_3),
+        (issue_4, delta_4), (issue_5, delta_5), (issue_6, delta_6),
+        (issue_7, delta_7), (issue_8, delta_8)
+    ]
+
+    tracker_helpers._EnforceNonMergeStatusDeltas(
+        self.cnxn, pairs, self.services)
+    self.assertEqual(
+        [
+            delta_1, delta_2, delta_3, delta_4, delta_5, delta_6, delta_7,
+            delta_8
+        ], [
+            exp_delta_1, exp_delta_2, exp_delta_3, exp_delta_4, exp_delta_5,
+            exp_delta_6, exp_delta_7, exp_delta_8
+        ])
+
+
+class IssueChangeImpactedIssuesTest(unittest.TestCase):
+  """Tests for the _IssueChangeImpactedIssues class."""
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        issue=fake.IssueService(), issue_star=fake.IssueStarService())
+    self.cnxn = 'fake connection'
+
+  def testComputeAllImpactedIDs(self):
+    tracker = tracker_helpers._IssueChangeImpactedIssues()
+    tracker.blocking_add[78901].append(1)
+    tracker.blocking_remove[78902].append(2)
+    tracker.blocked_on_add[78903].append(1)
+    tracker.blocked_on_remove[78904].append(1)
+    tracker.merged_from_add[78905].append(3)
+    tracker.merged_from_remove[78906].append(3)
+
+    # Repeat a few iids.
+    tracker.blocked_on_remove[78901].append(1)
+    tracker.merged_from_add[78903].append(1)
+
+    actual = tracker.ComputeAllImpactedIIDs()
+    expected = {78901, 78902, 78903, 78904, 78905, 78906}
+    self.assertEqual(actual, expected)
+
+  def testComputeAllImpactedIDs_Empty(self):
+    tracker = tracker_helpers._IssueChangeImpactedIssues()
+    actual = tracker.ComputeAllImpactedIIDs()
+    self.assertEqual(actual, set())
+
+  def testTrackImpactedIssues(self):
+    issue_delta_pairs = []
+
+    issue_1 = _Issue('project', 1)
+    issue_1.merged_into = 78906
+    delta_1 = tracker_pb2.IssueDelta(
+        merged_into=78905,
+        blocked_on_add=[78901, 78902],
+        blocked_on_remove=[78903, 78904],
+    )
+    issue_delta_pairs.append((issue_1, delta_1))
+
+    issue_2 = _Issue('project', 2)
+    issue_2.merged_into = 78905
+    delta_2 = tracker_pb2.IssueDelta(
+        merged_into=78905,  # This should be ignored.
+        blocking_add=[78901, 78902],
+        blocking_remove=[78903, 78904],
+    )
+    issue_delta_pairs.append((issue_2, delta_2))
+
+    issue_3 = _Issue('project', 3)
+    issue_3.merged_into = 78902
+    delta_3 = tracker_pb2.IssueDelta(merged_into=78901)
+    issue_delta_pairs.append((issue_3, delta_3))
+
+    issue_4 = _Issue('project', 4)
+    issue_4.merged_into = 78901
+    delta_4 = tracker_pb2.IssueDelta(
+        merged_into=framework_constants.NO_ISSUE_SPECIFIED)
+    issue_delta_pairs.append((issue_4, delta_4))
+
+    impacted_issues = tracker_helpers._IssueChangeImpactedIssues()
+    for issue, delta in issue_delta_pairs:
+      impacted_issues.TrackImpactedIssues(issue, delta)
+
+    self.assertEqual(
+        impacted_issues.blocking_add, {
+            78901: [issue_1.issue_id],
+            78902: [issue_1.issue_id]
+        })
+    self.assertEqual(
+        impacted_issues.blocking_remove, {
+            78903: [issue_1.issue_id],
+            78904: [issue_1.issue_id]
+        })
+    self.assertEqual(
+        impacted_issues.blocked_on_add, {
+            78901: [issue_2.issue_id],
+            78902: [issue_2.issue_id]
+        })
+    self.assertEqual(
+        impacted_issues.blocked_on_remove, {
+            78903: [issue_2.issue_id],
+            78904: [issue_2.issue_id]
+        })
+    self.assertEqual(
+        impacted_issues.merged_from_add, {
+            78901: [issue_3.issue_id],
+            78905: [issue_1.issue_id],
+        })
+    self.assertEqual(
+        impacted_issues.merged_from_remove, {
+            78901: [issue_4.issue_id],
+            78902: [issue_3.issue_id],
+            78906: [issue_1.issue_id],
+        })
+
+  def testApplyImpactedIssueChanges(self):
+    impacted_tracker = tracker_helpers._IssueChangeImpactedIssues()
+    impacted_issue = _Issue('proj', 1)
+    self.services.issue.TestAddIssue(impacted_issue)
+    impacted_iid = impacted_issue.issue_id
+
+    # Setup.
+    bo_add = _Issue('proj', 2)
+    self.services.issue.TestAddIssue(bo_add)
+    impacted_tracker.blocked_on_add[impacted_iid].append(bo_add.issue_id)
+
+    bo_remove = _Issue('proj', 3)
+    self.services.issue.TestAddIssue(bo_remove)
+    impacted_tracker.blocked_on_remove[impacted_iid].append(
+        bo_remove.issue_id)
+
+    b_add = _Issue('proj', 4)
+    self.services.issue.TestAddIssue(b_add)
+    impacted_tracker.blocking_add[impacted_iid].append(
+        b_add.issue_id)
+
+    b_remove = _Issue('proj', 5)
+    self.services.issue.TestAddIssue(b_remove)
+    impacted_tracker.blocking_remove[impacted_iid].append(
+        b_remove.issue_id)
+
+    m_add = _Issue('proj', 6)
+    m_add.cc_ids = [666, 777]
+    self.services.issue.TestAddIssue(m_add)
+    m_add_no_ccs = _Issue('proj', 7, '', '')
+    self.services.issue.TestAddIssue(m_add_no_ccs)
+    impacted_tracker.merged_from_add[impacted_iid].extend(
+        [m_add.issue_id, m_add_no_ccs.issue_id])
+    # Set up starrers.
+    self.services.issue_star.SetStar(
+        self.cnxn, self.services, None, impacted_iid, 111, True)
+    self.services.issue_star.SetStar(
+        self.cnxn, self.services, None, impacted_iid, 222, True)
+    self.services.issue_star.SetStar(
+        self.cnxn, self.services, None, m_add.issue_id, 222, True)
+    self.services.issue_star.SetStar(
+        self.cnxn, self.services, None, m_add.issue_id, 333, True)
+    self.services.issue_star.SetStar(
+        self.cnxn, self.services, None, m_add.issue_id, 444, True)
+
+    m_remove = _Issue('proj', 8)
+    m_remove.cc_ids = [888]
+    self.services.issue.TestAddIssue(m_remove)
+    impacted_tracker.merged_from_remove[impacted_iid].append(
+        m_remove.issue_id)
+
+
+    impacted_issue.cc_ids = [666]
+    impacted_issue.blocked_on_iids = [78404, bo_remove.issue_id]
+    impacted_issue.blocking_iids = [78405, b_remove.issue_id]
+    expected_issue = copy.deepcopy(impacted_issue)
+
+    # Verify.
+    (actual_amendments,
+     actual_new_starrers) = impacted_tracker.ApplyImpactedIssueChanges(
+         self.cnxn, impacted_issue, self.services)
+    expected_amendments = [
+        tracker_bizobj.MakeBlockedOnAmendment(
+            [('proj', bo_add.local_id)],
+            [('proj', bo_remove.local_id)], default_project_name='proj'),
+        tracker_bizobj.MakeBlockingAmendment(
+            [('proj', b_add.local_id)],
+            [('proj', b_remove.local_id)], default_project_name='proj'),
+        tracker_bizobj.MakeCcAmendment([777], []),
+        tracker_bizobj.MakeMergedIntoAmendment(
+            [('proj', m_add.local_id), ('proj', m_add_no_ccs.local_id)],
+            [('proj', m_remove.local_id)], default_project_name='proj')
+        ]
+    self.assertEqual(actual_amendments, expected_amendments)
+    self.assertItemsEqual(actual_new_starrers, [333, 444])
+
+    expected_issue.cc_ids.append(777)
+    expected_issue.blocked_on_iids = [78404, bo_add.issue_id]
+    # By default new blocked_on issues that appear in blocked_on_iids
+    # with no prior rank associated with it are un-ranked and assigned rank 0.
+    # See SortBlockedOn in issue_svc.py.
+    expected_issue.blocked_on_ranks = [0, 0]
+    expected_issue.blocking_iids = [78405, b_add.issue_id]
+    expected_issue.star_count = 4
+    self.assertEqual(impacted_issue, expected_issue)
+
+  def testApplyImpactedIssueChanges_Empty(self):
+    impacted_tracker = tracker_helpers._IssueChangeImpactedIssues()
+    impacted_issue = _Issue('proj', 1)
+    expected_issue = copy.deepcopy(impacted_issue)
+
+    (actual_amendments,
+     actual_new_starrers) = impacted_tracker.ApplyImpactedIssueChanges(
+         self.cnxn, impacted_issue, self.services)
+
+    expected_amendments = []
+    self.assertEqual(actual_amendments, expected_amendments)
+    expected_new_starrers = []
+    self.assertEqual(actual_new_starrers, expected_new_starrers)
+    self.assertEqual(impacted_issue, expected_issue)
+
+  def testApplyImpactedIssueChanges_PartiallyEmptyMergedFrom(self):
+    """We can process merged_from changes when one of the lists is empty."""
+    impacted_tracker = tracker_helpers._IssueChangeImpactedIssues()
+    impacted_issue = _Issue('proj', 1)
+    impacted_iid = impacted_issue.issue_id
+    expected_issue = copy.deepcopy(impacted_issue)
+
+    m_add = _Issue('proj', 2)
+    self.services.issue.TestAddIssue(m_add)
+    impacted_tracker.merged_from_add[impacted_iid].append(
+        m_add.issue_id)
+    # We're leaving impacted_tracker.merged_from_remove empty.
+
+    (actual_amendments,
+     actual_new_starrers) = impacted_tracker.ApplyImpactedIssueChanges(
+         self.cnxn, impacted_issue, self.services)
+
+    expected_amendments = [tracker_bizobj.MakeMergedIntoAmendment(
+            [('proj', m_add.local_id)], [], default_project_name='proj')]
+    self.assertEqual(actual_amendments, expected_amendments)
+    expected_new_starrers = []
+    self.assertEqual(actual_new_starrers, expected_new_starrers)
+    self.assertEqual(impacted_issue, expected_issue)
+
+
+class AssertUsersExistTest(unittest.TestCase):
+
+  def setUp(self):
+    self.cnxn = 'fake cnxn'
+    self.services = service_manager.Services(user=fake.UserService())
+    for user_id in [1, 1001, 1002, 1003, 2001, 2002, 3002]:
+      self.services.user.TestAddUser('test%d' % user_id, user_id, add_user=True)
+
+  def test_AssertUsersExist_Passes(self):
+    existing = [1, 1001, 1002, 1003, 2001, 2002, 3002]
+    with exceptions.ErrorAggregator(exceptions.InputException) as err_agg:
+      tracker_helpers.AssertUsersExist(
+          self.cnxn, self.services, existing, err_agg)
+
+  def test_AssertUsersExist_Empty(self):
+    with exceptions.ErrorAggregator(exceptions.InputException) as err_agg:
+      tracker_helpers.AssertUsersExist(
+          self.cnxn, self.services, [], err_agg)
+
+  def test_AssertUsersExist(self):
+    dne_users = [2, 3]
+    existing = [1, 1001, 1002, 1003, 2001, 2002, 3002]
+    all_users = existing + dne_users
+    with self.assertRaisesRegexp(
+        exceptions.InputException,
+        'users/2: User does not exist.\nusers/3: User does not exist.'):
+      with exceptions.ErrorAggregator(exceptions.InputException) as err_agg:
+        tracker_helpers.AssertUsersExist(
+            self.cnxn, self.services, all_users, err_agg)
diff --git a/tracker/test/tracker_views_test.py b/tracker/test/tracker_views_test.py
new file mode 100644
index 0000000..797b079
--- /dev/null
+++ b/tracker/test/tracker_views_test.py
@@ -0,0 +1,787 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unittest for issue tracker views."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import unittest
+
+import mox
+
+from google.appengine.api import app_identity
+import ezt
+
+from framework import framework_views
+from framework import gcs_helpers
+from framework import template_helpers
+from framework import urls
+from proto import project_pb2
+from proto import tracker_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+from tracker import attachment_helpers
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+from tracker import tracker_helpers
+from tracker import tracker_views
+
+
+def _Issue(project_name, local_id, summary, status):
+  issue = tracker_pb2.Issue()
+  issue.project_name = project_name
+  issue.local_id = local_id
+  issue.issue_id = 100000 + local_id
+  issue.summary = summary
+  issue.status = status
+  return issue
+
+
+def _MakeConfig():
+  config = tracker_pb2.ProjectIssueConfig()
+  config.well_known_labels = [
+    tracker_pb2.LabelDef(
+        label='Priority-High', label_docstring='Must be resolved'),
+    tracker_pb2.LabelDef(
+        label='Priority-Low', label_docstring='Can be slipped'),
+    ]
+  config.well_known_statuses.append(tracker_pb2.StatusDef(
+      status='New', means_open=True))
+  config.well_known_statuses.append(tracker_pb2.StatusDef(
+      status='Old', means_open=False))
+  return config
+
+
+class IssueViewTest(unittest.TestCase):
+
+  def setUp(self):
+    self.issue1 = _Issue('proj', 1, 'not too long summary', 'New')
+    self.issue2 = _Issue('proj', 2, 'sum 2', '')
+    self.issue3 = _Issue('proj', 3, 'sum 3', '')
+    self.issue4 = _Issue('proj', 4, 'sum 4', '')
+
+    self.issue1.reporter_id = 1002
+    self.issue1.owner_id = 2002
+    self.issue1.labels.extend(['A', 'B'])
+    self.issue1.derived_labels.extend(['C', 'D'])
+
+    self.issue2.reporter_id = 2002
+    self.issue2.labels.extend(['foo', 'bar'])
+    self.issue2.blocked_on_iids.extend(
+        [self.issue1.issue_id, self.issue3.issue_id])
+    self.issue2.blocking_iids.extend(
+        [self.issue1.issue_id, self.issue4.issue_id])
+    dref = tracker_pb2.DanglingIssueRef()
+    dref.project = 'codesite'
+    dref.issue_id = 5001
+    self.issue2.dangling_blocking_refs.append(dref)
+
+    self.issue3.reporter_id = 3002
+    self.issue3.labels.extend(['Hot'])
+
+    self.issue4.reporter_id = 3002
+    self.issue4.labels.extend(['Foo', 'Bar'])
+
+    self.restricted = _Issue('proj', 7, 'summary 7', '')
+    self.restricted.labels.extend([
+        'Restrict-View-Commit', 'Restrict-View-MyCustomPerm'])
+    self.restricted.derived_labels.extend([
+        'Restrict-AddIssueComment-Commit', 'Restrict-EditIssue-Commit',
+        'Restrict-Action-NeededPerm'])
+
+    self.users_by_id = {
+        0: 'user 0',
+        1002: 'user 1002',
+        2002: 'user 2002',
+        3002: 'user 3002',
+        4002: 'user 4002',
+        }
+
+  def CheckSimpleIssueView(self, config):
+    view1 = tracker_views.IssueView(
+        self.issue1, self.users_by_id, config)
+    self.assertEqual('not too long summary', view1.summary)
+    self.assertEqual('New', view1.status.name)
+    self.assertEqual('user 2002', view1.owner)
+    self.assertEqual('A', view1.labels[0].name)
+    self.assertEqual('B', view1.labels[1].name)
+    self.assertEqual('C', view1.derived_labels[0].name)
+    self.assertEqual('D', view1.derived_labels[1].name)
+    self.assertEqual([], view1.blocked_on)
+    self.assertEqual([], view1.blocking)
+    detail_url = '/p/%s%s?id=%d' % (
+        self.issue1.project_name, urls.ISSUE_DETAIL,
+        self.issue1.local_id)
+    self.assertEqual(detail_url, view1.detail_relative_url)
+    return view1
+
+  def testSimpleIssueView(self):
+    config = tracker_pb2.ProjectIssueConfig()
+    view1 = self.CheckSimpleIssueView(config)
+    self.assertEqual('', view1.status.docstring)
+
+    config.well_known_statuses.append(tracker_pb2.StatusDef(
+        status='New', status_docstring='Issue has not had review yet'))
+    view1 = self.CheckSimpleIssueView(config)
+    self.assertEqual('Issue has not had review yet',
+                     view1.status.docstring)
+    self.assertIsNone(view1.restrictions.has_restrictions)
+    self.assertEqual('', view1.restrictions.view)
+    self.assertEqual('', view1.restrictions.add_comment)
+    self.assertEqual('', view1.restrictions.edit)
+
+  def testIsOpen(self):
+    config = _MakeConfig()
+    view1 = tracker_views.IssueView(
+        self.issue1, self.users_by_id, config)
+    self.assertEqual(ezt.boolean(True), view1.is_open)
+
+    self.issue1.status = 'Old'
+    view1 = tracker_views.IssueView(
+        self.issue1, self.users_by_id, config)
+    self.assertEqual(ezt.boolean(False), view1.is_open)
+
+  def testIssueViewWithRestrictions(self):
+    view = tracker_views.IssueView(
+        self.restricted, self.users_by_id, _MakeConfig())
+    self.assertTrue(view.restrictions.has_restrictions)
+    self.assertEqual('Commit and MyCustomPerm', view.restrictions.view)
+    self.assertEqual('Commit', view.restrictions.add_comment)
+    self.assertEqual('Commit', view.restrictions.edit)
+    self.assertEqual(['Restrict-Action-NeededPerm'], view.restrictions.other)
+    self.assertEqual('Restrict-View-Commit', view.labels[0].name)
+    self.assertTrue(view.labels[0].is_restrict)
+
+
+class RestrictionsViewTest(unittest.TestCase):
+  pass  # TODO(jrobbins): write tests
+
+
+class AttachmentViewTest(unittest.TestCase):
+
+  def setUp(self):
+    self.orig_sign_attachment_id = attachment_helpers.SignAttachmentID
+    attachment_helpers.SignAttachmentID = (
+        lambda aid: 'signed_%d' % aid)
+
+  def tearDown(self):
+    attachment_helpers.SignAttachmentID = self.orig_sign_attachment_id
+
+  def MakeViewAndVerifyFields(
+      self, size, name, mimetype, expected_size_str, expect_viewable):
+    attach_pb = tracker_pb2.Attachment()
+    attach_pb.filesize = size
+    attach_pb.attachment_id = 12345
+    attach_pb.filename = name
+    attach_pb.mimetype = mimetype
+
+    view = tracker_views.AttachmentView(attach_pb, 'proj')
+    self.assertEqual('/images/paperclip.png', view.iconurl)
+    self.assertEqual(expected_size_str, view.filesizestr)
+    dl = 'attachment?aid=12345&signed_aid=signed_12345'
+    self.assertEqual(dl, view.downloadurl)
+    if expect_viewable:
+      self.assertEqual(dl + '&inline=1', view.url)
+      self.assertEqual(dl + '&inline=1&thumb=1', view.thumbnail_url)
+    else:
+      self.assertEqual(None, view.url)
+      self.assertEqual(None, view.thumbnail_url)
+
+  def testNonImage(self):
+    self.MakeViewAndVerifyFields(
+        123, 'file.ext', 'funky/bits', '123 bytes', False)
+
+  def testViewableImage(self):
+    self.MakeViewAndVerifyFields(
+        123, 'logo.gif', 'image/gif', '123 bytes', True)
+
+    self.MakeViewAndVerifyFields(
+        123, 'screenshot.jpg', 'image/jpeg', '123 bytes', True)
+
+  def testHugeImage(self):
+    self.MakeViewAndVerifyFields(
+        18 * 1024 * 1024, 'panorama.png', 'image/jpeg', '18.0 MB', False)
+
+  def testViewableText(self):
+    name = 'hello.c'
+    attach_pb = tracker_pb2.Attachment()
+    attach_pb.filesize = 1234
+    attach_pb.attachment_id = 12345
+    attach_pb.filename = name
+    attach_pb.mimetype = 'text/plain'
+    view = tracker_views.AttachmentView(attach_pb, 'proj')
+
+    view_url = '/p/proj/issues/attachmentText?aid=12345'
+    self.assertEqual(view_url, view.url)
+
+
+class LogoViewTest(unittest.TestCase):
+
+  def setUp(self):
+    self.mox = mox.Mox()
+
+  def tearDown(self):
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def testProjectWithLogo(self):
+    bucket_name = 'testbucket'
+    logo_gcs_id = '123'
+    logo_file_name = 'logo.png'
+    project_pb = project_pb2.MakeProject(
+        'testProject', logo_gcs_id=logo_gcs_id, logo_file_name=logo_file_name)
+
+    self.mox.StubOutWithMock(app_identity, 'get_default_gcs_bucket_name')
+    app_identity.get_default_gcs_bucket_name().AndReturn(bucket_name)
+
+    self.mox.StubOutWithMock(gcs_helpers, 'SignUrl')
+    gcs_helpers.SignUrl(bucket_name,
+        logo_gcs_id + '-thumbnail').AndReturn('signed/url')
+    gcs_helpers.SignUrl(bucket_name, logo_gcs_id).AndReturn('signed/url')
+
+    self.mox.ReplayAll()
+
+    view = tracker_views.LogoView(project_pb)
+    self.mox.VerifyAll()
+    self.assertEqual('logo.png', view.filename)
+    self.assertEqual('image/png', view.mimetype)
+    self.assertEqual('signed/url', view.thumbnail_url)
+    self.assertEqual(
+        'signed/url&response-content-displacement=attachment%3B'
+        '+filename%3Dlogo.png', view.viewurl)
+
+  def testProjectWithNoLogo(self):
+    project_pb = project_pb2.MakeProject('testProject')
+    view = tracker_views.LogoView(project_pb)
+    self.assertEqual('', view.thumbnail_url)
+    self.assertEqual('', view.viewurl)
+
+
+class AmendmentViewTest(unittest.TestCase):
+  pass  # TODO(jrobbins): write tests
+
+
+class ComponentDefViewTest(unittest.TestCase):
+  def setUp(self):
+    self.services = service_manager.Services(
+        user=fake.UserService(),
+        config=fake.ConfigService())
+    self.services.user.TestAddUser('admin@example.com', 111)
+    self.services.user.TestAddUser('cc@example.com', 222)
+    self.users_by_id = framework_views.MakeAllUserViews(
+      'cnxn', self.services.user, [111, 222])
+    self.services.config.TestAddLabelsDict({'Hot': 1, 'Cold': 2})
+    self.cd = tracker_bizobj.MakeComponentDef(
+      10, 789, 'UI', 'User interface', False,
+      [111], [222], 0, 111, label_ids=[1, 2])
+
+  def testRootComponent(self):
+    view = tracker_views.ComponentDefView(
+       'cnxn', self.services, self.cd, self.users_by_id)
+    self.assertEqual('', view.parent_path)
+    self.assertEqual('UI', view.leaf_name)
+    self.assertEqual('User interface', view.docstring_short)
+    self.assertEqual('admin@example.com', view.admins[0].email)
+    self.assertEqual(['Hot', 'Cold'], view.labels)
+    self.assertEqual('all toplevel active ', view.classes)
+
+  def testNestedComponent(self):
+    self.cd.path = 'UI>Dialogs>Print'
+    view = tracker_views.ComponentDefView(
+       'cnxn', self.services, self.cd, self.users_by_id)
+    self.assertEqual('UI>Dialogs', view.parent_path)
+    self.assertEqual('Print', view.leaf_name)
+    self.assertEqual('User interface', view.docstring_short)
+    self.assertEqual('admin@example.com', view.admins[0].email)
+    self.assertEqual(['Hot', 'Cold'], view.labels)
+    self.assertEqual('all active ', view.classes)
+
+
+class ComponentValueTest(unittest.TestCase):
+  pass  # TODO(jrobbins): write tests
+
+
+class FieldValueViewTest(unittest.TestCase):
+
+  def setUp(self):
+    self.config = tracker_pb2.ProjectIssueConfig()
+    self.estdays_fd = tracker_bizobj.MakeFieldDef(
+        1, 789, 'EstDays', tracker_pb2.FieldTypes.INT_TYPE, None,
+        None, False, False, False, 3, 99, None, False, None, None,
+        None, 'no_action', 'descriptive docstring', False, approval_id=None,
+        is_phase_field=False)
+    self.designdoc_fd = tracker_bizobj.MakeFieldDef(
+        2, 789, 'DesignDoc', tracker_pb2.FieldTypes.STR_TYPE, 'Enhancement',
+        None, False, False, False, None, None, None, False, None, None,
+        None, 'no_action', 'descriptive docstring', False, approval_id=None,
+        is_phase_field=False)
+    self.mtarget_fd = tracker_bizobj.MakeFieldDef(
+        3, 789, 'M-Target', tracker_pb2.FieldTypes.INT_TYPE, 'Enhancement',
+        None, False, False, False, None, None, None, False, None, None,
+        None, 'no_action', 'doc doc', False, approval_id=None,
+        is_phase_field=True)
+    self.config.field_defs = [self.estdays_fd, self.designdoc_fd]
+
+  def testNoValues(self):
+    """We can create a FieldValueView with no values."""
+    values = []
+    derived_values = []
+    estdays_fvv = tracker_views.FieldValueView(
+        self.estdays_fd, self.config, values, derived_values, ['defect'],
+        phase_name='Gate')
+    self.assertEqual('EstDays', estdays_fvv.field_def.field_name)
+    self.assertEqual(3, estdays_fvv.field_def.min_value)
+    self.assertEqual(99, estdays_fvv.field_def.max_value)
+    self.assertEqual([], estdays_fvv.values)
+    self.assertEqual([], estdays_fvv.derived_values)
+
+  def testSomeValues(self):
+    """We can create a FieldValueView with some values."""
+    values = [template_helpers.EZTItem(val=12, docstring=None, idx=0)]
+    derived_values = [template_helpers.EZTItem(val=88, docstring=None, idx=0)]
+    estdays_fvv = tracker_views.FieldValueView(
+        self.estdays_fd, self.config, values, derived_values, ['defect'])
+    self.assertEqual(self.estdays_fd, estdays_fvv.field_def.field_def)
+    self.assertTrue(estdays_fvv.is_editable)
+    self.assertEqual(values, estdays_fvv.values)
+    self.assertEqual(derived_values, estdays_fvv.derived_values)
+    self.assertEqual('', estdays_fvv.phase_name)
+    self.assertEqual(ezt.boolean(False), estdays_fvv.field_def.is_phase_field)
+
+  def testApplicability(self):
+    """We know whether a field should show an editing widget."""
+    # Not the right type and has no values.
+    designdoc_fvv = tracker_views.FieldValueView(
+        self.designdoc_fd, self.config, [], [], ['defect'])
+    self.assertFalse(designdoc_fvv.applicable)
+    self.assertEqual('', designdoc_fvv.phase_name)
+    self.assertEqual(ezt.boolean(False), designdoc_fvv.field_def.is_phase_field)
+
+    # Has a value.
+    designdoc_fvv = tracker_views.FieldValueView(
+        self.designdoc_fd, self.config, ['fake value item'], [], ['defect'])
+    self.assertTrue(designdoc_fvv.applicable)
+
+    # Derived values don't cause editing fields to display.
+    designdoc_fvv = tracker_views.FieldValueView(
+        self.designdoc_fd, self.config, [], ['fake value item'], ['defect'])
+    self.assertFalse(designdoc_fvv.applicable)
+
+    # Applicable to this type of issue.
+    designdoc_fvv = tracker_views.FieldValueView(
+        self.designdoc_fd, self.config, [], [], ['enhancement'])
+    self.assertTrue(designdoc_fvv.applicable)
+
+    # Applicable to some issues in a bulk edit.
+    designdoc_fvv = tracker_views.FieldValueView(
+        self.designdoc_fd, self.config, [], [],
+        ['defect', 'task', 'enhancement'])
+    self.assertTrue(designdoc_fvv.applicable)
+
+    # Applicable to all issues.
+    estdays_fvv = tracker_views.FieldValueView(
+        self.estdays_fd, self.config, [], [], ['enhancement'])
+    self.assertTrue(estdays_fvv.applicable)
+
+    # Explicitly set to be applicable when showing bounce values.
+    designdoc_fvv = tracker_views.FieldValueView(
+        self.designdoc_fd, self.config, [], [], ['defect'],
+        applicable=True)
+    self.assertTrue(designdoc_fvv.applicable)
+
+  def testDisplay(self):
+    """We know when a value (or --) should be shown in the metadata column."""
+    # Not the right type and has no values.
+    designdoc_fvv = tracker_views.FieldValueView(
+        self.designdoc_fd, self.config, [], [], ['defect'])
+    self.assertFalse(designdoc_fvv.display)
+
+    # Has a value.
+    designdoc_fvv = tracker_views.FieldValueView(
+        self.designdoc_fd, self.config, ['fake value item'], [], ['defect'])
+    self.assertTrue(designdoc_fvv.display)
+
+    # Has a derived value.
+    designdoc_fvv = tracker_views.FieldValueView(
+        self.designdoc_fd, self.config, [], ['fake value item'], ['defect'])
+    self.assertTrue(designdoc_fvv.display)
+
+    # Applicable to this type of issue, it will show "--".
+    designdoc_fvv = tracker_views.FieldValueView(
+        self.designdoc_fd, self.config, [], [], ['enhancement'])
+    self.assertTrue(designdoc_fvv.display)
+
+    # Applicable to all issues, it will show "--".
+    estdays_fvv = tracker_views.FieldValueView(
+        self.estdays_fd, self.config, [], [], ['enhancement'])
+    self.assertTrue(estdays_fvv.display)
+
+  def testPhaseField(self):
+    mtarget_fvv = tracker_views.FieldValueView(
+        self.mtarget_fd, self.config, [], [], [], phase_name='Stage')
+    self.assertEqual('Stage', mtarget_fvv.phase_name)
+    self.assertEqual(ezt.boolean(True), mtarget_fvv.field_def.is_phase_field)
+
+
+class FVVFunctionsTest(unittest.TestCase):
+
+  def setUp(self):
+    self.config = tracker_pb2.ProjectIssueConfig()
+    self.estdays_fd = tracker_bizobj.MakeFieldDef(
+        1, 789, 'EstDays', tracker_pb2.FieldTypes.INT_TYPE, None,
+        None, False, False, False, 3, 99, None, False, None, None,
+        None, 'no_action', 'descriptive docstring', False, None, False)
+    self.os_fd = tracker_bizobj.MakeFieldDef(
+        2, 789, 'OS', tracker_pb2.FieldTypes.ENUM_TYPE,
+        'Enhancement', None, False, False, False, None, None, None,
+        False, None, None, None, 'no_action', 'descriptive docstring',
+        False, None, False)
+    self.milestone_fd = tracker_bizobj.MakeFieldDef(
+        3, 789, 'Launch-Milestone', tracker_pb2.FieldTypes.ENUM_TYPE,
+        'Enhancement', None, False, False, False, None, None, None,
+        False, None, None, None, 'no_action', 'descriptive docstring',
+        False, None, False)
+    self.config.field_defs = [self.estdays_fd, self.os_fd, self.milestone_fd]
+    self.config.well_known_labels = [
+        tracker_pb2.LabelDef(
+            label='Priority-High', label_docstring='Must be resolved'),
+        tracker_pb2.LabelDef(
+            label='Priority-Low', label_docstring='Can be slipped'),
+        ]
+
+  def testPrecomputeInfoForValueViews_NoValues(self):
+    """We can precompute info needed for an issue with no fields or labels."""
+    labels = []
+    derived_labels = []
+    field_values = []
+    phases = []
+    precomp_view_info = tracker_views._PrecomputeInfoForValueViews(
+        labels, derived_labels, field_values, self.config, phases)
+    (labels_by_prefix, der_labels_by_prefix, field_values_by_id,
+     label_docs, phases_by_name) = precomp_view_info
+    self.assertEqual({}, labels_by_prefix)
+    self.assertEqual({}, der_labels_by_prefix)
+    self.assertEqual({}, field_values_by_id)
+    self.assertEqual(
+        {'priority-high': 'Must be resolved',
+         'priority-low': 'Can be slipped'},
+        label_docs)
+    self.assertEqual({}, phases_by_name)
+
+  def testPrecomputeInfoForValueViews_SomeValues(self):
+    """We can precompute info needed for an issue with fields and labels."""
+    labels = ['Priority-Low', 'GoodFirstBug', 'Feature-UI', 'Feature-Installer',
+              'Launch-Milestone-66']
+    derived_labels = ['OS-Windows', 'OS-Linux']
+    field_values = [
+        tracker_bizobj.MakeFieldValue(1, 5, None, None, None, None, False),
+        ]
+    phase_1 = tracker_pb2.Phase(phase_id=1, name='Stable')
+    phase_2 = tracker_pb2.Phase(phase_id=2, name='Beta')
+    phase_3 = tracker_pb2.Phase(phase_id=3, name='stable')
+    precomp_view_info = tracker_views._PrecomputeInfoForValueViews(
+        labels, derived_labels, field_values, self.config,
+        phases=[phase_1, phase_2, phase_3])
+    (labels_by_prefix, der_labels_by_prefix, field_values_by_id,
+     _label_docs, phases_by_name) = precomp_view_info
+    self.assertEqual(
+        {'priority': ['Low'],
+         'feature': ['UI', 'Installer'],
+         'launch-milestone': ['66']},
+        labels_by_prefix)
+    self.assertEqual(
+        {'os': ['Windows', 'Linux']},
+        der_labels_by_prefix)
+    self.assertEqual(
+        {1: field_values},
+        field_values_by_id)
+    self.assertEqual(
+        {'stable': [phase_1, phase_3],
+         'beta': [phase_2]},
+        phases_by_name)
+
+  def testMakeAllFieldValueViews(self):
+    labels = ['Priority-Low', 'GoodFirstBug', 'Feature-UI', 'Feature-Installer',
+              'Launch-Milestone-66']
+    derived_labels = ['OS-Windows', 'OS-Linux']
+    self.config.field_defs.append(tracker_bizobj.MakeFieldDef(
+        4, 789, 'UIMocks', tracker_pb2.FieldTypes.URL_TYPE,
+        'Enhancement', None, False, False, False, None, None, None,
+        False, None, None, None, 'no_action', 'descriptive docstring',
+        False, approval_id=23, is_phase_field=False))
+    self.config.field_defs.append(tracker_bizobj.MakeFieldDef(
+        5, 789, 'LegalFAQs', tracker_pb2.FieldTypes.URL_TYPE,
+        'Enhancement', None, False, False, False, None, None, None,
+        False, None, None, None, 'no_action', 'descriptive docstring',
+        False, approval_id=26, is_phase_field=False))
+    self.config.field_defs.append(tracker_bizobj.MakeFieldDef(
+        23, 789, 'Legal', tracker_pb2.FieldTypes.APPROVAL_TYPE,
+        'Enhancement', None, False, False, False, None, None, None,
+        False, None, None, None, 'no_action', 'descriptive docstring',
+        False, approval_id=None, is_phase_field=False))
+    self.config.field_defs.append(tracker_bizobj.MakeFieldDef(
+        26, 789, 'UI', tracker_pb2.FieldTypes.APPROVAL_TYPE,
+        'Enhancement', None, False, False, False, None, None, None,
+        False, None, None, None, 'no_action', 'descriptive docstring',
+        False, approval_id=None, is_phase_field=False))
+    self.config.field_defs.append(tracker_bizobj.MakeFieldDef(
+        27, 789, 'M-Target', tracker_pb2.FieldTypes.INT_TYPE,
+        'Enhancement', None, False, False, False, None, None, None,
+        False, None, None, None, 'no_action', 'descriptive docstring',
+        False, approval_id=None, is_phase_field=True))
+    field_values = [
+        tracker_bizobj.MakeFieldValue(1, 5, None, None, None, None, False),
+        tracker_bizobj.MakeFieldValue(
+            27, 74, None, None, None, None, False, phase_id=3),
+        # phase_id=4 does not belong to any of the phases given below.
+        # this field value should not show up in the views.
+        tracker_bizobj.MakeFieldValue(
+            27, 79, None, None, None, None, False, phase_id=4),
+        ]
+    users_by_id = {}
+    phase_1 = tracker_pb2.Phase(phase_id=1, name='Stable')
+    phase_2 = tracker_pb2.Phase(phase_id=2, name='Beta')
+    phase_3 = tracker_pb2.Phase(phase_id=3, name='stable')
+    fvvs = tracker_views.MakeAllFieldValueViews(
+        self.config, labels, derived_labels, field_values, users_by_id,
+        parent_approval_ids=[23], phases=[phase_1, phase_2, phase_3])
+    self.assertEqual(9, len(fvvs))
+    # Values are sorted by (applicable_type, field_name).
+    logging.info([fv.field_name for fv in fvvs])
+    (estdays_fvv, launch_milestone_fvv, legal_fvv, legal_faq_fvv,
+      beta_mtarget_fvv, stable_mtarget_fvv, os_fvv, ui_fvv, ui_mocks_fvv) = fvvs
+    self.assertEqual('EstDays', estdays_fvv.field_name)
+    self.assertEqual(1, len(estdays_fvv.values))
+    self.assertEqual(0, len(estdays_fvv.derived_values))
+    self.assertEqual('Launch-Milestone', launch_milestone_fvv.field_name)
+    self.assertEqual(1, len(launch_milestone_fvv.values))
+    self.assertEqual(0, len(launch_milestone_fvv.derived_values))
+    self.assertEqual('OS', os_fvv.field_name)
+    self.assertEqual(0, len(os_fvv.values))
+    self.assertEqual(2, len(os_fvv.derived_values))
+    self.assertEqual(ui_mocks_fvv.field_name, 'UIMocks')
+    self.assertEqual(ui_mocks_fvv.phase_name, '')
+    self.assertTrue(ui_mocks_fvv.applicable)
+    self.assertEqual(legal_faq_fvv.field_name, 'LegalFAQs')
+    self.assertFalse(legal_faq_fvv.applicable)
+    self.assertFalse(legal_fvv.applicable)
+    self.assertFalse(ui_fvv.applicable)
+    self.assertEqual('M-Target', stable_mtarget_fvv.field_name)
+    self.assertEqual('stable', stable_mtarget_fvv.phase_name)
+    self.assertEqual(1, len(stable_mtarget_fvv.values))
+    self.assertEqual(74, stable_mtarget_fvv.values[0].val)
+    self.assertEqual(0, len(stable_mtarget_fvv.derived_values))
+    self.assertEqual('M-Target', beta_mtarget_fvv.field_name)
+    self.assertEqual('beta', beta_mtarget_fvv.phase_name)
+    self.assertEqual(0, len(beta_mtarget_fvv.values))
+    self.assertEqual(0, len(beta_mtarget_fvv.values))
+
+  def testMakeFieldValueView(self):
+    pass  # Covered by testMakeAllFieldValueViews()
+
+  def testMakeFieldValueItemsTest(self):
+    pass  # Covered by testMakeAllFieldValueViews()
+
+  def testMakeBounceFieldValueViews(self):
+    config = tracker_pb2.ProjectIssueConfig()
+    fd = tracker_pb2.FieldDef(
+        field_id=3, field_type=tracker_pb2.FieldTypes.INT_TYPE,
+        applicable_type='', field_name='EstDays')
+    phase_fd = tracker_pb2.FieldDef(
+        field_id=4, field_type=tracker_pb2.FieldTypes.INT_TYPE,
+        applicable_type='', field_name='Gump')
+    config.field_defs = [fd,
+                         phase_fd,
+                         tracker_pb2.FieldDef(
+        field_id=5, field_type=tracker_pb2.FieldTypes.STR_TYPE)
+    ]
+    parsed_fvs = {3: [455]}
+    parsed_phase_fvs = {
+        4: {'stable': [73, 74], 'beta': [8], 'beta-exp': [75]},
+    }
+    fvs = tracker_views.MakeBounceFieldValueViews(
+        parsed_fvs, parsed_phase_fvs, config)
+
+    self.assertEqual(len(fvs), 4)
+
+    estdays_ezt_fv = template_helpers.EZTItem(val=455, docstring='', idx=0)
+    expected = tracker_views.FieldValueView(
+        fd, config, [estdays_ezt_fv], [], [])
+    self.assertEqual(fvs[0].field_name, expected.field_name)
+    self.assertEqual(fvs[0].values[0].val, expected.values[0].val)
+    self.assertEqual(fvs[0].values[0].idx, expected.values[0].idx)
+    self.assertTrue(fvs[0].applicable)
+
+    self.assertEqual(fvs[1].field_name, phase_fd.field_name)
+    self.assertEqual(fvs[2].field_name, phase_fd.field_name)
+    self.assertEqual(fvs[3].field_name, phase_fd.field_name)
+
+    fd.approval_id = 23
+    config.field_defs = [fd,
+                         tracker_pb2.FieldDef(
+                             field_id=23, field_name='Legal',
+                             field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE)]
+    fvs = tracker_views.MakeBounceFieldValueViews(parsed_fvs, {}, config)
+    self.assertTrue(fvs[0].applicable)
+
+
+class ConvertLabelsToFieldValuesTest(unittest.TestCase):
+
+  def testConvertLabelsToFieldValues_NoLabels(self):
+    result = tracker_views._ConvertLabelsToFieldValues(
+        [], 'opsys', {})
+    self.assertEqual([], result)
+
+  def testConvertLabelsToFieldValues_NoMatch(self):
+    result = tracker_views._ConvertLabelsToFieldValues(
+        [], 'opsys', {})
+    self.assertEqual([], result)
+
+  def testConvertLabelsToFieldValues_HasMatch(self):
+    result = tracker_views._ConvertLabelsToFieldValues(
+        ['OSX'], 'opsys', {})
+    self.assertEqual(1, len(result))
+    self.assertEqual('OSX', result[0].val)
+    self.assertEqual('', result[0].docstring)
+
+    result = tracker_views._ConvertLabelsToFieldValues(
+        ['OSX', 'All'], 'opsys', {'opsys-all': 'Happens everywhere'})
+    self.assertEqual(2, len(result))
+    self.assertEqual('OSX', result[0].val)
+    self.assertEqual('', result[0].docstring)
+    self.assertEqual('All', result[1].val)
+    self.assertEqual('Happens everywhere', result[1].docstring)
+
+
+class FieldDefViewTest(unittest.TestCase):
+
+  def setUp(self):
+    self.approval_fd = tracker_bizobj.MakeFieldDef(
+        1, 789, 'LaunchApproval', tracker_pb2.FieldTypes.APPROVAL_TYPE, None,
+        None, True, True, False, 3, 99, None, False, None, None,
+        None, 'no_action', 'descriptive docstring', False, None, False)
+
+    self.approval_def = tracker_pb2.ApprovalDef(
+        approval_id=1, approver_ids=[111], survey='question?')
+
+    self.field_def = tracker_bizobj.MakeFieldDef(
+        2, 789, 'AffectedUsers', tracker_pb2.FieldTypes.INT_TYPE, None,
+        None, True, True, False, 3, 99, None, False, None, None,
+        None, 'no_action', 'descriptive docstring', False, 1, False)
+
+    self.field_def.admin_ids = [222]
+    self.field_def.editor_ids = [111, 333]
+
+  def testFieldDefView_Normal(self):
+    config = _MakeConfig()
+    config.field_defs.append(self.approval_fd)
+    config.approval_defs.append(self.approval_def)
+
+    user_view_1 = framework_views.StuffUserView(111, 'uv1@example.com', False)
+    user_view_2 = framework_views.StuffUserView(222, 'uv2@example.com', False)
+    user_view_3 = framework_views.StuffUserView(333, 'uv3@example.com', False)
+    user_views = {111: user_view_1, 222: user_view_2, 333: user_view_3}
+    view = tracker_views.FieldDefView(
+        self.field_def, config, user_views=user_views)
+
+    self.assertEqual('AffectedUsers', view.field_name)
+    self.assertEqual(self.field_def, view.field_def)
+    self.assertEqual('descriptive docstring', view.docstring_short)
+    self.assertEqual('INT_TYPE', view.type_name)
+    self.assertEqual([], view.choices)
+    self.assertEqual('required', view.importance)
+    self.assertEqual(3, view.min_value)
+    self.assertEqual(99, view.max_value)
+    self.assertEqual('no_action', view.date_action_str)
+    self.assertEqual(view.approval_id, 1)
+    self.assertEqual(view.is_approval_subfield, ezt.boolean(True))
+    self.assertEqual(view.approvers, [])
+    self.assertEqual(view.survey, '')
+    self.assertEqual(view.survey_questions, [])
+    self.assertEqual(len(view.admins), 1)
+    self.assertEqual(len(view.editors), 2)
+    self.assertIsNone(view.is_phase_field)
+    self.assertIsNone(view.is_restricted_field)
+
+  def testFieldDefView_Approval(self):
+    config = _MakeConfig()
+    approver_view = framework_views.StuffUserView(
+        111, 'shouldnotmatter@ch.org', False)
+    user_views = {111: approver_view}
+
+    view = tracker_views.FieldDefView(
+        self.approval_fd, config,
+        user_views= user_views, approval_def=self.approval_def)
+    self.assertEqual(view.approvers, [approver_view])
+    self.assertEqual(view.survey, self.approval_def.survey)
+    self.assertEqual(view.survey_questions, [view.survey])
+
+    self.approval_def.survey = None
+    view = tracker_views.FieldDefView(
+        self.approval_fd, config,
+        user_views= user_views, approval_def=self.approval_def)
+    self.assertEqual(view.survey, '')
+    self.assertEqual(view.survey_questions, [])
+
+    self.approval_def.survey = 'Q1\nQ2\nQ3'
+    view = tracker_views.FieldDefView(
+        self.approval_fd, config,
+        user_views= user_views, approval_def=self.approval_def)
+    self.assertEqual(view.survey, self.approval_def.survey)
+    self.assertEqual(view.survey_questions, ['Q1', 'Q2', 'Q3'])
+
+
+class IssueTemplateViewTest(unittest.TestCase):
+  pass  # TODO(jrobbins): write tests
+
+
+class MakeFieldUserViewsTest(unittest.TestCase):
+  pass  # TODO(jrobbins): write tests
+
+
+class ConfigViewTest(unittest.TestCase):
+  pass  # TODO(jrobbins): write tests
+
+
+class ConfigFunctionsTest(unittest.TestCase):
+
+  def setUp(self):
+    self.config = tracker_bizobj.MakeDefaultProjectIssueConfig(768)
+
+  def testStatusDefsAsText(self):
+    open_text, closed_text = tracker_views.StatusDefsAsText(self.config)
+
+    for wks in tracker_constants.DEFAULT_WELL_KNOWN_STATUSES:
+      status, doc, means_open, _deprecated = wks
+      if means_open:
+        self.assertIn(status, open_text)
+        self.assertIn(doc, open_text)
+      else:
+        self.assertIn(status, closed_text)
+        self.assertIn(doc, closed_text)
+
+    self.assertEqual(
+        len(tracker_constants.DEFAULT_WELL_KNOWN_STATUSES),
+        len(open_text.split('\n')) + len(closed_text.split('\n')))
+
+  def testLabelDefsAsText(self):
+    # Note: Day-Monday will not be part of the result because it is masked.
+    self.config.field_defs.append(tracker_pb2.FieldDef(
+        field_id=1, field_name='Day',
+        field_type=tracker_pb2.FieldTypes.ENUM_TYPE))
+    self.config.well_known_labels.append(tracker_pb2.LabelDef(
+        label='Day-Monday'))
+    labels_text = tracker_views.LabelDefsAsText(self.config)
+
+    for wkl in tracker_constants.DEFAULT_WELL_KNOWN_LABELS:
+      label, doc, _deprecated = wkl
+      self.assertIn(label, labels_text)
+      self.assertIn(doc, labels_text)
+    self.assertEqual(
+        len(tracker_constants.DEFAULT_WELL_KNOWN_LABELS),
+        len(labels_text.split('\n')))
diff --git a/tracker/test/webcomponentspage_test.py b/tracker/test/webcomponentspage_test.py
new file mode 100644
index 0000000..65cfc66
--- /dev/null
+++ b/tracker/test/webcomponentspage_test.py
@@ -0,0 +1,120 @@
+# Copyright 2020 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+"""Tests for the Monorail SPA pages, as served by EZT."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import mock
+import unittest
+
+import ezt
+
+import settings
+from framework import permissions
+from proto import project_pb2
+from proto import site_pb2
+from services import service_manager
+from tracker import webcomponentspage
+from testing import fake
+from testing import testing_helpers
+
+
+class WebComponentsPageTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        user=fake.UserService(),
+        project=fake.ProjectService(),
+        features=fake.FeaturesService())
+
+    self.user = self.services.user.TestAddUser('user@example.com', 111)
+    self.project = self.services.project.TestAddProject('proj', project_id=789)
+    self.hotlist = self.services.features.TestAddHotlist(
+        'HotlistName', summary='summary', owner_ids=[111], hotlist_id=1236)
+
+    self.servlet = webcomponentspage.WebComponentsPage(
+        'req', 'res', services=self.services)
+
+  def testHotlistPage_OldUiUrl(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 111},
+        path='/hotlists/1236',
+        services=self.services)
+
+    page_data = self.servlet.GatherPageData(mr)
+    self.assertEqual('/u/111/hotlists/HotlistName', page_data['old_ui_url'])
+
+  def testHotlistPage_OldUiUrl_People(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 111},
+        path='/hotlists/1236/people',
+        services=self.services)
+
+    page_data = self.servlet.GatherPageData(mr)
+    self.assertEqual(
+        '/u/111/hotlists/HotlistName/people', page_data['old_ui_url'])
+
+  def testHotlistPage_OldUiUrl_Settings(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 111},
+        path='/hotlists/1236/settings',
+        services=self.services)
+
+    page_data = self.servlet.GatherPageData(mr)
+    self.assertEqual(
+        '/u/111/hotlists/HotlistName/details', page_data['old_ui_url'])
+
+
+class ProjectListPageTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(project=fake.ProjectService())
+
+    self.project_a = self.services.project.TestAddProject('a', project_id=1)
+    self.project_b = self.services.project.TestAddProject('b', project_id=2)
+
+    self.servlet = webcomponentspage.ProjectListPage(
+        'req', 'res', services=self.services)
+
+  @mock.patch('settings.domain_to_default_project', {})
+  def testMaybeRedirectToDomainDefaultProject_NoMatch(self):
+    """No redirect if the user is not accessing via a configured domain."""
+    mr = testing_helpers.MakeMonorailRequest()
+    mr.request.host = 'example.com'
+    msg = self.servlet._MaybeRedirectToDomainDefaultProject(mr)
+    print('msg: ' + msg)
+    self.assertTrue(msg.startswith('No configured'))
+
+  @mock.patch('settings.domain_to_default_project', {'example.com': 'huh'})
+  def testMaybeRedirectToDomainDefaultProject_NoSuchProject(self):
+    """No redirect if the configured project does not exist."""
+    mr = testing_helpers.MakeMonorailRequest()
+    mr.request.host = 'example.com'
+    print('host is %r' % mr.request.host)
+    msg = self.servlet._MaybeRedirectToDomainDefaultProject(mr)
+    print('msg: ' + msg)
+    self.assertTrue(msg.endswith('not found'))
+
+  @mock.patch('settings.domain_to_default_project', {'example.com': 'a'})
+  def testMaybeRedirectToDomainDefaultProject_CantView(self):
+    """No redirect if the user can't view the configured project."""
+    self.project_a.access = project_pb2.ProjectAccess.MEMBERS_ONLY
+    mr = testing_helpers.MakeMonorailRequest()
+    mr.request.host = 'example.com'
+    msg = self.servlet._MaybeRedirectToDomainDefaultProject(mr)
+    print('msg: ' + msg)
+    self.assertTrue(msg.startswith('User cannot'))
+
+  @mock.patch('settings.domain_to_default_project', {'example.com': 'a'})
+  def testMaybeRedirectToDomainDefaultProject_Redirect(self):
+    """We redirect if there's a configured project that the user can view."""
+    mr = testing_helpers.MakeMonorailRequest()
+    mr.request.host = 'example.com'
+    self.servlet.redirect = mock.Mock()
+    msg = self.servlet._MaybeRedirectToDomainDefaultProject(mr)
+    print('msg: ' + msg)
+    self.assertTrue(msg.startswith('Redirected'))
+    self.servlet.redirect.assert_called_once()
diff --git a/tracker/tracker_bizobj.py b/tracker/tracker_bizobj.py
new file mode 100644
index 0000000..f3f2594
--- /dev/null
+++ b/tracker/tracker_bizobj.py
@@ -0,0 +1,1831 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Business objects for the Monorail issue tracker.
+
+These are classes and functions that operate on the objects that
+users care about in the issue tracker: e.g., issues, and the issue
+tracker configuration.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import collections
+import logging
+import time
+
+from six import string_types
+
+from features import federated
+from framework import exceptions
+from framework import framework_bizobj
+from framework import framework_constants
+from framework import framework_helpers
+from framework import timestr
+from framework import urls
+from proto import tracker_pb2
+from tracker import tracker_constants
+
+
+def GetOwnerId(issue):
+  """Get the owner of an issue, whether it is explicit or derived."""
+  return (issue.owner_id or issue.derived_owner_id or
+          framework_constants.NO_USER_SPECIFIED)
+
+
+def GetStatus(issue):
+  """Get the status of an issue, whether it is explicit or derived."""
+  return issue.status or issue.derived_status or  ''
+
+
+def GetCcIds(issue):
+  """Get the Cc's of an issue, whether they are explicit or derived."""
+  return issue.cc_ids + issue.derived_cc_ids
+
+
+def GetApproverIds(issue):
+  """Get the Approvers' ids of an isuses approval_values."""
+  approver_ids = []
+  for av in issue.approval_values:
+    approver_ids.extend(av.approver_ids)
+
+  return list(set(approver_ids))
+
+
+def GetLabels(issue):
+  """Get the labels of an issue, whether explicit or derived."""
+  return issue.labels + issue.derived_labels
+
+
+def MakeProjectIssueConfig(
+    project_id, well_known_statuses, statuses_offer_merge, well_known_labels,
+    excl_label_prefixes, col_spec):
+  """Return a ProjectIssueConfig with the given values."""
+  # pylint: disable=multiple-statements
+  if not well_known_statuses: well_known_statuses = []
+  if not statuses_offer_merge: statuses_offer_merge = []
+  if not well_known_labels: well_known_labels = []
+  if not excl_label_prefixes: excl_label_prefixes = []
+  if not col_spec: col_spec = ' '
+
+  project_config = tracker_pb2.ProjectIssueConfig()
+  if project_id:  # There is no ID for harmonized configs.
+    project_config.project_id = project_id
+
+  SetConfigStatuses(project_config, well_known_statuses)
+  project_config.statuses_offer_merge = statuses_offer_merge
+  SetConfigLabels(project_config, well_known_labels)
+  project_config.exclusive_label_prefixes = excl_label_prefixes
+
+  # ID 0 means that nothing has been specified, so use hard-coded defaults.
+  project_config.default_template_for_developers = 0
+  project_config.default_template_for_users = 0
+
+  project_config.default_col_spec = col_spec
+
+  # Note: default project issue config has no filter rules.
+
+  return project_config
+
+
+def FindFieldDef(field_name, config):
+  """Find the specified field, or return None."""
+  if not field_name:
+    return None
+  field_name_lower = field_name.lower()
+  for fd in config.field_defs:
+    if fd.field_name.lower() == field_name_lower:
+      return fd
+
+  return None
+
+
+def FindFieldDefByID(field_id, config):
+  """Find the specified field, or return None."""
+  for fd in config.field_defs:
+    if fd.field_id == field_id:
+      return fd
+
+  return None
+
+
+def FindApprovalDef(approval_name, config):
+  """Find the specified approval, or return None."""
+  fd = FindFieldDef(approval_name, config)
+  if fd:
+    return FindApprovalDefByID(fd.field_id, config)
+
+  return None
+
+
+def FindApprovalDefByID(approval_id, config):
+  """Find the specified approval, or return None."""
+  for approval_def in config.approval_defs:
+    if approval_def.approval_id == approval_id:
+      return approval_def
+
+  return None
+
+
+def FindApprovalValueByID(approval_id, approval_values):
+  """Find the specified approval_value in the given list or return None."""
+  for av in approval_values:
+    if av.approval_id == approval_id:
+      return av
+
+  return None
+
+
+def FindApprovalsSubfields(approval_ids, config):
+  """Return a dict of {approval_ids: approval_subfields}."""
+  approval_subfields_dict = collections.defaultdict(list)
+  for fd in config.field_defs:
+    if fd.approval_id in approval_ids:
+      approval_subfields_dict[fd.approval_id].append(fd)
+
+  return approval_subfields_dict
+
+
+def FindPhaseByID(phase_id, phases):
+  """Find the specified phase, or return None"""
+  for phase in phases:
+    if phase.phase_id == phase_id:
+      return phase
+
+  return None
+
+
+def FindPhase(name, phases):
+  """Find the specified phase, or return None"""
+  for phase in phases:
+    if phase.name.lower() == name.lower():
+      return phase
+
+  return None
+
+
+def GetGrantedPerms(issue, effective_ids, config):
+  """Return a set of permissions granted by user-valued fields in an issue."""
+  granted_perms = set()
+  for field_value in issue.field_values:
+    if field_value.user_id in effective_ids:
+      field_def = FindFieldDefByID(field_value.field_id, config)
+      if field_def and field_def.grants_perm:
+        # TODO(jrobbins): allow comma-separated list in grants_perm
+        granted_perms.add(field_def.grants_perm.lower())
+
+  return granted_perms
+
+
+def LabelsByPrefix(labels, lower_field_names):
+  """Convert a list of key-value labels into {lower_prefix: [value, ...]}.
+
+  It also handles custom fields with dashes in the field name.
+  """
+  label_values_by_prefix = collections.defaultdict(list)
+  for lab in labels:
+    if '-' not in lab:
+      continue
+    lower_lab = lab.lower()
+    for lower_field_name in lower_field_names:
+      if lower_lab.startswith(lower_field_name + '-'):
+        prefix = lower_field_name
+        value = lab[len(lower_field_name)+1:]
+        break
+    else:  # No field name matched
+      prefix, value = lab.split('-', 1)
+      prefix = prefix.lower()
+    label_values_by_prefix[prefix].append(value)
+  return label_values_by_prefix
+
+
+def LabelIsMaskedByField(label, field_names):
+  """If the label should be displayed as a field, return the field name.
+
+  Args:
+    label: string label to consider.
+    field_names: a list of field names in lowercase.
+
+  Returns:
+    If masked, return the lowercase name of the field, otherwise None.  A label
+    is masked by a custom field if the field name "Foo" matches the key part of
+    a key-value label "Foo-Bar".
+  """
+  if '-' not in label:
+    return None
+
+  for field_name_lower in field_names:
+    if label.lower().startswith(field_name_lower + '-'):
+      return field_name_lower
+
+  return None
+
+
+def NonMaskedLabels(labels, field_names):
+  """Return only those labels that are not masked by custom fields."""
+  return [lab for lab in labels
+          if not LabelIsMaskedByField(lab, field_names)]
+
+
+def ExplicitAndDerivedNonMaskedLabels(labels, derived_labels, config):
+  """Return two lists of labels that are not masked by enum custom fields."""
+  field_names = [fd.field_name.lower() for fd in config.field_defs
+                 if fd.field_type is tracker_pb2.FieldTypes.ENUM_TYPE and
+                 not fd.is_deleted]  # TODO(jrobbins): restricts
+  labels = [
+      lab for lab in labels
+      if not LabelIsMaskedByField(lab, field_names)]
+  derived_labels = [
+    lab for lab in derived_labels
+    if not LabelIsMaskedByField(lab, field_names)]
+  return labels, derived_labels
+
+
+def MakeApprovalValue(approval_id, approver_ids=None, status=None,
+                      setter_id=None, set_on=None, phase_id=None):
+  """Return an ApprovalValue PB with the given field values."""
+  av = tracker_pb2.ApprovalValue(
+      approval_id=approval_id, status=status,
+      setter_id=setter_id, set_on=set_on, phase_id=phase_id)
+  if approver_ids is not None:
+    av.approver_ids = approver_ids
+  return av
+
+
+def MakeFieldDef(
+    field_id,
+    project_id,
+    field_name,
+    field_type_int,
+    applic_type,
+    applic_pred,
+    is_required,
+    is_niche,
+    is_multivalued,
+    min_value,
+    max_value,
+    regex,
+    needs_member,
+    needs_perm,
+    grants_perm,
+    notify_on,
+    date_action,
+    docstring,
+    is_deleted,
+    approval_id=None,
+    is_phase_field=False,
+    is_restricted_field=False,
+    admin_ids=None,
+    editor_ids=None):
+  """Make a FieldDef PB for the given FieldDef table row tuple."""
+  if isinstance(date_action, string_types):
+    date_action = date_action.upper()
+  fd = tracker_pb2.FieldDef(
+      field_id=field_id,
+      project_id=project_id,
+      field_name=field_name,
+      field_type=field_type_int,
+      is_required=bool(is_required),
+      is_niche=bool(is_niche),
+      is_multivalued=bool(is_multivalued),
+      docstring=docstring,
+      is_deleted=bool(is_deleted),
+      applicable_type=applic_type or '',
+      applicable_predicate=applic_pred or '',
+      needs_member=bool(needs_member),
+      grants_perm=grants_perm or '',
+      notify_on=tracker_pb2.NotifyTriggers(notify_on or 0),
+      date_action=tracker_pb2.DateAction(date_action or 0),
+      is_phase_field=bool(is_phase_field),
+      is_restricted_field=bool(is_restricted_field))
+  if min_value is not None:
+    fd.min_value = min_value
+  if max_value is not None:
+    fd.max_value = max_value
+  if regex is not None:
+    fd.regex = regex
+  if needs_perm is not None:
+    fd.needs_perm = needs_perm
+  if approval_id is not None:
+    fd.approval_id = approval_id
+  if admin_ids:
+    fd.admin_ids = admin_ids
+  if editor_ids:
+    fd.editor_ids = editor_ids
+  return fd
+
+
+def MakeFieldValue(
+    field_id, int_value, str_value, user_id, date_value, url_value, derived,
+    phase_id=None):
+  """Make a FieldValue based on the given information."""
+  fv = tracker_pb2.FieldValue(field_id=field_id, derived=derived)
+  if phase_id is not None:
+    fv.phase_id = phase_id
+  if int_value is not None:
+    fv.int_value = int_value
+  elif str_value is not None:
+    fv.str_value = str_value
+  elif user_id is not None:
+    fv.user_id = user_id
+  elif date_value is not None:
+    fv.date_value = date_value
+  elif url_value is not None:
+    fv.url_value = url_value
+  else:
+    raise ValueError('Unexpected field value')
+  return fv
+
+
+def GetFieldValueWithRawValue(field_type, field_value, users_by_id, raw_value):
+  """Find and return the field value of the specified field type.
+
+  If the specified field_value is None or is empty then the raw_value is
+  returned. When the field type is USER_TYPE the raw_value is used as a key to
+  lookup users_by_id.
+
+  Args:
+    field_type: tracker_pb2.FieldTypes type.
+    field_value: tracker_pb2.FieldValue type.
+    users_by_id: Dict mapping user_ids to UserViews.
+    raw_value: String to use if field_value is not specified.
+
+  Returns:
+    Value of the specified field type.
+  """
+  ret_value = GetFieldValue(field_value, users_by_id)
+  if ret_value:
+    return ret_value
+  # Special case for user types.
+  if field_type == tracker_pb2.FieldTypes.USER_TYPE:
+    if raw_value in users_by_id:
+      return users_by_id[raw_value].email
+  return raw_value
+
+
+def GetFieldValue(fv, users_by_id):
+  """Return the value of this field.  Give emails for users in users_by_id."""
+  if fv is None:
+    return None
+  elif fv.int_value is not None:
+    return fv.int_value
+  elif fv.str_value is not None:
+    return fv.str_value
+  elif fv.user_id is not None:
+    if fv.user_id in users_by_id:
+      return users_by_id[fv.user_id].email
+    else:
+      logging.info('Failed to lookup user %d when getting field', fv.user_id)
+      return fv.user_id
+  elif fv.date_value is not None:
+    return timestr.TimestampToDateWidgetStr(fv.date_value)
+  elif fv.url_value is not None:
+    return fv.url_value
+  else:
+    return None
+
+
+def FindComponentDef(path, config):
+  """Find the specified component, or return None."""
+  path_lower = path.lower()
+  for cd in config.component_defs:
+    if cd.path.lower() == path_lower:
+      return cd
+
+  return None
+
+
+def FindMatchingComponentIDs(path, config, exact=True):
+  """Return a list of components that match the given path."""
+  component_ids = []
+  path_lower = path.lower()
+
+  if exact:
+    for cd in config.component_defs:
+      if cd.path.lower() == path_lower:
+        component_ids.append(cd.component_id)
+  else:
+    path_lower_delim = path.lower() + '>'
+    for cd in config.component_defs:
+      target_delim = cd.path.lower() + '>'
+      if target_delim.startswith(path_lower_delim):
+        component_ids.append(cd.component_id)
+
+  return component_ids
+
+
+def FindComponentDefByID(component_id, config):
+  """Find the specified component, or return None."""
+  for cd in config.component_defs:
+    if cd.component_id == component_id:
+      return cd
+
+  return None
+
+
+def FindAncestorComponents(config, component_def):
+  """Return a list of all components the given component is under."""
+  path_lower = component_def.path.lower()
+  return [cd for cd in config.component_defs
+          if path_lower.startswith(cd.path.lower() + '>')]
+
+
+def GetIssueComponentsAndAncestors(issue, config):
+  """Return a list of all the components that an issue is in."""
+  result = set()
+  for component_id in issue.component_ids:
+    cd = FindComponentDefByID(component_id, config)
+    if cd is None:
+      logging.error('Tried to look up non-existent component %r' % component_id)
+      continue
+    ancestors = FindAncestorComponents(config, cd)
+    result.add(cd)
+    result.update(ancestors)
+
+  return sorted(result, key=lambda cd: cd.path)
+
+
+def FindDescendantComponents(config, component_def):
+  """Return a list of all nested components under the given component."""
+  path_plus_delim = component_def.path.lower() + '>'
+  return [cd for cd in config.component_defs
+          if cd.path.lower().startswith(path_plus_delim)]
+
+
+def MakeComponentDef(
+    component_id, project_id, path, docstring, deprecated, admin_ids, cc_ids,
+    created, creator_id, modified=None, modifier_id=None, label_ids=None):
+  """Make a ComponentDef PB for the given FieldDef table row tuple."""
+  cd = tracker_pb2.ComponentDef(
+      component_id=component_id, project_id=project_id, path=path,
+      docstring=docstring, deprecated=bool(deprecated),
+      admin_ids=admin_ids, cc_ids=cc_ids, created=created,
+      creator_id=creator_id, modified=modified, modifier_id=modifier_id,
+      label_ids=label_ids or [])
+  return cd
+
+
+def MakeSavedQuery(
+    query_id, name, base_query_id, query, subscription_mode=None,
+    executes_in_project_ids=None):
+  """Make SavedQuery PB for the given info."""
+  saved_query = tracker_pb2.SavedQuery(
+      name=name, base_query_id=base_query_id, query=query)
+  if query_id is not None:
+    saved_query.query_id = query_id
+  if subscription_mode is not None:
+    saved_query.subscription_mode = subscription_mode
+  if executes_in_project_ids is not None:
+    saved_query.executes_in_project_ids = executes_in_project_ids
+  return saved_query
+
+
+def SetConfigStatuses(project_config, well_known_statuses):
+  """Internal method to set the well-known statuses of ProjectIssueConfig."""
+  project_config.well_known_statuses = []
+  for status, docstring, means_open, deprecated in well_known_statuses:
+    canonical_status = framework_bizobj.CanonicalizeLabel(status)
+    project_config.well_known_statuses.append(tracker_pb2.StatusDef(
+        status_docstring=docstring, status=canonical_status,
+        means_open=means_open, deprecated=deprecated))
+
+
+def SetConfigLabels(project_config, well_known_labels):
+  """Internal method to set the well-known labels of a ProjectIssueConfig."""
+  project_config.well_known_labels = []
+  for label, docstring, deprecated in well_known_labels:
+    canonical_label = framework_bizobj.CanonicalizeLabel(label)
+    project_config.well_known_labels.append(tracker_pb2.LabelDef(
+        label=canonical_label, label_docstring=docstring,
+        deprecated=deprecated))
+
+
+def SetConfigApprovals(project_config, approval_def_tuples):
+  """Internal method to set up approval defs of a ProjectissueConfig."""
+  project_config.approval_defs = []
+  for approval_id, approver_ids, survey in approval_def_tuples:
+    project_config.approval_defs.append(tracker_pb2.ApprovalDef(
+        approval_id=approval_id, approver_ids=approver_ids, survey=survey))
+
+
+def ConvertDictToTemplate(template_dict):
+  """Construct a Template PB with the values from template_dict.
+
+  Args:
+    template_dict: dictionary with fields corresponding to the Template
+        PB fields.
+
+  Returns:
+    A Template protocol buffer that can be stored in the
+    project's ProjectIssueConfig PB.
+  """
+  return MakeIssueTemplate(
+      template_dict.get('name'), template_dict.get('summary'),
+      template_dict.get('status'), template_dict.get('owner_id'),
+      template_dict.get('content'), template_dict.get('labels'), [], [],
+      template_dict.get('components'),
+      summary_must_be_edited=template_dict.get('summary_must_be_edited'),
+      owner_defaults_to_member=template_dict.get('owner_defaults_to_member'),
+      component_required=template_dict.get('component_required'),
+      members_only=template_dict.get('members_only'))
+
+
+def MakeIssueTemplate(
+    name,
+    summary,
+    status,
+    owner_id,
+    content,
+    labels,
+    field_values,
+    admin_ids,
+    component_ids,
+    summary_must_be_edited=None,
+    owner_defaults_to_member=None,
+    component_required=None,
+    members_only=None,
+    phases=None,
+    approval_values=None):
+  """Make an issue template PB."""
+  template = tracker_pb2.TemplateDef()
+  template.name = name
+  if summary:
+    template.summary = summary
+  if status:
+    template.status = status
+  if owner_id:
+    template.owner_id = owner_id
+  template.content = content
+  template.field_values = field_values
+  template.labels = labels or []
+  template.admin_ids = admin_ids
+  template.component_ids = component_ids or []
+  template.approval_values = approval_values or []
+
+  if summary_must_be_edited is not None:
+    template.summary_must_be_edited = summary_must_be_edited
+  if owner_defaults_to_member is not None:
+    template.owner_defaults_to_member = owner_defaults_to_member
+  if component_required is not None:
+    template.component_required = component_required
+  if members_only is not None:
+    template.members_only = members_only
+  if phases is not None:
+    template.phases = phases
+
+  return template
+
+
+def MakeDefaultProjectIssueConfig(project_id):
+  """Return a ProjectIssueConfig with use by projects that don't have one."""
+  return MakeProjectIssueConfig(
+      project_id,
+      tracker_constants.DEFAULT_WELL_KNOWN_STATUSES,
+      tracker_constants.DEFAULT_STATUSES_OFFER_MERGE,
+      tracker_constants.DEFAULT_WELL_KNOWN_LABELS,
+      tracker_constants.DEFAULT_EXCL_LABEL_PREFIXES,
+      tracker_constants.DEFAULT_COL_SPEC)
+
+
+def HarmonizeConfigs(config_list):
+  """Combine several ProjectIssueConfigs into one for cross-project sorting.
+
+  Args:
+    config_list: a list of ProjectIssueConfig PBs with labels and statuses
+        among other fields.
+
+  Returns:
+    A new ProjectIssueConfig with just the labels and status values filled
+    in to be a logical union of the given configs.  Specifically, the order
+    of the combined status and label lists should be maintained.
+  """
+  if not config_list:
+    return MakeDefaultProjectIssueConfig(None)
+
+  harmonized_status_names = _CombineOrderedLists(
+      [[stat.status for stat in config.well_known_statuses]
+       for config in config_list])
+  harmonized_label_names = _CombineOrderedLists(
+      [[lab.label for lab in config.well_known_labels]
+       for config in config_list])
+  harmonized_default_sort_spec = ' '.join(
+      config.default_sort_spec for config in config_list)
+  harmonized_means_open = {
+      status: any([stat.means_open
+                   for config in config_list
+                   for stat in config.well_known_statuses
+                   if stat.status == status])
+      for status in harmonized_status_names}
+
+  # This col_spec is probably not what the user wants to view because it is
+  # too much information.  We join all the col_specs here so that we are sure
+  # to lookup all users needed for sorting, even if it is more than needed.
+  # xxx we need to look up users based on colspec rather than sortspec?
+  harmonized_default_col_spec = ' '.join(
+      config.default_col_spec for config in config_list)
+
+  result_config = tracker_pb2.ProjectIssueConfig()
+  # The combined config is only used during sorting, never stored.
+  result_config.default_col_spec = harmonized_default_col_spec
+  result_config.default_sort_spec = harmonized_default_sort_spec
+
+  for status_name in harmonized_status_names:
+    result_config.well_known_statuses.append(tracker_pb2.StatusDef(
+        status=status_name, means_open=harmonized_means_open[status_name]))
+
+  for label_name in harmonized_label_names:
+    result_config.well_known_labels.append(tracker_pb2.LabelDef(
+        label=label_name))
+
+  for config in config_list:
+    result_config.field_defs.extend(
+      list(fd for fd in config.field_defs if not fd.is_deleted))
+    result_config.component_defs.extend(config.component_defs)
+    result_config.approval_defs.extend(config.approval_defs)
+
+  return result_config
+
+
+def HarmonizeLabelOrStatusRows(def_rows):
+  """Put the given label defs into a logical global order."""
+  ranked_defs_by_project = {}
+  oddball_defs = []
+  for row in def_rows:
+    def_id, project_id, rank, label = row[0], row[1], row[2], row[3]
+    if rank is not None:
+      ranked_defs_by_project.setdefault(project_id, []).append(
+          (def_id, rank, label))
+    else:
+      oddball_defs.append((def_id, rank, label))
+
+  oddball_defs.sort(reverse=True, key=lambda def_tuple: def_tuple[2].lower())
+  # Compose the list-of-lists in a consistent order by project_id.
+  list_of_lists = [ranked_defs_by_project[pid]
+                   for pid in sorted(ranked_defs_by_project.keys())]
+  harmonized_ranked_defs = _CombineOrderedLists(
+      list_of_lists, include_duplicate_keys=True,
+      key=lambda def_tuple: def_tuple[2])
+
+  return oddball_defs + harmonized_ranked_defs
+
+
+def _CombineOrderedLists(
+    list_of_lists, include_duplicate_keys=False, key=lambda x: x):
+  """Combine lists of items while maintaining their desired order.
+
+  Args:
+    list_of_lists: a list of lists of strings.
+    include_duplicate_keys: Pass True to make the combined list have the
+        same total number of elements as the sum of the input lists.
+    key: optional function to choose which part of the list items hold the
+        string used for comparison.  The result will have the whole items.
+
+  Returns:
+    A single list of items containing one copy of each of the items
+    in any of the original list, and in an order that maintains the original
+    list ordering as much as possible.
+  """
+  combined_items = []
+  combined_keys = []
+  seen_keys_set = set()
+  for one_list in list_of_lists:
+    _AccumulateCombinedList(
+        one_list, combined_items, combined_keys, seen_keys_set, key=key,
+        include_duplicate_keys=include_duplicate_keys)
+
+  return combined_items
+
+
+def _AccumulateCombinedList(
+    one_list, combined_items, combined_keys, seen_keys_set,
+    include_duplicate_keys=False, key=lambda x: x):
+  """Accumulate strings into a combined list while its maintaining ordering.
+
+  Args:
+    one_list: list of strings in a desired order.
+    combined_items: accumulated list of items in the desired order.
+    combined_keys: accumulated list of key strings in the desired order.
+    seen_keys_set: set of strings that are already in combined_list.
+    include_duplicate_keys: Pass True to make the combined list have the
+        same total number of elements as the sum of the input lists.
+    key: optional function to choose which part of the list items hold the
+        string used for comparison.  The result will have the whole items.
+
+  Returns:
+    Nothing.  But, combined_items is modified to mix in all the items of
+    one_list at appropriate points such that nothing in combined_items
+    is reordered, and the ordering of items from one_list is maintained
+    as much as possible.  Also, seen_keys_set is modified to add any keys
+    for items that were added to combined_items.
+
+  Also, any strings that begin with "#" are compared regardless of the "#".
+  The purpose of such strings is to guide the final ordering.
+  """
+  insert_idx = 0
+  for item in one_list:
+    s = key(item).lower()
+    if s in seen_keys_set:
+      item_idx = combined_keys.index(s)  # Need parallel list of keys
+      insert_idx = max(insert_idx, item_idx + 1)
+
+    if s not in seen_keys_set or include_duplicate_keys:
+      combined_items.insert(insert_idx, item)
+      combined_keys.insert(insert_idx, s)
+      insert_idx += 1
+
+    seen_keys_set.add(s)
+
+
+def GetBuiltInQuery(query_id):
+  """If the given query ID is for a built-in query, return that string."""
+  return tracker_constants.DEFAULT_CANNED_QUERY_CONDS.get(query_id, '')
+
+
+def UsersInvolvedInAmendments(amendments):
+  """Return a set of all user IDs mentioned in the given Amendments."""
+  user_id_set = set()
+  for amendment in amendments:
+    user_id_set.update(amendment.added_user_ids)
+    user_id_set.update(amendment.removed_user_ids)
+
+  return user_id_set
+
+
+def _AccumulateUsersInvolvedInComment(comment, user_id_set):
+  """Build up a set of all users involved in an IssueComment.
+
+  Args:
+    comment: an IssueComment PB.
+    user_id_set: a set of user IDs to build up.
+
+  Returns:
+    The same set, but modified to have the user IDs of user who
+    entered the comment, and all the users mentioned in any amendments.
+  """
+  user_id_set.add(comment.user_id)
+  user_id_set.update(UsersInvolvedInAmendments(comment.amendments))
+
+  return user_id_set
+
+
+def UsersInvolvedInComment(comment):
+  """Return a set of all users involved in an IssueComment.
+
+  Args:
+    comment: an IssueComment PB.
+
+  Returns:
+    A set with the user IDs of user who entered the comment, and all the
+    users mentioned in any amendments.
+  """
+  return _AccumulateUsersInvolvedInComment(comment, set())
+
+
+def UsersInvolvedInCommentList(comments):
+  """Return a set of all users involved in a list of IssueComments.
+
+  Args:
+    comments: a list of IssueComment PBs.
+
+  Returns:
+    A set with the user IDs of user who entered the comment, and all the
+    users mentioned in any amendments.
+  """
+  result = set()
+  for c in comments:
+    _AccumulateUsersInvolvedInComment(c, result)
+
+  return result
+
+
+def UsersInvolvedInIssues(issues):
+  """Return a set of all user IDs referenced in the issues' metadata."""
+  result = set()
+  for issue in issues:
+    result.update([issue.reporter_id, issue.owner_id, issue.derived_owner_id])
+    result.update(issue.cc_ids)
+    result.update(issue.derived_cc_ids)
+    result.update(fv.user_id for fv in issue.field_values if fv.user_id)
+    for av in issue.approval_values:
+      result.update(approver_id for approver_id in av.approver_ids)
+      if av.setter_id:
+        result.update([av.setter_id])
+
+  return result
+
+
+def UsersInvolvedInTemplate(template):
+  """Return a set of all user IDs referenced in the template."""
+  result = set(
+    template.admin_ids +
+    [fv.user_id for fv in template.field_values if fv.user_id])
+  if template.owner_id:
+    result.add(template.owner_id)
+  for av in template.approval_values:
+    result.update(set(av.approver_ids))
+    if av.setter_id:
+      result.add(av.setter_id)
+  return result
+
+
+def UsersInvolvedInTemplates(templates):
+  """Return a set of all user IDs referenced in the given templates."""
+  result = set()
+  for template in templates:
+    result.update(UsersInvolvedInTemplate(template))
+  return result
+
+
+def UsersInvolvedInComponents(component_defs):
+  """Return a set of user IDs referenced in the given components."""
+  result = set()
+  for cd in component_defs:
+    result.update(cd.admin_ids)
+    result.update(cd.cc_ids)
+    if cd.creator_id:
+      result.add(cd.creator_id)
+    if cd.modifier_id:
+      result.add(cd.modifier_id)
+
+  return result
+
+
+def UsersInvolvedInApprovalDefs(approval_defs, matching_fds):
+  # type: (Sequence[proto.tracker_pb2.ApprovalDef],
+  #     Sequence[proto.tracker_pb2.FieldDef]) -> Collection[int]
+  """Return a set of user IDs referenced in the approval_defs and field defs"""
+  result = set()
+  for ad in approval_defs:
+    result.update(ad.approver_ids)
+  for fd in matching_fds:
+    result.update(fd.admin_ids)
+  return result
+
+
+def UsersInvolvedInConfig(config):
+  """Return a set of all user IDs referenced in the config."""
+  result = set()
+  for ad in config.approval_defs:
+    result.update(ad.approver_ids)
+  for fd in config.field_defs:
+    result.update(fd.admin_ids)
+  result.update(UsersInvolvedInComponents(config.component_defs))
+  return result
+
+
+def LabelIDsInvolvedInConfig(config):
+  """Return a set of all label IDs referenced in the config."""
+  result = set()
+  for cd in config.component_defs:
+    result.update(cd.label_ids)
+  return result
+
+
+def MakeApprovalDelta(
+    status, setter_id, approver_ids_add, approver_ids_remove,
+    subfield_vals_add, subfield_vals_remove, subfields_clear, labels_add,
+    labels_remove, set_on=None):
+  approval_delta = tracker_pb2.ApprovalDelta(
+      approver_ids_add=approver_ids_add,
+      approver_ids_remove=approver_ids_remove,
+      subfield_vals_add=subfield_vals_add,
+      subfield_vals_remove=subfield_vals_remove,
+      subfields_clear=subfields_clear,
+      labels_add=labels_add,
+      labels_remove=labels_remove
+  )
+  if status is not None:
+    approval_delta.status = status
+    approval_delta.set_on = set_on or int(time.time())
+    approval_delta.setter_id = setter_id
+
+  return approval_delta
+
+
+def MakeIssueDelta(
+    status, owner_id, cc_ids_add, cc_ids_remove, comp_ids_add, comp_ids_remove,
+    labels_add, labels_remove, field_vals_add, field_vals_remove, fields_clear,
+    blocked_on_add, blocked_on_remove, blocking_add, blocking_remove,
+    merged_into, summary, ext_blocked_on_add=None, ext_blocked_on_remove=None,
+    ext_blocking_add=None, ext_blocking_remove=None, merged_into_external=None):
+  """Construct an IssueDelta object with the given fields, iff non-None."""
+  delta = tracker_pb2.IssueDelta(
+      cc_ids_add=cc_ids_add, cc_ids_remove=cc_ids_remove,
+      comp_ids_add=comp_ids_add, comp_ids_remove=comp_ids_remove,
+      labels_add=labels_add, labels_remove=labels_remove,
+      field_vals_add=field_vals_add, field_vals_remove=field_vals_remove,
+      fields_clear=fields_clear,
+      blocked_on_add=blocked_on_add, blocked_on_remove=blocked_on_remove,
+      blocking_add=blocking_add, blocking_remove=blocking_remove)
+  if status is not None:
+    delta.status = status
+  if owner_id is not None:
+    delta.owner_id = owner_id
+  if merged_into is not None:
+    delta.merged_into = merged_into
+  if merged_into_external is not None:
+    delta.merged_into_external = merged_into_external
+  if summary is not None:
+    delta.summary = summary
+  if ext_blocked_on_add is not None:
+    delta.ext_blocked_on_add = ext_blocked_on_add
+  if ext_blocked_on_remove is not None:
+    delta.ext_blocked_on_remove = ext_blocked_on_remove
+  if ext_blocking_add is not None:
+    delta.ext_blocking_add = ext_blocking_add
+  if ext_blocking_remove is not None:
+    delta.ext_blocking_remove = ext_blocking_remove
+
+  return delta
+
+
+def ApplyLabelChanges(issue, config, labels_add, labels_remove):
+  """Updates the PB issue's labels and returns the amendment or None."""
+  canon_labels_add = [framework_bizobj.CanonicalizeLabel(l)
+                      for l in labels_add]
+  labels_add = [l for l in canon_labels_add if l]
+  canon_labels_remove = [framework_bizobj.CanonicalizeLabel(l)
+                         for l in labels_remove]
+  labels_remove = [l for l in canon_labels_remove if l]
+
+  (labels, update_labels_add,
+   update_labels_remove) = framework_bizobj.MergeLabels(
+       issue.labels, labels_add, labels_remove, config)
+
+  if update_labels_add or update_labels_remove:
+    issue.labels = labels
+    return MakeLabelsAmendment(
+          update_labels_add, update_labels_remove)
+  return None
+
+
+def ApplyFieldValueChanges(issue, config, fvs_add, fvs_remove, fields_clear):
+  """Updates the PB issue's field_values and returns an amendments list."""
+  phase_names_dict = {phase.phase_id: phase.name for phase in issue.phases}
+  phase_ids = list(phase_names_dict.keys())
+  (field_vals, added_fvs_by_id,
+   removed_fvs_by_id) = _MergeFields(
+       issue.field_values,
+       [fv for fv in fvs_add if not fv.phase_id or fv.phase_id in phase_ids],
+       [fv for fv in fvs_remove if not fv.phase_id or fv.phase_id in phase_ids],
+       config.field_defs)
+  amendments = []
+  if added_fvs_by_id or removed_fvs_by_id:
+    issue.field_values = field_vals
+    for fd in config.field_defs:
+      fd_added_values_by_phase = collections.defaultdict(list)
+      fd_removed_values_by_phase = collections.defaultdict(list)
+      # Split fd's added/removed fvs by the phase they belong to.
+      # non-phase fds will result in {None: [added_fvs]}
+      for fv in added_fvs_by_id.get(fd.field_id, []):
+        fd_added_values_by_phase[fv.phase_id].append(fv)
+      for fv in removed_fvs_by_id.get(fd.field_id, []):
+        fd_removed_values_by_phase[fv.phase_id].append(fv)
+      # Use all_fv_phase_ids to create Amendments, so no empty amendments
+      # are created for issue phases that had no field value changes.
+      all_fv_phase_ids = set(
+          fd_removed_values_by_phase.keys() + fd_added_values_by_phase.keys())
+      for phase_id in all_fv_phase_ids:
+        new_values = [GetFieldValue(fv, {}) for fv
+                      in fd_added_values_by_phase.get(phase_id, [])]
+        old_values = [GetFieldValue(fv, {}) for fv
+                      in fd_removed_values_by_phase.get(phase_id, [])]
+        amendments.append(MakeFieldAmendment(
+              fd.field_id, config, new_values, old_values=old_values,
+              phase_name=phase_names_dict.get(phase_id)))
+
+  # Note: Clearing fields is used with bulk-editing and phase fields do
+  # not appear there and cannot be bulk-edited.
+  if fields_clear:
+    field_clear_set = set(fields_clear)
+    revised_fields = []
+    for fd in config.field_defs:
+      if fd.field_id not in field_clear_set:
+        revised_fields.extend(
+            fv for fv in issue.field_values if fv.field_id == fd.field_id)
+      else:
+        amendments.append(
+            MakeFieldClearedAmendment(fd.field_id, config))
+        if fd.field_type == tracker_pb2.FieldTypes.ENUM_TYPE:
+          prefix = fd.field_name.lower() + '-'
+          filtered_labels = [
+              lab for lab in issue.labels
+              if not lab.lower().startswith(prefix)]
+          issue.labels = filtered_labels
+
+    issue.field_values = revised_fields
+  return amendments
+
+
+def ApplyIssueDelta(cnxn, issue_service, issue, delta, config):
+  """Apply an issue delta to an issue in RAM.
+
+  Args:
+    cnxn: connection to SQL database.
+    issue_service: object to access issue-related data in the database.
+    issue: Issue to be updated.
+    delta: IssueDelta object with new values for everything being changed.
+    config: ProjectIssueConfig object for the project containing the issue.
+
+  Returns:
+    A pair (amendments, impacted_iids) where amendments is a list of Amendment
+    protos to describe what changed, and impacted_iids is a set of other IIDs
+    for issues that are modified because they are related to the given issue.
+  """
+  amendments = []
+  impacted_iids = set()
+  if (delta.status is not None and delta.status != issue.status):
+    status = framework_bizobj.CanonicalizeLabel(delta.status)
+    amendments.append(MakeStatusAmendment(status, issue.status))
+    issue.status = status
+  if (delta.owner_id is not None and delta.owner_id != issue.owner_id):
+    amendments.append(MakeOwnerAmendment(delta.owner_id, issue.owner_id))
+    issue.owner_id = delta.owner_id
+
+  # compute the set of cc'd users added and removed
+  cc_add = [cc for cc in delta.cc_ids_add if cc not in issue.cc_ids]
+  cc_remove = [cc for cc in delta.cc_ids_remove if cc in issue.cc_ids]
+  if cc_add or cc_remove:
+    cc_ids = [cc for cc in list(issue.cc_ids) + cc_add
+              if cc not in cc_remove]
+    issue.cc_ids = cc_ids
+    amendments.append(MakeCcAmendment(cc_add, cc_remove))
+
+  # compute the set of components added and removed
+  comp_ids_add = [
+      c for c in delta.comp_ids_add if c not in issue.component_ids]
+  comp_ids_remove = [
+      c for c in delta.comp_ids_remove if c in issue.component_ids]
+  if comp_ids_add or comp_ids_remove:
+    comp_ids = [cid for cid in list(issue.component_ids) + comp_ids_add
+                if cid not in comp_ids_remove]
+    issue.component_ids = comp_ids
+    amendments.append(MakeComponentsAmendment(
+        comp_ids_add, comp_ids_remove, config))
+
+  # compute the set of labels added and removed
+  label_amendment = ApplyLabelChanges(
+      issue, config, delta.labels_add, delta.labels_remove)
+  if label_amendment:
+    amendments.append(label_amendment)
+
+  # compute the set of custom fields added and removed
+  fv_amendments = ApplyFieldValueChanges(
+      issue, config, delta.field_vals_add, delta.field_vals_remove,
+      delta.fields_clear)
+  amendments.extend(fv_amendments)
+
+  # Update blocking and blocked on issues.
+  (block_changes_amendments,
+   block_changes_impacted_iids) = ApplyIssueBlockRelationChanges(
+       cnxn, issue, delta.blocked_on_add, delta.blocked_on_remove,
+       delta.blocking_add, delta.blocking_remove, issue_service)
+  amendments.extend(block_changes_amendments)
+  impacted_iids.update(block_changes_impacted_iids)
+
+  # Update external issue references.
+  if delta.ext_blocked_on_add or delta.ext_blocked_on_remove:
+    add_refs = []
+    for ext_id in delta.ext_blocked_on_add:
+      ref = tracker_pb2.DanglingIssueRef(ext_issue_identifier=ext_id)
+      if (federated.IsShortlinkValid(ext_id) and
+          ref not in issue.dangling_blocked_on_refs and
+          ext_id not in delta.ext_blocked_on_remove):
+        add_refs.append(ref)
+    remove_refs = []
+    for ext_id in delta.ext_blocked_on_remove:
+      ref = tracker_pb2.DanglingIssueRef(ext_issue_identifier=ext_id)
+      if (federated.IsShortlinkValid(ext_id) and
+          ref in issue.dangling_blocked_on_refs):
+        remove_refs.append(ref)
+    if add_refs or remove_refs:
+      amendments.append(MakeBlockedOnAmendment(add_refs, remove_refs))
+    issue.dangling_blocked_on_refs = [
+        ref for ref in issue.dangling_blocked_on_refs + add_refs
+        if ref.ext_issue_identifier not in delta.ext_blocked_on_remove]
+
+  # Update external issue references.
+  if delta.ext_blocking_add or delta.ext_blocking_remove:
+    add_refs = []
+    for ext_id in delta.ext_blocking_add:
+      ref = tracker_pb2.DanglingIssueRef(ext_issue_identifier=ext_id)
+      if (federated.IsShortlinkValid(ext_id) and
+          ref not in issue.dangling_blocking_refs and
+          ext_id not in delta.ext_blocking_remove):
+        add_refs.append(ref)
+    remove_refs = []
+    for ext_id in delta.ext_blocking_remove:
+      ref = tracker_pb2.DanglingIssueRef(ext_issue_identifier=ext_id)
+      if (federated.IsShortlinkValid(ext_id) and
+          ref in issue.dangling_blocking_refs):
+        remove_refs.append(ref)
+    if add_refs or remove_refs:
+      amendments.append(MakeBlockingAmendment(add_refs, remove_refs))
+    issue.dangling_blocking_refs = [
+        ref for ref in issue.dangling_blocking_refs + add_refs
+        if ref.ext_issue_identifier not in delta.ext_blocking_remove]
+
+  if delta.merged_into is not None and delta.merged_into_external is not None:
+    raise ValueError(('Cannot update merged_into and merged_into_external'
+      ' fields at the same time.'))
+
+  if (delta.merged_into is not None and
+      delta.merged_into != issue.merged_into and
+      ((delta.merged_into == 0 and issue.merged_into is not None) or
+       delta.merged_into != 0)):
+
+    # Handle removing the existing internal merged_into.
+    try:
+      merged_remove = issue.merged_into
+      remove_issue = issue_service.GetIssue(cnxn, merged_remove)
+      remove_ref = remove_issue.project_name, remove_issue.local_id
+      impacted_iids.add(merged_remove)
+    except exceptions.NoSuchIssueException:
+      remove_ref = None
+
+    # Handle going from external->internal mergedinto.
+    if issue.merged_into_external:
+      remove_ref = tracker_pb2.DanglingIssueRef(
+          ext_issue_identifier=issue.merged_into_external)
+      issue.merged_into_external = None
+
+    # Handle adding the new merged_into.
+    try:
+      merged_add = delta.merged_into
+      issue.merged_into = delta.merged_into
+      add_issue = issue_service.GetIssue(cnxn, merged_add)
+      add_ref = add_issue.project_name, add_issue.local_id
+      impacted_iids.add(merged_add)
+    except exceptions.NoSuchIssueException:
+      add_ref = None
+
+    amendments.append(MakeMergedIntoAmendment(
+        [add_ref], [remove_ref], default_project_name=issue.project_name))
+
+  if (delta.merged_into_external is not None and
+      delta.merged_into_external != issue.merged_into_external and
+      (federated.IsShortlinkValid(delta.merged_into_external) or
+       (delta.merged_into_external == '' and issue.merged_into_external))):
+
+    remove_ref = None
+    if issue.merged_into_external:
+      remove_ref = tracker_pb2.DanglingIssueRef(
+          ext_issue_identifier=issue.merged_into_external)
+    elif issue.merged_into:
+      # Handle moving from internal->external mergedinto.
+      try:
+        remove_issue = issue_service.GetIssue(cnxn, issue.merged_into)
+        remove_ref = remove_issue.project_name, remove_issue.local_id
+        impacted_iids.add(issue.merged_into)
+      except exceptions.NoSuchIssueException:
+        pass
+
+    add_ref = tracker_pb2.DanglingIssueRef(
+        ext_issue_identifier=delta.merged_into_external)
+    issue.merged_into = 0
+    issue.merged_into_external = delta.merged_into_external
+    amendments.append(MakeMergedIntoAmendment([add_ref], [remove_ref],
+        default_project_name=issue.project_name))
+
+  if delta.summary and delta.summary != issue.summary:
+    amendments.append(MakeSummaryAmendment(delta.summary, issue.summary))
+    issue.summary = delta.summary
+
+  return amendments, impacted_iids
+
+
+def ApplyIssueBlockRelationChanges(
+    cnxn, issue, blocked_on_add, blocked_on_remove, blocking_add,
+    blocking_remove, issue_service):
+  # type: (MonorailConnection, Issue, Collection[int], Collection[int],
+  #     Collection[int], Collection[int], IssueService) ->
+  #     Sequence[Amendment], Collection[int]
+  """Apply issue blocking/blocked_on relation changes to an issue in RAM.
+
+  Args:
+    cnxn: connection to SQL database.
+    issue: Issue PB that we are applying the changes to.
+    blocked_on_add: list of issue IDs that we want to add as blocked_on.
+    blocked_on_remove: list of issue IDs that we want to remove from blocked_on.
+    blocking_add: list of issue IDs that we want to add as blocking.
+    blocking_remove: list of issue IDs that we want to remove from blocking.
+    issue_service: IssueService used to fetch info from DB or cache.
+
+  Returns:
+    A tuple that holds the list of Amendments that represent the applied changes
+    and a set of issue IDs that are impacted by the changes.
+
+
+  Side-effect:
+    The given issue's blocked_on and blocking fields will be modified.
+  """
+  amendments = []
+  impacted_iids = set()
+
+  def addAmendment(add_iids, remove_iids, amendment_func):
+    add_refs = issue_service.LookupIssueRefs(cnxn, add_iids).values()
+    remove_refs = issue_service.LookupIssueRefs(cnxn, remove_iids).values()
+    new_am = amendment_func(
+        add_refs, remove_refs, default_project_name=issue.project_name)
+    amendments.append(new_am)
+
+  # Apply blocked_on changes.
+  old_blocked_on = issue.blocked_on_iids
+  blocked_on_add = [iid for iid in blocked_on_add if iid not in old_blocked_on]
+  blocked_on_remove = [
+      iid for iid in blocked_on_remove if iid in old_blocked_on
+  ]
+  # blocked_on_add and blocked_on_remove are filtered above such that they
+  # could not contain matching items.
+  if blocked_on_add or blocked_on_remove:
+    addAmendment(blocked_on_add, blocked_on_remove, MakeBlockedOnAmendment)
+
+    new_blocked_on_iids = [
+        iid for iid in old_blocked_on + blocked_on_add
+        if iid not in blocked_on_remove
+    ]
+    (issue.blocked_on_iids,
+     issue.blocked_on_ranks) = issue_service.SortBlockedOn(
+         cnxn, issue, new_blocked_on_iids)
+    impacted_iids.update(blocked_on_add + blocked_on_remove)
+
+  # Apply blocking changes.
+  old_blocking = issue.blocking_iids
+  blocking_add = [iid for iid in blocking_add if iid not in old_blocking]
+  blocking_remove = [iid for iid in blocking_remove if iid in old_blocking]
+  # blocking_add and blocking_remove are filtered above such that they
+  # could not contain matching items.
+  if blocking_add or blocking_remove:
+    addAmendment(blocking_add, blocking_remove, MakeBlockingAmendment)
+    issue.blocking_iids = [
+        iid for iid in old_blocking + blocking_add if iid not in blocking_remove
+    ]
+    impacted_iids.update(blocking_add + blocking_remove)
+
+  return amendments, impacted_iids
+
+
+def MakeAmendment(
+    field, new_value, added_ids, removed_ids, custom_field_name=None,
+    old_value=None):
+  """Utility function to populate an Amendment PB.
+
+  Args:
+    field: enum for the field being updated.
+    new_value: new string value of that field.
+    added_ids: list of user IDs being added.
+    removed_ids: list of user IDs being removed.
+    custom_field_name: optional name of a custom field.
+    old_value: old string value of that field.
+
+  Returns:
+    An instance of Amendment.
+  """
+  amendment = tracker_pb2.Amendment()
+  amendment.field = field
+  amendment.newvalue = new_value
+  amendment.added_user_ids.extend(added_ids)
+  amendment.removed_user_ids.extend(removed_ids)
+
+  if old_value is not None:
+    amendment.oldvalue = old_value
+
+  if custom_field_name is not None:
+    amendment.custom_field_name = custom_field_name
+
+  return amendment
+
+
+def _PlusMinusString(added_items, removed_items):
+  """Return a concatenation of the items, with a minus on removed items.
+
+  Args:
+    added_items: list of string items added.
+    removed_items: list of string items removed.
+
+  Returns:
+    A unicode string with all the removed items first (preceeded by minus
+    signs) and then the added items.
+  """
+  assert all(isinstance(item, string_types)
+             for item in added_items + removed_items)
+  # TODO(jrobbins): this is not good when values can be negative ints.
+  return ' '.join(
+      ['-%s' % item.strip()
+       for item in removed_items if item] +
+      ['%s' % item for item in added_items if item])
+
+
+def _PlusMinusAmendment(
+    field, added_items, removed_items, custom_field_name=None):
+  """Make an Amendment PB with the given added/removed items."""
+  return MakeAmendment(
+      field, _PlusMinusString(added_items, removed_items), [], [],
+      custom_field_name=custom_field_name)
+
+
+def _PlusMinusRefsAmendment(
+    field, added_refs, removed_refs, default_project_name=None):
+  """Make an Amendment PB with the given added/removed refs."""
+  return _PlusMinusAmendment(
+      field,
+      [FormatIssueRef(r, default_project_name=default_project_name)
+       for r in added_refs if r],
+      [FormatIssueRef(r, default_project_name=default_project_name)
+       for r in removed_refs if r])
+
+
+def MakeSummaryAmendment(new_summary, old_summary):
+  """Make an Amendment PB for a change to the summary."""
+  return MakeAmendment(
+      tracker_pb2.FieldID.SUMMARY, new_summary, [], [], old_value=old_summary)
+
+
+def MakeStatusAmendment(new_status, old_status):
+  """Make an Amendment PB for a change to the status."""
+  return MakeAmendment(
+      tracker_pb2.FieldID.STATUS, new_status, [], [], old_value=old_status)
+
+
+def MakeOwnerAmendment(new_owner_id, old_owner_id):
+  """Make an Amendment PB for a change to the owner."""
+  return MakeAmendment(
+      tracker_pb2.FieldID.OWNER, '', [new_owner_id], [old_owner_id])
+
+
+def MakeCcAmendment(added_cc_ids, removed_cc_ids):
+  """Make an Amendment PB for a change to the Cc list."""
+  return MakeAmendment(
+      tracker_pb2.FieldID.CC, '', added_cc_ids, removed_cc_ids)
+
+
+def MakeLabelsAmendment(added_labels, removed_labels):
+  """Make an Amendment PB for a change to the labels."""
+  return _PlusMinusAmendment(
+      tracker_pb2.FieldID.LABELS, added_labels, removed_labels)
+
+
+def DiffValueLists(new_list, old_list):
+  """Give an old list and a new list, return the added and removed items."""
+  if not old_list:
+    return new_list, []
+  if not new_list:
+    return [], old_list
+
+  added = []
+  removed = old_list[:]  # Assume everything was removed, then narrow that down
+  for val in new_list:
+    if val in removed:
+      removed.remove(val)
+    else:
+      added.append(val)
+
+  return added, removed
+
+
+def MakeFieldAmendment(
+    field_id, config, new_values, old_values=None, phase_name=None):
+  """Return an amendment showing how an issue's field changed.
+
+  Args:
+    field_id: int field ID of a built-in or custom issue field.
+    config: config info for the current project, including field_defs.
+    new_values: list of strings representing new values of field.
+    old_values: list of strings representing old values of field.
+    phase_name: name of the phase that owned the field that was changed.
+
+  Returns:
+    A new Amemdnent object.
+
+  Raises:
+    ValueError: if the specified field was not found.
+  """
+  fd = FindFieldDefByID(field_id, config)
+
+  if fd is None:
+    raise ValueError('field %r vanished mid-request', field_id)
+
+  field_name = fd.field_name if not phase_name else '%s-%s' % (
+      phase_name, fd.field_name)
+  if fd.is_multivalued:
+    old_values = old_values or []
+    added, removed = DiffValueLists(new_values, old_values)
+    if fd.field_type == tracker_pb2.FieldTypes.USER_TYPE:
+      return MakeAmendment(
+          tracker_pb2.FieldID.CUSTOM, '', added, removed,
+          custom_field_name=field_name)
+    else:
+      return _PlusMinusAmendment(
+          tracker_pb2.FieldID.CUSTOM,
+          ['%s' % item for item in added],
+          ['%s' % item for item in removed],
+          custom_field_name=field_name)
+
+  else:
+    if fd.field_type == tracker_pb2.FieldTypes.USER_TYPE:
+      return MakeAmendment(
+          tracker_pb2.FieldID.CUSTOM, '', new_values, [],
+          custom_field_name=field_name)
+
+    if new_values:
+      new_str = ', '.join('%s' % item for item in new_values)
+    else:
+      new_str = '----'
+
+    return MakeAmendment(
+        tracker_pb2.FieldID.CUSTOM, new_str, [], [],
+        custom_field_name=field_name)
+
+
+def MakeFieldClearedAmendment(field_id, config):
+  fd = FindFieldDefByID(field_id, config)
+
+  if fd is None:
+    raise ValueError('field %r vanished mid-request', field_id)
+
+  return MakeAmendment(
+      tracker_pb2.FieldID.CUSTOM, '----', [], [],
+      custom_field_name=fd.field_name)
+
+
+def MakeApprovalStructureAmendment(new_approvals, old_approvals):
+  """Return an Amendment showing an issue's approval structure changed.
+
+  Args:
+    new_approvals: the new list of approvals.
+    old_approvals: the old list of approvals.
+
+  Returns:
+    A new Amendment object.
+  """
+
+  approvals_added, approvals_removed = DiffValueLists(
+      new_approvals, old_approvals)
+  return MakeAmendment(
+      tracker_pb2.FieldID.CUSTOM, _PlusMinusString(
+          approvals_added, approvals_removed),
+      [], [], custom_field_name='Approvals')
+
+
+def MakeApprovalStatusAmendment(new_status):
+  """Return an Amendment showing an issue approval's status changed.
+
+  Args:
+    new_status: ApprovalStatus representing the new approval status.
+
+  Returns:
+    A new Amemdnent object.
+  """
+  return MakeAmendment(
+      tracker_pb2.FieldID.CUSTOM, new_status.name.lower(), [], [],
+      custom_field_name='Status')
+
+
+def MakeApprovalApproversAmendment(approvers_add, approvers_remove):
+  """Return an Amendment showing an issue approval's approvers changed.
+
+  Args:
+    approvers_add: list of approver user_ids being added.
+    approvers_remove: list of approver user_ids being removed.
+
+  Returns:
+    A new Amendment object.
+  """
+  return MakeAmendment(
+      tracker_pb2.FieldID.CUSTOM, '', approvers_add, approvers_remove,
+      custom_field_name='Approvers')
+
+
+def MakeComponentsAmendment(added_comp_ids, removed_comp_ids, config):
+  """Make an Amendment PB for a change to the components."""
+  # TODO(jrobbins): record component IDs as ints and display them with
+  # lookups (and maybe permission checks in the future).  But, what
+  # about history that references deleleted components?
+  added_comp_paths = []
+  for comp_id in added_comp_ids:
+    cd = FindComponentDefByID(comp_id, config)
+    if cd:
+      added_comp_paths.append(cd.path)
+
+  removed_comp_paths = []
+  for comp_id in removed_comp_ids:
+    cd = FindComponentDefByID(comp_id, config)
+    if cd:
+      removed_comp_paths.append(cd.path)
+
+  return _PlusMinusAmendment(
+      tracker_pb2.FieldID.COMPONENTS,
+      added_comp_paths, removed_comp_paths)
+
+
+def MakeBlockedOnAmendment(
+    added_refs, removed_refs, default_project_name=None):
+  """Make an Amendment PB for a change to the blocked on issues."""
+  return _PlusMinusRefsAmendment(
+      tracker_pb2.FieldID.BLOCKEDON, added_refs, removed_refs,
+      default_project_name=default_project_name)
+
+
+def MakeBlockingAmendment(added_refs, removed_refs, default_project_name=None):
+  """Make an Amendment PB for a change to the blocking issues."""
+  return _PlusMinusRefsAmendment(
+      tracker_pb2.FieldID.BLOCKING, added_refs, removed_refs,
+      default_project_name=default_project_name)
+
+
+def MakeMergedIntoAmendment(
+    added_refs, removed_refs, default_project_name=None):
+  """Make an Amendment PB for a change to the merged-into issue."""
+  return _PlusMinusRefsAmendment(
+      tracker_pb2.FieldID.MERGEDINTO, added_refs, removed_refs,
+      default_project_name=default_project_name)
+
+
+def MakeProjectAmendment(new_project_name):
+  """Make an Amendment PB for a change to an issue's project."""
+  return MakeAmendment(
+      tracker_pb2.FieldID.PROJECT, new_project_name, [], [])
+
+
+def AmendmentString_New(amendment, user_display_names):
+  # type: (tracker_pb2.Amendment, Mapping[int, str]) -> str
+  """Produce a displayable string for an Amendment PB.
+
+  Args:
+    amendment: Amendment PB to display.
+    user_display_names: dict {user_id: display_name, ...} including all users
+        mentioned in amendment.
+
+  Returns:
+    A string that could be displayed on a web page or sent in email.
+  """
+  if amendment.newvalue:
+    return amendment.newvalue
+
+  # Display new owner only
+  if amendment.field == tracker_pb2.FieldID.OWNER:
+    if amendment.added_user_ids and amendment.added_user_ids[0]:
+      uid = amendment.added_user_ids[0]
+      result = user_display_names[uid]
+    else:
+      result = framework_constants.NO_USER_NAME
+  else:
+    added = [
+        user_display_names[uid]
+        for uid in amendment.added_user_ids
+        if uid in user_display_names
+    ]
+    removed = [
+        user_display_names[uid]
+        for uid in amendment.removed_user_ids
+        if uid in user_display_names
+    ]
+    result = _PlusMinusString(added, removed)
+
+  return result
+
+
+def AmendmentString(amendment, user_views_by_id):
+  """Produce a displayable string for an Amendment PB.
+
+  TODO(crbug.com/monorail/7571): Delete this function in favor of _New.
+
+  Args:
+    amendment: Amendment PB to display.
+    user_views_by_id: dict {user_id: user_view, ...} including all users
+        mentioned in amendment.
+
+  Returns:
+    A string that could be displayed on a web page or sent in email.
+  """
+  if amendment.newvalue:
+    return amendment.newvalue
+
+  # Display new owner only
+  if amendment.field == tracker_pb2.FieldID.OWNER:
+    if amendment.added_user_ids and amendment.added_user_ids[0]:
+      uid = amendment.added_user_ids[0]
+      result = user_views_by_id[uid].display_name
+    else:
+      result = framework_constants.NO_USER_NAME
+  else:
+    result = _PlusMinusString(
+        [user_views_by_id[uid].display_name for uid in amendment.added_user_ids
+         if uid in user_views_by_id],
+        [user_views_by_id[uid].display_name
+         for uid in amendment.removed_user_ids if uid in user_views_by_id])
+
+  return result
+
+
+def AmendmentLinks(amendment, users_by_id, project_name):
+  """Produce a list of value/url pairs for an Amendment PB.
+
+  Args:
+    amendment: Amendment PB to display.
+    users_by_id: dict {user_id: user_view, ...} including all users
+      mentioned in amendment.
+    project_nme: Name of project the issue/comment/amendment is in.
+
+  Returns:
+    A list of dicts with 'value' and 'url' keys. 'url' may be None.
+  """
+  # Display both old and new summary, status
+  if (amendment.field == tracker_pb2.FieldID.SUMMARY or
+      amendment.field == tracker_pb2.FieldID.STATUS):
+    result = amendment.newvalue
+    oldValue = amendment.oldvalue;
+    # Old issues have a 'NULL' string as the old value of the summary
+    # or status fields. See crbug.com/monorail/3805
+    if oldValue and oldValue != 'NULL':
+      result += ' (was: %s)' % amendment.oldvalue
+    return [{'value': result, 'url': None}]
+  # Display new owner only
+  elif amendment.field == tracker_pb2.FieldID.OWNER:
+    if amendment.added_user_ids and amendment.added_user_ids[0]:
+      uid = amendment.added_user_ids[0]
+      return [{'value': users_by_id[uid].display_name, 'url': None}]
+    return [{'value': framework_constants.NO_USER_NAME, 'url': None}]
+  elif amendment.field in (tracker_pb2.FieldID.BLOCKEDON,
+                           tracker_pb2.FieldID.BLOCKING,
+                           tracker_pb2.FieldID.MERGEDINTO):
+    values = amendment.newvalue.split()
+    bug_refs = [_SafeParseIssueRef(v.strip()) for v in values]
+    issue_urls = [FormatIssueURL(ref, default_project_name=project_name)
+                  for ref in bug_refs]
+    # TODO(jrobbins): Permission checks on referenced issues to allow
+    # showing summary on hover.
+    return [{'value': v, 'url': u} for (v, u) in zip(values, issue_urls)]
+  elif amendment.newvalue:
+    # Catchall for everything except user-valued fields.
+    return [{'value': v, 'url': None} for v in amendment.newvalue.split()]
+  else:
+    # Applies to field==CC or CUSTOM with user type.
+    values = _PlusMinusString(
+        [users_by_id[uid].display_name for uid in amendment.added_user_ids
+         if uid in users_by_id],
+        [users_by_id[uid].display_name for uid in amendment.removed_user_ids
+         if uid in users_by_id])
+    return [{'value': v.strip(), 'url': None} for v in values.split()]
+
+
+def GetAmendmentFieldName(amendment):
+  """Get user-visible name for an amendment to a built-in or custom field."""
+  if amendment.custom_field_name:
+    return amendment.custom_field_name
+  else:
+    field_name = str(amendment.field)
+    return field_name.capitalize()
+
+
+def MakeDanglingIssueRef(project_name, issue_id, ext_id=''):
+  """Create a DanglingIssueRef pb."""
+  ret = tracker_pb2.DanglingIssueRef()
+  ret.project = project_name
+  ret.issue_id = issue_id
+  ret.ext_issue_identifier = ext_id
+  return ret
+
+
+def FormatIssueURL(issue_ref_tuple, default_project_name=None):
+  """Format an issue url from an issue ref."""
+  if issue_ref_tuple is None:
+    return ''
+  project_name, local_id = issue_ref_tuple
+  project_name = project_name or default_project_name
+  url = framework_helpers.FormatURL(
+    None, '/p/%s%s' % (project_name, urls.ISSUE_DETAIL), id=local_id)
+  return url
+
+
+def FormatIssueRef(issue_ref_tuple, default_project_name=None):
+  """Format an issue reference for users: e.g., 123, or projectname:123."""
+  if issue_ref_tuple is None:
+    return ''
+
+  # TODO(jeffcarp): Improve method signature to not require isinstance.
+  if isinstance(issue_ref_tuple, tracker_pb2.DanglingIssueRef):
+    return issue_ref_tuple.ext_issue_identifier or ''
+
+  project_name, local_id = issue_ref_tuple
+  if project_name and project_name != default_project_name:
+    return '%s:%d' % (project_name, local_id)
+  else:
+    return str(local_id)
+
+
+def ParseIssueRef(ref_str):
+  """Parse an issue ref string: e.g., 123, or projectname:123 into a tuple.
+
+  Raises ValueError if the ref string exists but can't be parsed.
+  """
+  if not ref_str.strip():
+    return None
+
+  if ':' in ref_str:
+    project_name, id_str = ref_str.split(':', 1)
+    project_name = project_name.strip().lstrip('-')
+  else:
+    project_name = None
+    id_str = ref_str
+
+  id_str = id_str.lstrip('-')
+
+  return project_name, int(id_str)
+
+
+def _SafeParseIssueRef(ref_str):
+  """Same as ParseIssueRef, but catches ValueError and returns None instead."""
+  try:
+    return ParseIssueRef(ref_str)
+  except ValueError:
+    return None
+
+
+def _MergeFields(field_values, fields_add, fields_remove, field_defs):
+  """Merge the fields to add/remove into the current field values.
+
+  Args:
+    field_values: list of current FieldValue PBs.
+    fields_add: list of FieldValue PBs to add to field_values.  If any of these
+        is for a single-valued field, it replaces all previous values for the
+        same field_id in field_values.
+    fields_remove: list of FieldValues to remove from field_values, if found.
+    field_defs: list of FieldDef PBs from the issue's project's config.
+
+  Returns:
+    A 3-tuple with the merged list of field values and {field_id: field_values}
+    dict for the specific values that are added or removed.  The actual added
+    or removed might be fewer than the requested ones if the issue already had
+    one of the values-to-add or lacked one of the values-to-remove.
+  """
+  is_multi = {fd.field_id: fd.is_multivalued for fd in field_defs}
+  merged_fvs = list(field_values)
+  added_fvs_by_id = collections.defaultdict(list)
+  for fv_consider in fields_add:
+    consider_value = GetFieldValue(fv_consider, {})
+    for old_fv in field_values:
+      # Don't add fv_consider if field_values already contains consider_value
+      if (fv_consider.field_id == old_fv.field_id and
+          GetFieldValue(old_fv, {}) == consider_value and
+          fv_consider.phase_id == old_fv.phase_id):
+        break
+    else:
+      # Drop any existing values for non-multi fields.
+      if not is_multi.get(fv_consider.field_id):
+        if fv_consider.phase_id:
+          # Drop existing phase fvs that belong to the same phase
+          merged_fvs = [fv for fv in merged_fvs if
+                        not (fv.field_id == fv_consider.field_id
+                             and fv.phase_id == fv_consider.phase_id)]
+        else:
+          # Drop existing non-phase fvs
+          merged_fvs = [fv for fv in merged_fvs if
+                        not fv.field_id == fv_consider.field_id]
+      added_fvs_by_id[fv_consider.field_id].append(fv_consider)
+      merged_fvs.append(fv_consider)
+
+  removed_fvs_by_id = collections.defaultdict(list)
+  for fv_consider in fields_remove:
+    consider_value = GetFieldValue(fv_consider, {})
+    for old_fv in field_values:
+      # Only remove fv_consider if field_values contains consider_value
+      if (fv_consider.field_id == old_fv.field_id and
+          GetFieldValue(old_fv, {}) == consider_value and
+          fv_consider.phase_id == old_fv.phase_id):
+        removed_fvs_by_id[fv_consider.field_id].append(fv_consider)
+        merged_fvs.remove(old_fv)
+  return merged_fvs, added_fvs_by_id, removed_fvs_by_id
+
+
+def SplitBlockedOnRanks(issue, target_iid, split_above, open_iids):
+  """Splits issue relation rankings by some target issue's rank
+
+  Args:
+    issue: Issue PB for the issue considered.
+    target_iid: the global ID of the issue to split rankings about.
+    split_above: False to split below the target issue, True to split above.
+    open_iids: a list of global IDs of open and visible issues blocking
+      the considered issue.
+
+  Returns:
+    A tuple (lower, higher) where both are lists of
+    [(blocker_iid, rank),...] of issues in rank order. If split_above is False
+    the target issue is included in higher, otherwise it is included in lower
+  """
+  issue_rank_pairs = [(dst_iid, rank)
+      for (dst_iid, rank) in zip(issue.blocked_on_iids, issue.blocked_on_ranks)
+      if dst_iid in open_iids]
+  # blocked_on_iids is sorted high-to-low, we need low-to-high
+  issue_rank_pairs.reverse()
+  offset = int(split_above)
+  for i, (dst_iid, _) in enumerate(issue_rank_pairs):
+    if dst_iid == target_iid:
+      return issue_rank_pairs[:i + offset], issue_rank_pairs[i + offset:]
+
+  logging.error('Target issue %r was not found in blocked_on_iids of %r',
+                target_iid, issue)
+  return issue_rank_pairs, []
diff --git a/tracker/tracker_constants.py b/tracker/tracker_constants.py
new file mode 100644
index 0000000..e0fe1b2
--- /dev/null
+++ b/tracker/tracker_constants.py
@@ -0,0 +1,252 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Some constants used in Monorail issue tracker pages."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import re
+
+from proto import user_pb2
+
+
+# Default columns shown on issue list page, and other built-in cols.
+DEFAULT_COL_SPEC = 'ID Type Status Priority Milestone Owner Summary'
+OTHER_BUILT_IN_COLS = [
+    'AllLabels', 'Attachments', 'Stars', 'Opened', 'Closed', 'Modified',
+    'BlockedOn', 'Blocking', 'Blocked', 'MergedInto',
+    'Reporter', 'Cc', 'Project', 'Component',
+    'OwnerModified', 'StatusModified', 'ComponentModified',
+    'OwnerLastVisit']
+
+# These are label prefixes that would conflict with built-in column names.
+# E.g., no issue should have a *label* id-1234 or status-foo because any
+# search for "id:1234" or "status:foo" would not look at labels.
+RESERVED_PREFIXES = [
+    'id', 'project', 'reporter', 'summary', 'status', 'owner', 'cc',
+    'attachments', 'attachment', 'component', 'opened', 'closed',
+    'modified', 'is', 'has', 'blockedon', 'blocking', 'blocked', 'mergedinto',
+    'stars', 'starredby', 'description', 'comment', 'commentby', 'label',
+    'hotlist', 'rank', 'explicit_status', 'derived_status', 'explicit_owner',
+    'derived_owner', 'explicit_cc', 'derived_cc', 'explicit_label',
+    'derived_label', 'last_comment_by', 'exact_component',
+    'explicit_component', 'derived_component', 'alllabels', 'gate']
+
+# Suffix of a column name for an approval's approvers.
+APPROVER_COL_SUFFIX = '-approver'
+APPROVAL_SETTER_COL_SUFFIX = '-setter'
+APPROVAL_SET_ON_COL_SUFFIX = '-on'
+
+# Reserved column name suffixes that field names cannot end with.
+RESERVED_COL_NAME_SUFFIXES = [
+    APPROVER_COL_SUFFIX,
+    APPROVAL_SETTER_COL_SUFFIX,
+    APPROVAL_SET_ON_COL_SUFFIX
+]
+
+# The columns are useless in the grid view, so don't offer them.
+# These are also not used in groupby in the issue list.
+NOT_USED_IN_GRID_AXES = [
+    'Summary', 'ID', 'Opened', 'Closed', 'Modified', 'Cc',
+    'OwnerModified', 'StatusModified', 'ComponentModified',
+    'OwnerLastVisit', 'AllLabels']
+
+# Issues per page in the issue list
+DEFAULT_RESULTS_PER_PAGE = 100
+
+# Search field input indicating that the user wants to
+# jump to the specified issue.
+JUMP_RE = re.compile(r'^\d+$')
+
+# Regular expression defining a single search term.
+# Used when parsing the contents of the issue search field.
+TERM_RE = re.compile(r'[-a-zA-Z0-9._]+')
+
+# Pattern used to validate component leaf names, the parts of
+# a component path between the ">" symbols.
+COMPONENT_LEAF_PATTERN = '[a-zA-Z]([-_]?[a-zA-Z0-9])+'
+
+# Regular expression used to validate new component leaf names.
+# This should never match any string with a ">" in it.
+COMPONENT_NAME_RE = re.compile(r'^%s$' % (COMPONENT_LEAF_PATTERN))
+
+# Pattern for matching a full component name, not just a single leaf.
+# Allows any number of repeating valid leaf names separated by ">" characters.
+COMPONENT_PATH_PATTERN = '%s(\>%s)*' % (
+    COMPONENT_LEAF_PATTERN, COMPONENT_LEAF_PATTERN)
+
+# Regular expression used to validate new field names.
+FIELD_NAME_RE = re.compile(r'^[a-zA-Z]([-_]?[a-zA-Z0-9])*$')
+
+# Regular expression used to validate new phase_names.
+PHASE_NAME_RE = re.compile(r'^[a-z]([-_]?[a-z0-9])*$', re.IGNORECASE)
+
+# The next few items are specifications of the defaults for project
+# issue configurations.  These are used for projects that do not have
+# their own config.
+DEFAULT_CANNED_QUERIES = [
+    # Query ID, Name, Base query ID (not used for built-in queries), conditions
+    (1, 'All issues', 0, ''),
+    (2, 'Open issues', 0, 'is:open'),
+    (3, 'Open and owned by me', 0, 'is:open owner:me'),
+    (4, 'Open and reported by me', 0, 'is:open reporter:me'),
+    (5, 'Open and starred by me', 0, 'is:open is:starred'),
+    (6, 'New issues', 0, 'status:new'),
+    (7, 'Issues to verify', 0, 'status=fixed,done'),
+    (8, 'Open with comment by me', 0, 'is:open commentby:me'),
+    ]
+
+DEFAULT_CANNED_QUERY_CONDS = {
+    query_id: cond
+    for (query_id, _name, _base, cond) in DEFAULT_CANNED_QUERIES}
+
+ALL_ISSUES_CAN = 1
+OPEN_ISSUES_CAN = 2
+
+# Define well-known issue statuses.  Each status has 3 parts: a name, a
+# description, and True if the status means that an issue should be
+# considered to be open or False if it should be considered closed.
+DEFAULT_WELL_KNOWN_STATUSES = [
+    # Name, docstring, means_open, deprecated
+    ('New', 'Issue has not had initial review yet', True, False),
+    ('Accepted', 'Problem reproduced / Need acknowledged', True, False),
+    ('Started', 'Work on this issue has begun', True, False),
+    ('Fixed', 'Developer made source code changes, QA should verify', False,
+     False),
+    ('Verified', 'QA has verified that the fix worked', False, False),
+    ('Invalid', 'This was not a valid issue report', False, False),
+    ('Duplicate', 'This report duplicates an existing issue', False, False),
+    ('WontFix', 'We decided to not take action on this issue', False, False),
+    ('Done', 'The requested non-coding task was completed', False, False),
+    ]
+
+DEFAULT_WELL_KNOWN_LABELS = [
+    # Name, docstring, deprecated
+    ('Type-Defect', 'Report of a software defect', False),
+    ('Type-Enhancement', 'Request for enhancement', False),
+    ('Type-Task', 'Work item that doesn\'t change the code or docs', False),
+    ('Type-Other', 'Some other kind of issue', False),
+    ('Priority-Critical', 'Must resolve in the specified milestone', False),
+    ('Priority-High', 'Strongly want to resolve in the specified milestone',
+        False),
+    ('Priority-Medium', 'Normal priority', False),
+    ('Priority-Low', 'Might slip to later milestone', False),
+    ('OpSys-All', 'Affects all operating systems', False),
+    ('OpSys-Windows', 'Affects Windows users', False),
+    ('OpSys-Linux', 'Affects Linux users', False),
+    ('OpSys-OSX', 'Affects Mac OS X users', False),
+    ('Milestone-Release1.0', 'All essential functionality working', False),
+    ('Security', 'Security risk to users', False),
+    ('Performance', 'Performance issue', False),
+    ('Usability', 'Affects program usability', False),
+    ('Maintainability', 'Hinders future changes', False),
+    ]
+
+# Exclusive label prefixes are ones that can only be used once per issue.
+# For example, an issue would normally have only one Priority-* label, whereas
+# an issue might have many OpSys-* labels.
+DEFAULT_EXCL_LABEL_PREFIXES = ['Type', 'Priority', 'Milestone']
+
+DEFAULT_USER_DEFECT_REPORT_TEMPLATE = {
+    'name': 'Defect report from user',
+    'summary': 'Enter one-line summary',
+    'summary_must_be_edited': True,
+    'content': (
+        'What steps will reproduce the problem?\n'
+        '1. \n'
+        '2. \n'
+        '3. \n'
+        '\n'
+        'What is the expected output?\n'
+        '\n'
+        '\n'
+        'What do you see instead?\n'
+        '\n'
+        '\n'
+        'What version of the product are you using? '
+        'On what operating system?\n'
+        '\n'
+        '\n'
+        'Please provide any additional information below.\n'),
+    'status': 'New',
+    'labels': ['Type-Defect', 'Priority-Medium'],
+    }
+
+DEFAULT_DEVELOPER_DEFECT_REPORT_TEMPLATE = {
+    'name': 'Defect report from developer',
+    'summary': 'Enter one-line summary',
+    'summary_must_be_edited': True,
+    'content': (
+        'What steps will reproduce the problem?\n'
+        '1. \n'
+        '2. \n'
+        '3. \n'
+        '\n'
+        'What is the expected output?\n'
+        '\n'
+        '\n'
+        'What do you see instead?\n'
+        '\n'
+        '\n'
+        'Please use labels and text to provide additional information.\n'),
+    'status': 'Accepted',
+    'labels': ['Type-Defect', 'Priority-Medium'],
+    'members_only': True,
+    }
+
+
+DEFAULT_TEMPLATES = [
+    DEFAULT_DEVELOPER_DEFECT_REPORT_TEMPLATE,
+    DEFAULT_USER_DEFECT_REPORT_TEMPLATE,
+    ]
+
+DEFAULT_STATUSES_OFFER_MERGE = ['Duplicate']
+
+
+# This is used by JS on the issue admin page to indicate that the user deleted
+# this template, so it should not be considered when updating the project's
+# issue config.
+DELETED_TEMPLATE_NAME = '<DELETED>'
+
+
+# This is the default maximum total bytes of files attached
+# to all the issues in a project.
+ISSUE_ATTACHMENTS_QUOTA_HARD = 50 * 1024 * 1024
+ISSUE_ATTACHMENTS_QUOTA_SOFT = ISSUE_ATTACHMENTS_QUOTA_HARD - 1 * 1024 * 1024
+
+# Default value for nav action after updating an issue.
+DEFAULT_AFTER_ISSUE_UPDATE = user_pb2.IssueUpdateNav.STAY_SAME_ISSUE
+
+# Maximum comment length to mitigate spammy comments
+MAX_COMMENT_CHARS = 50 * 1024
+MAX_SUMMARY_CHARS = 500
+
+SHORT_SUMMARY_LENGTH = 45
+
+# Number of recent commands to offer the user on the quick edit form.
+MAX_RECENT_COMMANDS = 5
+
+# These recent commands are shown if the user has no history of their own.
+DEFAULT_RECENT_COMMANDS = [
+    ('owner=me status=Accepted', "I'll handle this one."),
+    ('owner=me Priority=High status=Accepted', "I'll look into it soon."),
+    ('status=Fixed', 'The change for this is done now.'),
+    ('Type=Enhancement', 'This is an enhancement, not a defect.'),
+    ('status=Invalid', 'Please report this in a more appropriate place.'),
+    ]
+
+# Consider an issue to be a "noisy" issue if it has more than these:
+NOISY_ISSUE_COMMENT_COUNT = 100
+NOISY_ISSUE_STARRER_COUNT = 100
+
+# After a project owner edits the filter rules, we recompute the
+# derived field values in work items that each handle a chunk of
+# of this many items.
+RECOMPUTE_DERIVED_FIELDS_BLOCK_SIZE = 1000
+
+# This is the number of issues listed in the ReindexQueue table that will
+# be processed each minute.
+MAX_ISSUES_TO_REINDEX_PER_MINUTE = 1000
diff --git a/tracker/tracker_helpers.py b/tracker/tracker_helpers.py
new file mode 100644
index 0000000..c9f9e5a
--- /dev/null
+++ b/tracker/tracker_helpers.py
@@ -0,0 +1,1826 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Helper functions and classes used by the Monorail Issue Tracker pages.
+
+This module has functions that are reused in multiple servlets or
+other modules.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import collections
+import itertools
+import logging
+import re
+import time
+import urllib
+
+from google.appengine.api import app_identity
+
+from six import string_types
+
+import settings
+
+from features import federated
+from framework import authdata
+from framework import exceptions
+from framework import filecontent
+from framework import framework_bizobj
+from framework import framework_constants
+from framework import framework_helpers
+from framework import framework_views
+from framework import permissions
+from framework import sorting
+from framework import template_helpers
+from framework import urls
+from project import project_helpers
+from proto import tracker_pb2
+from services import client_config_svc
+from tracker import field_helpers
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+
+
+# HTML input field names for blocked on and blocking issue refs.
+BLOCKED_ON = 'blocked_on'
+BLOCKING = 'blocking'
+
+# This string is used in HTML form element names to identify custom fields.
+# E.g., a value for a custom field with field_id 12 would be specified in
+# an HTML form element with name="custom_12".
+_CUSTOM_FIELD_NAME_PREFIX = 'custom_'
+
+# When the attachment quota gets within 1MB of the limit, stop offering
+# users the option to attach files.
+_SOFT_QUOTA_LEEWAY = 1024 * 1024
+
+# Accessors for sorting built-in fields.
+SORTABLE_FIELDS = {
+    'project': lambda issue: issue.project_name,
+    'id': lambda issue: issue.local_id,
+    'owner': tracker_bizobj.GetOwnerId,  # And postprocessor
+    'reporter': lambda issue: issue.reporter_id,  # And postprocessor
+    'component': lambda issue: issue.component_ids,
+    'cc': tracker_bizobj.GetCcIds,  # And postprocessor
+    'summary': lambda issue: issue.summary.lower(),
+    'stars': lambda issue: issue.star_count,
+    'attachments': lambda issue: issue.attachment_count,
+    'opened': lambda issue: issue.opened_timestamp,
+    'closed': lambda issue: issue.closed_timestamp,
+    'modified': lambda issue: issue.modified_timestamp,
+    'status': tracker_bizobj.GetStatus,
+    'blocked': lambda issue: bool(issue.blocked_on_iids),
+    'blockedon': lambda issue: issue.blocked_on_iids or sorting.MAX_STRING,
+    'blocking': lambda issue: issue.blocking_iids or sorting.MAX_STRING,
+    'mergedinto': lambda issue: issue.merged_into or sorting.MAX_STRING,
+    'ownermodified': lambda issue: issue.owner_modified_timestamp,
+    'statusmodified': lambda issue: issue.status_modified_timestamp,
+    'componentmodified': lambda issue: issue.component_modified_timestamp,
+    'ownerlastvisit': tracker_bizobj.GetOwnerId,  # And postprocessor
+    }
+
+# Some fields take a user ID from the issue and then use that to index
+# into a dictionary of user views, and then get a field of the user view
+# as the value to sort key.
+SORTABLE_FIELDS_POSTPROCESSORS = {
+    'owner': lambda user_view: user_view.email,
+    'reporter': lambda user_view: user_view.email,
+    'cc': lambda user_view: user_view.email,
+    'ownerlastvisit': lambda user_view: -user_view.user.last_visit_timestamp,
+    }
+
+# Here are some restriction labels to help people do the most common things
+# that they might want to do with restrictions.
+_FREQUENT_ISSUE_RESTRICTIONS = [
+    (permissions.VIEW, permissions.EDIT_ISSUE,
+     'Only users who can edit the issue may access it'),
+    (permissions.ADD_ISSUE_COMMENT, permissions.EDIT_ISSUE,
+     'Only users who can edit the issue may add comments'),
+    ]
+
+# These issue restrictions should be offered as examples whenever the project
+# does not have any custom permissions in use already.
+_EXAMPLE_ISSUE_RESTRICTIONS = [
+    (permissions.VIEW, 'CoreTeam',
+     'Custom permission CoreTeam is needed to access'),
+    ]
+
+# Namedtuples that hold data parsed from post_data.
+ParsedComponents = collections.namedtuple(
+    'ParsedComponents', 'entered_str, paths, paths_remove')
+ParsedFields = collections.namedtuple(
+    'ParsedFields',
+    'vals, vals_remove, fields_clear, '
+    'phase_vals, phase_vals_remove')
+ParsedUsers = collections.namedtuple(
+    'ParsedUsers', 'owner_username, owner_id, cc_usernames, '
+    'cc_usernames_remove, cc_ids, cc_ids_remove')
+ParsedBlockers = collections.namedtuple(
+    'ParsedBlockers', 'entered_str, iids, dangling_refs, '
+    'federated_ref_strings')
+ParsedHotlistRef = collections.namedtuple(
+    'ParsedHotlistRef', 'user_email, hotlist_name')
+ParsedHotlists = collections.namedtuple(
+    'ParsedHotlists', 'entered_str, hotlist_refs')
+ParsedIssue = collections.namedtuple(
+    'ParsedIssue', 'summary, comment, is_description, status, users, labels, '
+    'labels_remove, components, fields, template_name, attachments, '
+    'kept_attachments, blocked_on, blocking, hotlists')
+
+
+def ParseIssueRequest(cnxn, post_data, services, errors, default_project_name):
+  """Parse all the possible arguments out of the request.
+
+  Args:
+    cnxn: connection to SQL database.
+    post_data: HTML form information.
+    services: Connections to persistence layer.
+    errors: object to accumulate validation error info.
+    default_project_name: name of the project that contains the issue.
+
+  Returns:
+    A namedtuple with all parsed information.  User IDs are looked up, but
+    also the strings are returned to allow bouncing the user back to correct
+    any errors.
+  """
+  summary = post_data.get('summary', '')
+  comment = post_data.get('comment', '')
+  is_description = bool(post_data.get('description', ''))
+  status = post_data.get('status', '')
+  template_name = urllib.unquote_plus(post_data.get('template_name', ''))
+  component_str = post_data.get('components', '')
+  label_strs = post_data.getall('label')
+
+  if is_description:
+    tmpl_txt = post_data.get('tmpl_txt', '')
+    comment = MarkupDescriptionOnInput(comment, tmpl_txt)
+
+  comp_paths, comp_paths_remove = _ClassifyPlusMinusItems(
+      re.split('[,;\s]+', component_str))
+  parsed_components = ParsedComponents(
+      component_str, comp_paths, comp_paths_remove)
+  labels, labels_remove = _ClassifyPlusMinusItems(label_strs)
+  parsed_fields = _ParseIssueRequestFields(post_data)
+  # TODO(jrobbins): change from numbered fields to a multi-valued field.
+  attachments = _ParseIssueRequestAttachments(post_data)
+  kept_attachments = _ParseIssueRequestKeptAttachments(post_data)
+  parsed_users = _ParseIssueRequestUsers(cnxn, post_data, services)
+  parsed_blocked_on = _ParseBlockers(
+      cnxn, post_data, services, errors, default_project_name, BLOCKED_ON)
+  parsed_blocking = _ParseBlockers(
+      cnxn, post_data, services, errors, default_project_name, BLOCKING)
+  parsed_hotlists = _ParseHotlists(post_data)
+
+  parsed_issue = ParsedIssue(
+      summary, comment, is_description, status, parsed_users, labels,
+      labels_remove, parsed_components, parsed_fields, template_name,
+      attachments, kept_attachments, parsed_blocked_on, parsed_blocking,
+      parsed_hotlists)
+  return parsed_issue
+
+
+def MarkupDescriptionOnInput(content, tmpl_text):
+  """Return HTML for the content of an issue description or comment.
+
+  Args:
+    content: the text sumbitted by the user, any user-entered markup
+             has already been escaped.
+    tmpl_text: the initial text that was put into the textarea.
+
+  Returns:
+    The description content text with template lines highlighted.
+  """
+  tmpl_lines = tmpl_text.split('\n')
+  tmpl_lines = [pl.strip() for pl in tmpl_lines if pl.strip()]
+
+  entered_lines = content.split('\n')
+  marked_lines = [_MarkupDescriptionLineOnInput(line, tmpl_lines)
+                  for line in entered_lines]
+  return '\n'.join(marked_lines)
+
+
+def _MarkupDescriptionLineOnInput(line, tmpl_lines):
+  """Markup one line of an issue description that was just entered.
+
+  Args:
+    line: string containing one line of the user-entered comment.
+    tmpl_lines: list of strings for the text of the template lines.
+
+  Returns:
+    The same user-entered line, or that line highlighted to
+    indicate that it came from the issue template.
+  """
+  for tmpl_line in tmpl_lines:
+    if line.startswith(tmpl_line):
+      return '<b>' + tmpl_line + '</b>' + line[len(tmpl_line):]
+
+  return line
+
+
+def _ClassifyPlusMinusItems(add_remove_list):
+  """Classify the given plus-or-minus items into add and remove lists."""
+  add_remove_set = {s.strip() for s in add_remove_list}
+  add_strs = [s for s in add_remove_set if s and not s.startswith('-')]
+  remove_strs = [s[1:] for s in add_remove_set if s[1:] and s.startswith('-')]
+  return add_strs, remove_strs
+
+
+def _ParseHotlists(post_data):
+  entered_str = post_data.get('hotlists', '').strip()
+  hotlist_refs = []
+  for ref_str in re.split('[,;\s]+', entered_str):
+    if not ref_str:
+      continue
+    if ':' in ref_str:
+      if ref_str.split(':')[0]:
+        # E-mail isn't empty; full reference.
+        hotlist_refs.append(ParsedHotlistRef(*ref_str.split(':', 1)))
+      else:
+        # Short reference.
+        hotlist_refs.append(ParsedHotlistRef(None, ref_str.split(':', 1)[1]))
+    else:
+      # Short reference
+      hotlist_refs.append(ParsedHotlistRef(None, ref_str))
+  parsed_hotlists = ParsedHotlists(entered_str, hotlist_refs)
+  return parsed_hotlists
+
+
+def _ParseIssueRequestFields(post_data):
+  """Iterate over post_data and return custom field values found in it."""
+  field_val_strs = {}
+  field_val_strs_remove = {}
+  phase_field_val_strs = collections.defaultdict(dict)
+  phase_field_val_strs_remove = collections.defaultdict(dict)
+  for key in post_data.keys():
+    if key.startswith(_CUSTOM_FIELD_NAME_PREFIX):
+      val_strs = [v for v in post_data.getall(key) if v]
+      if val_strs:
+        try:
+          field_id = int(key[len(_CUSTOM_FIELD_NAME_PREFIX):])
+          phase_name = None
+        except ValueError:  # key must be in format <field_id>_<phase_name>
+          field_id, phase_name = key[len(_CUSTOM_FIELD_NAME_PREFIX):].split(
+              '_', 1)
+          field_id = int(field_id)
+        if post_data.get('op_' + key) == 'remove':
+          if phase_name:
+            phase_field_val_strs_remove[field_id][phase_name] = val_strs
+          else:
+            field_val_strs_remove[field_id] = val_strs
+        else:
+          if phase_name:
+            phase_field_val_strs[field_id][phase_name] = val_strs
+          else:
+            field_val_strs[field_id] = val_strs
+
+  # TODO(jojwang): monorail:5154, no support for clearing phase field values.
+  fields_clear = []
+  op_prefix = 'op_' + _CUSTOM_FIELD_NAME_PREFIX
+  for op_key in post_data.keys():
+    if op_key.startswith(op_prefix):
+      if post_data.get(op_key) == 'clear':
+        field_id = int(op_key[len(op_prefix):])
+        fields_clear.append(field_id)
+
+  return ParsedFields(
+      field_val_strs, field_val_strs_remove, fields_clear,
+      phase_field_val_strs, phase_field_val_strs_remove)
+
+
+def _ParseIssueRequestAttachments(post_data):
+  """Extract and clean-up any attached files from the post data.
+
+  Args:
+    post_data: dict w/ values from the user's HTTP POST form data.
+
+  Returns:
+    [(filename, filecontents, mimetype), ...] with items for each attachment.
+  """
+  # TODO(jrobbins): change from numbered fields to a multi-valued field.
+  attachments = []
+  for i in range(1, 16):
+    if 'file%s' % i in post_data:
+      item = post_data['file%s' % i]
+      if isinstance(item, string_types):
+        continue
+      if '\\' in item.filename:  # IE insists on giving us the whole path.
+        item.filename = item.filename[item.filename.rindex('\\') + 1:]
+      if not item.filename:
+        continue  # Skip any FILE fields that were not filled in.
+      attachments.append((
+          item.filename, item.value,
+          filecontent.GuessContentTypeFromFilename(item.filename)))
+
+  return attachments
+
+
+def _ParseIssueRequestKeptAttachments(post_data):
+  """Extract attachment ids for attachments kept when updating description
+
+  Args:
+    post_data: dict w/ values from the user's HTTP POST form data.
+
+  Returns:
+    a list of attachment ids for kept attachments
+  """
+  kept_attachments = post_data.getall('keep-attachment')
+  return [int(aid) for aid in kept_attachments]
+
+
+def _ParseIssueRequestUsers(cnxn, post_data, services):
+  """Extract usernames from the POST data, categorize them, and look up IDs.
+
+  Args:
+    cnxn: connection to SQL database.
+    post_data: dict w/ data from the HTTP POST.
+    services: Services.
+
+  Returns:
+    A namedtuple (owner_username, owner_id, cc_usernames, cc_usernames_remove,
+    cc_ids, cc_ids_remove), containing:
+      - issue owner's name and user ID, if any
+      - the list of all cc'd usernames
+      - the user IDs to add or remove from the issue CC list.
+    Any of these user IDs may be  None if the corresponding username
+    or email address is invalid.
+  """
+  # Get the user-entered values from post_data.
+  cc_username_str = post_data.get('cc', '').lower()
+  owner_email = post_data.get('owner', '').strip().lower()
+
+  cc_usernames, cc_usernames_remove = _ClassifyPlusMinusItems(
+      re.split('[,;\s]+', cc_username_str))
+
+  # Figure out the email addresses to lookup and do the lookup.
+  emails_to_lookup = cc_usernames + cc_usernames_remove
+  if owner_email:
+    emails_to_lookup.append(owner_email)
+  all_user_ids = services.user.LookupUserIDs(
+      cnxn, emails_to_lookup, autocreate=True)
+  if owner_email:
+    owner_id = all_user_ids.get(owner_email)
+  else:
+    owner_id = framework_constants.NO_USER_SPECIFIED
+
+  # Lookup the user IDs of the Cc addresses to add or remove.
+  cc_ids = [all_user_ids.get(cc) for cc in cc_usernames if cc]
+  cc_ids_remove = [all_user_ids.get(cc) for cc in cc_usernames_remove if cc]
+
+  return ParsedUsers(owner_email, owner_id, cc_usernames, cc_usernames_remove,
+                     cc_ids, cc_ids_remove)
+
+
+def _ParseBlockers(cnxn, post_data, services, errors, default_project_name,
+                   field_name):
+  """Parse input for issues that the current issue is blocking/blocked on.
+
+  Args:
+    cnxn: connection to SQL database.
+    post_data: dict w/ values from the user's HTTP POST.
+    services: connections to backend services.
+    errors: object to accumulate validation error info.
+    default_project_name: name of the project that contains the issue.
+    field_name: string HTML input field name, e.g., BLOCKED_ON or BLOCKING.
+
+  Returns:
+    A namedtuple with the user input string, and a list of issue IDs.
+  """
+  entered_str = post_data.get(field_name, '').strip()
+  blocker_iids = []
+  dangling_ref_tuples = []
+  federated_ref_strings = []
+
+  issue_ref = None
+  for ref_str in re.split('[,;\s]+', entered_str):
+    # Handle federated references.
+    if federated.IsShortlinkValid(ref_str):
+      federated_ref_strings.append(ref_str)
+      continue
+
+    try:
+      issue_ref = tracker_bizobj.ParseIssueRef(ref_str)
+    except ValueError:
+      setattr(errors, field_name, 'Invalid issue ID %s' % ref_str.strip())
+      break
+
+    if not issue_ref:
+      continue
+
+    blocker_project_name, blocker_issue_id = issue_ref
+    if not blocker_project_name:
+      blocker_project_name = default_project_name
+
+    # Detect and report if the same issue was specified.
+    current_issue_id = int(post_data.get('id')) if post_data.get('id') else -1
+    if (blocker_issue_id == current_issue_id and
+        blocker_project_name == default_project_name):
+      setattr(errors, field_name, 'Cannot be %s the same issue' % field_name)
+      break
+
+    ref_projects = services.project.GetProjectsByName(
+        cnxn, set([blocker_project_name]))
+    blocker_iid, _misses = services.issue.ResolveIssueRefs(
+        cnxn, ref_projects, default_project_name, [issue_ref])
+    if not blocker_iid:
+      if blocker_project_name in settings.recognized_codesite_projects:
+        # We didn't find the issue, but it had a explicitly-specified project
+        # which we know is on Codesite. Allow it as a dangling reference.
+        dangling_ref_tuples.append(issue_ref)
+        continue
+      else:
+        # Otherwise, it doesn't exist, so report it.
+        setattr(errors, field_name, 'Invalid issue ID %s' % ref_str.strip())
+        break
+    if blocker_iid[0] not in blocker_iids:
+      blocker_iids.extend(blocker_iid)
+
+  blocker_iids.sort()
+  dangling_ref_tuples.sort()
+  return ParsedBlockers(entered_str, blocker_iids, dangling_ref_tuples,
+      federated_ref_strings)
+
+
+def PairDerivedValuesWithRuleExplanations(
+    proposed_issue, traces, derived_users_by_id):
+  """Pair up values and explanations into JSON objects."""
+  derived_labels_and_why = [
+      {'value': lab,
+       'why': traces.get((tracker_pb2.FieldID.LABELS, lab))}
+      for lab in proposed_issue.derived_labels]
+
+  derived_users_by_id = {
+      user_id: user_view.display_name
+      for user_id, user_view in derived_users_by_id.items()
+      if user_view.display_name}
+
+  derived_owner_and_why = []
+  if proposed_issue.derived_owner_id:
+    derived_owner_and_why = [{
+        'value': derived_users_by_id[proposed_issue.derived_owner_id],
+        'why': traces.get(
+            (tracker_pb2.FieldID.OWNER, proposed_issue.derived_owner_id))}]
+  derived_cc_and_why = [
+      {'value': derived_users_by_id[cc_id],
+       'why': traces.get((tracker_pb2.FieldID.CC, cc_id))}
+      for cc_id in proposed_issue.derived_cc_ids
+      if cc_id in derived_users_by_id]
+
+  warnings_and_why = [
+      {'value': warning,
+       'why': traces.get((tracker_pb2.FieldID.WARNING, warning))}
+      for warning in proposed_issue.derived_warnings]
+
+  errors_and_why = [
+      {'value': error,
+       'why': traces.get((tracker_pb2.FieldID.ERROR, error))}
+      for error in proposed_issue.derived_errors]
+
+  return (derived_labels_and_why, derived_owner_and_why, derived_cc_and_why,
+          warnings_and_why, errors_and_why)
+
+
+def IsValidIssueOwner(cnxn, project, owner_id, services):
+  """Return True if the given user ID can be an issue owner.
+
+  Args:
+    cnxn: connection to SQL database.
+    project: the current Project PB.
+    owner_id: the user ID of the proposed issue owner.
+    services: connections to backends.
+
+  It is OK to have 0 for the owner_id, that simply means that the issue is
+  unassigned.
+
+  Returns:
+    A pair (valid, err_msg).  valid is True if the given user ID can be an
+    issue owner. err_msg is an error message string to display to the user
+    if valid == False, and is None if valid == True.
+  """
+  # An issue is always allowed to have no owner specified.
+  if owner_id == framework_constants.NO_USER_SPECIFIED:
+    return True, None
+
+  try:
+    auth = authdata.AuthData.FromUserID(cnxn, owner_id, services)
+    if not framework_bizobj.UserIsInProject(project, auth.effective_ids):
+      return False, 'Issue owner must be a project member.'
+  except exceptions.NoSuchUserException:
+    return False, 'Issue owner user ID not found.'
+
+  group_ids = services.usergroup.DetermineWhichUserIDsAreGroups(
+      cnxn, [owner_id])
+  if owner_id in group_ids:
+    return False, 'Issue owner cannot be a user group.'
+
+  return True, None
+
+
+def GetAllowedOpenedAndClosedIssues(mr, issue_ids, services):
+  """Get filtered lists of open and closed issues identified by issue_ids.
+
+  The function then filters the results to only the issues that the user
+  is allowed to view.  E.g., we only auto-link to issues that the user
+  would be able to view if they clicked the link.
+
+  Args:
+    mr: commonly used info parsed from the request.
+    issue_ids: list of int issue IDs for the target issues.
+    services: connection to issue, config, and project persistence layers.
+
+  Returns:
+    Two lists of issues that the user is allowed to view: one for open
+    issues and one for closed issues.
+  """
+  open_issues, closed_issues = services.issue.GetOpenAndClosedIssues(
+      mr.cnxn, issue_ids)
+  return GetAllowedIssues(mr, [open_issues, closed_issues], services)
+
+
+def GetAllowedIssues(mr, issue_groups, services):
+  """Filter lists of issues identified by issue_groups.
+
+  Args:
+    mr: commonly used info parsed from the request.
+    issue_groups: list of list of issues to filter.
+    services: connection to issue, config, and project persistence layers.
+
+  Returns:
+    List of filtered list of issues.
+  """
+
+  project_dict = GetAllIssueProjects(
+      mr.cnxn, itertools.chain.from_iterable(issue_groups), services.project)
+  config_dict = services.config.GetProjectConfigs(mr.cnxn,
+      list(project_dict.keys()))
+  return [FilterOutNonViewableIssues(
+      mr.auth.effective_ids, mr.auth.user_pb, project_dict, config_dict,
+      issues)
+          for issues in issue_groups]
+
+
+def MakeViewsForUsersInIssues(cnxn, issue_list, user_service, omit_ids=None):
+  """Lookup all the users involved in any of the given issues.
+
+  Args:
+    cnxn: connection to SQL database.
+    issue_list: list of Issue PBs from a result query.
+    user_service: Connection to User backend storage.
+    omit_ids: a list of user_ids to omit, e.g., because we already have them.
+
+  Returns:
+    A dictionary {user_id: user_view,...} for all the users involved
+    in the given issues.
+  """
+  issue_participant_id_set = tracker_bizobj.UsersInvolvedInIssues(issue_list)
+  if omit_ids:
+    issue_participant_id_set.difference_update(omit_ids)
+
+  # TODO(jrobbins): consider caching View objects as well.
+  users_by_id = framework_views.MakeAllUserViews(
+      cnxn, user_service, issue_participant_id_set)
+
+  return users_by_id
+
+
+def FormatIssueListURL(
+    mr, config, absolute=True, project_names=None, **kwargs):
+  """Format a link back to list view as configured by user."""
+  if project_names is None:
+    project_names = [mr.project_name]
+  if tracker_constants.JUMP_RE.match(mr.query):
+    kwargs['q'] = 'id=%s' % mr.query
+    kwargs['can'] = 1  # The specified issue might be closed.
+  else:
+    kwargs['q'] = mr.query
+    if mr.can and mr.can != 2:
+      kwargs['can'] = mr.can
+  def_col_spec = config.default_col_spec
+  if mr.col_spec and mr.col_spec != def_col_spec:
+    kwargs['colspec'] = mr.col_spec
+  if mr.sort_spec:
+    kwargs['sort'] = mr.sort_spec
+  if mr.group_by_spec:
+    kwargs['groupby'] = mr.group_by_spec
+  if mr.start:
+    kwargs['start'] = mr.start
+  if mr.num != tracker_constants.DEFAULT_RESULTS_PER_PAGE:
+    kwargs['num'] = mr.num
+
+  if len(project_names) == 1:
+    url = '/p/%s%s' % (project_names[0], urls.ISSUE_LIST)
+  else:
+    url = urls.ISSUE_LIST
+    kwargs['projects'] = ','.join(sorted(project_names))
+
+  param_strings = ['%s=%s' % (k, urllib.quote((u'%s' % v).encode('utf-8')))
+                   for k, v in kwargs.items()]
+  if param_strings:
+    url += '?' + '&'.join(sorted(param_strings))
+  if absolute:
+    url = '%s://%s%s' % (mr.request.scheme, mr.request.host, url)
+
+  return url
+
+
+def FormatRelativeIssueURL(project_name, path, **kwargs):
+  """Format a URL to get to an issue in the named project.
+
+  Args:
+    project_name: string name of the project containing the issue.
+    path: string servlet path, e.g., from framework/urls.py.
+    **kwargs: additional query-string parameters to include in the URL.
+
+  Returns:
+    A URL string.
+  """
+  return framework_helpers.FormatURL(
+      None, '/p/%s%s' % (project_name, path), **kwargs)
+
+
+def FormatCrBugURL(project_name, local_id):
+  """Format a short URL to get to an issue in the named project.
+
+  Args:
+    project_name: string name of the project containing the issue.
+    local_id: int local ID of the issue.
+
+  Returns:
+    A URL string.
+  """
+  if app_identity.get_application_id() != 'monorail-prod':
+    return FormatRelativeIssueURL(
+      project_name, urls.ISSUE_DETAIL, id=local_id)
+
+  if project_name == 'chromium':
+    return 'https://crbug.com/%d' % local_id
+
+  return 'https://crbug.com/%s/%d' % (project_name, local_id)
+
+
+def ComputeNewQuotaBytesUsed(project, attachments):
+  """Add the given attachments to the project's attachment quota usage.
+
+  Args:
+    project: Project PB  for the project being updated.
+    attachments: a list of attachments being added to an issue.
+
+  Returns:
+    The new number of bytes used.
+
+  Raises:
+    OverAttachmentQuota: If project would go over quota.
+  """
+  total_attach_size = 0
+  for _filename, content, _mimetype in attachments:
+    total_attach_size += len(content)
+
+  new_bytes_used = project.attachment_bytes_used + total_attach_size
+  quota = (project.attachment_quota or
+           tracker_constants.ISSUE_ATTACHMENTS_QUOTA_HARD)
+  if new_bytes_used > quota:
+    raise exceptions.OverAttachmentQuota(new_bytes_used - quota)
+  return new_bytes_used
+
+
+def IsUnderSoftAttachmentQuota(project):
+  """Check the project's attachment quota against the soft quota limit.
+
+  If there is a custom quota on the project, this will check against
+  that instead of the system-wide default quota.
+
+  Args:
+    project: Project PB for the project to examine
+
+  Returns:
+    True if the project is under quota, false otherwise.
+  """
+  quota = tracker_constants.ISSUE_ATTACHMENTS_QUOTA_SOFT
+  if project.attachment_quota:
+    quota = project.attachment_quota - _SOFT_QUOTA_LEEWAY
+
+  return project.attachment_bytes_used < quota
+
+
+def GetAllIssueProjects(cnxn, issues, project_service):
+  """Get all the projects that the given issues belong to.
+
+  Args:
+    cnxn: connection to SQL database.
+    issues: list of issues, which may come from different projects.
+    project_service: connection to project persistence layer.
+
+  Returns:
+    A dictionary {project_id: project} of all the projects that
+    any of the given issues belongs to.
+  """
+  needed_project_ids = {issue.project_id for issue in issues}
+  project_dict = project_service.GetProjects(cnxn, needed_project_ids)
+  return project_dict
+
+
+def GetPermissionsInAllProjects(user, effective_ids, projects):
+  """Look up the permissions for the given user in each project."""
+  return {
+      project.project_id:
+      permissions.GetPermissions(user, effective_ids, project)
+      for project in projects}
+
+
+def FilterOutNonViewableIssues(
+    effective_ids, user, project_dict, config_dict, issues):
+  """Return a filtered list of issues that the user can view."""
+  perms_dict = GetPermissionsInAllProjects(
+      user, effective_ids, list(project_dict.values()))
+
+  denied_project_ids = {
+      pid for pid, p in project_dict.items()
+      if not permissions.CanView(effective_ids, perms_dict[pid], p, [])}
+
+  results = []
+  for issue in issues:
+    if issue.deleted or issue.project_id in denied_project_ids:
+      continue
+
+    if not permissions.HasRestrictions(issue):
+      may_view = True
+    else:
+      perms = perms_dict[issue.project_id]
+      project = project_dict[issue.project_id]
+      config = config_dict.get(issue.project_id, config_dict.get('harmonized'))
+      granted_perms = tracker_bizobj.GetGrantedPerms(
+          issue, effective_ids, config)
+      may_view = permissions.CanViewIssue(
+          effective_ids, perms, project, issue, granted_perms=granted_perms)
+
+    if may_view:
+      results.append(issue)
+
+  return results
+
+
+def MeansOpenInProject(status, config):
+  """Return true if this status means that the issue is still open.
+
+  This defaults to true if we could not find a matching status.
+
+  Args:
+    status: issue status string. E.g., 'New'.
+    config: the config of the current project.
+
+  Returns:
+    Boolean True if the status means that the issue is open.
+  """
+  status_lower = status.lower()
+
+  # iterate over the list of known statuses for this project
+  # return true if we find a match that declares itself to be open
+  for wks in config.well_known_statuses:
+    if wks.status.lower() == status_lower:
+      return wks.means_open
+
+  return True
+
+
+def IsNoisy(num_comments, num_starrers):
+  """Return True if this is a "noisy" issue that would send a ton of emails.
+
+  The rule is that a very active issue with a large number of comments
+  and starrers will only send notification when a comment (or change)
+  is made by a project member.
+
+  Args:
+    num_comments: int number of comments on issue so far.
+    num_starrers: int number of users who starred the issue.
+
+  Returns:
+    True if we will not bother starrers with an email notification for
+    changes made by non-members.
+  """
+  return (num_comments >= tracker_constants.NOISY_ISSUE_COMMENT_COUNT and
+          num_starrers >= tracker_constants.NOISY_ISSUE_STARRER_COUNT)
+
+
+def MergeCCsAndAddComment(services, mr, issue, merge_into_issue):
+  """Modify the CC field of the target issue and add a comment to it."""
+  return MergeCCsAndAddCommentMultipleIssues(
+      services, mr, [issue], merge_into_issue)
+
+
+def MergeCCsAndAddCommentMultipleIssues(
+    services, mr, issues, merge_into_issue):
+  """Modify the CC field of the target issue and add a comment to it."""
+  merge_comment = ''
+  for issue in issues:
+    if issue.project_name == merge_into_issue.project_name:
+      issue_ref_str = '%d' % issue.local_id
+    else:
+      issue_ref_str = '%s:%d' % (issue.project_name, issue.local_id)
+    if merge_comment:
+      merge_comment += '\n'
+    merge_comment += 'Issue %s has been merged into this issue.' % issue_ref_str
+
+  add_cc = _ComputeNewCcsFromIssueMerge(merge_into_issue, issues)
+
+  config = services.config.GetProjectConfig(
+      mr.cnxn, merge_into_issue.project_id)
+  delta = tracker_pb2.IssueDelta(cc_ids_add=add_cc)
+  _, merge_comment_pb = services.issue.DeltaUpdateIssue(
+    mr.cnxn, services, mr.auth.user_id, merge_into_issue.project_id,
+    config, merge_into_issue, delta, index_now=False, comment=merge_comment)
+
+  return merge_comment_pb
+
+
+def GetAttachmentIfAllowed(mr, services):
+  """Retrieve the requested attachment, or raise an appropriate exception.
+
+  Args:
+    mr: commonly used info parsed from the request.
+    services: connections to backend services.
+
+  Returns:
+    The requested Attachment PB, and the Issue that it belongs to.
+
+  Raises:
+    NoSuchAttachmentException: attachment was not found or was marked deleted.
+    NoSuchIssueException: issue that contains attachment was not found.
+    PermissionException: the user is not allowed to view the attachment.
+  """
+  attachment = None
+
+  attachment, cid, issue_id = services.issue.GetAttachmentAndContext(
+      mr.cnxn, mr.aid)
+
+  issue = services.issue.GetIssue(mr.cnxn, issue_id)
+  config = services.config.GetProjectConfig(mr.cnxn, issue.project_id)
+  granted_perms = tracker_bizobj.GetGrantedPerms(
+      issue, mr.auth.effective_ids, config)
+  permit_view = permissions.CanViewIssue(
+      mr.auth.effective_ids, mr.perms, mr.project, issue,
+      granted_perms=granted_perms)
+  if not permit_view:
+    raise permissions.PermissionException('Cannot view attachment\'s issue')
+
+  comment = services.issue.GetComment(mr.cnxn, cid)
+  commenter = services.user.GetUser(mr.cnxn, comment.user_id)
+  issue_perms = permissions.UpdateIssuePermissions(
+      mr.perms, mr.project, issue, mr.auth.effective_ids,
+      granted_perms=granted_perms)
+  can_view_comment = permissions.CanViewComment(
+      comment, commenter, mr.auth.user_id, issue_perms)
+  if not can_view_comment:
+    raise permissions.PermissionException('Cannot view attachment\'s comment')
+
+  return attachment, issue
+
+
+def LabelsMaskedByFields(config, field_names, trim_prefix=False):
+  """Return a list of EZTItems for labels that would be masked by fields."""
+  return _LabelsMaskedOrNot(config, field_names, trim_prefix=trim_prefix)
+
+
+def LabelsNotMaskedByFields(config, field_names, trim_prefix=False):
+  """Return a list of EZTItems for labels that would not be masked."""
+  return _LabelsMaskedOrNot(
+      config, field_names, invert=True, trim_prefix=trim_prefix)
+
+
+def _LabelsMaskedOrNot(config, field_names, invert=False, trim_prefix=False):
+  """Return EZTItems for labels that'd be masked. Or not, when invert=True."""
+  field_names = [fn.lower() for fn in field_names]
+  result = []
+  for wkl in config.well_known_labels:
+    masked_by = tracker_bizobj.LabelIsMaskedByField(wkl.label, field_names)
+    if (masked_by and not invert) or (not masked_by and invert):
+      display_name = wkl.label
+      if trim_prefix:
+        display_name = display_name[len(masked_by) + 1:]
+      result.append(template_helpers.EZTItem(
+          name=display_name,
+          name_padded=display_name.ljust(20),
+          commented='#' if wkl.deprecated else '',
+          docstring=wkl.label_docstring,
+          docstring_short=template_helpers.FitUnsafeText(
+              wkl.label_docstring, 40),
+          idx=len(result)))
+
+  return result
+
+
+def LookupComponentIDs(component_paths, config, errors=None):
+  """Look up the IDs of the specified components in the given config."""
+  component_ids = []
+  for path in component_paths:
+    if not path:
+      continue
+    cd = tracker_bizobj.FindComponentDef(path, config)
+    if cd:
+      component_ids.append(cd.component_id)
+    else:
+      error_text = 'Unknown component %s' % path
+      if errors:
+        errors.components = error_text
+      else:
+        logging.info(error_text)
+
+  return component_ids
+
+
+def ParsePostDataUsers(cnxn, pd_users_str, user_service):
+  """Parse all the usernames from a users string found in a post data."""
+  emails, _remove = _ClassifyPlusMinusItems(re.split('[,;\s]+', pd_users_str))
+  users_ids_by_email = user_service.LookupUserIDs(cnxn, emails, autocreate=True)
+  user_ids = [users_ids_by_email[username] for username in emails if username]
+  return user_ids, pd_users_str
+
+
+def FilterIssueTypes(config):
+  """Return a list of well-known issue types."""
+  well_known_issue_types = []
+  for wk_label in config.well_known_labels:
+    if wk_label.label.lower().startswith('type-'):
+      _, type_name = wk_label.label.split('-', 1)
+      well_known_issue_types.append(type_name)
+
+  return well_known_issue_types
+
+
+def ParseMergeFields(
+    cnxn, services, project_name, post_data, status, config, issue, errors):
+  """Parse info that identifies the issue to merge into, if any."""
+  merge_into_text = ''
+  merge_into_ref = None
+  merge_into_issue = None
+
+  if status not in config.statuses_offer_merge:
+    return '', None
+
+  merge_into_text = post_data.get('merge_into', '')
+  if merge_into_text:
+    try:
+      merge_into_ref = tracker_bizobj.ParseIssueRef(merge_into_text)
+    except ValueError:
+      logging.info('merge_into not an int: %r', merge_into_text)
+      errors.merge_into_id = 'Please enter a valid issue ID'
+
+  if not merge_into_ref:
+    errors.merge_into_id = 'Please enter an issue ID'
+    return merge_into_text, None
+
+  merge_into_project_name, merge_into_id = merge_into_ref
+  if (merge_into_id == issue.local_id and
+      (merge_into_project_name == project_name or
+       not merge_into_project_name)):
+    logging.info('user tried to merge issue into itself: %r', merge_into_ref)
+    errors.merge_into_id = 'Cannot merge issue into itself'
+    return merge_into_text, None
+
+  project = services.project.GetProjectByName(
+      cnxn, merge_into_project_name or project_name)
+  try:
+    # Because we will modify this issue, load from DB rather than cache.
+    merge_into_issue = services.issue.GetIssueByLocalID(
+        cnxn, project.project_id, merge_into_id, use_cache=False)
+  except Exception:
+    logging.info('merge_into issue not found: %r', merge_into_ref)
+    errors.merge_into_id = 'No such issue'
+    return merge_into_text, None
+
+  return merge_into_text, merge_into_issue
+
+
+def GetNewIssueStarrers(cnxn, services, issue_ids, merge_into_iid):
+  # type: (MonorailConnection, Services, Sequence[int], int) ->
+  #     Collection[int]
+  """Get starrers of current issue who have not starred the target issue."""
+  source_starrers_dict = services.issue_star.LookupItemsStarrers(
+      cnxn, issue_ids)
+  source_starrers = list(
+      itertools.chain.from_iterable(source_starrers_dict.values()))
+  target_starrers = services.issue_star.LookupItemStarrers(
+      cnxn, merge_into_iid)
+  return set(source_starrers) - set(target_starrers)
+
+
+def AddIssueStarrers(
+    cnxn, services, mr, merge_into_iid, merge_into_project, new_starrers):
+  """Merge all the starrers for the current issue into the target issue."""
+  project = merge_into_project or mr.project
+  config = services.config.GetProjectConfig(mr.cnxn, project.project_id)
+  services.issue_star.SetStarsBatch(
+      cnxn, services, config, merge_into_iid, new_starrers, True)
+
+
+def IsMergeAllowed(merge_into_issue, mr, services):
+  """Check to see if user has permission to merge with specified issue."""
+  merge_into_project = services.project.GetProjectByName(
+      mr.cnxn, merge_into_issue.project_name)
+  merge_into_config = services.config.GetProjectConfig(
+      mr.cnxn, merge_into_project.project_id)
+  merge_granted_perms = tracker_bizobj.GetGrantedPerms(
+      merge_into_issue, mr.auth.effective_ids, merge_into_config)
+
+  merge_view_allowed = mr.perms.CanUsePerm(
+      permissions.VIEW, mr.auth.effective_ids,
+      merge_into_project, permissions.GetRestrictions(merge_into_issue),
+      granted_perms=merge_granted_perms)
+  merge_edit_allowed = mr.perms.CanUsePerm(
+      permissions.EDIT_ISSUE, mr.auth.effective_ids,
+      merge_into_project, permissions.GetRestrictions(merge_into_issue),
+      granted_perms=merge_granted_perms)
+
+  return merge_view_allowed and merge_edit_allowed
+
+
+def GetVisibleMembers(mr, project, services):
+  all_member_ids = project_helpers.AllProjectMembers(project)
+
+  all_group_ids = services.usergroup.DetermineWhichUserIDsAreGroups(
+      mr.cnxn, all_member_ids)
+
+  (ac_exclusion_ids, no_expand_ids
+   ) = services.project.GetProjectAutocompleteExclusion(
+      mr.cnxn, project.project_id)
+
+  group_ids_to_expand = [
+    gid for gid in all_group_ids if gid not in no_expand_ids]
+
+  # TODO(jrobbins): Normally, users will be allowed view the members
+  # of any user group if the project From: email address is listed
+  # as a group member, as well as any group that they are personally
+  # members of.
+  member_ids, owner_ids = services.usergroup.LookupVisibleMembers(
+      mr.cnxn, group_ids_to_expand, mr.perms, mr.auth.effective_ids, services)
+  indirect_user_ids = set()
+  for gids in member_ids.values():
+    indirect_user_ids.update(gids)
+  for gids in owner_ids.values():
+    indirect_user_ids.update(gids)
+
+  visible_member_ids = _FilterMemberData(
+      mr, project.owner_ids, project.committer_ids, project.contributor_ids,
+      indirect_user_ids, project)
+
+  visible_member_ids = _MergeLinkedMembers(
+      mr.cnxn, services.user, visible_member_ids)
+
+  visible_member_views = framework_views.MakeAllUserViews(
+      mr.cnxn, services.user, visible_member_ids, group_ids=all_group_ids)
+  framework_views.RevealAllEmailsToMembers(
+      mr.cnxn, services, mr.auth, visible_member_views, project)
+
+  # Filter out service accounts
+  service_acct_emails = set(
+      client_config_svc.GetClientConfigSvc().GetClientIDEmails()[1])
+  visible_member_views = {
+      m.user_id: m
+      for m in visible_member_views.values()
+      # Hide service accounts from autocomplete.
+      if not framework_helpers.IsServiceAccount(
+          m.email, client_emails=service_acct_emails)
+      # Hide users who opted out of autocomplete.
+      and not m.user_id in ac_exclusion_ids
+      # Hide users who have obscured email addresses.
+      and not m.obscure_email
+  }
+
+  return visible_member_views
+
+
+def _MergeLinkedMembers(cnxn, user_service, user_ids):
+  """Remove any linked child accounts if the parent would also be shown."""
+  all_ids = set(user_ids)
+  users_by_id = user_service.GetUsersByIDs(cnxn, user_ids)
+  result = [uid for uid in user_ids
+            if users_by_id[uid].linked_parent_id not in all_ids]
+  return result
+
+
+def _FilterMemberData(
+    mr, owner_ids, committer_ids, contributor_ids, indirect_member_ids,
+    project):
+  """Return a filtered list of members that the user can view.
+
+  In most projects, everyone can view the entire member list.  But,
+  some projects are configured to only allow project owners to see
+  all members. In those projects, committers and contributors do not
+  see any contributors.  Regardless of how the project is configured
+  or the role that the user plays in the current project, we include
+  any indirect members through user groups that the user has access
+  to view.
+
+  Args:
+    mr: Commonly used info parsed from the HTTP request.
+    owner_views: list of user IDs for project owners.
+    committer_views: list of user IDs for project committers.
+    contributor_views: list of user IDs for project contributors.
+    indirect_member_views: list of user IDs for users who have
+        an indirect role in the project via a user group, and that the
+        logged in user is allowed to see.
+    project: the Project we're interested in.
+
+  Returns:
+    A list of owners, committer and visible indirect members if the user is not
+    signed in.  If the project is set to display contributors to non-owners or
+    the signed in user has necessary permissions then additionally a list of
+    contributors.
+  """
+  visible_members_ids = set()
+
+  # Everyone can view owners and committers
+  visible_members_ids.update(owner_ids)
+  visible_members_ids.update(committer_ids)
+
+  # The list of indirect members is already limited to ones that the user
+  # is allowed to see according to user group settings.
+  visible_members_ids.update(indirect_member_ids)
+
+  # If the user is allowed to view the list of contributors, add those too.
+  if permissions.CanViewContributorList(mr, project):
+    visible_members_ids.update(contributor_ids)
+
+  return sorted(visible_members_ids)
+
+
+def GetLabelOptions(config, custom_permissions):
+  """Prepares label options for autocomplete."""
+  labels = []
+  field_names = [
+    fd.field_name
+    for fd in config.field_defs
+    if not fd.is_deleted
+    and fd.field_type is tracker_pb2.FieldTypes.ENUM_TYPE
+  ]
+  non_masked_labels = LabelsNotMaskedByFields(config, field_names)
+  for wkl in non_masked_labels:
+    if not wkl.commented:
+      item = {'name': wkl.name, 'doc': wkl.docstring}
+      labels.append(item)
+
+  frequent_restrictions = _FREQUENT_ISSUE_RESTRICTIONS[:]
+  if not custom_permissions:
+    frequent_restrictions.extend(_EXAMPLE_ISSUE_RESTRICTIONS)
+
+  labels.extend(_BuildRestrictionChoices(
+      frequent_restrictions, permissions.STANDARD_ISSUE_PERMISSIONS,
+      custom_permissions))
+
+  return labels
+
+
+def _BuildRestrictionChoices(freq_restrictions, actions, custom_permissions):
+  """Return a list of autocompletion choices for restriction labels.
+
+  Args:
+    freq_restrictions: list of (action, perm, doc) tuples for restrictions
+        that are frequently used.
+    actions: list of strings for actions that are relevant to the current
+      artifact.
+    custom_permissions: list of strings with custom permissions for the project.
+
+  Returns:
+    A list of dictionaries [{'name': 'perm name', 'doc': 'docstring'}, ...]
+    suitable for use in a JSON feed to our JS autocompletion functions.
+  """
+  choices = []
+
+  for action, perm, doc in freq_restrictions:
+    choices.append({
+        'name': 'Restrict-%s-%s' % (action, perm),
+        'doc': doc,
+        })
+
+  for action in actions:
+    for perm in custom_permissions:
+      choices.append({
+          'name': 'Restrict-%s-%s' % (action, perm),
+          'doc': 'Permission %s needed to use %s' % (perm, action),
+          })
+
+  return choices
+
+
+def FilterKeptAttachments(
+    is_description, kept_attachments, comments, approval_id):
+  """Filter kept attachments to be a subset of last description's attachments.
+
+  Args:
+    is_description: bool, if the comment is a change to the issue description.
+    kept_attachments: list of ints with the attachment ids for attachments
+        kept from previous descriptions, if the comment is a change to the
+        issue description.
+    comments: list of IssueComment PBs for the issue we want to edit.
+    approval_id: int id of the APPROVAL_TYPE fielddef, if we're editing an
+        approval description, or None otherwise.
+
+  Returns:
+    A list of kept_attachment ids that are a subset of the last description.
+  """
+  if not is_description:
+    return None
+
+  attachment_ids = set()
+  for comment in reversed(comments):
+    if comment.is_description and comment.approval_id == approval_id:
+      attachment_ids = set([a.attachment_id for a in comment.attachments])
+      break
+
+  kept_attachments = [
+      aid for aid in kept_attachments if aid in attachment_ids]
+  return kept_attachments
+
+
+def _GetEnumFieldValuesAndDocstrings(field_def, config):
+  # type: (proto.tracker_pb2.LabelDef, proto.tracker_pb2.ProjectIssueConfig) ->
+  #     Sequence[tuple(string, string)]
+  """Get sequence of value, docstring tuples for an enum field"""
+  label_defs = config.well_known_labels
+  lower_field_name = field_def.field_name.lower()
+  tuples = []
+  for ld in label_defs:
+    if (ld.label.lower().startswith(lower_field_name + '-') and
+        not ld.deprecated):
+      label_value = ld.label[len(lower_field_name) + 1:]
+      tuples.append((label_value, ld.label_docstring))
+    else:
+      continue
+  return tuples
+
+
+# _IssueChangesTuple is returned by ApplyAllIssueChanges() and is used to bundle
+# the updated issues. resulting amendments, and other information needed by the
+# called to process the changes in the DB and send notifications.
+_IssueChangesTuple = collections.namedtuple(
+    '_IssueChangesTuple', [
+        'issues_to_update_dict', 'merged_from_add_by_iid', 'amendments_by_iid',
+        'imp_amendments_by_iid', 'old_owners_by_iid', 'old_statuses_by_iid',
+        'old_components_by_iid', 'new_starrers_by_iid'
+    ])
+# type: (Mapping[int, Issue], DefaultDict[int, Sequence[int]],
+#     Mapping[int, Amendment], Mapping[int, Amendment], Mapping[int, int],
+#     Mapping[int, str], Mapping[int, Sequence[int]],
+#     Mapping[int, Sequence[int]])-> None
+
+
+def ApplyAllIssueChanges(cnxn, issue_delta_pairs, services):
+  # type: (MonorailConnection, Sequence[Tuple[Issue, IssueDelta]], Services) ->
+  #     IssueChangesTuple
+  """Modify the given issues with the given deltas and impacted issues in RAM.
+
+    Filter rules are not applied in this method.
+    This method implements phases 3 and 4 of the process for modifying issues.
+    See WorkEnv.ModifyIssues() for other phases and overall process.
+
+    Args:
+      cnxn: MonorailConnection object.
+      issue_delta_pairs: List of tuples that couple Issues with the IssueDeltas
+          that represent the updates we want to make to each Issue.
+      services: Services object for connection to backend services.
+
+    Returns:
+      An _IssueChangesTuple named tuple.
+  """
+  impacted_tracker = _IssueChangeImpactedIssues()
+  project_ids = {issue.project_id for issue, _delta in issue_delta_pairs}
+  configs_by_pid = services.config.GetProjectConfigs(cnxn, list(project_ids))
+
+  # Track issues which have been modified in RAM and will need to
+  # be updated in the DB.
+  issues_to_update_dict = {}
+
+  amendments_by_iid = {}
+  old_owners_by_iid = {}
+  old_statuses_by_iid = {}
+  old_components_by_iid = {}
+  # PHASE 3: Update the main issues in RAM (not indirectly, impacted issues).
+  for issue, delta in issue_delta_pairs:
+    # Cache old data that will be used by future computations.
+    old_owner = tracker_bizobj.GetOwnerId(issue)
+    old_status = tracker_bizobj.GetStatus(issue)
+    if delta.owner_id is not None and delta.owner_id != old_owner:
+      old_owners_by_iid[issue.issue_id] = old_owner
+    if delta.status is not None and delta.status != old_status:
+      old_statuses_by_iid[issue.issue_id] = old_status
+    new_components = set(issue.component_ids)
+    new_components.update(delta.comp_ids_add or [])
+    new_components.difference_update(delta.comp_ids_remove or [])
+    if set(issue.component_ids) != new_components:
+      old_components_by_iid[issue.issue_id] = issue.component_ids
+
+    impacted_tracker.TrackImpactedIssues(issue, delta)
+    config = configs_by_pid.get(issue.project_id)
+    amendments, _impacted_iids = tracker_bizobj.ApplyIssueDelta(
+        cnxn, services.issue, issue, delta, config)
+    if amendments:
+      issues_to_update_dict[issue.issue_id] = issue
+      amendments_by_iid[issue.issue_id] = amendments
+
+  # PHASE 4: Update impacted issues in RAM.
+  logging.info('Applying impacted issue changes: %r', impacted_tracker.__dict__)
+  imp_amendments_by_iid = {}
+  impacted_iids = impacted_tracker.ComputeAllImpactedIIDs()
+  new_starrers_by_iid = {}
+  for issue_id in impacted_iids:
+    # Changes made to an impacted issue should be on top of changes
+    # made to it in PHASE 3 where it might have been a 'main' issue.
+    issue = issues_to_update_dict.get(
+        issue_id, services.issue.GetIssue(cnxn, issue_id, use_cache=False))
+
+    # Apply impacted changes.
+    amendments, new_starrers = impacted_tracker.ApplyImpactedIssueChanges(
+        cnxn, issue, services)
+    if amendments:
+      imp_amendments_by_iid[issue.issue_id] = amendments
+      issues_to_update_dict[issue.issue_id] = issue
+      if new_starrers:
+        new_starrers_by_iid[issue.issue_id] = new_starrers
+
+  return _IssueChangesTuple(
+      issues_to_update_dict, impacted_tracker.merged_from_add,
+      amendments_by_iid, imp_amendments_by_iid, old_owners_by_iid,
+      old_statuses_by_iid, old_components_by_iid, new_starrers_by_iid)
+
+
+def UpdateClosedTimestamp(config, issue, old_effective_status):
+  # type: (proto.tracker_pb2.ProjectIssueConfig, proto.tracker_pb2.Issue, str)
+  #     -> None
+  """Sets or unsets the closed_timestamp based based on status changes.
+
+  If the status is changing from open to closed, the closed_timestamp is set to
+  the current time.
+
+  If the status is changing form closed to open, the close_timestamp is unset.
+
+  If the status is changing from one closed to another closed, or from one
+  open to another open, no operations are performed.
+
+  Args:
+    config: the project configuration
+    issue: the issue being updated (a protocol buffer)
+    old_effective_status: the old issue status string. E.g., 'New'
+
+  SIDE EFFECTS:
+    Updated issue in place with new closed timestamp.
+  """
+  old_effective_status = old_effective_status or ''
+  # open -> closed
+  if (MeansOpenInProject(old_effective_status, config) and
+      not MeansOpenInProject(tracker_bizobj.GetStatus(issue), config)):
+
+    issue.closed_timestamp = int(time.time())
+    return
+
+  # closed -> open
+  if (not MeansOpenInProject(old_effective_status, config) and
+      MeansOpenInProject(tracker_bizobj.GetStatus(issue), config)):
+
+    issue.reset('closed_timestamp')
+    return
+
+
+def GroupUniqueDeltaIssues(issue_delta_pairs):
+  # type: (Tuple[Issue, IssueDelta]) -> (
+  #     Sequence[IssueDelta], Sequence[Sequence[Issue]])
+  """Identifies unique IssueDeltas and groups Issues with identical IssueDeltas.
+
+    Args:
+      issue_delta_pairs: List of tuples that couple Issues with the IssueDeltas
+          that represent the updates we want to make to each Issue.
+
+    Returns:
+      (unique_deltas, issues_for_unique_deltas):
+      unique_deltas: List of unique IssueDeltas found in issue_delta_pairs.
+      issues_for_unique_deltas: List of Issue lists. Each Issue list
+              contains all the Issues that had identical IssueDeltas.
+              Each issues_for_unique_deltas[i] is the list of Issues
+              that had unique_deltas[i] as their IssueDeltas.
+  """
+  unique_deltas = []
+  issues_for_unique_deltas = []
+  for issue, delta in issue_delta_pairs:
+    try:
+      delta_index = unique_deltas.index(delta)
+      issues_for_unique_deltas[delta_index].append(issue)
+    except ValueError:
+      # delta is not in unique_deltas yet.
+      # Add delta to unique_deltas and add a new list of issues
+      # to issues_for_unique_deltas at the same index.
+      unique_deltas.append(delta)
+      issues_for_unique_deltas.append([issue])
+
+  return unique_deltas, issues_for_unique_deltas
+
+
+def _AssertNoConflictingDeltas(issue_delta_pairs, refs_dict, err_agg):
+  # type: (Sequence[Tuple[Issue, IssueDelta]], Mapping[int, str],
+  #     exceptions.ErrorAggregator) -> None
+  """Checks if any issue deltas conflict with each other or themselves.
+
+  Note: refs_dict should contain issue ref strings for all issues found
+      in issue_delta_pairs, including all issues found in
+      {blocked_on|blocking}_{add|remove}.
+  """
+  err_message = 'Changes for {} conflict for {}'
+
+  # Track all delta blocked_on_add and blocking_add in terms of
+  # 'blocking_add' so we can track when a {blocked_on|blocking}_remove
+  # is in conflict with some {blocked_on|blocking}_add.
+  blocking_add = collections.defaultdict(list)
+  for issue, delta in issue_delta_pairs:
+    blocking_add[issue.issue_id].extend(delta.blocking_add)
+
+    for imp_iid in delta.blocked_on_add:
+      blocking_add[imp_iid].append(issue.issue_id)
+
+  # Check *_remove for conflicts with tracking blocking_add.
+  for issue, delta in issue_delta_pairs:
+    added_iids = blocking_add[issue.issue_id]
+    # Get intersection of iids that are in `blocking_remove` and
+    # the tracked `blocking_add`.
+    conflict_iids = set(delta.blocking_remove) & set(added_iids)
+
+    # Get iids of `blocked_on_remove` that conflict with the
+    # tracked `blocking_add`.
+    for possible_conflict_iid in delta.blocked_on_remove:
+      if issue.issue_id in blocking_add[possible_conflict_iid]:
+        conflict_iids.add(possible_conflict_iid)
+
+    if conflict_iids:
+      refs_str = ', '.join([refs_dict[iid] for iid in conflict_iids])
+      err_agg.AddErrorMessage(err_message, refs_dict[issue.issue_id], refs_str)
+
+
+def PrepareIssueChanges(
+    cnxn,
+    issue_delta_pairs,
+    services,
+    attachment_uploads=None,
+    comment_content=None):
+  # type: (MonorailConnection, Sequence[Tuple[Issue, IssueDelta]], Services,
+  #     Optional[Sequence[framework_helpers.AttachmentUpload]], Optional[str])
+  #     -> Mapping[int, int]
+  """Clean the deltas and assert they are valid for each paired issue."""
+  _EnforceNonMergeStatusDeltas(cnxn, issue_delta_pairs, services)
+  _AssertIssueChangesValid(
+      cnxn, issue_delta_pairs, services, comment_content=comment_content)
+
+  if attachment_uploads:
+    return _EnforceAttachmentQuotaLimits(
+        cnxn, issue_delta_pairs, services, attachment_uploads)
+  return {}
+
+
+def _EnforceAttachmentQuotaLimits(
+    cnxn, issue_delta_pairs, services, attachment_uploads):
+  # type: (MonorailConnection, Sequence[Tuple[Issue, IssueDelta]], Services
+  #     Optional[Sequence[framework_helpers.AttachmentUpload]]
+  #     -> Mapping[int, int]
+  """Assert that the attachments don't exceed project quotas."""
+  issue_count_by_pid = collections.defaultdict(int)
+  for issue, _delta in issue_delta_pairs:
+    issue_count_by_pid[issue.project_id] += 1
+
+  projects_by_id = services.project.GetProjects(cnxn, issue_count_by_pid.keys())
+
+  new_bytes_by_pid = {}
+  with exceptions.ErrorAggregator(exceptions.OverAttachmentQuota) as err_agg:
+    for pid, count in issue_count_by_pid.items():
+      project = projects_by_id[pid]
+      try:
+        new_bytes_used = ComputeNewQuotaBytesUsed(
+            project, attachment_uploads * count)
+        new_bytes_by_pid[pid] = new_bytes_used
+      except exceptions.OverAttachmentQuota:
+        err_agg.AddErrorMessage(
+            'Attachment quota exceeded for project {}', project.project_name)
+  return new_bytes_by_pid
+
+
+def _AssertIssueChangesValid(
+    cnxn, issue_delta_pairs, services, comment_content=None):
+  # type: (MonorailConnection, Sequence[Tuple[Issue, IssueDelta]], Services,
+  #     Optional[str]) -> None
+  """Assert that the delta changes are valid for each paired issue.
+
+    Note: this method does not check if the changes trigger any FilterRule
+      `warnings` or `errors`.
+  """
+  project_ids = list(
+      {issue.project_id for (issue, _delta) in issue_delta_pairs})
+  projects_by_id = services.project.GetProjects(cnxn, project_ids)
+  configs_by_id = services.config.GetProjectConfigs(cnxn, project_ids)
+  refs_dict = {
+      iss.issue_id: '%s:%d' % (iss.project_name, iss.local_id)
+      for iss, _delta in issue_delta_pairs
+  }
+  # Add refs of deltas' blocking/blocked_on issues needed by
+  # _AssertNoConflictingDeltas.
+  relation_iids = set()
+  for _iss, delta in issue_delta_pairs:
+    relation_iids.update(
+        delta.blocked_on_remove + delta.blocking_remove + delta.blocked_on_add +
+        delta.blocking_add)
+  relation_issues_dict, misses = services.issue.GetIssuesDict(
+      cnxn, relation_iids)
+  if misses:
+    raise exceptions.NoSuchIssueException(
+        'Could not find issues with ids: %r' % misses)
+  for iid, iss in relation_issues_dict.items():
+    if iid not in refs_dict:
+      refs_dict[iid] = '%s:%d' % (iss.project_name, iss.local_id)
+
+  with exceptions.ErrorAggregator(exceptions.InputException) as err_agg:
+    if (comment_content and
+        len(comment_content.strip()) > tracker_constants.MAX_COMMENT_CHARS):
+      err_agg.AddErrorMessage('Comment is too long.')
+
+    _AssertNoConflictingDeltas(issue_delta_pairs, refs_dict, err_agg)
+
+    for issue, delta in issue_delta_pairs:
+      project = projects_by_id.get(issue.project_id)
+      config = configs_by_id.get(issue.project_id)
+      issue_ref = refs_dict[issue.issue_id]
+
+      if (delta.merged_into is not None or
+          delta.merged_into_external is not None or delta.status is not None):
+        end_status = delta.status or issue.status
+        merged_options = [
+            delta.merged_into, delta.merged_into_external, issue.merged_into,
+            issue.merged_into_external
+        ]
+        end_merged_into = next(
+            (merge for merge in merged_options if merge is not None), None)
+
+        is_merge_status = end_status.lower() in [
+            status.lower() for status in config.statuses_offer_merge
+        ]
+
+        if ((is_merge_status and not end_merged_into) or
+            (not is_merge_status and end_merged_into)):
+          err_agg.AddErrorMessage(
+              '{}: MERGED type statuses must accompany mergedInto values.',
+              issue_ref)
+
+      if delta.merged_into and issue.issue_id == delta.merged_into:
+        err_agg.AddErrorMessage(
+            '{}: Cannot merge an issue into itself.', issue_ref)
+      if (issue.issue_id in set(
+          delta.blocked_on_add)) or (issue.issue_id in set(delta.blocking_add)):
+        err_agg.AddErrorMessage(
+            '{}: Cannot block an issue on itself.', issue_ref)
+      if (delta.owner_id is not None) and (delta.owner_id != issue.owner_id):
+        parsed_owner_valid, msg = IsValidIssueOwner(
+            cnxn, project, delta.owner_id, services)
+        if not parsed_owner_valid:
+          err_agg.AddErrorMessage('{}: {}', issue_ref, msg)
+      # Owner already check by IsValidIssueOwner
+      all_users = [uid for uid in delta.cc_ids_add]
+      field_users = [fv.user_id for fv in delta.field_vals_add if fv.user_id]
+      all_users.extend(field_users)
+      AssertUsersExist(cnxn, services, all_users, err_agg)
+      if (delta.summary and
+          len(delta.summary.strip()) > tracker_constants.MAX_SUMMARY_CHARS):
+        err_agg.AddErrorMessage('{}: Summary is too long.', issue_ref)
+      if delta.summary == '':
+        err_agg.AddErrorMessage('{}: Summary required.', issue_ref)
+      if delta.status == '':
+        err_agg.AddErrorMessage('{}: Status is required.', issue_ref)
+      # Do not pass in issue for validation, as issue is pre-update, and would
+      # result in being unable to edit issues in invalid states.
+      fvs_err_msgs = field_helpers.ValidateCustomFields(
+          cnxn, services, delta.field_vals_add, config, project)
+      if fvs_err_msgs:
+        err_agg.AddErrorMessage('{}: {}', issue_ref, '\n'.join(fvs_err_msgs))
+      # TODO(crbug.com/monorail/9156): Validate that we do not remove fields
+      # such that a required field becomes unset.
+
+
+def AssertUsersExist(cnxn, services, user_ids, err_agg):
+  # type: (MonorailConnection, Services, Sequence[int], ErrorAggregator) -> None
+  """Assert that all users exist.
+
+    Has the side-effect of adding error messages to the input ErrorAggregator.
+  """
+  users_dict = services.user.GetUsersByIDs(cnxn, user_ids, skip_missed=True)
+  found_ids = set(users_dict.keys())
+  missing = [user_id for user_id in user_ids if user_id not in found_ids]
+  for missing_user_id in missing:
+    err_agg.AddErrorMessage(
+        'users/{}: User does not exist.'.format(missing_user_id))
+
+
+def AssertValidIssueForCreate(cnxn, services, issue, description):
+  # type: (MonorailConnection, Services, Issue, str) -> None
+  """Assert that issue proto is valid for issue creation.
+
+  Args:
+    cnxn: A connection object to use services with.
+    services: An object containing services to use to look up relevant data.
+    issues: A PB containing the issue to validate.
+    description: The description for the issue.
+
+  Raises:
+    InputException if the issue is not valid.
+  """
+  project = services.project.GetProject(cnxn, issue.project_id)
+  config = services.config.GetProjectConfig(cnxn, issue.project_id)
+
+  with exceptions.ErrorAggregator(exceptions.InputException) as err_agg:
+    owner_is_valid, owner_err_msg = IsValidIssueOwner(
+        cnxn, project, issue.owner_id, services)
+    if not owner_is_valid:
+      err_agg.AddErrorMessage(owner_err_msg)
+    if not issue.summary.strip():
+      err_agg.AddErrorMessage('Summary is required')
+    if not description.strip():
+      err_agg.AddErrorMessage('Description is required')
+    if len(issue.summary) > tracker_constants.MAX_SUMMARY_CHARS:
+      err_agg.AddErrorMessage('Summary is too long')
+    if len(description) > tracker_constants.MAX_COMMENT_CHARS:
+      err_agg.AddErrorMessage('Description is too long')
+
+    # Check all users exist. Owner already check by IsValidIssueOwner.
+    all_users = [uid for uid in issue.cc_ids]
+    for av in issue.approval_values:
+      all_users.extend(av.approver_ids)
+    field_users = [fv.user_id for fv in issue.field_values if fv.user_id]
+    all_users.extend(field_users)
+    AssertUsersExist(cnxn, services, all_users, err_agg)
+
+    field_validity_errors = field_helpers.ValidateCustomFields(
+        cnxn, services, issue.field_values, config, project, issue=issue)
+    if field_validity_errors:
+      err_agg.AddErrorMessage("\n".join(field_validity_errors))
+    if not services.config.LookupStatusID(cnxn, issue.project_id, issue.status,
+                                          autocreate=False):
+      err_agg.AddErrorMessage('Undefined status: %s' % issue.status)
+    all_comp_ids = {
+        cd.component_id for cd in config.component_defs if not cd.deprecated
+    }
+    for comp_id in issue.component_ids:
+      if comp_id not in all_comp_ids:
+        err_agg.AddErrorMessage(
+            'Undefined or deprecated component with id: %d' % comp_id)
+
+
+def _ComputeNewCcsFromIssueMerge(merge_into_issue, source_issues):
+  # type: (Issue, Collection[Issue]) -> Collection[int]
+  """Compute ccs that should be added from source_issues to merge_into_issue."""
+
+  merge_into_restrictions = permissions.GetRestrictions(merge_into_issue)
+  new_cc_ids = set()
+  for issue in source_issues:
+    # We don't want to leak metadata like ccs of restricted issues.
+    # So we don't merge ccs from restricted source issues, unless their
+    # restrictions match the restrictions of the target.
+    if permissions.HasRestrictions(issue, perm='View'):
+      source_restrictions = permissions.GetRestrictions(issue)
+      if (issue.project_id != merge_into_issue.project_id or
+          set(source_restrictions) != set(merge_into_restrictions)):
+        continue
+
+    new_cc_ids.update(issue.cc_ids)
+    if issue.owner_id:
+      new_cc_ids.add(issue.owner_id)
+
+  return [cc_id for cc_id in new_cc_ids if cc_id not in merge_into_issue.cc_ids]
+
+
+def _EnforceNonMergeStatusDeltas(cnxn, issue_delta_pairs, services):
+  # type: (MonorailConnection, Sequence[Tuple[Issue, IssueDelta]], Services)
+  """Update deltas in RAM to remove merged if a MERGED status is removed."""
+  project_ids = list(
+      {issue.project_id for (issue, _delta) in issue_delta_pairs})
+  configs_by_id = services.config.GetProjectConfigs(cnxn, project_ids)
+  statuses_offer_merge_by_pid = {
+      pid:
+      [status.lower() for status in configs_by_id[pid].statuses_offer_merge]
+      for pid in project_ids
+  }
+
+  for issue, delta in issue_delta_pairs:
+    statuses_offer_merge = statuses_offer_merge_by_pid[issue.project_id]
+    # Remove merged_into and merged_into_external when a status is moved
+    # to a non-MERGED status ONLY if the delta does not have merged_into values
+    # If delta does change merged_into values, the request will fail from
+    # AssertIssueChangesValue().
+    if (delta.status and delta.status.lower() not in statuses_offer_merge and
+        delta.merged_into is None and delta.merged_into_external is None):
+      if issue.merged_into:
+        delta.merged_into = 0
+      elif issue.merged_into_external:
+        delta.merged_into_external = ''
+
+
+class _IssueChangeImpactedIssues():
+  """Class to track changes of issues impacted by updates to other issues."""
+
+  def __init__(self):
+
+    # Each of the dicts below should be used to track
+    # {impacted_issue_id: [issues being modified that impact the keyed issue]}.
+
+    # e.g. `blocking_remove` with {iid_1: [iid_2, iid_3]} means that
+    # `TrackImpactedIssues` has been called with a delta of
+    # IssueDelta(blocked_on_remove=[iid_1]) for both issue 2 and issue 3.
+    self.blocking_add = collections.defaultdict(list)
+    self.blocking_remove = collections.defaultdict(list)
+    self.blocked_on_add = collections.defaultdict(list)
+    self.blocked_on_remove = collections.defaultdict(list)
+    self.merged_from_add = collections.defaultdict(list)
+    self.merged_from_remove = collections.defaultdict(list)
+
+  def ComputeAllImpactedIIDs(self):
+    # type: () -> Collection[int]
+    """Computes the unique set of all impacted issue ids."""
+    return set(self.blocking_add.keys() + self.blocking_remove.keys() +
+               self.blocked_on_add.keys() + self.blocked_on_remove.keys() +
+               self.merged_from_add.keys() + self.merged_from_remove.keys())
+
+  def TrackImpactedIssues(self, issue, delta):
+    # type: (Issue, IssueDelta) -> None
+    """Track impacted issues from when `delta` is applied to `issue`.
+
+    Args:
+      issue: Issue that the delta will be applied to, but has not yet.
+      delta: IssueDelta representing the changes that will be made to
+        the issue.
+    """
+    for impacted_iid in delta.blocked_on_add:
+      self.blocking_add[impacted_iid].append(issue.issue_id)
+    for impacted_iid in delta.blocked_on_remove:
+      self.blocking_remove[impacted_iid].append(issue.issue_id)
+
+    for impacted_iid in delta.blocking_add:
+      self.blocked_on_add[impacted_iid].append(issue.issue_id)
+    for impacted_iid in delta.blocking_remove:
+      self.blocked_on_remove[impacted_iid].append(issue.issue_id)
+
+    if (delta.merged_into == framework_constants.NO_ISSUE_SPECIFIED and
+        issue.merged_into):
+      self.merged_from_remove[issue.merged_into].append(issue.issue_id)
+    elif delta.merged_into and issue.merged_into != delta.merged_into:
+      self.merged_from_add[delta.merged_into].append(issue.issue_id)
+      if issue.merged_into:
+        self.merged_from_remove[issue.merged_into].append(issue.issue_id)
+
+  def ApplyImpactedIssueChanges(self, cnxn, impacted_issue, services):
+    # type: (MonorailConnection, Issue, Services) ->
+    #     Tuple[Collection[Amendment], Sequence[int]]
+    """Apply the tracked changes in RAM for the given impacted issue.
+
+    Args:
+      cnxn: connection to SQL database.
+      impacted_issue: Issue PB that we are applying the changes to.
+      services: Services used to fetch info from DB or cache.
+
+    Returns:
+      All the amendments that represent the changes applied to the issue
+      and a list of the new issue starrers.
+
+    Side-effect:
+      The given impacted_issue will be updated in RAM.
+    """
+    issue_id = impacted_issue.issue_id
+
+    # Process changes for blocking/blocked_on issue changes.
+    amendments, _impacted_iids = tracker_bizobj.ApplyIssueBlockRelationChanges(
+        cnxn, impacted_issue, self.blocked_on_add[issue_id],
+        self.blocked_on_remove[issue_id], self.blocking_add[issue_id],
+        self.blocking_remove[issue_id], services.issue)
+
+    # Process changes in merged issues.
+    merged_from_add = self.merged_from_add.get(issue_id, [])
+    merged_from_remove = self.merged_from_remove.get(issue_id, [])
+
+    # Merge ccs into impacted_issue from all merged issues,
+    # compute new starrers, and set star_count.
+    new_starrers = []
+    if merged_from_add:
+      issues_dict, _misses = services.issue.GetIssuesDict(cnxn, merged_from_add)
+      merged_from_add_issues = issues_dict.values()
+      new_cc_ids = _ComputeNewCcsFromIssueMerge(
+          impacted_issue, merged_from_add_issues)
+      if new_cc_ids:
+        impacted_issue.cc_ids.extend(new_cc_ids)
+        amendments.append(
+            tracker_bizobj.MakeCcAmendment(new_cc_ids, []))
+      new_starrers = list(
+          GetNewIssueStarrers(cnxn, services, merged_from_add, issue_id))
+      if new_starrers:
+        impacted_issue.star_count += len(new_starrers)
+
+    if merged_from_add or merged_from_remove:
+      merged_from_add_refs = services.issue.LookupIssueRefs(
+          cnxn, merged_from_add).values()
+      merged_from_remove_refs = services.issue.LookupIssueRefs(
+          cnxn, merged_from_remove).values()
+      amendments.append(
+          tracker_bizobj.MakeMergedIntoAmendment(
+              merged_from_add_refs, merged_from_remove_refs,
+              default_project_name=impacted_issue.project_name))
+    return amendments, new_starrers
diff --git a/tracker/tracker_views.py b/tracker/tracker_views.py
new file mode 100644
index 0000000..0c54555
--- /dev/null
+++ b/tracker/tracker_views.py
@@ -0,0 +1,871 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""View objects to help display tracker business objects in templates."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import collections
+import logging
+import re
+import time
+import urllib
+
+from google.appengine.api import app_identity
+import ezt
+
+from features import federated
+from framework import exceptions
+from framework import filecontent
+from framework import framework_bizobj
+from framework import framework_constants
+from framework import framework_helpers
+from framework import framework_views
+from framework import gcs_helpers
+from framework import permissions
+from framework import template_helpers
+from framework import timestr
+from framework import urls
+from proto import tracker_pb2
+from tracker import attachment_helpers
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+from tracker import tracker_helpers
+
+
+class IssueView(template_helpers.PBProxy):
+  """Wrapper class that makes it easier to display an Issue via EZT."""
+
+  def __init__(self, issue, users_by_id, config):
+    """Store relevant values for later display by EZT.
+
+    Args:
+      issue: An Issue protocol buffer.
+      users_by_id: dict {user_id: UserViews} for all users mentioned in issue.
+      config: ProjectIssueConfig for this issue.
+    """
+    super(IssueView, self).__init__(issue)
+
+    # The users involved in this issue must be present in users_by_id if
+    # this IssueView is to be used on the issue detail or peek pages. But,
+    # they can be absent from users_by_id if the IssueView is used as a
+    # tile in the grid view.
+    self.owner = users_by_id.get(issue.owner_id)
+    self.derived_owner = users_by_id.get(issue.derived_owner_id)
+    self.cc = [users_by_id.get(cc_id) for cc_id in issue.cc_ids
+               if cc_id]
+    self.derived_cc = [users_by_id.get(cc_id)
+                       for cc_id in issue.derived_cc_ids
+                       if cc_id]
+    self.status = framework_views.StatusView(issue.status, config)
+    self.derived_status = framework_views.StatusView(
+        issue.derived_status, config)
+    # If we don't have a config available, we don't need to access is_open, so
+    # let it be True.
+    self.is_open = ezt.boolean(
+        not config or
+        tracker_helpers.MeansOpenInProject(
+            tracker_bizobj.GetStatus(issue), config))
+
+    self.components = sorted(
+        [ComponentValueView(component_id, config, False)
+         for component_id in issue.component_ids
+         if tracker_bizobj.FindComponentDefByID(component_id, config)] +
+        [ComponentValueView(component_id, config, True)
+         for component_id in issue.derived_component_ids
+         if tracker_bizobj.FindComponentDefByID(component_id, config)],
+        key=lambda cvv: cvv.path)
+
+    self.fields = MakeAllFieldValueViews(
+        config, issue.labels, issue.derived_labels, issue.field_values,
+        users_by_id)
+
+    labels, derived_labels = tracker_bizobj.ExplicitAndDerivedNonMaskedLabels(
+        issue.labels, issue.derived_labels, config)
+    self.labels = [
+        framework_views.LabelView(label, config)
+        for label in labels]
+    self.derived_labels = [
+        framework_views.LabelView(label, config)
+        for label in derived_labels]
+    self.restrictions = _RestrictionsView(issue)
+
+    # TODO(jrobbins): sort by order of labels in project config
+
+    self.short_summary = issue.summary[:tracker_constants.SHORT_SUMMARY_LENGTH]
+
+    if issue.closed_timestamp:
+      self.closed = timestr.FormatAbsoluteDate(issue.closed_timestamp)
+    else:
+      self.closed = ''
+
+    self.blocked_on = []
+    self.has_dangling = ezt.boolean(self.dangling_blocked_on_refs)
+    self.blocking = []
+
+    self.detail_relative_url = tracker_helpers.FormatRelativeIssueURL(
+        issue.project_name, urls.ISSUE_DETAIL, id=issue.local_id)
+    self.crbug_url = tracker_helpers.FormatCrBugURL(
+        issue.project_name, issue.local_id)
+
+
+class _RestrictionsView(object):
+  """An EZT object for the restrictions associated with an issue."""
+
+  # Restrict label fragments that correspond to known permissions.
+  _VIEW = permissions.VIEW.lower()
+  _EDIT = permissions.EDIT_ISSUE.lower()
+  _ADD_COMMENT = permissions.ADD_ISSUE_COMMENT.lower()
+  _KNOWN_ACTION_KINDS = {_VIEW, _EDIT, _ADD_COMMENT}
+
+  def __init__(self, issue):
+    # List of restrictions that don't map to a known action kind.
+    self.other = []
+
+    restrictions_by_action = collections.defaultdict(list)
+    # We can't use GetRestrictions here, as we prefer to preserve
+    # the case of the label when showing restrictions in the UI.
+    for label in tracker_bizobj.GetLabels(issue):
+      if permissions.IsRestrictLabel(label):
+        _kw, action_kind, needed_perm = label.split('-', 2)
+        action_kind = action_kind.lower()
+        if action_kind in self._KNOWN_ACTION_KINDS:
+          restrictions_by_action[action_kind].append(needed_perm)
+        else:
+          self.other.append(label)
+
+    self.view = ' and '.join(restrictions_by_action[self._VIEW])
+    self.add_comment = ' and '.join(restrictions_by_action[self._ADD_COMMENT])
+    self.edit = ' and '.join(restrictions_by_action[self._EDIT])
+
+    self.has_restrictions = ezt.boolean(
+        self.view or self.add_comment or self.edit or self.other)
+
+
+class IssueCommentView(template_helpers.PBProxy):
+  """Wrapper class that makes it easier to display an IssueComment via EZT."""
+
+  def __init__(
+      self, project_name, comment_pb, users_by_id, autolink,
+      all_referenced_artifacts, mr, issue, effective_ids=None):
+    """Get IssueComment PB and make its fields available as attrs.
+
+    Args:
+      project_name: Name of the project this issue belongs to.
+      comment_pb: Comment protocol buffer.
+      users_by_id: dict mapping user_ids to UserViews, including
+          the user that entered the comment, and any changed participants.
+      autolink: utility object for automatically linking to other
+        issues, git revisions, etc.
+      all_referenced_artifacts: opaque object with details of referenced
+        artifacts that is needed by autolink.
+      mr: common information parsed from the HTTP request.
+      issue: Issue PB for the issue that this comment is part of.
+      effective_ids: optional set of int user IDs for the comment author.
+    """
+    super(IssueCommentView, self).__init__(comment_pb)
+
+    self.id = comment_pb.id
+    self.creator = users_by_id[comment_pb.user_id]
+
+    # TODO(jrobbins): this should be based on the issue project, not the
+    # request project for non-project views and cross-project.
+    if mr.project:
+      self.creator_role = framework_helpers.GetRoleName(
+          effective_ids or {self.creator.user_id}, mr.project)
+    else:
+      self.creator_role = None
+
+    time_tuple = time.localtime(comment_pb.timestamp)
+    self.date_string = timestr.FormatAbsoluteDate(
+        comment_pb.timestamp, old_format=timestr.MONTH_DAY_YEAR_FMT)
+    self.date_relative = timestr.FormatRelativeDate(comment_pb.timestamp)
+    self.date_tooltip = time.asctime(time_tuple)
+    self.date_yyyymmdd = timestr.FormatAbsoluteDate(
+        comment_pb.timestamp, recent_format=timestr.MONTH_DAY_YEAR_FMT,
+        old_format=timestr.MONTH_DAY_YEAR_FMT)
+    self.text_runs = _ParseTextRuns(comment_pb.content)
+    if autolink and not comment_pb.deleted_by:
+      self.text_runs = autolink.MarkupAutolinks(
+          mr, self.text_runs, all_referenced_artifacts)
+
+    self.attachments = [AttachmentView(attachment, project_name)
+                        for attachment in comment_pb.attachments]
+    self.amendments = sorted([
+        AmendmentView(amendment, users_by_id, mr.project_name)
+        for amendment in comment_pb.amendments],
+        key=lambda amendment: amendment.field_name.lower())
+    # Treat comments from banned users as being deleted.
+    self.is_deleted = (comment_pb.deleted_by or
+                       (self.creator and self.creator.banned))
+    self.can_delete = False
+
+    # TODO(jrobbins): pass through config to get granted permissions.
+    perms = permissions.UpdateIssuePermissions(
+        mr.perms, mr.project, issue, mr.auth.effective_ids)
+    if mr.auth.user_id and mr.project:
+      self.can_delete = permissions.CanDeleteComment(
+          comment_pb, self.creator, mr.auth.user_id, perms)
+
+    self.visible = permissions.CanViewComment(
+        comment_pb, self.creator, mr.auth.user_id, perms)
+
+
+_TEMPLATE_TEXT_RE = re.compile('^(<b>[^<]+</b>)', re.MULTILINE)
+
+
+def _ParseTextRuns(content):
+  """Convert the user's comment to a list of TextRun objects."""
+  chunks = _TEMPLATE_TEXT_RE.split(content.strip())
+  runs = [_ChunkToRun(chunk) for chunk in chunks]
+  return runs
+
+
+def _ChunkToRun(chunk):
+  """Convert a substring of the user's comment to a TextRun object."""
+  if chunk.startswith('<b>') and chunk.endswith('</b>'):
+    return template_helpers.TextRun(chunk[3:-4], tag='b')
+  else:
+    return template_helpers.TextRun(chunk)
+
+
+class LogoView(template_helpers.PBProxy):
+  """Wrapper class to make it easier to display project logos via EZT."""
+
+  def __init__(self, project_pb):
+    super(LogoView, self).__init__(None)
+    if (not project_pb or
+        not project_pb.logo_gcs_id or
+        not project_pb.logo_file_name):
+      self.thumbnail_url = ''
+      self.viewurl = ''
+      return
+
+    bucket_name = app_identity.get_default_gcs_bucket_name()
+    gcs_object = project_pb.logo_gcs_id
+    self.filename = project_pb.logo_file_name
+    self.mimetype = filecontent.GuessContentTypeFromFilename(self.filename)
+
+    self.thumbnail_url = gcs_helpers.SignUrl(bucket_name,
+        gcs_object + '-thumbnail')
+    self.viewurl = (
+        gcs_helpers.SignUrl(bucket_name, gcs_object) + '&' + urllib.urlencode(
+            {'response-content-displacement':
+                ('attachment; filename=%s' % self.filename)}))
+
+
+class AttachmentView(template_helpers.PBProxy):
+  """Wrapper class to make it easier to display issue attachments via EZT."""
+
+  def __init__(self, attach_pb, project_name):
+    """Get IssueAttachmentContent PB and make its fields available as attrs.
+
+    Args:
+      attach_pb: Attachment part of IssueComment protocol buffer.
+      project_name: string Name of the current project.
+    """
+    super(AttachmentView, self).__init__(attach_pb)
+    self.filesizestr = template_helpers.BytesKbOrMb(attach_pb.filesize)
+    self.downloadurl = attachment_helpers.GetDownloadURL(
+        attach_pb.attachment_id)
+    self.url = attachment_helpers.GetViewURL(
+        attach_pb, self.downloadurl, project_name)
+    self.thumbnail_url = attachment_helpers.GetThumbnailURL(
+        attach_pb, self.downloadurl)
+    self.video_url = attachment_helpers.GetVideoURL(
+        attach_pb, self.downloadurl)
+
+    self.iconurl = '/images/paperclip.png'
+
+
+class AmendmentView(object):
+  """Wrapper class that makes it easier to display an Amendment via EZT."""
+
+  def __init__(self, amendment, users_by_id, project_name):
+    """Get the info from the PB and put it into easily accessible attrs.
+
+    Args:
+      amendment: Amendment part of an IssueComment protocol buffer.
+      users_by_id: dict mapping user_ids to UserViews.
+      project_name: Name of the project the issue/comment/amendment is in.
+    """
+    # TODO(jrobbins): take field-level restrictions into account.
+    # Including the case where user is not allowed to see any amendments.
+    self.field_name = tracker_bizobj.GetAmendmentFieldName(amendment)
+    self.newvalue = tracker_bizobj.AmendmentString(amendment, users_by_id)
+    self.values = tracker_bizobj.AmendmentLinks(
+        amendment, users_by_id, project_name)
+
+
+class ComponentDefView(template_helpers.PBProxy):
+  """Wrapper class to make it easier to display component definitions."""
+
+  def __init__(self, cnxn, services, component_def, users_by_id):
+    super(ComponentDefView, self).__init__(component_def)
+
+    c_path = component_def.path
+    if '>' in c_path:
+      self.parent_path = c_path[:c_path.rindex('>')]
+      self.leaf_name = c_path[c_path.rindex('>') + 1:]
+    else:
+      self.parent_path = ''
+      self.leaf_name = c_path
+
+    self.docstring_short = template_helpers.FitUnsafeText(
+        component_def.docstring, 200)
+
+    self.admins = [users_by_id.get(admin_id)
+                   for admin_id in component_def.admin_ids]
+    self.cc = [users_by_id.get(cc_id) for cc_id in component_def.cc_ids]
+    self.labels = [
+        services.config.LookupLabel(cnxn, component_def.project_id, label_id)
+        for label_id in component_def.label_ids]
+    self.classes = 'all '
+    if self.parent_path == '':
+      self.classes += 'toplevel '
+    self.classes += 'deprecated ' if component_def.deprecated else 'active '
+
+
+class ComponentValueView(object):
+  """Wrapper class that makes it easier to display a component value."""
+
+  def __init__(self, component_id, config, derived):
+    """Make the component name and docstring available as attrs.
+
+    Args:
+      component_id: int component_id to look up in the config
+      config: ProjectIssueConfig PB for the issue's project.
+      derived: True if this component was derived.
+    """
+    cd = tracker_bizobj.FindComponentDefByID(component_id, config)
+    self.path = cd.path
+    self.docstring = cd.docstring
+    self.docstring_short = template_helpers.FitUnsafeText(cd.docstring, 60)
+    self.derived = ezt.boolean(derived)
+
+
+class FieldValueView(object):
+  """Wrapper class that makes it easier to display a custom field value."""
+
+  def __init__(
+      self, fd, config, values, derived_values, issue_types, applicable=None,
+      phase_name=None):
+    """Make several values related to this field available as attrs.
+
+    Args:
+      fd: field definition to be displayed (or not, if no value).
+      config: ProjectIssueConfig PB for the issue's project.
+      values: list of explicit field values.
+      derived_values: list of derived field values.
+      issue_types: set of lowered string values from issues' "Type-*" labels.
+      applicable: optional boolean that overrides the rule that determines
+          when a field is applicable.
+      phase_name: name of the phase this field value belongs to.
+    """
+    self.field_def = FieldDefView(fd, config)
+    self.field_id = fd.field_id
+    self.field_name = fd.field_name
+    self.field_docstring = fd.docstring
+    self.field_docstring_short = template_helpers.FitUnsafeText(
+        fd.docstring, 60)
+    self.phase_name = phase_name or ""
+
+    self.values = values
+    self.derived_values = derived_values
+
+    self.applicable_type = fd.applicable_type
+    if applicable is not None:
+      self.applicable = ezt.boolean(applicable)
+    else:
+      # Note: We don't show approval types, approval sub fields, or
+      # phase fields in ezt issue pages.
+      if (fd.field_type == tracker_pb2.FieldTypes.APPROVAL_TYPE or
+          fd.approval_id or fd.is_phase_field):
+        self.applicable = ezt.boolean(False)
+      else:
+        # A field is applicable to a given issue if it (a) applies to all,
+        # issues or (b) already has a value on this issue, or (c) says that
+        # it applies to issues with this type (or a prefix of it).
+        applicable_type_lower = self.applicable_type.lower()
+        self.applicable = ezt.boolean(
+            not self.applicable_type or values or
+            any(type_label.startswith(applicable_type_lower)
+                for type_label in issue_types))
+      # TODO(jrobbins): also evaluate applicable_predicate
+
+    self.display = ezt.boolean(   # or fd.show_empty
+        self.values or self.derived_values or
+        (self.applicable and not fd.is_niche))
+
+    #FieldValueView does not handle determining if it's editable
+    #by the logged-in user. This can be determined by using
+    #permission.CanEditValueForFieldDef.
+    self.is_editable = ezt.boolean(True)
+
+
+def _PrecomputeInfoForValueViews(labels, derived_labels, field_values, config,
+                                 phases):
+  """Organize issue values into datastructures used to make FieldValueViews."""
+  field_values_by_id = collections.defaultdict(list)
+  for fv in field_values:
+    field_values_by_id[fv.field_id].append(fv)
+  lower_enum_field_names = [
+      fd.field_name.lower() for fd in config.field_defs
+      if fd.field_type == tracker_pb2.FieldTypes.ENUM_TYPE]
+  labels_by_prefix = tracker_bizobj.LabelsByPrefix(
+      labels, lower_enum_field_names)
+  der_labels_by_prefix = tracker_bizobj.LabelsByPrefix(
+      derived_labels, lower_enum_field_names)
+  label_docs = {wkl.label.lower(): wkl.label_docstring
+                for wkl in config.well_known_labels}
+  phases_by_name = collections.defaultdict(list)
+  # group issue phases by name
+  for phase in phases:
+    phases_by_name[phase.name.lower()].append(phase)
+  return (labels_by_prefix, der_labels_by_prefix, field_values_by_id,
+          label_docs, phases_by_name)
+
+
+def MakeAllFieldValueViews(
+    config, labels, derived_labels, field_values, users_by_id,
+    parent_approval_ids=None, phases=None):
+  """Return a list of FieldValues, each containing values from the issue.
+     A phase field value view will be created for each unique phase name found
+     in the given list a phases. Phase field value views will not be created
+     if the phases list is empty.
+  """
+  parent_approval_ids = parent_approval_ids or []
+  precomp_view_info = _PrecomputeInfoForValueViews(
+      labels, derived_labels, field_values, config, phases or [])
+  def GetApplicable(fd):
+    if fd.approval_id and fd.approval_id in parent_approval_ids:
+      return True
+    return None
+  field_value_views = [
+      _MakeFieldValueView(fd, config, precomp_view_info, users_by_id,
+                          applicable=GetApplicable(fd))
+      # TODO(jrobbins): field-level view restrictions, display options
+      for fd in config.field_defs
+      if not fd.is_deleted and not fd.is_phase_field]
+
+  # Make a phase field's view for each unique phase_name found in phases.
+  (_, _, _, _, phases_by_name) = precomp_view_info
+  for phase_name in phases_by_name.keys():
+    field_value_views.extend([
+        _MakeFieldValueView(
+            fd, config, precomp_view_info, users_by_id, phase_name=phase_name)
+        for fd in config.field_defs if fd.is_phase_field])
+
+  field_value_views = sorted(
+      field_value_views, key=lambda f: (f.applicable_type, f.field_name))
+  return field_value_views
+
+
+def _MakeFieldValueView(
+    fd, config, precomp_view_info, users_by_id, applicable=None,
+    phase_name=None):
+  """Return a FieldValueView with all values from the issue for that field."""
+  (labels_by_prefix, der_labels_by_prefix, field_values_by_id,
+   label_docs, phases_by_name) = precomp_view_info
+
+  field_name_lower = fd.field_name.lower()
+  values = []
+  derived_values = []
+
+  if fd.field_type == tracker_pb2.FieldTypes.ENUM_TYPE:
+    values = _ConvertLabelsToFieldValues(
+        labels_by_prefix.get(field_name_lower, []),
+        field_name_lower, label_docs)
+    derived_values = _ConvertLabelsToFieldValues(
+        der_labels_by_prefix.get(field_name_lower, []),
+        field_name_lower, label_docs)
+  else:
+    # Phases with the same name may have different phase_ids. Phases
+    # are defined during template creation and updating a template structure
+    # may result in new phase rows to be created while existing issues
+    # are referencing older phase rows.
+    phase_ids_for_phase_name = [
+        phase.phase_id for phase in phases_by_name.get(phase_name, [])]
+    # If a phase_name is given, we must filter field_values_by_id fvs to those
+    # that belong to the given phase. This is not done for labels
+    # because monorail does not support phase enum_type field values.
+    values = _MakeFieldValueItems(
+        [fv for fv in field_values_by_id.get(fd.field_id, [])
+         if not fv.derived and
+         (not phase_name or (fv.phase_id in phase_ids_for_phase_name))],
+        users_by_id)
+    derived_values = _MakeFieldValueItems(
+        [fv for fv in field_values_by_id.get(fd.field_id, [])
+         if fv.derived and
+         (not phase_name or (fv.phase_id in phase_ids_for_phase_name))],
+        users_by_id)
+
+  issue_types = (labels_by_prefix.get('type', []) +
+                 der_labels_by_prefix.get('type', []))
+  issue_types_lower = [it.lower() for it in issue_types]
+
+  return FieldValueView(fd, config, values, derived_values, issue_types_lower,
+                        applicable=applicable, phase_name=phase_name)
+
+
+def _MakeFieldValueItems(field_values, users_by_id):
+  """Make appropriate int, string, or user values in the given fields."""
+  result = []
+  for fv in field_values:
+    val = tracker_bizobj.GetFieldValue(fv, users_by_id)
+    result.append(template_helpers.EZTItem(
+        val=val, docstring=val, idx=len(result)))
+
+  return result
+
+
+def MakeBounceFieldValueViews(
+    field_vals, phase_field_vals, config, applicable_fields=None):
+  # type: (Sequence[proto.tracker_pb2.FieldValue],
+  #     Sequence[proto.tracker_pb2.FieldValue],
+  #     proto.tracker_pb2.ProjectIssueConfig
+  #     Sequence[proto.tracker_pb2.FieldDef]) -> Sequence[FieldValueView]
+  """Return a list of field values to display on a validation bounce page."""
+  applicable_set = set()
+  # Handle required fields
+  if applicable_fields:
+    for fd in applicable_fields:
+      applicable_set.add(fd.field_id)
+
+  field_value_views = []
+  for fd in config.field_defs:
+    if fd.field_id in field_vals:
+      # TODO(jrobbins): also bounce derived values.
+      val_items = [
+          template_helpers.EZTItem(val=v, docstring='', idx=idx)
+          for idx, v in enumerate(field_vals[fd.field_id])]
+      field_value_views.append(FieldValueView(
+          fd, config, val_items, [], None, applicable=True))
+    elif fd.field_id in phase_field_vals:
+      vals_by_phase_name = phase_field_vals.get(fd.field_id)
+      for phase_name, values in vals_by_phase_name.items():
+        val_items = [
+            template_helpers.EZTItem(val=v, docstring='', idx=idx)
+            for idx, v in enumerate(values)]
+        field_value_views.append(FieldValueView(
+            fd, config, val_items, [], None, applicable=False,
+            phase_name=phase_name))
+    elif fd.is_required and fd.field_id in applicable_set:
+      # Show required fields that have no value set.
+      field_value_views.append(
+          FieldValueView(fd, config, [], [], None, applicable=True))
+
+  return field_value_views
+
+
+def _ConvertLabelsToFieldValues(label_values, field_name_lower, label_docs):
+  """Iterate through the given labels and pull out values for the field.
+
+  Args:
+    label_values: a list of label strings for the given field.
+    field_name_lower: lowercase string name of the custom field.
+    label_docs: {lower_label: docstring} for well-known labels in the project.
+
+  Returns:
+    A list of EZT items with val and docstring fields.  One item is included
+    for each label that matches the given field name.
+  """
+  values = []
+  for idx, lab_val in enumerate(label_values):
+    full_label_lower = '%s-%s' % (field_name_lower, lab_val.lower())
+    values.append(template_helpers.EZTItem(
+        val=lab_val, docstring=label_docs.get(full_label_lower, ''), idx=idx))
+
+  return values
+
+
+class FieldDefView(template_helpers.PBProxy):
+  """Wrapper class to make it easier to display field definitions via EZT."""
+
+  def __init__(self, field_def, config, user_views=None, approval_def=None):
+    super(FieldDefView, self).__init__(field_def)
+
+    self.type_name = str(field_def.field_type)
+    self.field_def = field_def
+
+    self.choices = []
+    if field_def.field_type == tracker_pb2.FieldTypes.ENUM_TYPE:
+      self.choices = tracker_helpers.LabelsMaskedByFields(
+          config, [field_def.field_name], trim_prefix=True)
+
+    self.approvers = []
+    self.survey = ''
+    self.survey_questions = []
+    if (approval_def and
+        field_def.field_type == tracker_pb2.FieldTypes.APPROVAL_TYPE):
+      self.approvers = [user_views.get(approver_id) for
+                             approver_id in approval_def.approver_ids]
+      if approval_def.survey:
+        self.survey = approval_def.survey
+        self.survey_questions = self.survey.split('\n')
+
+
+    self.docstring_short = template_helpers.FitUnsafeText(
+        field_def.docstring, 200)
+    self.validate_help = None
+
+    if field_def.is_required:
+      self.importance = 'required'
+    elif field_def.is_niche:
+      self.importance = 'niche'
+    else:
+      self.importance = 'normal'
+
+    if field_def.min_value is not None:
+      self.min_value = field_def.min_value
+      self.validate_help = 'Value must be >= %d' % field_def.min_value
+    else:
+      self.min_value = None  # Otherwise it would default to 0
+
+    if field_def.max_value is not None:
+      self.max_value = field_def.max_value
+      self.validate_help = 'Value must be <= %d' % field_def.max_value
+    else:
+      self.max_value = None  # Otherwise it would default to 0
+
+    if field_def.min_value is not None and field_def.max_value is not None:
+      self.validate_help = 'Value must be between %d and %d' % (
+          field_def.min_value, field_def.max_value)
+
+    if field_def.regex:
+      self.validate_help = 'Value must match regex: %s' % field_def.regex
+
+    if field_def.needs_member:
+      self.validate_help = 'Value must be a project member'
+
+    if field_def.needs_perm:
+      self.validate_help = (
+          'Value must be a project member with permission %s' %
+          field_def.needs_perm)
+
+    self.date_action_str = str(field_def.date_action or 'no_action').lower()
+
+    self.admins = []
+    if user_views:
+      self.admins = [user_views.get(admin_id)
+                     for admin_id in field_def.admin_ids]
+
+    self.editors = []
+    if user_views:
+      self.editors = [
+          user_views.get(editor_id) for editor_id in field_def.editor_ids
+      ]
+
+    if field_def.approval_id:
+      self.is_approval_subfield = ezt.boolean(True)
+      self.parent_approval_name = tracker_bizobj.FindFieldDefByID(
+          field_def.approval_id, config).field_name
+    else:
+      self.is_approval_subfield = ezt.boolean(False)
+
+    self.is_phase_field = ezt.boolean(field_def.is_phase_field)
+    self.is_restricted_field = ezt.boolean(field_def.is_restricted_field)
+
+
+class IssueTemplateView(template_helpers.PBProxy):
+  """Wrapper class to make it easier to display an issue template via EZT."""
+
+  def __init__(self, mr, template, user_service, config):
+    super(IssueTemplateView, self).__init__(template)
+
+    self.ownername = ''
+    try:
+      self.owner_view = framework_views.MakeUserView(
+          mr.cnxn, user_service, template.owner_id)
+    except exceptions.NoSuchUserException:
+      self.owner_view = None
+    if self.owner_view:
+      self.ownername = self.owner_view.email
+
+    self.admin_views = list(framework_views.MakeAllUserViews(
+        mr.cnxn, user_service, template.admin_ids).values())
+    self.admin_names = ', '.join(sorted([
+        admin_view.email for admin_view in self.admin_views]))
+
+    self.summary_must_be_edited = ezt.boolean(template.summary_must_be_edited)
+    self.members_only = ezt.boolean(template.members_only)
+    self.owner_defaults_to_member = ezt.boolean(
+        template.owner_defaults_to_member)
+    self.component_required = ezt.boolean(template.component_required)
+
+    component_paths = []
+    for component_id in template.component_ids:
+      component_paths.append(
+          tracker_bizobj.FindComponentDefByID(component_id, config).path)
+    self.components = ', '.join(component_paths)
+
+    self.can_view = ezt.boolean(permissions.CanViewTemplate(
+        mr.auth.effective_ids, mr.perms, mr.project, template))
+    self.can_edit = ezt.boolean(permissions.CanEditTemplate(
+        mr.auth.effective_ids, mr.perms, mr.project, template))
+
+    field_name_set = {fd.field_name.lower() for fd in config.field_defs
+                      if fd.field_type is tracker_pb2.FieldTypes.ENUM_TYPE and
+                      not fd.is_deleted}  # TODO(jrobbins): restrictions
+    non_masked_labels = [
+        lab for lab in template.labels
+        if not tracker_bizobj.LabelIsMaskedByField(lab, field_name_set)]
+
+    for i, label in enumerate(non_masked_labels):
+      setattr(self, 'label%d' % i, label)
+    for i in range(len(non_masked_labels), framework_constants.MAX_LABELS):
+      setattr(self, 'label%d' % i, '')
+
+    field_user_views = MakeFieldUserViews(mr.cnxn, template, user_service)
+
+    self.field_values = []
+    for fv in template.field_values:
+      self.field_values.append(template_helpers.EZTItem(
+          field_id=fv.field_id,
+          val=tracker_bizobj.GetFieldValue(fv, field_user_views),
+          idx=len(self.field_values)))
+
+    self.complete_field_values = MakeAllFieldValueViews(
+        config, template.labels, [], template.field_values, field_user_views)
+
+    # Templates only display and edit the first value of multi-valued fields, so
+    # expose a single value, if any.
+    # TODO(jrobbins): Fully support multi-valued fields in templates.
+    for idx, field_value_view in enumerate(self.complete_field_values):
+      field_value_view.idx = idx
+      if field_value_view.values:
+        field_value_view.val = field_value_view.values[0].val
+      else:
+        field_value_view.val = None
+
+
+def MakeFieldUserViews(cnxn, template, user_service):
+  """Return {user_id: user_view} for users in template field values."""
+  field_user_ids = [
+      fv.user_id for fv in template.field_values
+      if fv.user_id]
+  field_user_views = framework_views.MakeAllUserViews(
+      cnxn, user_service, field_user_ids)
+  return field_user_views
+
+
+class ConfigView(template_helpers.PBProxy):
+  """Make it easy to display most fields of a ProjectIssueConfig in EZT."""
+
+  def __init__(self, mr, services, config, template=None,
+               load_all_templates=False):
+    """Gather data for the issue section of a project admin page.
+
+    Args:
+      mr: MonorailRequest, including a database connection, the current
+          project, and authenticated user IDs.
+      services: Persist services with ProjectService, ConfigService,
+          TemplateService and UserService included.
+      config: ProjectIssueConfig for the current project..
+      template (TemplateDef, optional): the current template.
+      load_all_templates (boolean): default False. If true loads self.templates.
+
+    Returns:
+      Project info in a dict suitable for EZT.
+    """
+    super(ConfigView, self).__init__(config)
+    self.open_statuses = []
+    self.closed_statuses = []
+    for wks in config.well_known_statuses:
+      item = template_helpers.EZTItem(
+          name=wks.status,
+          name_padded=wks.status.ljust(20),
+          commented='#' if wks.deprecated else '',
+          docstring=wks.status_docstring)
+      if tracker_helpers.MeansOpenInProject(wks.status, config):
+        self.open_statuses.append(item)
+      else:
+        self.closed_statuses.append(item)
+
+    is_member = framework_bizobj.UserIsInProject(
+        mr.project, mr.auth.effective_ids)
+    template_set = services.template.GetTemplateSetForProject(mr.cnxn,
+        config.project_id)
+
+    # Filter non-viewable templates
+    self.template_names = []
+    for _, template_name, members_only in template_set:
+      if members_only and not is_member:
+        continue
+      self.template_names.append(template_name)
+
+    if load_all_templates:
+      templates = services.template.GetProjectTemplates(mr.cnxn,
+          config.project_id)
+      self.templates = [
+          IssueTemplateView(mr, tmpl, services.user, config)
+          for tmpl in templates]
+      for index, template_view in enumerate(self.templates):
+        template_view.index = index
+
+    if template:
+      self.template_view = IssueTemplateView(mr, template, services.user,
+          config)
+
+    self.field_names = [  # TODO(jrobbins): field-level controls
+        fd.field_name for fd in config.field_defs if
+        fd.field_type is tracker_pb2.FieldTypes.ENUM_TYPE and
+        not fd.is_deleted]
+    self.issue_labels = tracker_helpers.LabelsNotMaskedByFields(
+        config, self.field_names)
+    self.excl_prefixes = [
+        prefix.lower() for prefix in config.exclusive_label_prefixes]
+    self.restrict_to_known = ezt.boolean(config.restrict_to_known)
+
+    self.default_col_spec = (
+        config.default_col_spec or tracker_constants.DEFAULT_COL_SPEC)
+
+
+def StatusDefsAsText(config):
+  """Return two strings for editing open and closed status definitions."""
+  open_lines = []
+  closed_lines = []
+  for wks in config.well_known_statuses:
+    line = '%s%s%s%s' % (
+      '#' if wks.deprecated else '',
+      wks.status.ljust(20),
+      '\t= ' if wks.status_docstring else '',
+      wks.status_docstring)
+
+    if tracker_helpers.MeansOpenInProject(wks.status, config):
+      open_lines.append(line)
+    else:
+      closed_lines.append(line)
+
+  open_text = '\n'.join(open_lines)
+  closed_text = '\n'.join(closed_lines)
+  logging.info('open_text is \n%s', open_text)
+  logging.info('closed_text is \n%s', closed_text)
+  return open_text, closed_text
+
+
+def LabelDefsAsText(config):
+  """Return a string for editing label definitions."""
+  field_names = [fd.field_name for fd in config.field_defs
+                 if fd.field_type is tracker_pb2.FieldTypes.ENUM_TYPE
+                 and not fd.is_deleted]
+  masked_labels = tracker_helpers.LabelsMaskedByFields(config, field_names)
+  masked_set = set(masked.name for masked in masked_labels)
+
+  label_def_lines = []
+  for wkl in config.well_known_labels:
+    if wkl.label in masked_set:
+      continue
+    line = '%s%s%s%s' % (
+      '#' if wkl.deprecated else '',
+      wkl.label.ljust(20),
+      '\t= ' if wkl.label_docstring else '',
+      wkl.label_docstring)
+    label_def_lines.append(line)
+
+  labels_text = '\n'.join(label_def_lines)
+  logging.info('labels_text is \n%s', labels_text)
+  return labels_text
diff --git a/tracker/webcomponentspage.py b/tracker/webcomponentspage.py
new file mode 100644
index 0000000..4e2ad0d
--- /dev/null
+++ b/tracker/webcomponentspage.py
@@ -0,0 +1,117 @@
+# Copyright 2018 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Classes that implement a web components page.
+
+Summary of classes:
+ WebComponentsPage: Show one web components page.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+
+import logging
+
+import settings
+from framework import servlet
+from framework import framework_helpers
+from framework import permissions
+from framework import urls
+
+
+class WebComponentsPage(servlet.Servlet):
+
+  _PAGE_TEMPLATE = 'tracker/web-components-page.ezt'
+
+  def AssertBasePermission(self, mr):
+    # type: (MonorailRequest) -> None
+    """Check that the user has permission to visit this page."""
+    super(WebComponentsPage, self).AssertBasePermission(mr)
+
+  def GatherPageData(self, mr):
+    # type: (MonorailRequest) -> Mapping[str, Any]
+    """Build up a dictionary of data values to use when rendering the page.
+
+    Args:
+      mr: commonly used info parsed from the request.
+
+    Returns:
+      Dict of values used by EZT for rendering the page.
+    """
+    # Create link to view in old UI for the list view pages.
+    old_ui_url = None
+    url = mr.request.url
+    if '/hotlists/' in url:
+      hotlist = self.services.features.GetHotlist(mr.cnxn, mr.hotlist_id)
+      if '/people' in url:
+        old_ui_url = '/u/%s/hotlists/%s/people' % (
+            hotlist.owner_ids[0], hotlist.name)
+      elif '/settings' in url:
+        old_ui_url = '/u/%s/hotlists/%s/details' % (
+            hotlist.owner_ids[0], hotlist.name)
+      else:
+        old_ui_url = '/u/%s/hotlists/%s' % (hotlist.owner_ids[0], hotlist.name)
+
+    return {
+       'local_id': mr.local_id,
+       'old_ui_url': old_ui_url,
+      }
+
+
+class ProjectListPage(WebComponentsPage):
+
+  def GatherPageData(self, mr):
+    # type: (MonorailRequest) -> Mapping[str, Any]
+    """Build up a dictionary of data values to use when rendering the page.
+
+    May redirect the user to a default project if one is configured for
+    the current domain.
+
+    Args:
+      mr: commonly used info parsed from the request.
+
+    Returns:
+      Dict of values used by EZT for rendering the page.
+    """
+    redirect_msg = self._MaybeRedirectToDomainDefaultProject(mr)
+    logging.info(redirect_msg)
+    return {
+        'local_id': None,
+        'old_ui_url': '/hosting_old/',
+    }
+
+  def _MaybeRedirectToDomainDefaultProject(self, mr):
+    # type: (MonorailRequest) -> str
+    """If there is a relevant default project, redirect to it.
+
+      This function is copied from: sitewide/hostinghome.py
+
+      Args:
+        mr: commonly used info parsed from the request.
+
+      Returns:
+        String with a message about what happened for logging purposes.
+    """
+    project_name = settings.domain_to_default_project.get(mr.request.host)
+    if not project_name:
+      return 'No configured default project redirect for this domain.'
+
+    project = None
+    try:
+      project = self.services.project.GetProjectByName(mr.cnxn, project_name)
+    except exceptions.NoSuchProjectException:
+      pass
+
+    if not project:
+      return 'Domain default project %s not found' % project_name
+
+    if not permissions.UserCanViewProject(mr.auth.user_pb,
+                                          mr.auth.effective_ids, project):
+      return 'User cannot view default project: %r' % project
+
+    project_url = '/p/%s' % project_name
+    self.redirect(project_url, abort=True)
+    return 'Redirected to %r' % project_url