Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/features/__init__.py b/features/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/features/__init__.py
@@ -0,0 +1 @@
+
diff --git a/features/activities.py b/features/activities.py
new file mode 100644
index 0000000..35c6a64
--- /dev/null
+++ b/features/activities.py
@@ -0,0 +1,285 @@
+# 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
+
+"""Code to support project and user activies pages."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import time
+
+import ezt
+
+from framework import framework_constants
+from framework import framework_helpers
+from framework import framework_views
+from framework import sql
+from framework import template_helpers
+from framework import timestr
+from project import project_views
+from proto import tracker_pb2
+from tracker import tracker_helpers
+from tracker import tracker_views
+
+UPDATES_PER_PAGE = 50
+MAX_UPDATES_PER_PAGE = 200
+
+
+class ActivityView(template_helpers.PBProxy):
+  """EZT-friendly wrapper for Activities."""
+
+  _TITLE_TEMPLATE = template_helpers.MonorailTemplate(
+      framework_constants.TEMPLATE_PATH + 'features/activity-title.ezt',
+      compress_whitespace=True, base_format=ezt.FORMAT_HTML)
+
+  _BODY_TEMPLATE = template_helpers.MonorailTemplate(
+      framework_constants.TEMPLATE_PATH + 'features/activity-body.ezt',
+      compress_whitespace=True, base_format=ezt.FORMAT_HTML)
+
+  def __init__(
+      self, pb, mr, prefetched_issues, users_by_id,
+      prefetched_projects, prefetched_configs,
+      autolink=None, all_ref_artifacts=None, ending=None, highlight=None):
+    """Constructs an ActivityView out of an Activity protocol buffer.
+
+    Args:
+      pb: an IssueComment or Activity protocol buffer.
+      mr: HTTP request info, used by the artifact autolink.
+      prefetched_issues: dictionary of the issues for the comments being shown.
+      users_by_id: dict {user_id: UserView} for all relevant users.
+      prefetched_projects: dict {project_id: project} including all the projects
+          that we might need.
+      prefetched_configs: dict {project_id: config} for those projects.
+      autolink: Autolink instance.
+      all_ref_artifacts: list of all artifacts in the activity stream.
+      ending: ending type for activity titles, 'in_project' or 'by_user'
+      highlight: what to highlight in the middle column on user updates pages
+          i.e. 'project', 'user', or None
+    """
+    template_helpers.PBProxy.__init__(self, pb)
+
+    activity_type = 'ProjectIssueUpdate'  # TODO(jrobbins): more types
+
+    self.comment = None
+    self.issue = None
+    self.field_changed = None
+    self.multiple_fields_changed = ezt.boolean(False)
+    self.project = None
+    self.user = None
+    self.timestamp = time.time()  # Bogus value makes bad ones highly visible.
+
+    if isinstance(pb, tracker_pb2.IssueComment):
+      self.timestamp = pb.timestamp
+      issue = prefetched_issues[pb.issue_id]
+      if self.timestamp == issue.opened_timestamp:
+        issue_change_id = None  # This comment is the description.
+      else:
+        issue_change_id = pb.timestamp  # instead of seq num.
+
+      self.comment = tracker_views.IssueCommentView(
+          mr.project_name, pb, users_by_id, autolink,
+          all_ref_artifacts, mr, issue)
+
+      # TODO(jrobbins): pass effective_ids of the commenter so that they
+      # can be identified as a project member or not.
+      config = prefetched_configs[issue.project_id]
+      self.issue = tracker_views.IssueView(issue, users_by_id, config)
+      self.user = self.comment.creator
+      project = prefetched_projects[issue.project_id]
+      self.project_name = project.project_name
+      self.project = project_views.ProjectView(project)
+
+    else:
+      logging.warn('unknown activity object %r', pb)
+
+    nested_page_data = {
+        'activity_type': activity_type,
+        'issue_change_id': issue_change_id,
+        'comment': self.comment,
+        'issue': self.issue,
+        'project': self.project,
+        'user': self.user,
+        'timestamp': self.timestamp,
+        'ending_type': ending,
+        }
+
+    self.escaped_title = self._TITLE_TEMPLATE.GetResponse(
+        nested_page_data).strip()
+    self.escaped_body = self._BODY_TEMPLATE.GetResponse(
+        nested_page_data).strip()
+
+    if autolink is not None and all_ref_artifacts is not None:
+      # TODO(jrobbins): actually parse the comment text.  Actually render runs.
+      runs = autolink.MarkupAutolinks(
+          mr, [template_helpers.TextRun(self.escaped_body)], all_ref_artifacts)
+      self.escaped_body = ''.join(run.content for run in runs)
+
+    self.date_bucket, self.date_relative = timestr.GetHumanScaleDate(
+        self.timestamp)
+    time_tuple = time.localtime(self.timestamp)
+    self.date_tooltip = time.asctime(time_tuple)
+
+    # We always highlight the user for starring activities
+    if activity_type.startswith('UserStar'):
+      self.highlight = 'user'
+    else:
+      self.highlight = highlight
+
+
+def GatherUpdatesData(
+    services, mr, project_ids=None, user_ids=None, ending=None,
+    updates_page_url=None, autolink=None, highlight=None):
+  """Gathers and returns updates data.
+
+  Args:
+    services: Connections to backend services.
+    mr: HTTP request info, used by the artifact autolink.
+    project_ids: List of project IDs we want updates for.
+    user_ids: List of user IDs we want updates for.
+    ending: Ending type for activity titles, 'in_project' or 'by_user'.
+    updates_page_url: The URL that will be used to create pagination links from.
+    autolink: Autolink instance.
+    highlight: What to highlight in the middle column on user updates pages
+        i.e. 'project', 'user', or None.
+  """
+  # num should be non-negative number
+  num = mr.GetPositiveIntParam('num', UPDATES_PER_PAGE)
+  num = min(num, MAX_UPDATES_PER_PAGE)
+
+  updates_data = {
+      'no_stars': None,
+      'no_activities': None,
+      'pagination': None,
+      'updates_data': None,
+      'ending_type': ending,
+      }
+
+  if not user_ids and not project_ids:
+    updates_data['no_stars'] = ezt.boolean(True)
+    return updates_data
+
+  ascending = bool(mr.after)
+  with mr.profiler.Phase('get activities'):
+    comments = services.issue.GetIssueActivity(mr.cnxn, num=num,
+        before=mr.before, after=mr.after, project_ids=project_ids,
+        user_ids=user_ids, ascending=ascending)
+    # Filter the comments based on permission to view the issue.
+    # TODO(jrobbins): push permission checking in the query so that
+    # pagination pages never become underfilled, or use backends to shard.
+    # TODO(jrobbins): come back to this when I implement private comments.
+    # TODO(jrobbins): it would be better if we could just get the dict directly.
+    prefetched_issues_list = services.issue.GetIssues(
+        mr.cnxn, {c.issue_id for c in comments})
+    prefetched_issues = {
+        issue.issue_id: issue for issue in prefetched_issues_list}
+    needed_project_ids = {issue.project_id for issue
+        in prefetched_issues_list}
+    prefetched_projects = services.project.GetProjects(
+        mr.cnxn, needed_project_ids)
+    prefetched_configs = services.config.GetProjectConfigs(
+        mr.cnxn, needed_project_ids)
+    viewable_issues_list = tracker_helpers.FilterOutNonViewableIssues(
+        mr.auth.effective_ids, mr.auth.user_pb, prefetched_projects,
+        prefetched_configs, prefetched_issues_list)
+    viewable_iids = {issue.issue_id for issue in viewable_issues_list}
+    comments = [
+        c for c in comments if c.issue_id in viewable_iids]
+    if ascending:
+      comments.reverse()
+
+  amendment_user_ids = []
+  for comment in comments:
+    for amendment in comment.amendments:
+      amendment_user_ids.extend(amendment.added_user_ids)
+      amendment_user_ids.extend(amendment.removed_user_ids)
+
+  users_by_id = framework_views.MakeAllUserViews(
+      mr.cnxn, services.user, [c.user_id for c in comments],
+      amendment_user_ids)
+  framework_views.RevealAllEmailsToMembers(
+      mr.cnxn, services, mr.auth, users_by_id, mr.project)
+
+  num_results_returned = len(comments)
+  displayed_activities = comments[:UPDATES_PER_PAGE]
+
+  if not num_results_returned:
+    updates_data['no_activities'] = ezt.boolean(True)
+    return updates_data
+
+  # Get all referenced artifacts first
+  all_ref_artifacts = None
+  if autolink is not None:
+    content_list = []
+    for activity in comments:
+      content_list.append(activity.content)
+
+    all_ref_artifacts = autolink.GetAllReferencedArtifacts(
+        mr, content_list)
+
+  # Now process content and gather activities
+  today = []
+  yesterday = []
+  pastweek = []
+  pastmonth = []
+  thisyear = []
+  older = []
+
+  with mr.profiler.Phase('rendering activities'):
+    for activity in displayed_activities:
+      entry = ActivityView(
+          activity, mr, prefetched_issues, users_by_id,
+          prefetched_projects, prefetched_configs,
+          autolink=autolink, all_ref_artifacts=all_ref_artifacts, ending=ending,
+          highlight=highlight)
+
+      if entry.date_bucket == 'Today':
+        today.append(entry)
+      elif entry.date_bucket == 'Yesterday':
+        yesterday.append(entry)
+      elif entry.date_bucket == 'Last 7 days':
+        pastweek.append(entry)
+      elif entry.date_bucket == 'Last 30 days':
+        pastmonth.append(entry)
+      elif entry.date_bucket == 'Earlier this year':
+        thisyear.append(entry)
+      elif entry.date_bucket == 'Before this year':
+        older.append(entry)
+
+  new_after = None
+  new_before = None
+  if displayed_activities:
+    new_after = displayed_activities[0].timestamp
+    new_before = displayed_activities[-1].timestamp
+
+  prev_url = None
+  next_url = None
+  if updates_page_url:
+    list_servlet_rel_url = updates_page_url.split('/')[-1]
+    recognized_params = [(name, mr.GetParam(name))
+                         for name in framework_helpers.RECOGNIZED_PARAMS]
+    if displayed_activities and (mr.before or mr.after):
+      prev_url = framework_helpers.FormatURL(
+          recognized_params, list_servlet_rel_url, after=new_after)
+    if mr.after or len(comments) > UPDATES_PER_PAGE:
+      next_url = framework_helpers.FormatURL(
+          recognized_params, list_servlet_rel_url, before=new_before)
+
+  if prev_url or next_url:
+    pagination = template_helpers.EZTItem(
+        start=None, last=None, prev_url=prev_url, next_url=next_url,
+        reload_url=None, visible=ezt.boolean(True), total_count=None)
+  else:
+    pagination = None
+
+  updates_data.update({
+      'no_activities': ezt.boolean(False),
+      'pagination': pagination,
+      'updates_data': template_helpers.EZTItem(
+          today=today, yesterday=yesterday, pastweek=pastweek,
+          pastmonth=pastmonth, thisyear=thisyear, older=older),
+      })
+
+  return updates_data
diff --git a/features/alert2issue.py b/features/alert2issue.py
new file mode 100644
index 0000000..fbaf5d9
--- /dev/null
+++ b/features/alert2issue.py
@@ -0,0 +1,305 @@
+# Copyright 2019 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
+
+"""Handlers to process alert notification messages."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import itertools
+import logging
+import rfc822
+
+import settings
+from businesslogic import work_env
+from features import commitlogcommands
+from framework import framework_constants
+from framework import monorailcontext
+from framework import emailfmt
+from tracker import tracker_constants
+from tracker import tracker_helpers
+
+AlertEmailHeader = emailfmt.AlertEmailHeader
+
+
+def IsAllowlisted(email_addr):
+  """Returns whether a given email is from one of the allowlisted domains."""
+  return email_addr.endswith(settings.alert_allowlisted_suffixes)
+
+
+def IsCommentSizeReasonable(comment):
+  # type: str -> bool
+  """Returns whether a given comment string is a reasonable size."""
+  return len(comment) <= tracker_constants.MAX_COMMENT_CHARS
+
+
+def FindAlertIssue(services, cnxn, project_id, incident_label):
+  """Find the existing issue with the incident_label."""
+  if not incident_label:
+    return None
+
+  label_id = services.config.LookupLabelID(
+      cnxn, project_id, incident_label)
+  if not label_id:
+    return None
+
+  # If a new notification is sent with an existing incident ID, then it
+  # should be added as a new comment into the existing issue.
+  #
+  # If there are more than one issues with a given incident ID, then
+  # it's either
+  # - there is a bug in this module,
+  # - the issues were manually updated with the same incident ID, OR
+  # - an issue auto update program updated the issues with the same
+  #  incident ID, which also sounds like a bug.
+  #
+  # In any cases, the latest issue should be used, whichever status it has.
+  # - The issue of an ongoing incident can be mistakenly closed by
+  # engineers.
+  # - A closed incident can be reopened, and, therefore, the issue also
+  # needs to be re-opened.
+  issue_ids = services.issue.GetIIDsByLabelIDs(
+      cnxn, [label_id], project_id, None)
+  issues = services.issue.GetIssues(cnxn, issue_ids)
+  if issues:
+    return max(issues, key=lambda issue: issue.modified_timestamp)
+  return None
+
+
+def GetAlertProperties(services, cnxn, project_id, incident_id, trooper_queue,
+                       msg):
+  """Create a dict of issue property values for the alert to be created with.
+
+  Args:
+    cnxn: connection to SQL database.
+    project_id: the ID of the Monorail project, in which the alert should
+      be created in.
+    incident_id: string containing an optional unique incident used to
+      de-dupe alert issues.
+    trooper_queue: the label specifying the trooper queue to add an issue into.
+    msg: the email.Message object containing the alert notification.
+
+  Returns:
+    A dict of issue property values to be used for issue creation.
+  """
+  proj_config = services.config.GetProjectConfig(cnxn, project_id)
+  user_svc = services.user
+  known_labels = set(wkl.label.lower() for wkl in proj_config.well_known_labels)
+
+  props = dict(
+      owner_id=_GetOwnerID(user_svc, cnxn, msg.get(AlertEmailHeader.OWNER)),
+      cc_ids=_GetCCIDs(user_svc, cnxn, msg.get(AlertEmailHeader.CC)),
+      component_ids=_GetComponentIDs(
+          proj_config, msg.get(AlertEmailHeader.COMPONENT)),
+
+      # Props that are added as labels.
+      trooper_queue=(trooper_queue or 'Infra-Troopers-Alerts'),
+      incident_label=_GetIncidentLabel(incident_id),
+      priority=_GetPriority(known_labels, msg.get(AlertEmailHeader.PRIORITY)),
+      oses=_GetOSes(known_labels, msg.get(AlertEmailHeader.OS)),
+      issue_type=_GetIssueType(known_labels, msg.get(AlertEmailHeader.TYPE)),
+
+      field_values=[],
+  )
+
+  # Props that depend on other props.
+  props.update(
+      status=_GetStatus(proj_config, props['owner_id'],
+                        msg.get(AlertEmailHeader.STATUS)),
+      labels=_GetLabels(msg.get(AlertEmailHeader.LABEL),
+                        props['trooper_queue'], props['incident_label'],
+                        props['priority'], props['issue_type'], props['oses']),
+  )
+
+  return props
+
+
+def ProcessEmailNotification(
+    services, cnxn, project, project_addr, from_addr, auth, subject, body,
+    incident_id, msg, trooper_queue=None):
+  # type: (...) -> None
+  """Process an alert notification email to create or update issues.""
+
+  Args:
+    cnxn: connection to SQL database.
+    project: Project PB for the project containing the issue.
+    project_addr: string email address the alert email was sent to.
+    from_addr: string email address of the user who sent the alert email
+        to our server.
+    auth: AuthData object with user_id and email address of the user who
+        will file the alert issue.
+    subject: the subject of the email message
+    body: the body text of the email message
+    incident_id: string containing an optional unique incident used to
+        de-dupe alert issues.
+    msg: the email.Message object that the notification was delivered via.
+    trooper_queue: the label specifying the trooper queue that the alert
+      notification was sent to. If not given, the notification is sent to
+      Infra-Troopers-Alerts.
+
+  Side-effect:
+    Creates an issue or issue comment, if no error was reported.
+  """
+  # Make sure the email address is allowlisted.
+  if not IsAllowlisted(from_addr):
+    logging.info('Unauthorized %s tried to send alert to %s',
+                 from_addr, project_addr)
+    return
+
+  formatted_body = 'Filed by %s on behalf of %s\n\n%s' % (
+      auth.email, from_addr, body)
+  if not IsCommentSizeReasonable(formatted_body):
+    logging.info(
+        '%s tried to send an alert comment that is too long in %s', from_addr,
+        project_addr)
+    return
+
+  mc = monorailcontext.MonorailContext(services, auth=auth, cnxn=cnxn)
+  mc.LookupLoggedInUserPerms(project)
+  with work_env.WorkEnv(mc, services) as we:
+    alert_props = GetAlertProperties(
+        services, cnxn, project.project_id, incident_id, trooper_queue, msg)
+    alert_issue = FindAlertIssue(
+        services, cnxn, project.project_id, alert_props['incident_label'])
+
+    if alert_issue:
+      # Add a reply to the existing issue for this incident.
+      services.issue.CreateIssueComment(
+          cnxn, alert_issue, auth.user_id, formatted_body)
+    else:
+      # Create a new issue for this incident. To preserve previous behavior do
+      # not raise filter rule errors.
+      alert_issue, _ = we.CreateIssue(
+          project.project_id,
+          subject,
+          alert_props['status'],
+          alert_props['owner_id'],
+          alert_props['cc_ids'],
+          alert_props['labels'],
+          alert_props['field_values'],
+          alert_props['component_ids'],
+          formatted_body,
+          raise_filter_errors=False)
+
+    # Update issue using commands.
+    lines = body.strip().split('\n')
+    uia = commitlogcommands.UpdateIssueAction(alert_issue.local_id)
+    commands_found = uia.Parse(
+        cnxn, project.project_name, auth.user_id, lines,
+        services, strip_quoted_lines=True)
+
+    if commands_found:
+      uia.Run(mc, services)
+
+
+def _GetComponentIDs(proj_config, components):
+  comps = ['Infra']
+  if components:
+    components = components.strip()
+  if components:
+    comps = [c.strip() for c in components.split(',')]
+  return tracker_helpers.LookupComponentIDs(comps, proj_config)
+
+
+def _GetIncidentLabel(incident_id):
+  return 'Incident-Id-%s'.strip().lower() % incident_id if incident_id else ''
+
+
+def _GetLabels(custom_labels, trooper_queue, incident_label, priority,
+               issue_type, oses):
+  labels = set(['Restrict-View-Google'.lower()])
+  labels.update(
+      # Whitespaces in a label can cause UI rendering each of the words as
+      # a separate label.
+      ''.join(label.split()).lower() for label in itertools.chain(
+          custom_labels.split(',') if custom_labels else [],
+          [trooper_queue, incident_label, priority, issue_type],
+          oses)
+      if label
+  )
+  return list(labels)
+
+
+def _GetOwnerID(user_svc, cnxn, owner_email):
+  if owner_email:
+    owner_email = owner_email.strip()
+  if not owner_email:
+    return framework_constants.NO_USER_SPECIFIED
+  emails = [addr for _, addr in rfc822.AddressList(owner_email)]
+  return user_svc.LookupExistingUserIDs(
+      cnxn, emails).get(owner_email) or framework_constants.NO_USER_SPECIFIED
+
+
+def _GetCCIDs(user_svc, cnxn, cc_emails):
+  if cc_emails:
+    cc_emails = cc_emails.strip()
+  if not cc_emails:
+    return []
+  emails = [addr for _, addr in rfc822.AddressList(cc_emails)]
+  return [userID for _, userID
+          in user_svc.LookupExistingUserIDs(cnxn, emails).iteritems()
+          if userID is not None]
+
+
+def _GetPriority(known_labels, priority):
+  priority_label = ('Pri-%s' % priority).strip().lower()
+  if priority:
+    if priority_label in known_labels:
+      return priority_label
+    logging.info('invalid priority %s for alerts; default to pri-2', priority)
+
+  # XXX: what if 'Pri-2' doesn't exist in known_labels?
+  return 'pri-2'
+
+
+def _GetStatus(proj_config, owner_id, status):
+  # XXX: what if assigned and available are not in known_statuses?
+  if status:
+    status = status.strip().lower()
+  if owner_id:
+    # If there is an owner, the status must be 'Assigned'.
+    if status and status != 'assigned':
+      logging.info(
+          'invalid status %s for an alert with an owner; default to assigned',
+          status)
+    return 'assigned'
+
+  if status:
+    if tracker_helpers.MeansOpenInProject(status, proj_config):
+      return status
+    logging.info('invalid status %s for an alert; default to available', status)
+
+  return 'available'
+
+
+def _GetOSes(known_labels, oses):
+  if oses:
+    oses = oses.strip().lower()
+  if not oses:
+    return []
+
+  os_labels_to_lookup = {
+      ('os-%s' % os).strip() for os in oses.split(',') if os
+  }
+  os_labels_to_return = os_labels_to_lookup & known_labels
+  invalid_os_labels = os_labels_to_lookup - os_labels_to_return
+  if invalid_os_labels:
+    logging.info('invalid OSes %s', ','.join(invalid_os_labels))
+
+  return list(os_labels_to_return)
+
+
+def _GetIssueType(known_labels, issue_type):
+  if issue_type:
+    issue_type = issue_type.strip().lower()
+  if issue_type is None:
+    return None
+
+  issue_type_label = 'type-%s' % issue_type
+  if issue_type_label in known_labels:
+    return issue_type_label
+
+  logging.info('invalid type %s for an alert; default to None', issue_type)
+  return None
diff --git a/features/autolink.py b/features/autolink.py
new file mode 100644
index 0000000..2787b9c
--- /dev/null
+++ b/features/autolink.py
@@ -0,0 +1,624 @@
+# 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
+
+"""Autolink helps auto-link references to artifacts in text.
+
+This class maintains a registry of artifact autolink syntax specs and
+callbacks. The structure of that registry is:
+  { component_name: (lookup_callback, match_to_reference_function,
+                     { regex: substitution_callback, ...}),
+    ...
+  }
+
+For example:
+  { 'tracker':
+     (GetReferencedIssues,
+      ExtractProjectAndIssueIds,
+      {_ISSUE_REF_RE: ReplaceIssueRef}),
+    'versioncontrol':
+     (GetReferencedRevisions,
+      ExtractProjectAndRevNum,
+      {_GIT_HASH_RE: ReplaceRevisionRef}),
+  }
+
+The dictionary of regexes is used here because, in the future, we
+might add more regexes for each component rather than have one complex
+regex per component.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import re
+import urllib
+import urlparse
+
+import settings
+from features import autolink_constants
+from framework import template_helpers
+from framework import validate
+from proto import project_pb2
+from tracker import tracker_helpers
+
+
+# If the total length of all comments is too large, we don't autolink.
+_MAX_TOTAL_LENGTH = 150 * 1024  # 150KB
+# Special all_referenced_artifacts value used to indicate that the
+# text content is too big to lookup all referenced artifacts quickly.
+SKIP_LOOKUPS = 'skip lookups'
+
+_CLOSING_TAG_RE = re.compile('</[a-z0-9]+>$', re.IGNORECASE)
+
+# These are allowed in links, but if any of closing delimiters appear
+# at the end of the link, and the opening one is not part of the link,
+# then trim off the closing delimiters.
+_LINK_TRAILING_CHARS = [
+    (None, ':'),
+    (None, '.'),
+    (None, ','),
+    ('(', ')'),
+    ('[', ']'),
+    ('{', '}'),
+    ('<', '>'),
+    ("'", "'"),
+    ('"', '"'),
+    ]
+
+
+def LinkifyEmail(_mr, autolink_regex_match, component_ref_artifacts):
+  """Examine a textual reference and replace it with a hyperlink or not.
+
+  This is a callback for use with the autolink feature.  The function
+  parameters are standard for this type of callback.
+
+  Args:
+    _mr: unused information parsed from the HTTP request.
+    autolink_regex_match: regex match for the textual reference.
+    component_ref_artifacts: result of call to GetReferencedUsers.
+
+  Returns:
+    A list of TextRuns with tag=a linking to the user profile page of
+    any defined users, otherwise a mailto: link is generated.
+  """
+  email = autolink_regex_match.group(0)
+
+  if not validate.IsValidEmail(email):
+    return [template_helpers.TextRun(email)]
+
+  if component_ref_artifacts and email in component_ref_artifacts:
+    href = '/u/%s' % email
+  else:
+    href = 'mailto:' + email
+
+  result = [template_helpers.TextRun(email, tag='a', href=href)]
+  return result
+
+
+def CurryGetReferencedUsers(services):
+  """Return a function to get ref'd users with these services objects bound.
+
+  Currying is a convienent way to give the callback access to the services
+  objects, but without requiring that all possible services objects be passed
+  through the autolink registry and functions.
+
+  Args:
+    services: connection to the user persistence layer.
+
+  Returns:
+    A ready-to-use function that accepts the arguments that autolink
+    expects to pass to it.
+  """
+
+  def GetReferencedUsers(mr, emails):
+    """Return a dict of users referenced by these comments.
+
+    Args:
+      mr: commonly used info parsed from the request.
+      ref_tuples: email address strings for each user
+          that is mentioned in the comment text.
+
+    Returns:
+      A dictionary {email: user_pb} including all existing users.
+    """
+    user_id_dict = services.user.LookupExistingUserIDs(mr.cnxn, emails)
+    users_by_id = services.user.GetUsersByIDs(mr.cnxn,
+        list(user_id_dict.values()))
+    users_by_email = {
+      email: users_by_id[user_id]
+      for email, user_id in user_id_dict.items()}
+    return users_by_email
+
+  return GetReferencedUsers
+
+
+def Linkify(_mr, autolink_regex_match, _component_ref_artifacts):
+  """Examine a textual reference and replace it with a hyperlink or not.
+
+  This is a callback for use with the autolink feature.  The function
+  parameters are standard for this type of callback.
+
+  Args:
+    _mr: unused information parsed from the HTTP request.
+    autolink_regex_match: regex match for the textual reference.
+    _component_ref_artifacts: unused result of call to GetReferencedIssues.
+
+  Returns:
+    A list of TextRuns with tag=a for all matched ftp, http, https and mailto
+    links converted into HTML hyperlinks.
+  """
+  hyperlink = autolink_regex_match.group(0)
+
+  trailing = ''
+  for begin, end in _LINK_TRAILING_CHARS:
+    if hyperlink.endswith(end):
+      if not begin or hyperlink[:-len(end)].find(begin) == -1:
+        trailing = end + trailing
+        hyperlink = hyperlink[:-len(end)]
+
+  tag_match = _CLOSING_TAG_RE.search(hyperlink)
+  if tag_match:
+    trailing = hyperlink[tag_match.start(0):] + trailing
+    hyperlink = hyperlink[:tag_match.start(0)]
+
+  href = hyperlink
+  if not href.lower().startswith(('http', 'ftp', 'mailto')):
+    # We use http because redirects for https are not all set up.
+    href = 'http://' + href
+
+  if (not validate.IsValidURL(href) and
+      not (href.startswith('mailto') and validate.IsValidEmail(href[7:]))):
+    return [template_helpers.TextRun(autolink_regex_match.group(0))]
+
+  result = [template_helpers.TextRun(hyperlink, tag='a', href=href)]
+  if trailing:
+    result.append(template_helpers.TextRun(trailing))
+
+  return result
+
+
+# Regular expression to detect git hashes.
+# Used to auto-link to Git hashes on crrev.com when displaying issue details.
+# Matches "rN", "r#N", and "revision N" when "rN" is not part of a larger word
+# and N is a hexadecimal string of 40 chars.
+_GIT_HASH_RE = re.compile(
+    r'\b(?P<prefix>r(evision\s+#?)?)?(?P<revnum>([a-f0-9]{40}))\b',
+    re.IGNORECASE | re.MULTILINE)
+
+# This is for SVN revisions and Git commit posisitons.
+_SVN_REF_RE = re.compile(
+    r'\b(?P<prefix>r(evision\s+#?)?)(?P<revnum>([0-9]{4,7}))\b',
+    re.IGNORECASE | re.MULTILINE)
+
+
+def GetReferencedRevisions(_mr, _refs):
+  """Load the referenced revision objects."""
+  # For now we just autolink any revision hash without actually
+  # checking that such a revision exists,
+  # TODO(jrobbins): Hit crrev.com and check that the revision exists
+  # and show a rollover with revision info.
+  return None
+
+
+def ExtractRevNums(_mr, autolink_regex_match):
+  """Return internal representation of a rev reference."""
+  ref = autolink_regex_match.group('revnum')
+  logging.debug('revision ref = %s', ref)
+  return [ref]
+
+
+def ReplaceRevisionRef(
+    mr, autolink_regex_match, _component_ref_artifacts):
+  """Return HTML markup for an autolink reference."""
+  prefix = autolink_regex_match.group('prefix')
+  revnum = autolink_regex_match.group('revnum')
+  url = _GetRevisionURLFormat(mr.project).format(revnum=revnum)
+  content = revnum
+  if prefix:
+    content = '%s%s' % (prefix, revnum)
+  return [template_helpers.TextRun(content, tag='a', href=url)]
+
+
+def _GetRevisionURLFormat(project):
+  # TODO(jrobbins): Expose a UI to customize it to point to whatever site
+  # hosts the source code. Also, site-wide default.
+  return (project.revision_url_format or settings.revision_url_format)
+
+
+# Regular expression to detect issue references.
+# Used to auto-link to other issues when displaying issue details.
+# Matches "issue " when "issue" is not part of a larger word, or
+# "issue #", or just a "#" when it is preceeded by a space.
+_ISSUE_REF_RE = re.compile(r"""
+    (?P<prefix>\b(issues?|bugs?)[ \t]*(:|=)?)
+    ([ \t]*(?P<project_name>\b[-a-z0-9]+[:\#])?
+     (?P<number_sign>\#?)
+     (?P<local_id>\d+)\b
+     (,?[ \t]*(and|or)?)?)+""", re.IGNORECASE | re.VERBOSE)
+
+# This is for chromium.org's crbug.com shorthand domain.
+_CRBUG_REF_RE = re.compile(r"""
+    (?P<prefix>\b(https?://)?crbug.com/)
+    ((?P<project_name>\b[-a-z0-9]+)(?P<separator>/))?
+    (?P<local_id>\d+)\b
+    (?P<anchor>\#c[0-9]+)?""", re.IGNORECASE | re.VERBOSE)
+
+# Once the overall issue reference has been detected, pick out the specific
+# issue project:id items within it.  Often there is just one, but the "and|or"
+# syntax can allow multiple issues.
+_SINGLE_ISSUE_REF_RE = re.compile(r"""
+    (?P<prefix>\b(issue|bug)[ \t]*)?
+    (?P<project_name>\b[-a-z0-9]+[:\#])?
+    (?P<number_sign>\#?)
+    (?P<local_id>\d+)\b""", re.IGNORECASE | re.VERBOSE)
+
+
+def CurryGetReferencedIssues(services):
+  """Return a function to get ref'd issues with these services objects bound.
+
+  Currying is a convienent way to give the callback access to the services
+  objects, but without requiring that all possible services objects be passed
+  through the autolink registry and functions.
+
+  Args:
+    services: connection to issue, config, and project persistence layers.
+
+  Returns:
+    A ready-to-use function that accepts the arguments that autolink
+    expects to pass to it.
+  """
+
+  def GetReferencedIssues(mr, ref_tuples):
+    """Return lists of open and closed issues referenced by these comments.
+
+    Args:
+      mr: commonly used info parsed from the request.
+      ref_tuples: list of (project_name, local_id) tuples for each issue
+          that is mentioned in the comment text. The project_name may be None,
+          in which case the issue is assumed to be in the current project.
+
+    Returns:
+      A list of open and closed issue dicts.
+    """
+    ref_projects = services.project.GetProjectsByName(
+        mr.cnxn,
+        [(ref_pn or mr.project_name) for ref_pn, _ in ref_tuples])
+    issue_ids, _misses = services.issue.ResolveIssueRefs(
+        mr.cnxn, ref_projects, mr.project_name, ref_tuples)
+    open_issues, closed_issues = (
+        tracker_helpers.GetAllowedOpenedAndClosedIssues(
+            mr, issue_ids, services))
+
+    open_dict = {}
+    for issue in open_issues:
+      open_dict[_IssueProjectKey(issue.project_name, issue.local_id)] = issue
+
+    closed_dict = {}
+    for issue in closed_issues:
+      closed_dict[_IssueProjectKey(issue.project_name, issue.local_id)] = issue
+
+    logging.info('autolinking dicts %r and %r', open_dict, closed_dict)
+
+    return open_dict, closed_dict
+
+  return GetReferencedIssues
+
+
+def _ParseProjectNameMatch(project_name):
+  """Process the passed project name and determine the best representation.
+
+  Args:
+    project_name: a string with the project name matched in a regex
+
+  Returns:
+    A minimal representation of the project name, None if no valid content.
+  """
+  if not project_name:
+    return None
+  return project_name.lstrip().rstrip('#: \t\n')
+
+
+def _ExtractProjectAndIssueIds(
+    autolink_regex_match, subregex, default_project_name=None):
+  """Convert a regex match for a textual reference into our internal form."""
+  whole_str = autolink_regex_match.group(0)
+  refs = []
+  for submatch in subregex.finditer(whole_str):
+    project_name = (
+        _ParseProjectNameMatch(submatch.group('project_name')) or
+        default_project_name)
+    ref = (project_name, int(submatch.group('local_id')))
+    refs.append(ref)
+    logging.info('issue ref = %s', ref)
+
+  return refs
+
+
+def ExtractProjectAndIssueIdsNormal(_mr, autolink_regex_match):
+  """Convert a regex match for a textual reference into our internal form."""
+  return _ExtractProjectAndIssueIds(
+      autolink_regex_match, _SINGLE_ISSUE_REF_RE)
+
+
+def ExtractProjectAndIssueIdsCrBug(_mr, autolink_regex_match):
+  """Convert a regex match for a textual reference into our internal form."""
+  return _ExtractProjectAndIssueIds(
+      autolink_regex_match, _CRBUG_REF_RE, default_project_name='chromium')
+
+
+# This uses project name to avoid a lookup on project ID in a function
+# that has no services object.
+def _IssueProjectKey(project_name, local_id):
+  """Make a dictionary key to identify a referenced issue."""
+  return '%s:%d' % (project_name, local_id)
+
+
+class IssueRefRun(object):
+  """A text run that links to a referenced issue."""
+
+  def __init__(self, issue, is_closed, project_name, content, anchor):
+    self.tag = 'a'
+    self.css_class = 'closed_ref' if is_closed else None
+    self.title = issue.summary
+    self.href = '/p/%s/issues/detail?id=%d%s' % (
+        project_name, issue.local_id, anchor)
+
+    self.content = content
+    if is_closed:
+      self.content = ' %s ' % self.content
+
+
+def _ReplaceIssueRef(
+    autolink_regex_match, component_ref_artifacts, single_issue_regex,
+    default_project_name):
+  """Examine a textual reference and replace it with an autolink or not.
+
+  Args:
+    autolink_regex_match: regex match for the textual reference.
+    component_ref_artifacts: result of earlier call to GetReferencedIssues.
+    single_issue_regex: regular expression to parse individual issue references
+        out of a multi-issue-reference phrase.  E.g., "issues 12 and 34".
+    default_project_name: project name to use when not specified.
+
+  Returns:
+    A list of IssueRefRuns and TextRuns to replace the textual
+    reference.  If there is an issue to autolink to, we return an HTML
+    hyperlink.  Otherwise, we the run will have the original plain
+    text.
+  """
+  open_dict, closed_dict = {}, {}
+  if component_ref_artifacts:
+    open_dict, closed_dict = component_ref_artifacts
+  original = autolink_regex_match.group(0)
+  logging.info('called ReplaceIssueRef on %r', original)
+  result_runs = []
+  pos = 0
+  for submatch in single_issue_regex.finditer(original):
+    if submatch.start() >= pos:
+      if original[pos: submatch.start()]:
+        result_runs.append(template_helpers.TextRun(
+            original[pos: submatch.start()]))
+      replacement_run = _ReplaceSingleIssueRef(
+          submatch, open_dict, closed_dict, default_project_name)
+      result_runs.append(replacement_run)
+      pos = submatch.end()
+
+  if original[pos:]:
+    result_runs.append(template_helpers.TextRun(original[pos:]))
+
+  return result_runs
+
+
+def ReplaceIssueRefNormal(mr, autolink_regex_match, component_ref_artifacts):
+  """Replaces occurances of 'issue 123' with link TextRuns as needed."""
+  return _ReplaceIssueRef(
+      autolink_regex_match, component_ref_artifacts,
+      _SINGLE_ISSUE_REF_RE, mr.project_name)
+
+
+def ReplaceIssueRefCrBug(_mr, autolink_regex_match, component_ref_artifacts):
+  """Replaces occurances of 'crbug.com/123' with link TextRuns as needed."""
+  return _ReplaceIssueRef(
+      autolink_regex_match, component_ref_artifacts,
+      _CRBUG_REF_RE, 'chromium')
+
+
+def _ReplaceSingleIssueRef(
+    submatch, open_dict, closed_dict, default_project_name):
+  """Replace one issue reference with a link, or the original text."""
+  content = submatch.group(0)
+  project_name = submatch.group('project_name')
+  anchor = submatch.groupdict().get('anchor') or ''
+  if project_name:
+    project_name = project_name.lstrip().rstrip(':#')
+  else:
+    # We need project_name for the URL, even if it is not in the text.
+    project_name = default_project_name
+
+  local_id = int(submatch.group('local_id'))
+  issue_key = _IssueProjectKey(project_name, local_id)
+  if issue_key in open_dict:
+    return IssueRefRun(
+        open_dict[issue_key], False, project_name, content, anchor)
+  elif issue_key in closed_dict:
+    return IssueRefRun(
+        closed_dict[issue_key], True, project_name, content, anchor)
+  else:  # Don't link to non-existent issues.
+    return template_helpers.TextRun(content)
+
+
+class Autolink(object):
+  """Maintains a registry of autolink syntax and can apply it to comments."""
+
+  def __init__(self):
+    self.registry = {}
+
+  def RegisterComponent(self, component_name, artifact_lookup_function,
+                        match_to_reference_function, autolink_re_subst_dict):
+    """Register all the autolink info for a software component.
+
+    Args:
+      component_name: string name of software component, must be unique.
+      artifact_lookup_function: function to batch lookup all artifacts that
+          might have been referenced in a set of comments:
+          function(all_matches) -> referenced_artifacts
+          the referenced_artifacts will be pased to each subst function.
+      match_to_reference_function: convert a regex match object to
+          some internal representation of the artifact reference.
+      autolink_re_subst_dict: dictionary of regular expressions and
+          the substitution function that should be called for each match:
+          function(match, referenced_artifacts) -> replacement_markup
+    """
+    self.registry[component_name] = (artifact_lookup_function,
+                                     match_to_reference_function,
+                                     autolink_re_subst_dict)
+
+  def GetAllReferencedArtifacts(
+      self, mr, comment_text_list, max_total_length=_MAX_TOTAL_LENGTH):
+    """Call callbacks to lookup all artifacts possibly referenced.
+
+    Args:
+      mr: information parsed out of the user HTTP request.
+      comment_text_list: list of comment content strings.
+      max_total_length: int max number of characters to accept:
+          if more than this, then skip autolinking entirely.
+
+    Returns:
+      Opaque object that can be pased to MarkupAutolinks.  It's
+      structure happens to be {component_name: artifact_list, ...},
+      or the special value SKIP_LOOKUPS.
+    """
+    total_len = sum(len(comment_text) for comment_text in comment_text_list)
+    if total_len > max_total_length:
+      return SKIP_LOOKUPS
+
+    all_referenced_artifacts = {}
+    for comp, (lookup, match_to_refs, re_dict) in self.registry.items():
+      refs = set()
+      for comment_text in comment_text_list:
+        for regex in re_dict:
+          for match in regex.finditer(comment_text):
+            additional_refs = match_to_refs(mr, match)
+            if additional_refs:
+              refs.update(additional_refs)
+
+      all_referenced_artifacts[comp] = lookup(mr, refs)
+
+    return all_referenced_artifacts
+
+  def MarkupAutolinks(self, mr, text_runs, all_referenced_artifacts):
+    """Loop over components and regexes, applying all substitutions.
+
+    Args:
+      mr: info parsed from the user's HTTP request.
+      text_runs: List of text runs for the user's comment.
+      all_referenced_artifacts: result of previous call to
+        GetAllReferencedArtifacts.
+
+    Returns:
+      List of text runs for the entire user comment, some of which may have
+      attribures that cause them to render as links in render-rich-text.ezt.
+    """
+    items = list(self.registry.items())
+    items.sort()  # Process components in determinate alphabetical order.
+    for component, (_lookup, _match_ref, re_subst_dict) in items:
+      if all_referenced_artifacts == SKIP_LOOKUPS:
+        component_ref_artifacts = None
+      else:
+        component_ref_artifacts = all_referenced_artifacts[component]
+      for regex, subst_fun in re_subst_dict.items():
+        text_runs = self._ApplySubstFunctionToRuns(
+            text_runs, regex, subst_fun, mr, component_ref_artifacts)
+
+    return text_runs
+
+  def _ApplySubstFunctionToRuns(
+      self, text_runs, regex, subst_fun, mr, component_ref_artifacts):
+    """Apply autolink regex and substitution function to each text run.
+
+    Args:
+      text_runs: list of TextRun objects with parts of the original comment.
+      regex: Regular expression for detecting textual references to artifacts.
+      subst_fun: function to return autolink markup, or original text.
+      mr: common info parsed from the user HTTP request.
+      component_ref_artifacts: already-looked-up destination artifacts to use
+        when computing substitution text.
+
+    Returns:
+      A new list with more and smaller runs, some of which may have tag
+      and link attributes set.
+    """
+    result_runs = []
+    for run in text_runs:
+      content = run.content
+      if run.tag:
+        # This chunk has already been substituted, don't allow nested
+        # autolinking to mess up our output.
+        result_runs.append(run)
+      else:
+        pos = 0
+        for match in regex.finditer(content):
+          if match.start() > pos:
+            result_runs.append(template_helpers.TextRun(
+                content[pos: match.start()]))
+          replacement_runs = subst_fun(mr, match, component_ref_artifacts)
+          result_runs.extend(replacement_runs)
+          pos = match.end()
+
+        if run.content[pos:]:  # Keep any text that came after the last match
+          result_runs.append(template_helpers.TextRun(run.content[pos:]))
+
+    # TODO(jrobbins): ideally we would merge consecutive plain text runs
+    # so that regexes can match across those run boundaries.
+
+    return result_runs
+
+
+def RegisterAutolink(services):
+  """Register all the autolink hooks."""
+  # The order of the RegisterComponent() calls does not matter so that we could
+  # do this registration from separate modules in the future if needed.
+  # Priority order of application is determined by the names of the registered
+  # handers, which are sorted in MarkupAutolinks().
+
+  services.autolink.RegisterComponent(
+      '01-tracker-crbug',
+      CurryGetReferencedIssues(services),
+      ExtractProjectAndIssueIdsCrBug,
+      {_CRBUG_REF_RE: ReplaceIssueRefCrBug})
+
+  services.autolink.RegisterComponent(
+      '02-linkify-full-urls',
+      lambda request, mr: None,
+      lambda mr, match: None,
+      {autolink_constants.IS_A_LINK_RE: Linkify})
+
+  services.autolink.RegisterComponent(
+      '03-linkify-user-profiles-or-mailto',
+      CurryGetReferencedUsers(services),
+      lambda _mr, match: [match.group(0)],
+      {autolink_constants.IS_IMPLIED_EMAIL_RE: LinkifyEmail})
+
+  services.autolink.RegisterComponent(
+      '04-tracker-regular',
+      CurryGetReferencedIssues(services),
+      ExtractProjectAndIssueIdsNormal,
+      {_ISSUE_REF_RE: ReplaceIssueRefNormal})
+
+  services.autolink.RegisterComponent(
+      '05-linkify-shorthand',
+      lambda request, mr: None,
+      lambda mr, match: None,
+      {autolink_constants.IS_A_SHORT_LINK_RE: Linkify,
+       autolink_constants.IS_A_NUMERIC_SHORT_LINK_RE: Linkify,
+       autolink_constants.IS_IMPLIED_LINK_RE: Linkify,
+       })
+
+  services.autolink.RegisterComponent(
+      '06-versioncontrol',
+      GetReferencedRevisions,
+      ExtractRevNums,
+      {_GIT_HASH_RE: ReplaceRevisionRef,
+       _SVN_REF_RE: ReplaceRevisionRef})
diff --git a/features/autolink_constants.py b/features/autolink_constants.py
new file mode 100644
index 0000000..ddb9bb3
--- /dev/null
+++ b/features/autolink_constants.py
@@ -0,0 +1,58 @@
+# Copyright 2017 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 of regexes used in Monorail to validate urls and emails."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import re
+import settings
+
+# We linkify http, https, ftp, and mailto schemes only.
+LINKIFY_SCHEMES = r'https?://|ftp://|mailto:'
+
+# This regex matches shorthand URLs that we know are valid.
+# Example: go/monorail
+# The scheme is optional, and if it is missing we add it to the link.
+IS_A_SHORT_LINK_RE = re.compile(
+    r'(?<![-/._])\b(%s)?'     # Scheme is optional for short links.
+    r'(%s)'        # The list of know shorthand links from settings.py
+    r'/([^\s<]+)'  # Allow anything, checked with validation code.
+    % (LINKIFY_SCHEMES, '|'.join(settings.autolink_shorthand_hosts)),
+    re.UNICODE)
+IS_A_NUMERIC_SHORT_LINK_RE = re.compile(
+    r'(?<![-/._])\b(%s)?'     # Scheme is optional for short links.
+    r'(%s)'        # The list of know shorthand links from settings.py
+    r'/([0-9]+)'  # Allow digits only for these domains.
+    % (LINKIFY_SCHEMES, '|'.join(settings.autolink_numeric_shorthand_hosts)),
+    re.UNICODE)
+
+# This regex matches fully-formed URLs, starting with a scheme.
+# Example: http://chromium.org or mailto:user@example.com
+# We link to the specified URL without adding anything.
+# Also count a start-tag '<' as a url delimeter, since the autolinker
+# is sometimes run against html fragments.
+IS_A_LINK_RE = re.compile(
+    r'\b(%s)'    # Scheme must be a whole word.
+    r'([^\s<]+)' # Allow anything, checked with validation code.
+    % LINKIFY_SCHEMES, re.UNICODE)
+
+# This regex matches text that looks like a URL despite lacking a scheme.
+# Example: crrev.com
+# Since the scheme is not specified, we prepend "http://".
+IS_IMPLIED_LINK_RE = re.compile(
+    r'(?<![-/._])\b[a-z]((-|\.)?[a-z0-9])+\.(com|net|org|edu)\b'  # Domain.
+    r'(/[^\s<]*)?',  # Allow anything, check with validation code.
+    re.UNICODE)
+
+# This regex matches text that looks like an email address.
+# Example: user@example.com
+# These get linked to the user profile page if it exists, otherwise
+# they become a mailto:.
+IS_IMPLIED_EMAIL_RE = re.compile(
+    r'\b[a-z]((-|\.)?[a-z0-9])+@'  # Username@
+    r'[a-z]((-|\.)?[a-z0-9])+\.(com|net|org|edu)\b',  # Domain
+    re.UNICODE)
diff --git a/features/banspammer.py b/features/banspammer.py
new file mode 100644
index 0000000..4b66251
--- /dev/null
+++ b/features/banspammer.py
@@ -0,0 +1,97 @@
+# 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 for banning spammer users"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import json
+import time
+
+from framework import cloud_tasks_helpers
+from framework import framework_helpers
+from framework import permissions
+from framework import jsonfeed
+from framework import servlet
+from framework import urls
+
+class BanSpammer(servlet.Servlet):
+  """Ban a user and mark their content as spam"""
+
+  def AssertBasePermission(self, mr):
+    """Check whether the user has any permission to ban users.
+
+    Args:
+      mr: commonly used info parsed from the request.
+    """
+    super(BanSpammer, self).AssertBasePermission(mr)
+    if not permissions.CanBan(mr, self.services):
+      raise permissions.PermissionException(
+          'User is not allowed to ban users.')
+
+  def ProcessFormData(self, mr, post_data):
+    self.AssertBasePermission(mr)
+    viewed_user_id = mr.viewed_user_auth.user_pb.user_id
+    reporter_id = mr.auth.user_id
+
+    # First ban or un-ban the user as a spammer.
+    framework_helpers.UserSettings.ProcessBanForm(
+        mr.cnxn, self.services.user, post_data, mr.viewed_user_auth.user_id,
+        mr.viewed_user_auth.user_pb)
+
+    params = {
+        'spammer_id': viewed_user_id,
+        'reporter_id': reporter_id,
+        'is_spammer': 'banned' in post_data
+    }
+    task = cloud_tasks_helpers.generate_simple_task(
+        urls.BAN_SPAMMER_TASK + '.do', params)
+    cloud_tasks_helpers.create_task(task)
+
+    return framework_helpers.FormatAbsoluteURL(
+        mr, mr.viewed_user_auth.user_view.profile_url, include_project=False,
+        saved=1, ts=int(time.time()))
+
+
+class BanSpammerTask(jsonfeed.InternalTask):
+  """This task will update all of the comments and issues created by the
+     target user with is_spam=True, and also add a manual verdict attached
+     to the user who originated the ban request. This is a potentially long
+     running operation, so it is implemented as an async task.
+  """
+
+  def HandleRequest(self, mr):
+    spammer_id = mr.GetPositiveIntParam('spammer_id')
+    reporter_id = mr.GetPositiveIntParam('reporter_id')
+    is_spammer = mr.GetBoolParam('is_spammer')
+
+    # Get all of the issues reported by the spammer.
+    issue_ids = self.services.issue.GetIssueIDsReportedByUser(mr.cnxn,
+        spammer_id)
+
+    issues = []
+
+    if len(issue_ids) > 0:
+      issues = self.services.issue.GetIssues(
+          mr.cnxn, issue_ids, use_cache=False)
+
+      # Mark them as spam/ham in bulk.
+      self.services.spam.RecordManualIssueVerdicts(mr.cnxn, self.services.issue,
+          issues, reporter_id, is_spammer)
+
+    # Get all of the comments
+    comments = self.services.issue.GetCommentsByUser(mr.cnxn, spammer_id)
+
+    for comment in comments:
+      self.services.spam.RecordManualCommentVerdict(mr.cnxn,
+            self.services.issue, self.services.user, comment.id,
+            reporter_id, is_spammer)
+
+    self.response.body = json.dumps({
+      'comments': len(comments),
+      'issues': len(issues),
+    })
diff --git a/features/commands.py b/features/commands.py
new file mode 100644
index 0000000..3ba376e
--- /dev/null
+++ b/features/commands.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
+
+"""Classes and functions that implement command-line-like issue updates."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import re
+
+from framework import exceptions
+from framework import framework_bizobj
+from framework import framework_constants
+from tracker import tracker_constants
+
+
+def ParseQuickEditCommand(
+    cnxn, cmd, issue, config, logged_in_user_id, services):
+  """Parse a quick edit command into assignments and labels."""
+  parts = _BreakCommandIntoParts(cmd)
+  parser = AssignmentParser(None, easier_kv_labels=True)
+
+  for key, value in parts:
+    if key:  # A key=value assignment.
+      valid_assignment = parser.ParseAssignment(
+          cnxn, key, value, config, services, logged_in_user_id)
+      if not valid_assignment:
+        logging.info('ignoring assignment: %r, %r', key, value)
+
+    elif value.startswith('-'):  # Removing a label.
+      parser.labels_remove.append(_StandardizeLabel(value[1:], config))
+
+    else:  # Adding a label.
+      value = value.strip('+')
+      parser.labels_add.append(_StandardizeLabel(value, config))
+
+  new_summary = parser.summary or issue.summary
+
+  if parser.status is None:
+    new_status = issue.status
+  else:
+    new_status = parser.status
+
+  if parser.owner_id is None:
+    new_owner_id = issue.owner_id
+  else:
+    new_owner_id = parser.owner_id
+
+  new_cc_ids = [cc for cc in list(issue.cc_ids) + list(parser.cc_add)
+                if cc not in parser.cc_remove]
+  (new_labels, _update_add,
+   _update_remove) = framework_bizobj.MergeLabels(
+       issue.labels, parser.labels_add, parser.labels_remove, config)
+
+  return new_summary, new_status, new_owner_id, new_cc_ids, new_labels
+
+
+ASSIGN_COMMAND_RE = re.compile(
+    r'(?P<key>\w+(?:-|\w)*)(?:=|:)'
+    r'(?:(?P<value1>(?:-|\+|\.|%|@|=|,|\w)+)|'
+    r'"(?P<value2>[^"]+)"|'
+    r"'(?P<value3>[^']+)')",
+    re.UNICODE | re.IGNORECASE)
+
+LABEL_COMMAND_RE = re.compile(
+    r'(?P<label>(?:\+|-)?\w(?:-|\w)*)',
+    re.UNICODE | re.IGNORECASE)
+
+
+def _BreakCommandIntoParts(cmd):
+  """Break a quick edit command into assignment and label parts.
+
+  Args:
+    cmd: string command entered by the user.
+
+  Returns:
+    A list of (key, value) pairs where key is the name of the field
+    being assigned or None for OneWord labels, and value is the value
+    to assign to it, or the whole label.  Value may begin with a "+"
+    which is just ignored, or a "-" meaning that the label should be
+    removed, or neither.
+  """
+  parts = []
+  cmd = cmd.strip()
+  m = True
+
+  while m:
+    m = ASSIGN_COMMAND_RE.match(cmd)
+    if m:
+      key = m.group('key')
+      value = m.group('value1') or m.group('value2') or m.group('value3')
+      parts.append((key, value))
+      cmd = cmd[len(m.group(0)):].strip()
+    else:
+      m = LABEL_COMMAND_RE.match(cmd)
+      if m:
+        parts.append((None, m.group('label')))
+        cmd = cmd[len(m.group(0)):].strip()
+
+  return parts
+
+
+def _ParsePlusMinusList(value):
+  """Parse a string containing a series of plus/minuse values.
+
+  Strings are seprated by whitespace, comma and/or semi-colon.
+
+  Example:
+    value = "one +two -three"
+    plus = ['one', 'two']
+    minus = ['three']
+
+  Args:
+    value: string containing unparsed plus minus values.
+
+  Returns:
+    A tuple of (plus, minus) string values.
+  """
+  plus = []
+  minus = []
+  # Treat ';' and ',' as separators (in addition to SPACE)
+  for ch in [',', ';']:
+    value = value.replace(ch, ' ')
+  terms = [i.strip() for i in value.split()]
+  for item in terms:
+    if item.startswith('-'):
+      minus.append(item.lstrip('-'))
+    else:
+      plus.append(item.lstrip('+'))  # optional leading '+'
+
+  return plus, minus
+
+
+class AssignmentParser(object):
+  """Class to parse assignment statements in quick edits or email replies."""
+
+  def __init__(self, template, easier_kv_labels=False):
+    self.cc_list = []
+    self.cc_add = []
+    self.cc_remove = []
+    self.owner_id = None
+    self.status = None
+    self.summary = None
+    self.labels_list = []
+    self.labels_add = []
+    self.labels_remove = []
+    self.branch = None
+
+    # Accept "Anything=Anything" for quick-edit, but not in commit-log-commands
+    # because it would be too error-prone when mixed with plain text comment
+    # text and without autocomplete to help users triggering it via typos.
+    self.easier_kv_labels = easier_kv_labels
+
+    if template:
+      if template.owner_id:
+        self.owner_id = template.owner_id
+      if template.summary:
+        self.summary = template.summary
+      if template.labels:
+        self.labels_list = template.labels
+      # Do not have a similar check as above for status because it could be an
+      # empty string.
+      self.status = template.status
+
+  def ParseAssignment(self, cnxn, key, value, config, services, user_id):
+    """Parse command-style text entered by the user to update an issue.
+
+    E.g., The user may want to set the issue status to "reviewed", or
+    set the owner to "me".
+
+    Args:
+      cnxn: connection to SQL database.
+      key: string name of the field to set.
+      value: string value to be interpreted.
+      config: Projects' issue tracker configuration PB.
+      services: connections to backends.
+      user_id: int user ID of the user making the change.
+
+    Returns:
+      True if the line could be parsed as an assigment, False otherwise.
+      Also, as a side-effect, the assigned values are built up in the instance
+      variables of the parser.
+    """
+    valid_line = True
+
+    if key == 'owner':
+      if framework_constants.NO_VALUE_RE.match(value):
+        self.owner_id = framework_constants.NO_USER_SPECIFIED
+      else:
+        try:
+          self.owner_id = _LookupMeOrUsername(cnxn, value, services, user_id)
+        except exceptions.NoSuchUserException:
+          logging.warning('bad owner: %r when committing to project_id %r',
+                          value, config.project_id)
+          valid_line = False
+
+    elif key == 'cc':
+      try:
+        add, remove = _ParsePlusMinusList(value)
+        self.cc_add = [_LookupMeOrUsername(cnxn, cc, services, user_id)
+                       for cc in add if cc]
+        self.cc_remove = [_LookupMeOrUsername(cnxn, cc, services, user_id)
+                          for cc in remove if cc]
+        for user_id in self.cc_add:
+          if user_id not in self.cc_list:
+            self.cc_list.append(user_id)
+        self.cc_list = [user_id for user_id in self.cc_list
+                        if user_id not in self.cc_remove]
+      except exceptions.NoSuchUserException:
+        logging.warning('bad cc: %r when committing to project_id %r',
+                        value, config.project_id)
+        valid_line = False
+
+    elif key == 'summary':
+      self.summary = value
+
+    elif key == 'status':
+      if framework_constants.NO_VALUE_RE.match(value):
+        self.status = ''
+      else:
+        self.status = _StandardizeStatus(value, config)
+
+    elif key == 'label' or key == 'labels':
+      self.labels_add, self.labels_remove = _ParsePlusMinusList(value)
+      self.labels_add = [_StandardizeLabel(lab, config)
+                         for lab in self.labels_add]
+      self.labels_remove = [_StandardizeLabel(lab, config)
+                            for lab in self.labels_remove]
+      (self.labels_list, _update_add,
+       _update_remove) = framework_bizobj.MergeLabels(
+           self.labels_list, self.labels_add, self.labels_remove, config)
+
+    elif (self.easier_kv_labels and
+          key not in tracker_constants.RESERVED_PREFIXES and
+          key and value):
+      if key.startswith('-'):
+        self.labels_remove.append(_StandardizeLabel(
+            '%s-%s' % (key[1:], value), config))
+      else:
+        self.labels_add.append(_StandardizeLabel(
+            '%s-%s' % (key, value), config))
+
+    else:
+      valid_line = False
+
+    return valid_line
+
+
+def _StandardizeStatus(status, config):
+  """Attempt to match a user-supplied status with standard status values.
+
+  Args:
+    status: User-supplied status string.
+    config: Project's issue tracker configuration PB.
+
+  Returns:
+    A canonicalized status string, that matches a standard project
+    value, if found.
+  """
+  well_known_statuses = [wks.status for wks in config.well_known_statuses]
+  return _StandardizeArtifact(status, well_known_statuses)
+
+
+def _StandardizeLabel(label, config):
+  """Attempt to match a user-supplied label with standard label values.
+
+  Args:
+    label: User-supplied label string.
+    config: Project's issue tracker configuration PB.
+
+  Returns:
+    A canonicalized label string, that matches a standard project
+    value, if found.
+  """
+  well_known_labels = [wkl.label for wkl in config.well_known_labels]
+  return _StandardizeArtifact(label, well_known_labels)
+
+
+def _StandardizeArtifact(artifact, well_known_artifacts):
+  """Attempt to match a user-supplied artifact with standard artifact values.
+
+  Args:
+    artifact: User-supplied artifact string.
+    well_known_artifacts: List of well known values of the artifact.
+
+  Returns:
+    A canonicalized artifact string, that matches a standard project
+    value, if found.
+  """
+  artifact = framework_bizobj.CanonicalizeLabel(artifact)
+  for wka in well_known_artifacts:
+    if artifact.lower() == wka.lower():
+      return wka
+  # No match - use user-supplied artifact.
+  return artifact
+
+
+def _LookupMeOrUsername(cnxn, username, services, user_id):
+  """Handle the 'me' syntax or lookup a user's user ID."""
+  if username.lower() == 'me':
+    return user_id
+
+  return services.user.LookupUserID(cnxn, username)
diff --git a/features/commitlogcommands.py b/features/commitlogcommands.py
new file mode 100644
index 0000000..f570ae3
--- /dev/null
+++ b/features/commitlogcommands.py
@@ -0,0 +1,162 @@
+# 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
+
+"""Implements processing of issue update command lines.
+
+This currently processes the leading command-lines that appear
+at the top of inbound email messages to update existing issues.
+
+It could also be expanded to allow new issues to be created. Or, to
+handle commands in commit-log messages if the version control system
+invokes a webhook.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import re
+
+from businesslogic import work_env
+from features import commands
+from features import send_notifications
+from framework import emailfmt
+from framework import exceptions
+from framework import framework_bizobj
+from framework import framework_helpers
+from framework import permissions
+from proto import tracker_pb2
+
+
+# Actions have separate 'Parse' and 'Run' implementations to allow better
+# testing coverage.
+class IssueAction(object):
+  """Base class for all issue commands."""
+
+  def __init__(self):
+    self.parser = commands.AssignmentParser(None)
+    self.description = ''
+    self.inbound_message = None
+    self.commenter_id = None
+    self.project = None
+    self.config = None
+    self.hostport = framework_helpers.GetHostPort()
+
+  def Parse(
+      self, cnxn, project_name, commenter_id, lines, services,
+      strip_quoted_lines=False, hostport=None):
+    """Populate object from raw user input.
+
+    Args:
+      cnxn: connection to SQL database.
+      project_name: Name of the project containing the issue.
+      commenter_id: int user ID of user creating comment.
+      lines: list of strings containing test to be parsed.
+      services: References to existing objects from Monorail's service layer.
+      strip_quoted_lines: boolean for whether to remove quoted lines from text.
+      hostport: Optionally override the current instance's hostport variable.
+
+    Returns:
+      A boolean for whether any command lines were found while parsing.
+
+    Side-effect:
+      Edits the values of instance variables in this class with parsing output.
+    """
+    self.project = services.project.GetProjectByName(cnxn, project_name)
+    self.config = services.config.GetProjectConfig(
+        cnxn, self.project.project_id)
+    self.commenter_id = commenter_id
+
+    has_commands = False
+
+    # Process all valid key-value lines. Once we find a non key-value line,
+    # treat the rest as the 'description'.
+    for idx, line in enumerate(lines):
+      valid_line = False
+      m = re.match(r'^\s*(\w+)\s*\:\s*(.*?)\s*$', line)
+      if m:
+        has_commands = True
+        # Process Key-Value
+        key = m.group(1).lower()
+        value = m.group(2)
+        valid_line = self.parser.ParseAssignment(
+            cnxn, key, value, self.config, services, self.commenter_id)
+
+      if not valid_line:
+        # Not Key-Value. Treat this line and remaining as 'description'.
+        # First strip off any trailing blank lines.
+        while lines and not lines[-1].strip():
+          lines.pop()
+        if lines:
+          self.description = '\n'.join(lines[idx:])
+          break
+
+    if strip_quoted_lines:
+      self.inbound_message = '\n'.join(lines)
+      self.description = emailfmt.StripQuotedText(self.description)
+
+    if hostport:
+      self.hostport = hostport
+
+    for key in ['owner_id', 'cc_add', 'cc_remove', 'summary',
+                'status', 'labels_add', 'labels_remove', 'branch']:
+      logging.info('\t%s: %s', key, self.parser.__dict__[key])
+
+    for key in ['commenter_id', 'description', 'hostport']:
+      logging.info('\t%s: %s', key, self.__dict__[key])
+
+    return has_commands
+
+  def Run(self, mc, services):
+    """Execute this action."""
+    raise NotImplementedError()
+
+
+class UpdateIssueAction(IssueAction):
+  """Implements processing email replies or the "update issue" command."""
+
+  def __init__(self, local_id):
+    super(UpdateIssueAction, self).__init__()
+    self.local_id = local_id
+
+  def Run(self, mc, services):
+    """Updates an issue based on the parsed commands."""
+    try:
+      issue = services.issue.GetIssueByLocalID(
+          mc.cnxn, self.project.project_id, self.local_id, use_cache=False)
+    except exceptions.NoSuchIssueException:
+      return  # Issue does not exist, so do nothing
+
+    delta = tracker_pb2.IssueDelta()
+
+    allow_edit = permissions.CanEditIssue(
+        mc.auth.effective_ids, mc.perms, self.project, issue)
+
+    if allow_edit:
+      delta.summary = self.parser.summary or issue.summary
+      if self.parser.status is None:
+        delta.status = issue.status
+      else:
+        delta.status = self.parser.status
+
+      if self.parser.owner_id is None:
+        delta.owner_id = issue.owner_id
+      else:
+        delta.owner_id = self.parser.owner_id
+
+      delta.cc_ids_add = list(self.parser.cc_add)
+      delta.cc_ids_remove = list(self.parser.cc_remove)
+      delta.labels_add = self.parser.labels_add
+      delta.labels_remove = self.parser.labels_remove
+      # TODO(jrobbins): allow editing of custom fields
+
+    with work_env.WorkEnv(mc, services) as we:
+      we.UpdateIssue(
+          issue, delta, self.description, inbound_message=self.inbound_message)
+
+    logging.info('Updated issue %s:%s',
+                 self.project.project_name, issue.local_id)
+
+    # Note: notifications are generated in work_env.
diff --git a/features/component_helpers.py b/features/component_helpers.py
new file mode 100644
index 0000000..1392f0b
--- /dev/null
+++ b/features/component_helpers.py
@@ -0,0 +1,127 @@
+# 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
+
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import json
+import logging
+import re
+
+import settings
+import cloudstorage
+
+from features import generate_dataset
+from framework import framework_helpers
+from services import ml_helpers
+from tracker import tracker_bizobj
+
+from googleapiclient import discovery
+from oauth2client.client import GoogleCredentials
+
+
+MODEL_NAME = 'projects/{}/models/{}'.format(
+    settings.classifier_project_id, settings.component_model_name)
+
+
+def _GetTopWords(trainer_name):  # pragma: no cover
+  # TODO(carapew): Use memcache to get top words rather than storing as a
+  # variable.
+  credentials = GoogleCredentials.get_application_default()
+  storage = discovery.build('storage', 'v1', credentials=credentials)
+  request = storage.objects().get_media(
+      bucket=settings.component_ml_bucket,
+      object=trainer_name + '/topwords.txt')
+  response = request.execute()
+
+  # This turns the top words list into a dictionary for faster feature
+  # generation.
+  return {word: idx for idx, word in enumerate(response.split())}
+
+
+def _GetComponentsByIndex(trainer_name):
+  # TODO(carapew): Memcache the index mapping file.
+  mapping_path = '/%s/%s/component_index.json' % (
+      settings.component_ml_bucket, trainer_name)
+  logging.info('Mapping path full name: %r', mapping_path)
+
+  with cloudstorage.open(mapping_path, 'r') as index_mapping_file:
+    logging.info('Index component mapping opened')
+    mapping = index_mapping_file.read()
+    logging.info(mapping)
+    return json.loads(mapping)
+
+
+@framework_helpers.retry(3)
+def _GetComponentPrediction(ml_engine, instance):
+  """Predict the component from the default model based on the provided text.
+
+  Args:
+    ml_engine: An ML Engine instance for making predictions.
+    instance: The dict object returned from ml_helpers.GenerateFeaturesRaw
+      containing the features generated from the provided text.
+
+  Returns:
+    The index of the component with the highest score. ML engine's predict
+    api returns a dict of the format
+    {'predictions': [{'classes': ['0', '1', ...], 'scores': [.00234, ...]}]}
+    where each class has a score at the same index. Classes are sequential,
+    so the index of the highest score also happens to be the component's
+    index.
+  """
+  body = {'instances': [{'inputs': instance['word_features']}]}
+  request = ml_engine.projects().predict(name=MODEL_NAME, body=body)
+  response = request.execute()
+
+  logging.info('ML Engine API response: %r' % response)
+  scores = response['predictions'][0]['scores']
+
+  return scores.index(max(scores))
+
+
+def PredictComponent(raw_text, config):
+  """Get the component ID predicted for the given text.
+
+  Args:
+    raw_text: The raw text for which we want to predict a component.
+    config: The config of the project. Used to decide if the predicted component
+        is valid.
+
+  Returns:
+    The component ID predicted for the provided component, or None if no
+    component was predicted.
+  """
+  # Set-up ML engine.
+  ml_engine = ml_helpers.setup_ml_engine()
+
+  # Gets the timestamp number from the folder containing the model's trainer
+  # in order to get the correct files for mappings and features.
+  request = ml_engine.projects().models().get(name=MODEL_NAME)
+  response = request.execute()
+
+  version = re.search(r'v_(\d+)', response['defaultVersion']['name']).group(1)
+  trainer_name = 'component_trainer_%s' % version
+
+  top_words = _GetTopWords(trainer_name)
+  components_by_index = _GetComponentsByIndex(trainer_name)
+  logging.info('Length of top words list: %s', len(top_words))
+
+  clean_text = generate_dataset.CleanText(raw_text)
+  instance = ml_helpers.GenerateFeaturesRaw(
+      [clean_text], settings.component_features, top_words)
+
+  # Get the component id with the highest prediction score. Component ids are
+  # stored in GCS as strings, but represented in the app as longs.
+  best_score_index = _GetComponentPrediction(ml_engine, instance)
+  component_id = components_by_index.get(str(best_score_index))
+  if component_id:
+    component_id = int(component_id)
+
+  # The predicted component id might not exist.
+  if tracker_bizobj.FindComponentDefByID(component_id, config) is None:
+    return None
+
+  return component_id
diff --git a/features/componentexport.py b/features/componentexport.py
new file mode 100644
index 0000000..cadb6a8
--- /dev/null
+++ b/features/componentexport.py
@@ -0,0 +1,59 @@
+# 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
+""" Tasks and handlers for maintaining the spam classifier model. These
+    should be run via cron and task queue rather than manually.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import cloudstorage
+import datetime
+import logging
+import webapp2
+
+from google.appengine.api import app_identity
+
+from features.generate_dataset import build_component_dataset
+from framework import cloud_tasks_helpers
+from framework import servlet
+from framework import urls
+
+
+class ComponentTrainingDataExport(webapp2.RequestHandler):
+  """Trigger a training data export task"""
+  def get(self):
+    logging.info('Training data export requested.')
+    task = {
+        'app_engine_http_request':
+            {
+                'http_method': 'GET',
+                'relative_uri': urls.COMPONENT_DATA_EXPORT_TASK,
+            }
+    }
+    cloud_tasks_helpers.create_task(task, queue='componentexport')
+
+
+class ComponentTrainingDataExportTask(servlet.Servlet):
+  """Export training data for issues and their assigned components, to be used
+     to train  a model later.
+  """
+  def get(self):
+    logging.info('Training data export initiated.')
+    bucket_name = app_identity.get_default_gcs_bucket_name()
+    logging.info('Bucket name: %s', bucket_name)
+    date_str = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
+
+    logging.info('Opening cloud storage')
+    gcs_file = cloudstorage.open('/' + bucket_name
+                                 + '/component_training_data/'
+                                 + date_str + '.csv',
+        content_type='text/csv', mode='w')
+
+    logging.info('GCS file opened')
+
+    gcs_file = build_component_dataset(self.services.issue, gcs_file)
+
+    gcs_file.close()
diff --git a/features/dateaction.py b/features/dateaction.py
new file mode 100644
index 0000000..a525db1
--- /dev/null
+++ b/features/dateaction.py
@@ -0,0 +1,227 @@
+# 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
+
+"""Cron and task handlers for email notifications of issue date value arrival.
+
+If an issue has a date-type custom field, and that custom field is configured
+to perform an action when that date arrives, then this cron handler and the
+associated tasks carry out those actions on that issue.
+"""
+
+from __future__ import division
+from __future__ import print_function
+from __future__ import absolute_import
+
+import logging
+import time
+
+import ezt
+
+import settings
+
+from features import notify_helpers
+from features import notify_reasons
+from framework import cloud_tasks_helpers
+from framework import framework_constants
+from framework import framework_helpers
+from framework import framework_views
+from framework import jsonfeed
+from framework import permissions
+from framework import timestr
+from framework import urls
+from proto import tracker_pb2
+from tracker import tracker_bizobj
+from tracker import tracker_helpers
+from tracker import tracker_views
+
+
+TEMPLATE_PATH = framework_constants.TEMPLATE_PATH
+
+class DateActionCron(jsonfeed.InternalTask):
+  """Find and process issues with date-type values that arrived today."""
+
+  def HandleRequest(self, mr):
+    """Find issues with date-type-fields that arrived and spawn tasks."""
+    highest_iid_so_far = 0
+    capped = True
+    timestamp_min, timestamp_max = _GetTimestampRange(int(time.time()))
+    left_joins = [
+        ('Issue2FieldValue ON Issue.id = Issue2FieldValue.issue_id', []),
+        ('FieldDef ON Issue2FieldValue.field_id = FieldDef.id', []),
+        ]
+    where = [
+        ('FieldDef.field_type = %s', ['date_type']),
+        ('FieldDef.date_action IN (%s,%s)',
+         ['ping_owner_only', 'ping_participants']),
+        ('Issue2FieldValue.date_value >= %s', [timestamp_min]),
+        ('Issue2FieldValue.date_value < %s', [timestamp_max]),
+        ]
+    order_by = [
+        ('Issue.id', []),
+        ]
+    while capped:
+      chunk_issue_ids, capped = self.services.issue.RunIssueQuery(
+          mr.cnxn, left_joins,
+          where + [('Issue.id > %s', [highest_iid_so_far])], order_by)
+      if chunk_issue_ids:
+        logging.info('chunk_issue_ids = %r', chunk_issue_ids)
+        highest_iid_so_far = max(highest_iid_so_far, max(chunk_issue_ids))
+        for issue_id in chunk_issue_ids:
+          self.EnqueueDateAction(issue_id)
+
+  def EnqueueDateAction(self, issue_id):
+    """Create a task to notify users that an issue's date has arrived.
+
+    Args:
+      issue_id: int ID of the issue that was changed.
+
+    Returns nothing.
+    """
+    params = {'issue_id': issue_id}
+    task = cloud_tasks_helpers.generate_simple_task(
+        urls.ISSUE_DATE_ACTION_TASK + '.do', params)
+    cloud_tasks_helpers.create_task(task)
+
+
+def _GetTimestampRange(now):
+  """Return a (min, max) timestamp range for today."""
+  timestamp_min = (now // framework_constants.SECS_PER_DAY *
+                   framework_constants.SECS_PER_DAY)
+  timestamp_max = timestamp_min + framework_constants.SECS_PER_DAY
+  return timestamp_min, timestamp_max
+
+
+class IssueDateActionTask(notify_helpers.NotifyTaskBase):
+  """JSON servlet that notifies appropriate users after an issue change."""
+
+  _EMAIL_TEMPLATE = 'features/auto-ping-email.ezt'
+  _LINK_ONLY_EMAIL_TEMPLATE = (
+      'tracker/issue-change-notification-email-link-only.ezt')
+
+  def HandleRequest(self, mr):
+    """Process the task to process an issue date action.
+
+    Args:
+      mr: common information parsed from the HTTP request.
+
+    Returns:
+      Results dictionary in JSON format which is useful just for debugging.
+      The main goal is the side-effect of sending emails.
+    """
+    issue_id = mr.GetPositiveIntParam('issue_id')
+    issue = self.services.issue.GetIssue(mr.cnxn, issue_id, use_cache=False)
+    project = self.services.project.GetProject(mr.cnxn, issue.project_id)
+    hostport = framework_helpers.GetHostPort(project_name=project.project_name)
+    config = self.services.config.GetProjectConfig(mr.cnxn, issue.project_id)
+    pings = self._CalculateIssuePings(issue, config)
+    if not pings:
+      logging.warning('Issue %r has no dates to ping afterall?', issue_id)
+      return
+    comment = self._CreatePingComment(mr.cnxn, issue, pings, hostport)
+    starrer_ids = self.services.issue_star.LookupItemStarrers(
+        mr.cnxn, issue.issue_id)
+
+    users_by_id = framework_views.MakeAllUserViews(
+        mr.cnxn, self.services.user,
+        tracker_bizobj.UsersInvolvedInIssues([issue]),
+        tracker_bizobj.UsersInvolvedInComment(comment),
+        starrer_ids)
+    logging.info('users_by_id is %r', users_by_id)
+    tasks = self._MakeEmailTasks(
+      mr.cnxn, issue, project, config, comment, starrer_ids,
+      hostport, users_by_id, pings)
+
+    notified = notify_helpers.AddAllEmailTasks(tasks)
+    return {
+        'notified': notified,
+        }
+
+  def _CreatePingComment(self, cnxn, issue, pings, hostport):
+    """Create an issue comment saying that some dates have arrived."""
+    content = '\n'.join(self._FormatPingLine(ping) for ping in pings)
+    author_email_addr = '%s@%s' % (settings.date_action_ping_author, hostport)
+    date_action_user_id = self.services.user.LookupUserID(
+        cnxn, author_email_addr, autocreate=True)
+    comment = self.services.issue.CreateIssueComment(
+        cnxn, issue, date_action_user_id, content)
+    return comment
+
+  def _MakeEmailTasks(
+      self, cnxn, issue, project, config, comment, starrer_ids,
+      hostport, users_by_id, pings):
+    """Return a list of dicts for tasks to notify people."""
+    detail_url = framework_helpers.IssueCommentURL(
+        hostport, project, issue.local_id, seq_num=comment.sequence)
+    fields = sorted((field_def for (field_def, _date_value) in pings),
+                    key=lambda fd: fd.field_name)
+    email_data = {
+        'issue': tracker_views.IssueView(issue, users_by_id, config),
+        'summary': issue.summary,
+        'ping_comment_content': comment.content,
+        'detail_url': detail_url,
+        'fields': fields,
+        }
+
+    # Generate three versions of email body with progressively more info.
+    body_link_only = self.link_only_email_template.GetResponse(
+      {'detail_url': detail_url, 'was_created': ezt.boolean(False)})
+    body_for_non_members = self.email_template.GetResponse(email_data)
+    framework_views.RevealAllEmails(users_by_id)
+    body_for_members = self.email_template.GetResponse(email_data)
+    logging.info('body for non-members is:\n%r' % body_for_non_members)
+    logging.info('body for members is:\n%r' % body_for_members)
+
+    contributor_could_view = permissions.CanViewIssue(
+        set(), permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET,
+        project, issue)
+
+    group_reason_list = notify_reasons.ComputeGroupReasonList(
+        cnxn, self.services, project, issue, config, users_by_id,
+        [], contributor_could_view, starrer_ids=starrer_ids,
+        commenter_in_project=True, include_subscribers=False,
+        include_notify_all=False,
+        starrer_pref_check_function=lambda u: u.notify_starred_ping)
+
+    commenter_view = users_by_id[comment.user_id]
+    email_tasks = notify_helpers.MakeBulletedEmailWorkItems(
+        group_reason_list, issue, body_link_only, body_for_non_members,
+        body_for_members, project, hostport, commenter_view, detail_url,
+        seq_num=comment.sequence, subject_prefix='Follow up on issue ',
+        compact_subject_prefix='Follow up ')
+
+    return email_tasks
+
+  def _CalculateIssuePings(self, issue, config):
+    """Return a list of (field, timestamp) pairs for dates that should ping."""
+    timestamp_min, timestamp_max = _GetTimestampRange(int(time.time()))
+    arrived_dates_by_field_id = {
+        fv.field_id: fv.date_value
+        for fv in issue.field_values
+        if timestamp_min <= fv.date_value < timestamp_max}
+    logging.info('arrived_dates_by_field_id = %r', arrived_dates_by_field_id)
+    # TODO(jrobbins): Lookup field defs regardless of project_id to better
+    # handle foreign fields in issues that have been moved between projects.
+    pings = [
+      (field, arrived_dates_by_field_id[field.field_id])
+      for field in config.field_defs
+      if (field.field_id in arrived_dates_by_field_id and
+          field.date_action in (tracker_pb2.DateAction.PING_OWNER_ONLY,
+                                tracker_pb2.DateAction.PING_PARTICIPANTS))]
+
+    # TODO(jrobbins): For now, assume all pings apply only to open issues.
+    # Later, allow each date action to specify whether it applies to open
+    # issues or all issues.
+    means_open = tracker_helpers.MeansOpenInProject(
+        tracker_bizobj.GetStatus(issue), config)
+    pings = [ping for ping in pings if means_open]
+
+    pings = sorted(pings, key=lambda ping: ping[0].field_name)
+    return pings
+
+  def _FormatPingLine(self, ping):
+    """Return a one-line string describing the date that arrived."""
+    field, timestamp = ping
+    date_str = timestr.TimestampToDateWidgetStr(timestamp)
+    return 'The %s date has arrived: %s' % (field.field_name, date_str)
diff --git a/features/features_bizobj.py b/features/features_bizobj.py
new file mode 100644
index 0000000..804e6a4
--- /dev/null
+++ b/features/features_bizobj.py
@@ -0,0 +1,109 @@
+# 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 features.
+
+These are classes and functions that operate on the objects that users care
+about in features (eg. hotlists).
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+
+from framework import framework_bizobj
+from framework import urls
+from proto import features_pb2
+
+
+def GetOwnerIds(hotlist):
+  """Returns the list of ids for the given hotlist's owners."""
+  return hotlist.owner_ids
+
+
+def UsersInvolvedInHotlists(hotlists):
+  """Returns a set of all users who have roles in the given hotlists."""
+  result = set()
+  for hotlist in hotlists:
+    result.update(hotlist.owner_ids)
+    result.update(hotlist.editor_ids)
+    result.update(hotlist.follower_ids)
+  return result
+
+
+def UserOwnsHotlist(hotlist, effective_ids):
+  """Returns T/F if the user is the owner/not the owner of the hotlist."""
+  return not effective_ids.isdisjoint(hotlist.owner_ids or set())
+
+
+def IssueIsInHotlist(hotlist, issue_id):
+  """Returns T/F if the issue is in the hotlist."""
+  return any(issue_id == hotlist_issue.issue_id
+             for hotlist_issue in hotlist.items)
+
+
+def UserIsInHotlist(hotlist, effective_ids):
+  """Returns T/F if the user is involved/not involved in the hotlist."""
+  return (UserOwnsHotlist(hotlist, effective_ids) or
+          not effective_ids.isdisjoint(hotlist.editor_ids or set()) or
+          not effective_ids.isdisjoint(hotlist.follower_ids or set()))
+
+
+def SplitHotlistIssueRanks(target_iid, split_above, iid_rank_pairs):
+  """Splits hotlist issue relation rankings by some target issue's rank.
+
+  Hotlists issues are sorted Low to High. When split_above is true,
+  the split should occur before the target object and the objects
+  should be moved above the target, with lower ranks than the target.
+
+  Args:
+    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.
+    iid_rank_pairs: a list tuples [(issue_id, rank_in_hotlist),...} for all
+    issues in a hotlist excluding the one being moved.
+
+  Returns:
+    A tuple (lower, higher) where both are lists of [(issue_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.
+  """
+  iid_rank_pairs.reverse()
+  offset = int(not split_above)
+  for i, (issue_id, _) in enumerate(iid_rank_pairs):
+    if issue_id == target_iid:
+      return iid_rank_pairs[:i + offset], iid_rank_pairs[i + offset:]
+  logging.error(
+      'Target issue %r was not found in the list of issue_id rank pairs',
+                target_iid)
+  return iid_rank_pairs, []
+
+
+def DetermineHotlistIssuePosition(issue, issue_ids):
+  """Find position of an issue in a hotlist for a flipper.
+
+  Args:
+    issue: The issue PB currently being viewed
+    issue_ids: list of issue_id's
+
+  Returns:
+    A 3-tuple (prev_iid, index, next_iid) where prev_iid is the
+    IID of the previous issue in the total ordering (or None),
+    index is the index that the current issue has in the sorted
+    list of issues in the hotlist,
+    next_iid is the next issue (or None).
+  """
+
+  prev_iid, next_iid = None, None
+  total_issues = len(issue_ids)
+  for i, issue_id in enumerate(issue_ids):
+    if issue_id == issue.issue_id:
+      index = i
+      if i < total_issues - 1:
+        next_iid = issue_ids[i + 1]
+      if i > 0:
+        prev_iid = issue_ids[i - 1]
+      return prev_iid, index, next_iid
+  return None, None, None
diff --git a/features/features_constants.py b/features/features_constants.py
new file mode 100644
index 0000000..b21a7f5
--- /dev/null
+++ b/features/features_constants.py
@@ -0,0 +1,44 @@
+# 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 hotlist pages."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+from tracker import tracker_constants
+from project import project_constants
+
+DEFAULT_COL_SPEC = 'Rank Project Status Type ID Stars Owner Summary Modified'
+DEFAULT_RESULTS_PER_PAGE = 100
+OTHER_BUILT_IN_COLS = (
+    tracker_constants.OTHER_BUILT_IN_COLS + ['Adder', 'Added', 'Note'])
+# pylint: disable=line-too-long
+ISSUE_INPUT_REGEX = '%s:\d+(([,]|\s)+%s:\d+)*' % (
+    project_constants.PROJECT_NAME_PATTERN,
+    project_constants.PROJECT_NAME_PATTERN)
+FIELD_DEF_NAME_PATTERN = '[a-zA-Z]([_-]?[a-zA-Z0-9])*'
+
+QUEUE_NOTIFICATIONS = 'notifications'
+QUEUE_OUTBOUND_EMAIL = 'outboundemail'
+QUEUE_PUBSUB = 'pubsub-issueupdates'
+QUEUE_RECOMPUTE_DERIVED_FIELDS = 'recomputederivedfields'
+
+KNOWN_CUES = (
+    'privacy_click_through',
+    'code_of_conduct',
+    'how_to_join_project',
+    'search_for_numbers',
+    'dit_keystrokes',
+    'italics_mean_derived',
+    'availability_msgs',
+    'stale_fulltext',
+    'document_team_duties',
+    'showing_ids_instead_of_tiles',
+    'issue_timestamps',
+    'you_are_on_vacation',
+    'your_email_bounced',
+    'explain_hotlist_starring',
+)
diff --git a/features/federated.py b/features/federated.py
new file mode 100644
index 0000000..2e4486a
--- /dev/null
+++ b/features/federated.py
@@ -0,0 +1,78 @@
+# Copyright 2019 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
+
+"""Logic for storing and representing issues from external trackers."""
+
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import re
+
+from framework.exceptions import InvalidExternalIssueReference
+
+
+class FederatedIssue(object):
+  """Abstract base class for holding one federated issue.
+
+  Each distinct external tracker should subclass this.
+  """
+  shortlink_re = None
+
+  def __init__(self, shortlink):
+    if not self.IsShortlinkValid(shortlink):
+      raise InvalidExternalIssueReference(
+          'Shortlink does not match any valid tracker: %s' % shortlink)
+
+    self.shortlink = shortlink
+
+  @classmethod
+  def IsShortlinkValid(cls, shortlink):
+    """Returns whether given shortlink is correctly formatted."""
+    if not cls.shortlink_re:
+      raise NotImplementedError()
+    return re.match(cls.shortlink_re, shortlink)
+
+
+class GoogleIssueTrackerIssue(FederatedIssue):
+  """Holds one Google Issue Tracker issue.
+
+  URL: https://issuetracker.google.com/
+  """
+  shortlink_re = r'^b\/\d+$'
+  url_format = 'https://issuetracker.google.com/issues/{issue_id}'
+
+  def __init__(self, shortlink):
+    super(GoogleIssueTrackerIssue, self).__init__(shortlink)
+    self.issue_id = int(self.shortlink[2:])
+
+  def ToURL(self):
+    return self.url_format.format(issue_id=self.issue_id)
+
+  def Summary(self):
+    """Returns a short string description for UI."""
+    return 'Google Issue Tracker issue %s.' % self.issue_id
+
+
+# All supported tracker classes.
+_federated_issue_classes = [GoogleIssueTrackerIssue]
+
+
+def IsShortlinkValid(shortlink):
+  """Returns whether the given string is valid for any issue tracker."""
+  return any(tracker_class.IsShortlinkValid(shortlink)
+      for tracker_class in _federated_issue_classes)
+
+
+def FromShortlink(shortlink):
+  """Returns a FederatedIssue for the first matching tracker.
+
+  If no matching tracker is found, returns None.
+  """
+  for tracker_class in _federated_issue_classes:
+    if tracker_class.IsShortlinkValid(shortlink):
+      return tracker_class(shortlink)
+
+  return None
diff --git a/features/filterrules.py b/features/filterrules.py
new file mode 100644
index 0000000..3b1277e
--- /dev/null
+++ b/features/filterrules.py
@@ -0,0 +1,50 @@
+# 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
+
+"""Implementation of the filter rules feature."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+
+from features import filterrules_helpers
+from framework import jsonfeed
+from tracker import tracker_constants
+
+
+class RecomputeDerivedFieldsTask(jsonfeed.InternalTask):
+  """JSON servlet that recomputes derived fields on a batch of issues."""
+
+  def HandleRequest(self, mr):
+    """Recompute derived field values on one range of issues in a shard."""
+    logging.info(
+        'params are %r %r %r %r', mr.specified_project_id, mr.lower_bound,
+        mr.upper_bound, mr.shard_id)
+    project = self.services.project.GetProject(
+        mr.cnxn, mr.specified_project_id)
+    config = self.services.config.GetProjectConfig(
+        mr.cnxn, mr.specified_project_id)
+    filterrules_helpers.RecomputeAllDerivedFieldsNow(
+        mr.cnxn, self.services, project, config, lower_bound=mr.lower_bound,
+        upper_bound=mr.upper_bound)
+
+    return {
+        'success': True,
+        }
+
+
+class ReindexQueueCron(jsonfeed.InternalTask):
+  """JSON servlet that reindexes some issues each minute, as needed."""
+
+  def HandleRequest(self, mr):
+    """Reindex issues that are listed in the reindex table."""
+    num_reindexed = self.services.issue.ReindexIssues(
+        mr.cnxn, tracker_constants.MAX_ISSUES_TO_REINDEX_PER_MINUTE,
+        self.services.user)
+
+    return {
+        'num_reindexed': num_reindexed,
+        }
diff --git a/features/filterrules_helpers.py b/features/filterrules_helpers.py
new file mode 100644
index 0000000..22acc7d
--- /dev/null
+++ b/features/filterrules_helpers.py
@@ -0,0 +1,848 @@
+# 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
+
+"""Implementation of the filter rules helper functions."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import re
+import six
+
+from six import string_types
+
+import settings
+from features import features_constants
+from framework import cloud_tasks_helpers
+from framework import exceptions
+from framework import framework_bizobj
+from framework import framework_constants
+from framework import urls
+from framework import validate
+from proto import ast_pb2
+from proto import tracker_pb2
+from search import query2ast
+from search import searchpipeline
+from tracker import component_helpers
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+from tracker import tracker_helpers
+
+
+# Maximum number of filer rules that can be specified in a given
+# project.  This helps us bound the amount of time needed to
+# (re)compute derived fields.
+MAX_RULES = 200
+
+BLOCK = tracker_constants.RECOMPUTE_DERIVED_FIELDS_BLOCK_SIZE
+
+
+# TODO(jrobbins): implement a more efficient way to update just those
+# issues affected by a specific component change.
+def RecomputeAllDerivedFields(cnxn, services, project, config):
+  """Create work items to update all issues after filter rule changes.
+
+  Args:
+    cnxn: connection to SQL database.
+    services: connections to backend services.
+    project: Project PB for the project that was edited.
+    config: ProjectIssueConfig PB for the project that was edited,
+        including the edits made.
+  """
+  if not settings.recompute_derived_fields_in_worker:
+    # Background tasks are not enabled, just do everything in the servlet.
+    RecomputeAllDerivedFieldsNow(cnxn, services, project, config)
+    return
+
+  highest_id = services.issue.GetHighestLocalID(cnxn, project.project_id)
+  if highest_id == 0:
+    return  # No work to do.
+
+  # Enqueue work items for blocks of issues to recompute.
+  steps = list(range(1, highest_id + 1, BLOCK))
+  steps.reverse()  # Update higher numbered issues sooner, old issues last.
+  # Cycle through shard_ids just to load-balance among the replicas.  Each
+  # block includes all issues in that local_id range, not just 1/10 of them.
+  shard_id = 0
+  for step in steps:
+    params = {
+        'project_id': project.project_id,
+        'lower_bound': step,
+        'upper_bound': min(step + BLOCK, highest_id + 1),
+        'shard_id': shard_id,
+    }
+    task = cloud_tasks_helpers.generate_simple_task(
+        urls.RECOMPUTE_DERIVED_FIELDS_TASK + '.do', params)
+    cloud_tasks_helpers.create_task(
+        task, queue=features_constants.QUEUE_RECOMPUTE_DERIVED_FIELDS)
+
+    shard_id = (shard_id + 1) % settings.num_logical_shards
+
+
+def RecomputeAllDerivedFieldsNow(
+    cnxn, services, project, config, lower_bound=None, upper_bound=None):
+  """Re-apply all filter rules to all issues in a project.
+
+  Args:
+    cnxn: connection to SQL database.
+    services: connections to persistence layer.
+    project: Project PB for the project that was changed.
+    config: ProjectIssueConfig for that project.
+    lower_bound: optional int lowest issue ID to consider, inclusive.
+    upper_bound: optional int highest issue ID to consider, exclusive.
+
+  SIDE-EFFECT: updates all issues in the project. Stores and re-indexes
+  all those that were changed.
+  """
+  if lower_bound is not None and upper_bound is not None:
+    issues = services.issue.GetIssuesByLocalIDs(
+        cnxn, project.project_id, list(range(lower_bound, upper_bound)),
+        use_cache=False)
+  else:
+    issues = services.issue.GetAllIssuesInProject(
+        cnxn, project.project_id, use_cache=False)
+
+  rules = services.features.GetFilterRules(cnxn, project.project_id)
+  predicate_asts = ParsePredicateASTs(rules, config, [])
+  modified_issues = []
+  for issue in issues:
+    any_change, _traces = ApplyGivenRules(
+        cnxn, services, issue, config, rules, predicate_asts)
+    if any_change:
+      modified_issues.append(issue)
+
+  services.issue.UpdateIssues(cnxn, modified_issues, just_derived=True)
+
+  # Doing the FTS indexing can be too slow, so queue up the issues
+  # that need to be re-indexed by a cron-job later.
+  services.issue.EnqueueIssuesForIndexing(
+      cnxn, [issue.issue_id for issue in modified_issues])
+
+
+def ParsePredicateASTs(rules, config, me_user_ids):
+  """Parse the given rules in QueryAST PBs."""
+  predicates = [rule.predicate for rule in rules]
+  if me_user_ids:
+    predicates = [
+      searchpipeline.ReplaceKeywordsWithUserIDs(me_user_ids, pred)[0]
+      for pred in predicates]
+  predicate_asts = [
+      query2ast.ParseUserQuery(pred, '', query2ast.BUILTIN_ISSUE_FIELDS, config)
+      for pred in predicates]
+  return predicate_asts
+
+
+def ApplyFilterRules(cnxn, services, issue, config):
+  """Apply the filter rules for this project to the given issue.
+
+  Args:
+    cnxn: database connection, used to look up user IDs.
+    services: persistence layer for users, issues, and projects.
+    issue: An Issue PB that has just been updated with new explicit values.
+    config: The project's issue tracker config PB.
+
+  Returns:
+    A pair (any_changes, traces) where any_changes is true if any changes
+    were made to the issue derived fields, and traces is a dictionary
+    {(field_id, new_value): explanation_str} of traces that
+    explain which rule generated each derived value.
+
+  SIDE-EFFECT: update the derived_* fields of the Issue PB.
+  """
+  rules = services.features.GetFilterRules(cnxn, issue.project_id)
+  predicate_asts = ParsePredicateASTs(rules, config, [])
+  return ApplyGivenRules(cnxn, services, issue, config, rules, predicate_asts)
+
+
+def ApplyGivenRules(cnxn, services, issue, config, rules, predicate_asts):
+  """Apply the filter rules for this project to the given issue.
+
+  Args:
+    cnxn: database connection, used to look up user IDs.
+    services: persistence layer for users, issues, and projects.
+    issue: An Issue PB that has just been updated with new explicit values.
+    config: The project's issue tracker config PB.
+    rules: list of FilterRule PBs.
+
+  Returns:
+    A pair (any_changes, traces) where any_changes is true if any changes
+    were made to the issue derived fields, and traces is a dictionary
+    {(field_id, new_value): explanation_str} of traces that
+    explain which rule generated each derived value.
+
+  SIDE-EFFECT: update the derived_* fields of the Issue PB.
+  """
+  (derived_owner_id, derived_status, derived_cc_ids,
+   derived_labels, derived_notify_addrs, traces,
+   new_warnings, new_errors) = _ComputeDerivedFields(
+       cnxn, services, issue, config, rules, predicate_asts)
+
+  any_change = (derived_owner_id != issue.derived_owner_id or
+                derived_status != issue.derived_status or
+                derived_cc_ids != issue.derived_cc_ids or
+                derived_labels != issue.derived_labels or
+                derived_notify_addrs != issue.derived_notify_addrs)
+
+  # Remember any derived values.
+  issue.derived_owner_id = derived_owner_id
+  issue.derived_status = derived_status
+  issue.derived_cc_ids = derived_cc_ids
+  issue.derived_labels = derived_labels
+  issue.derived_notify_addrs = derived_notify_addrs
+  issue.derived_warnings = new_warnings
+  issue.derived_errors = new_errors
+
+  return any_change, traces
+
+
+def _ComputeDerivedFields(cnxn, services, issue, config, rules, predicate_asts):
+  """Compute derived field values for an issue based on filter rules.
+
+  Args:
+    cnxn: database connection, used to look up user IDs.
+    services: persistence layer for users, issues, and projects.
+    issue: the issue to examine.
+    config: ProjectIssueConfig for the project containing the issue.
+    rules: list of FilterRule PBs.
+    predicate_asts: QueryAST PB for each rule.
+
+  Returns:
+    A 8-tuple of derived values for owner_id, status, cc_ids, labels,
+    notify_addrs, traces, warnings, and errors.  These values are the result
+    of applying all rules in order.  Filter rules only produce derived values
+    that do not conflict with the explicit field values of the issue.
+  """
+  excl_prefixes = [
+      prefix.lower() for prefix in config.exclusive_label_prefixes]
+  # Examine the explicit labels and Cc's on the issue.
+  lower_labels = [lab.lower() for lab in issue.labels]
+  label_set = set(lower_labels)
+  cc_set = set(issue.cc_ids)
+  excl_prefixes_used = set()
+  for lab in lower_labels:
+    prefix = lab.split('-')[0]
+    if prefix in excl_prefixes:
+      excl_prefixes_used.add(prefix)
+  prefix_values_added = {}
+
+  # Start with the assumption that rules don't change anything, then
+  # accumulate changes.
+  derived_owner_id = framework_constants.NO_USER_SPECIFIED
+  derived_status = ''
+  derived_cc_ids = []
+  derived_labels = []
+  derived_notify_addrs = []
+  traces = {}  # {(field_id, new_value): explanation_str}
+  new_warnings = []
+  new_errors = []
+
+  def AddLabelConsideringExclusivePrefixes(label):
+    lab_lower = label.lower()
+    if lab_lower in label_set:
+      return False  # We already have that label.
+    prefix = lab_lower.split('-')[0]
+    if '-' in lab_lower and prefix in excl_prefixes:
+      if prefix in excl_prefixes_used:
+        return False  # Issue already has that prefix.
+      # Replace any earlied-added label that had the same exclusive prefix.
+      if prefix in prefix_values_added:
+        label_set.remove(prefix_values_added[prefix].lower())
+        derived_labels.remove(prefix_values_added[prefix])
+      prefix_values_added[prefix] = label
+
+    derived_labels.append(label)
+    label_set.add(lab_lower)
+    return True
+
+  # Apply component labels and auto-cc's before doing the rules.
+  components = tracker_bizobj.GetIssueComponentsAndAncestors(issue, config)
+  for cd in components:
+    for cc_id in cd.cc_ids:
+      if cc_id not in cc_set:
+        derived_cc_ids.append(cc_id)
+        cc_set.add(cc_id)
+        traces[(tracker_pb2.FieldID.CC, cc_id)] = (
+            'Added by component %s' % cd.path)
+
+    for label_id in cd.label_ids:
+      lab = services.config.LookupLabel(cnxn, config.project_id, label_id)
+      if AddLabelConsideringExclusivePrefixes(lab):
+        traces[(tracker_pb2.FieldID.LABELS, lab)] = (
+            'Added by component %s' % cd.path)
+
+  # Apply each rule in order. Later rules see the results of earlier rules.
+  # Later rules can overwrite or add to results of earlier rules.
+  # TODO(jrobbins): also pass in in-progress values for owner and CCs so
+  # that early rules that set those can affect later rules that check them.
+  for rule, predicate_ast in zip(rules, predicate_asts):
+    (rule_owner_id, rule_status, rule_add_cc_ids,
+     rule_add_labels, rule_add_notify, rule_add_warning,
+     rule_add_error) = _ApplyRule(
+         cnxn, services, rule, predicate_ast, issue, label_set, config)
+
+    # logging.info(
+    #    'rule "%s" gave %r, %r, %r, %r, %r',
+    #     rule.predicate, rule_owner_id, rule_status, rule_add_cc_ids,
+    #     rule_add_labels, rule_add_notify)
+
+    if rule_owner_id and not issue.owner_id:
+      derived_owner_id = rule_owner_id
+      traces[(tracker_pb2.FieldID.OWNER, rule_owner_id)] = (
+        'Added by rule: IF %s THEN SET DEFAULT OWNER' % rule.predicate)
+
+    if rule_status and not issue.status:
+      derived_status = rule_status
+      traces[(tracker_pb2.FieldID.STATUS, rule_status)] = (
+        'Added by rule: IF %s THEN SET DEFAULT STATUS' % rule.predicate)
+
+    for cc_id in rule_add_cc_ids:
+      if cc_id not in cc_set:
+        derived_cc_ids.append(cc_id)
+        cc_set.add(cc_id)
+        traces[(tracker_pb2.FieldID.CC, cc_id)] = (
+          'Added by rule: IF %s THEN ADD CC' % rule.predicate)
+
+    for lab in rule_add_labels:
+      if AddLabelConsideringExclusivePrefixes(lab):
+        traces[(tracker_pb2.FieldID.LABELS, lab)] = (
+            'Added by rule: IF %s THEN ADD LABEL' % rule.predicate)
+
+    for addr in rule_add_notify:
+      if addr not in derived_notify_addrs:
+        derived_notify_addrs.append(addr)
+        # Note: No trace because also-notify addresses are not shown in the UI.
+
+    if rule_add_warning:
+      new_warnings.append(rule_add_warning)
+      traces[(tracker_pb2.FieldID.WARNING, rule_add_warning)] = (
+        'Added by rule: IF %s THEN ADD WARNING' % rule.predicate)
+
+    if rule_add_error:
+      new_errors.append(rule_add_error)
+      traces[(tracker_pb2.FieldID.ERROR, rule_add_error)] = (
+        'Added by rule: IF %s THEN ADD ERROR' % rule.predicate)
+
+  return (derived_owner_id, derived_status, derived_cc_ids, derived_labels,
+          derived_notify_addrs, traces, new_warnings, new_errors)
+
+
+def EvalPredicate(
+    cnxn, services, predicate_ast, issue, label_set, config, owner_id, cc_ids,
+    status):
+  """Return True if the given issue satisfies the given predicate.
+
+  Args:
+    cnxn: Connection to SQL database.
+    services: persistence layer for users and issues.
+    predicate_ast: QueryAST for rule or saved query string.
+    issue: Issue PB of the issue to evaluate.
+    label_set: set of lower-cased labels on the issue.
+    config: ProjectIssueConfig for the project that contains the issue.
+    owner_id: int user ID of the issue owner.
+    cc_ids: list of int user IDs of the users Cc'd on the issue.
+    status: string status value of the issue.
+
+  Returns:
+    True if the issue satisfies the predicate.
+
+  Note: filter rule evaluation passes in only the explicit owner_id,
+  cc_ids, and status whereas subscription evaluation passes in the
+  combination of explicit values and derived values.
+  """
+  # TODO(jrobbins): Call ast2ast to simplify the predicate and do
+  # most lookups.  Refactor to allow that to be done once.
+  project = services.project.GetProject(cnxn, config.project_id)
+  for conj in predicate_ast.conjunctions:
+    if all(_ApplyCond(cnxn, services, project, cond, issue, label_set, config,
+                      owner_id, cc_ids, status)
+            for cond in conj.conds):
+      return True
+
+  # All OR-clauses were evaluated, but none of them was matched.
+  return False
+
+
+def _ApplyRule(
+    cnxn, services, rule_pb, predicate_ast, issue, label_set, config):
+  """Test if the given rule should fire and return its result.
+
+  Args:
+    cnxn: database connection, used to look up user IDs.
+    services: persistence layer for users and issues.
+    rule_pb: FilterRule PB instance with a predicate and various actions.
+    predicate_ast: QueryAST for the rule predicate.
+    issue: The Issue PB to be considered.
+    label_set: set of lowercased labels from an issue's explicit
+      label_list plus and labels that have accumlated from previous rules.
+    config: ProjectIssueConfig for the project containing the issue.
+
+  Returns:
+    A 6-tuple of the results from this rule: derived owner id, status,
+    cc_ids to add, labels to add, notify addresses to add, and a warning
+    string.  Currently only one will be set and the others will all be
+    None or an empty list.
+  """
+  if EvalPredicate(
+      cnxn, services, predicate_ast, issue, label_set, config,
+      issue.owner_id, issue.cc_ids, issue.status):
+    logging.info('rule adds: %r', rule_pb.add_labels)
+    return (rule_pb.default_owner_id, rule_pb.default_status,
+            rule_pb.add_cc_ids, rule_pb.add_labels,
+            rule_pb.add_notify_addrs, rule_pb.warning, rule_pb.error)
+  else:
+    return None, None, [], [], [], None, None
+
+
+def _ApplyCond(
+    cnxn, services, project, term, issue, label_set, config, owner_id, cc_ids,
+    status):
+  """Return True if the given issue satisfied the given predicate term."""
+  op = term.op
+  vals = term.str_values or term.int_values
+  # Since rules are per-project, there'll be exactly 1 field
+  fd = term.field_defs[0]
+  field = fd.field_name
+
+  if field == 'label':
+    return _Compare(op, vals, label_set)
+  if field == 'component':
+    return _CompareComponents(config, op, vals, issue.component_ids)
+  if field == 'any_field':
+    return _Compare(op, vals, label_set) or _Compare(op, vals, [issue.summary])
+  if field == 'attachments':
+    return _Compare(op, term.int_values, [issue.attachment_count])
+  if field == 'blocked':
+    return _Compare(op, vals, issue.blocked_on_iids)
+  if field == 'blockedon':
+    return _CompareIssueRefs(
+        cnxn, services, project, op, term.str_values, issue.blocked_on_iids)
+  if field == 'blocking':
+    return _CompareIssueRefs(
+        cnxn, services, project, op, term.str_values, issue.blocking_iids)
+  if field == 'cc':
+    return _CompareUsers(cnxn, services.user, op, vals, cc_ids)
+  if field == 'closed':
+    return (issue.closed_timestamp and
+            _Compare(op, vals, [issue.closed_timestamp]))
+  if field == 'id':
+    return _Compare(op, vals, [issue.local_id])
+  if field == 'mergedinto':
+    return _CompareIssueRefs(
+        cnxn, services, project, op, term.str_values, [issue.merged_into or 0])
+  if field == 'modified':
+    return (issue.modified_timestamp and
+            _Compare(op, vals, [issue.modified_timestamp]))
+  if field == 'open':
+    # TODO(jrobbins): this just checks the explicit status, not the result
+    # of any previous rules.
+    return tracker_helpers.MeansOpenInProject(status, config)
+  if field == 'opened':
+    return (issue.opened_timestamp and
+            _Compare(op, vals, [issue.opened_timestamp]))
+  if field == 'owner':
+    return _CompareUsers(cnxn, services.user, op, vals, [owner_id])
+  if field == 'reporter':
+    return _CompareUsers(cnxn, services.user, op, vals, [issue.reporter_id])
+  if field == 'stars':
+    return _Compare(op, term.int_values, [issue.star_count])
+  if field == 'status':
+    return _Compare(op, vals, [status.lower()])
+  if field == 'summary':
+    return _Compare(op, vals, [issue.summary])
+
+  # Since rules are per-project, it makes no sense to support field project.
+  # We would need to load comments to support fields comment, commentby,
+  # description, attachment.
+  # Supporting starredby is probably not worth the complexity.
+
+  logging.info('Rule with unsupported field %r was False', field)
+  return False
+
+
+def _CheckTrivialCases(op, issue_values):
+  """Check has:x and -has:x terms and no values.  Otherwise, return None."""
+  # We can do these operators without looking up anything or even knowing
+  # which field is being checked.
+  issue_values_exist = bool(
+      issue_values and issue_values != [''] and issue_values != [0])
+  if op == ast_pb2.QueryOp.IS_DEFINED:
+    return issue_values_exist
+  elif op == ast_pb2.QueryOp.IS_NOT_DEFINED:
+    return not issue_values_exist
+  elif not issue_values_exist:
+    # No other operator can match empty values.
+    return op in (ast_pb2.QueryOp.NE, ast_pb2.QueryOp.NOT_TEXT_HAS)
+
+  return None  # Caller should continue processing the term.
+
+def _CompareComponents(config, op, rule_values, issue_values):
+  """Compare the components specified in the rule vs those in the issue."""
+  trivial_result = _CheckTrivialCases(op, issue_values)
+  if trivial_result is not None:
+    return trivial_result
+
+  exact = op in (ast_pb2.QueryOp.EQ, ast_pb2.QueryOp.NE)
+  rule_component_ids = set()
+  for path in rule_values:
+    rule_component_ids.update(tracker_bizobj.FindMatchingComponentIDs(
+        path, config, exact=exact))
+
+  if op == ast_pb2.QueryOp.TEXT_HAS or op == ast_pb2.QueryOp.EQ:
+    return any(rv in issue_values for rv in rule_component_ids)
+  elif op == ast_pb2.QueryOp.NOT_TEXT_HAS or op == ast_pb2.QueryOp.NE:
+    return all(rv not in issue_values for rv in rule_component_ids)
+
+  return False
+
+
+def _CompareIssueRefs(
+  cnxn, services, project, op, rule_str_values, issue_values):
+  """Compare the issues specified in the rule vs referenced in the issue."""
+  trivial_result = _CheckTrivialCases(op, issue_values)
+  if trivial_result is not None:
+    return trivial_result
+
+  rule_refs = []
+  for str_val in rule_str_values:
+    ref = tracker_bizobj.ParseIssueRef(str_val)
+    if ref:
+      rule_refs.append(ref)
+  rule_ref_project_names = set(
+      pn for pn, local_id in rule_refs if pn)
+  rule_ref_projects_dict = services.project.GetProjectsByName(
+      cnxn, rule_ref_project_names)
+  rule_ref_projects_dict[project.project_name] = project
+  rule_iids, _misses = services.issue.ResolveIssueRefs(
+      cnxn, rule_ref_projects_dict, project.project_name, rule_refs)
+
+  if op == ast_pb2.QueryOp.TEXT_HAS:
+    op = ast_pb2.QueryOp.EQ
+  if op == ast_pb2.QueryOp.NOT_TEXT_HAS:
+    op = ast_pb2.QueryOp.NE
+
+  return _Compare(op, rule_iids, issue_values)
+
+
+def _CompareUsers(cnxn, user_service, op, rule_values, issue_values):
+  """Compare the user(s) specified in the rule and the issue."""
+  # Note that all occurances of "me" in rule_values should have already
+  # been resolved to str(user_id) of the subscribing user.
+  # TODO(jrobbins): Project filter rules should not be allowed to have "me".
+
+  trivial_result = _CheckTrivialCases(op, issue_values)
+  if trivial_result is not None:
+    return trivial_result
+
+  try:
+    return _CompareUserIDs(op, rule_values, issue_values)
+  except ValueError:
+    return _CompareEmails(cnxn, user_service, op, rule_values, issue_values)
+
+
+def _CompareUserIDs(op, rule_values, issue_values):
+  """Compare users according to specified user ID integer strings."""
+  rule_user_ids = [int(uid_str) for uid_str in rule_values]
+
+  if op == ast_pb2.QueryOp.TEXT_HAS or op == ast_pb2.QueryOp.EQ:
+    return any(rv in issue_values for rv in rule_user_ids)
+  elif op == ast_pb2.QueryOp.NOT_TEXT_HAS or op == ast_pb2.QueryOp.NE:
+    return all(rv not in issue_values for rv in rule_user_ids)
+
+  logging.info('unexpected numeric user operator %r %r %r',
+               op, rule_values, issue_values)
+  return False
+
+
+def _CompareEmails(cnxn, user_service, op, rule_values, issue_values):
+  """Compare users based on email addresses."""
+  issue_emails = list(
+      user_service.LookupUserEmails(cnxn, issue_values).values())
+
+  if op == ast_pb2.QueryOp.TEXT_HAS:
+    return any(_HasText(rv, issue_emails) for rv in rule_values)
+  elif op == ast_pb2.QueryOp.NOT_TEXT_HAS:
+    return all(not _HasText(rv, issue_emails) for rv in rule_values)
+  elif op == ast_pb2.QueryOp.EQ:
+    return any(rv in issue_emails for rv in rule_values)
+  elif op == ast_pb2.QueryOp.NE:
+    return all(rv not in issue_emails for rv in rule_values)
+
+  logging.info('unexpected user operator %r %r %r',
+               op, rule_values, issue_values)
+  return False
+
+
+def _Compare(op, rule_values, issue_values):
+  """Compare the values specified in the rule and the issue."""
+  trivial_result = _CheckTrivialCases(op, issue_values)
+  if trivial_result is not None:
+    return trivial_result
+
+  if (op in [ast_pb2.QueryOp.TEXT_HAS, ast_pb2.QueryOp.NOT_TEXT_HAS] and
+      issue_values and not isinstance(min(issue_values), string_types)):
+    return False  # Empty or numeric fields cannot match substrings
+  elif op == ast_pb2.QueryOp.TEXT_HAS:
+    return any(_HasText(rv, issue_values) for rv in rule_values)
+  elif op == ast_pb2.QueryOp.NOT_TEXT_HAS:
+    return all(not _HasText(rv, issue_values) for rv in rule_values)
+
+  val_type = type(min(issue_values))
+  if val_type in six.integer_types:
+    try:
+      rule_values = [int(rv) for rv in rule_values]
+    except ValueError:
+      logging.info('rule value conversion to int failed: %r', rule_values)
+      return False
+
+  if op == ast_pb2.QueryOp.EQ:
+    return any(rv in issue_values for rv in rule_values)
+  elif op == ast_pb2.QueryOp.NE:
+    return all(rv not in issue_values for rv in rule_values)
+
+  if val_type not in six.integer_types:
+    return False  # Inequalities only work on numeric fields
+
+  if op == ast_pb2.QueryOp.GT:
+    return min(issue_values) > min(rule_values)
+  elif op == ast_pb2.QueryOp.GE:
+    return min(issue_values) >= min(rule_values)
+  elif op == ast_pb2.QueryOp.LT:
+    return max(issue_values) < max(rule_values)
+  elif op == ast_pb2.QueryOp.LE:
+    return max(issue_values) <= max(rule_values)
+
+  logging.info('unexpected operator %r %r %r', op, rule_values, issue_values)
+  return False
+
+
+def _HasText(rule_text, issue_values):
+  """Return True if the issue contains the rule text, case insensitive."""
+  rule_lower = rule_text.lower()
+  for iv in issue_values:
+    if iv is not None and rule_lower in iv.lower():
+      return True
+
+  return False
+
+
+def MakeRule(
+    predicate, default_status=None, default_owner_id=None, add_cc_ids=None,
+    add_labels=None, add_notify=None, warning=None, error=None):
+  """Make a FilterRule PB with the supplied information.
+
+  Args:
+    predicate: string query that will trigger the rule if satisfied.
+    default_status: optional default status to set if rule fires.
+    default_owner_id: optional default owner_id to set if rule fires.
+    add_cc_ids: optional cc ids to set if rule fires.
+    add_labels: optional label strings to set if rule fires.
+    add_notify: optional notify email addresses to set if rule fires.
+    warning: optional string for a software development process warning.
+    error: optional string for a software development process error.
+
+  Returns:
+    A new FilterRule PB.
+  """
+  rule_pb = tracker_pb2.FilterRule()
+  rule_pb.predicate = predicate
+
+  if add_labels:
+    rule_pb.add_labels = add_labels
+  if default_status:
+    rule_pb.default_status = default_status
+  if default_owner_id:
+    rule_pb.default_owner_id = default_owner_id
+  if add_cc_ids:
+    rule_pb.add_cc_ids = add_cc_ids
+  if add_notify:
+    rule_pb.add_notify_addrs = add_notify
+  if warning:
+    rule_pb.warning = warning
+  if error:
+    rule_pb.error = error
+
+  return rule_pb
+
+
+def ParseRules(cnxn, post_data, user_service, errors, prefix=''):
+  """Parse rules from the user and return a list of FilterRule PBs.
+
+  Args:
+    cnxn: connection to database.
+    post_data: dictionary of html form data.
+    user_service: connection to user backend services.
+    errors: EZTErrors message used to display field validation errors.
+    prefix: optional string prefix used to differentiate the form fields
+      for existing rules from the form fields for new rules.
+
+  Returns:
+    A list of FilterRule PBs
+  """
+  rules = []
+
+  # The best we can do for now is show all validation errors at the bottom of
+  # the filter rules section, not directly on the rule that had the error :(.
+  error_list = []
+
+  for i in range(1, MAX_RULES + 1):
+    if ('%spredicate%s' % (prefix, i)) not in post_data:
+      continue  # skip any entries that are blank or have no predicate.
+    predicate = post_data['%spredicate%s' % (prefix, i)].strip()
+    action_type = post_data.get('%saction_type%s' % (prefix, i),
+                                'add_labels').strip()
+    action_value = post_data.get('%saction_value%s' % (prefix, i),
+                                 '').strip()
+    if predicate:
+      # Note: action_value may be '', meaning no-op.
+      rules.append(_ParseOneRule(
+          cnxn, predicate, action_type, action_value, user_service, i,
+          error_list))
+
+  if error_list:
+    errors.rules = error_list
+
+  return rules
+
+
+def _ParseOneRule(
+    cnxn, predicate, action_type, action_value, user_service,
+    rule_num, error_list):
+  """Parse one FilterRule based on the action type."""
+
+  if action_type == 'default_status':
+    status = framework_bizobj.CanonicalizeLabel(action_value)
+    rule = MakeRule(predicate, default_status=status)
+
+  elif action_type == 'default_owner':
+    if action_value:
+      try:
+        user_id = user_service.LookupUserID(cnxn, action_value)
+      except exceptions.NoSuchUserException:
+        user_id = framework_constants.NO_USER_SPECIFIED
+        error_list.append(
+            'Rule %d: No such user: %s' % (rule_num, action_value))
+    else:
+      user_id = framework_constants.NO_USER_SPECIFIED
+    rule = MakeRule(predicate, default_owner_id=user_id)
+
+  elif action_type == 'add_ccs':
+    cc_ids = []
+    for email in re.split('[,;\s]+', action_value):
+      if not email.strip():
+        continue
+      try:
+        user_id = user_service.LookupUserID(
+            cnxn, email.strip(), autocreate=True)
+        cc_ids.append(user_id)
+      except exceptions.NoSuchUserException:
+        error_list.append(
+            'Rule %d: No such user: %s' % (rule_num, email.strip()))
+
+    rule = MakeRule(predicate, add_cc_ids=cc_ids)
+
+  elif action_type == 'add_labels':
+    add_labels = framework_constants.IDENTIFIER_RE.findall(action_value)
+    rule = MakeRule(predicate, add_labels=add_labels)
+
+  elif action_type == 'also_notify':
+    add_notify = []
+    for addr in re.split('[,;\s]+', action_value):
+      if validate.IsValidEmail(addr.strip()):
+        add_notify.append(addr.strip())
+      else:
+        error_list.append(
+            'Rule %d: Invalid email address: %s' % (rule_num, addr.strip()))
+
+    rule = MakeRule(predicate, add_notify=add_notify)
+
+  elif action_type == 'warning':
+    rule = MakeRule(predicate, warning=action_value)
+
+  elif action_type == 'error':
+    rule = MakeRule(predicate, error=action_value)
+
+  else:
+    logging.info('unexpected action type, probably tampering:%r', action_type)
+    raise exceptions.InputException()
+
+  return rule
+
+
+def OwnerCcsInvolvedInFilterRules(rules):
+  """Finds all user_ids in the given rules and returns them.
+
+  Args:
+    rules: a list of FilterRule PBs.
+
+  Returns:
+    A set of user_ids.
+  """
+  user_ids = set()
+  for rule in rules:
+    if rule.default_owner_id:
+      user_ids.add(rule.default_owner_id)
+    user_ids.update(rule.add_cc_ids)
+  return user_ids
+
+
+def BuildFilterRuleStrings(filter_rules, emails_by_id):
+  """Builds strings that represent filter rules.
+
+  Args:
+    filter_rules: a list of FilterRule PBs.
+    emails_by_id: a dict of {user_id: email, ..} of user_ids in the FilterRules.
+
+  Returns:
+    A list of strings each representing a FilterRule.
+    eg. "if predicate then consequence"
+  """
+  rule_strs = []
+  for rule in filter_rules:
+    cons = ""
+    if rule.add_labels:
+      cons = 'add label(s): %s' % ', '.join(rule.add_labels)
+    elif rule.default_status:
+      cons = 'set default status: %s' % rule.default_status
+    elif rule.default_owner_id:
+      cons = 'set default owner: %s' % emails_by_id.get(
+          rule.default_owner_id, 'user not found')
+    elif rule.add_cc_ids:
+      cons = 'add cc(s): %s' % ', '.join(
+        [emails_by_id.get(user_id, 'user not found')
+         for user_id in rule.add_cc_ids])
+    elif rule.add_notify_addrs:
+      cons = 'notify: %s' % ', '.join(rule.add_notify_addrs)
+
+    rule_strs.append('if %s then %s' % (rule.predicate, cons))
+
+  return rule_strs
+
+
+def BuildRedactedFilterRuleStrings(
+    cnxn, rules_by_project, user_service, hide_emails):
+  """Converts FilterRule PBs in strings that hide references to hide_emails.
+
+  Args:
+    rules_by_project: a dict of {project_id, [filter_rule, ...], ...}
+      with FilterRule PBs.
+    user_service:
+    hide_emails: a list of emails that should not be shown in rule strings.
+  """
+  rule_strs_by_project = {}
+  prohibited_re = re.compile(
+      r'\b%s\b' % r'\b|\b'.join(map(re.escape, hide_emails)))
+  for project_id, rules in rules_by_project.items():
+    user_ids_in_rules = OwnerCcsInvolvedInFilterRules(rules)
+    emails_by_id = user_service.LookupUserEmails(
+        cnxn, user_ids_in_rules, ignore_missed=True)
+    rule_strs = BuildFilterRuleStrings(rules, emails_by_id)
+    censored_strs = [
+        prohibited_re.sub(framework_constants.DELETED_USER_NAME, rule_str)
+        for rule_str in rule_strs]
+
+    rule_strs_by_project[project_id] = censored_strs
+
+  return rule_strs_by_project
diff --git a/features/filterrules_views.py b/features/filterrules_views.py
new file mode 100644
index 0000000..75fb425
--- /dev/null
+++ b/features/filterrules_views.py
@@ -0,0 +1,53 @@
+# 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 to display filter rules in templates."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+
+from framework import template_helpers
+
+
+class RuleView(template_helpers.PBProxy):
+  """Wrapper class that makes it easier to display a Rule via EZT."""
+
+  def __init__(self, rule_pb, users_by_id):
+    super(RuleView, self).__init__(rule_pb)
+
+    self.action_type = ''
+    self.action_value = ''
+
+    if rule_pb is None:
+      return  # Just leave everything as ''
+
+    # self.predicate is automatically available.
+
+    # For the current UI, we assume that each rule has exactly
+    # one action, so we can determine the text value for it here.
+    if rule_pb.default_status:
+      self.action_type = 'default_status'
+      self.action_value = rule_pb.default_status
+    elif rule_pb.default_owner_id:
+      self.action_type = 'default_owner'
+      self.action_value = users_by_id[rule_pb.default_owner_id].email
+    elif rule_pb.add_cc_ids:
+      self.action_type = 'add_ccs'
+      usernames = [users_by_id[cc_id].email for cc_id in rule_pb.add_cc_ids]
+      self.action_value = ', '.join(usernames)
+    elif rule_pb.add_labels:
+      self.action_type = 'add_labels'
+      self.action_value = ', '.join(rule_pb.add_labels)
+    elif rule_pb.add_notify_addrs:
+      self.action_type = 'also_notify'
+      self.action_value = ', '.join(rule_pb.add_notify_addrs)
+    elif rule_pb.warning:
+      self.action_type = 'warning'
+      self.action_value = rule_pb.warning
+    elif rule_pb.error:
+      self.action_type = 'error'
+      self.action_value = rule_pb.error
diff --git a/features/generate_dataset.py b/features/generate_dataset.py
new file mode 100644
index 0000000..b13ae88
--- /dev/null
+++ b/features/generate_dataset.py
@@ -0,0 +1,123 @@
+"""This module is used to go from raw data to a csv dataset to build models for
+   component prediction.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import argparse
+import string
+import sys
+import csv
+import re
+import logging
+import random
+import time
+import os
+import settings
+from framework import sql
+from framework import servlet
+
+if not settings.unit_test_mode:
+  import MySQLdb as mdb
+ISSUE_LIMIT = 7000
+ISSUES_PER_RUN = 50
+COMPONENT_PREDICTOR_PROJECT = 16
+
+def build_component_dataset(issue, csv_file):
+  """Main function to build dataset for training models.
+
+  Args:
+    issue: The issue service with set up data.
+    csv_file: The csv file path to store the dataset.
+  """
+
+  logging.info('Building dataset')
+  con = sql.MonorailConnection()
+
+  csv_writer = csv.writer(csv_file)
+
+  logging.info('Downloading the dataset from database.')
+
+  issue_table = sql.SQLTableManager('Issue')
+  issue_component_table = sql.SQLTableManager('Issue2Component')
+  closed_index_table = sql.SQLTableManager('ComponentIssueClosedIndex')
+
+  close = closed_index_table.SelectValue(con, col='closed_index')
+
+  last_close = issue_table.Select(con,
+                                  cols=['closed'],
+                                  where=[('closed > %s', [str(close)]),
+                                         ('project_id = %s',
+                                          [str(COMPONENT_PREDICTOR_PROJECT)])],
+                                  order_by=[('closed', [])],
+                                  limit=ISSUE_LIMIT)[-1][0]
+
+  issue_ids = issue_table.Select(con,
+                              cols=['id'],
+                              where=[('closed > %s', [str(close)]),
+                                     ('closed <= %s', [str(last_close)]),
+                                     ('project_id = %s',
+                                      [str(COMPONENT_PREDICTOR_PROJECT)])])
+
+
+  logging.info('Close: ' + str(close))
+  logging.info('Last close: ' + str(last_close))
+
+  # Get the comments and components for 50 issues at a time so as to not
+  # overwhelm a single shard with all 7000 issues at once
+  for i in range(0, len(issue_ids), ISSUES_PER_RUN):
+    issue_list = [str(x[0]) for x in issue_ids[i:i+ISSUES_PER_RUN]]
+
+    comments = issue.GetCommentsForIssues(con, issue_list, content_only=True)
+
+    shard_id = random.randint(0, settings.num_logical_shards - 1)
+
+    components = issue_component_table.Select(con,
+                                        cols=['issue_id',
+                                              'GROUP_CONCAT(component_id '
+                                              + 'SEPARATOR \',\')'],
+                                        joins=[('ComponentDef ON '
+                                                'ComponentDef.id = '
+                                                'Issue2Component.component_id',
+                                                [])],
+                                        where=[('(deprecated = %s OR deprecated'
+                                                ' IS NULL)', [False]),
+                                                ('is_deleted = %s', [False])],
+                                        group_by=['issue_id'],
+                                        shard_id=shard_id,
+                                        issue_id=issue_list)
+
+    for issue_id, component_ids in components:
+      comment_string = ' '.join(
+          [comment.content for comment in comments[issue_id]])
+
+      final_text = CleanText(comment_string)
+
+      final_issue = component_ids, final_text
+      csv_writer.writerow(final_issue)
+
+  closed_index_table.Update(con, delta={'closed_index' : last_close})
+
+  return csv_file
+
+
+def CleanText(text):
+  """Cleans provided text by lower casing words, removing punctuation, and
+  normalizing spacing so that there is exactly one space between each word.
+
+  Args:
+    text: Raw text to be cleaned.
+
+  Returns:
+    Cleaned version of text.
+
+  """
+
+  pretty_issue = text.lower().strip()
+
+  quoteless_issue = re.sub('\'', '', pretty_issue)
+  no_punctuation_issue = re.sub('[^\w\s]|_+', ' ', quoteless_issue)
+  one_space_issue = ' '.join(no_punctuation_issue.split())
+
+  return one_space_issue
diff --git a/features/hotlist_helpers.py b/features/hotlist_helpers.py
new file mode 100644
index 0000000..f23f72e
--- /dev/null
+++ b/features/hotlist_helpers.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
+
+"""Helper functions and classes used by the hotlist pages."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import collections
+
+from features import features_constants
+from framework import framework_views
+from framework import framework_helpers
+from framework import sorting
+from framework import table_view_helpers
+from framework import timestr
+from framework import paginate
+from framework import permissions
+from framework import urls
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+from tracker import tracker_helpers
+from tracker import tablecell
+
+
+# Type to hold a HotlistRef
+HotlistRef = collections.namedtuple('HotlistRef', 'user_id, hotlist_name')
+
+
+def GetSortedHotlistIssues(
+    cnxn, hotlist_items, issues, auth, can, sort_spec, group_by_spec,
+    harmonized_config, services, profiler):
+  # type: (MonorailConnection, List[HotlistItem], List[Issue], AuthData,
+  #        ProjectIssueConfig, Services, Profiler) -> (List[Issue], Dict, Dict)
+  """Sorts the given HotlistItems and Issues and filters out Issues that
+     the user cannot view.
+
+  Args:
+    cnxn: MonorailConnection for connection to the SQL database.
+    hotlist_items: list of HotlistItems in the Hotlist we want to sort.
+    issues: list of Issues in the Hotlist we want to sort.
+    auth: AuthData object that identifies the logged in user.
+    can: int "canned query" number to scope the visible issues.
+    sort_spec: string that lists the sort order.
+    group_by_spec: string that lists the grouping order.
+    harmonized_config: ProjectIssueConfig created from all configs of projects
+      with issues in the issues list.
+    services: Services object for connections to backend services.
+    profiler: Profiler object to display and record processes.
+
+  Returns:
+    A tuple of (sorted_issues, hotlist_items_context, issues_users_by_id) where:
+
+    sorted_issues: list of Issues that are sorted and issues the user cannot
+      view are filtered out.
+    hotlist_items_context: a dict of dicts providing HotlistItem values that
+      are associated with each Hotlist Issue. E.g:
+      {issue.issue_id: {'issue_rank': hotlist item rank,
+                        'adder_id': hotlist item adder's user_id,
+                        'date_added': timestamp when this issue was added to the
+                          hotlist,
+                        'note': note for this issue in the hotlist,},
+       issue.issue_id: {...}}
+     issues_users_by_id: dict of {user_id: UserView, ...} for all users involved
+       in the hotlist items and issues.
+  """
+  with profiler.Phase('Checking issue permissions and getting ranks'):
+
+    allowed_issues = FilterIssues(cnxn, auth, can, issues, services)
+    allowed_iids = [issue.issue_id for issue in allowed_issues]
+    # The values for issues in a hotlist are specific to the hotlist
+    # (rank, adder, added) without invalidating the keys, an issue will retain
+    # the rank value it has in one hotlist when navigating to another hotlist.
+    sorting.InvalidateArtValuesKeys(
+        cnxn, [issue.issue_id for issue in allowed_issues])
+    sorted_ranks = sorted(
+        [hotlist_item.rank for hotlist_item in hotlist_items if
+         hotlist_item.issue_id in allowed_iids])
+    friendly_ranks = {
+        rank: friendly for friendly, rank in enumerate(sorted_ranks, 1)}
+    issue_adders = framework_views.MakeAllUserViews(
+        cnxn, services.user, [hotlist_item.adder_id for
+                                 hotlist_item in hotlist_items])
+    hotlist_items_context = {
+        hotlist_item.issue_id: {'issue_rank':
+                                 friendly_ranks[hotlist_item.rank],
+                                 'adder_id': hotlist_item.adder_id,
+                                 'date_added': timestr.FormatAbsoluteDate(
+                                     hotlist_item.date_added),
+                                 'note': hotlist_item.note}
+        for hotlist_item in hotlist_items if
+        hotlist_item.issue_id in allowed_iids}
+
+  with profiler.Phase('Making user views'):
+    issues_users_by_id = framework_views.MakeAllUserViews(
+        cnxn, services.user,
+        tracker_bizobj.UsersInvolvedInIssues(allowed_issues or []))
+    issues_users_by_id.update(issue_adders)
+
+  with profiler.Phase('Sorting issues'):
+    sortable_fields = tracker_helpers.SORTABLE_FIELDS.copy()
+    sortable_fields.update(
+        {'rank': lambda issue: hotlist_items_context[
+            issue.issue_id]['issue_rank'],
+         'adder': lambda issue: hotlist_items_context[
+             issue.issue_id]['adder_id'],
+         'added': lambda issue: hotlist_items_context[
+             issue.issue_id]['date_added'],
+         'note': lambda issue: hotlist_items_context[
+             issue.issue_id]['note']})
+    sortable_postproc = tracker_helpers.SORTABLE_FIELDS_POSTPROCESSORS.copy()
+    sortable_postproc.update(
+        {'adder': lambda user_view: user_view.email,
+        })
+
+    sorted_issues = sorting.SortArtifacts(
+        allowed_issues, harmonized_config, sortable_fields,
+        sortable_postproc, group_by_spec, sort_spec,
+        users_by_id=issues_users_by_id, tie_breakers=['rank', 'id'])
+    return sorted_issues, hotlist_items_context, issues_users_by_id
+
+
+def CreateHotlistTableData(mr, hotlist_issues, services):
+  """Creates the table data for the hotlistissues table."""
+  with mr.profiler.Phase('getting stars'):
+    starred_iid_set = set(services.issue_star.LookupStarredItemIDs(
+        mr.cnxn, mr.auth.user_id))
+
+  with mr.profiler.Phase('Computing col_spec'):
+    mr.ComputeColSpec(mr.hotlist)
+
+  issues_list = services.issue.GetIssues(
+        mr.cnxn,
+        [hotlist_issue.issue_id for hotlist_issue in hotlist_issues])
+  with mr.profiler.Phase('Getting config'):
+    hotlist_issues_project_ids = GetAllProjectsOfIssues(
+        [issue for issue in issues_list])
+    is_cross_project = len(hotlist_issues_project_ids) > 1
+    config_list = GetAllConfigsOfProjects(
+        mr.cnxn, hotlist_issues_project_ids, services)
+    harmonized_config = tracker_bizobj.HarmonizeConfigs(config_list)
+
+  # With no sort_spec specified, a hotlist should default to be sorted by
+  # 'rank'. sort_spec needs to be modified because hotlistissues.py
+  # checks for 'rank' in sort_spec to set 'allow_rerank' which determines if
+  # drag and drop reranking should be enabled.
+  if not mr.sort_spec:
+    mr.sort_spec = 'rank'
+  (sorted_issues, hotlist_issues_context,
+   issues_users_by_id) = GetSortedHotlistIssues(
+       mr.cnxn, hotlist_issues, issues_list, mr.auth, mr.can, mr.sort_spec,
+       mr.group_by_spec, harmonized_config, services, mr.profiler)
+
+  with mr.profiler.Phase("getting related issues"):
+    related_iids = set()
+    results_needing_related = sorted_issues
+    lower_cols = mr.col_spec.lower().split()
+    for issue in results_needing_related:
+      if 'blockedon' in lower_cols:
+        related_iids.update(issue.blocked_on_iids)
+      if 'blocking' in lower_cols:
+        related_iids.update(issue.blocking_iids)
+      if 'mergedinto' in lower_cols:
+        related_iids.add(issue.merged_into)
+    related_issues_list = services.issue.GetIssues(
+        mr.cnxn, list(related_iids))
+    related_issues = {issue.issue_id: issue for issue in related_issues_list}
+
+  with mr.profiler.Phase('filtering unviewable issues'):
+    viewable_iids_set = {issue.issue_id
+                         for issue in tracker_helpers.GetAllowedIssues(
+                             mr, [related_issues.values()], services)[0]}
+
+  with mr.profiler.Phase('building table'):
+    context_for_all_issues = {
+        issue.issue_id: hotlist_issues_context[issue.issue_id]
+                              for issue in sorted_issues}
+
+    column_values = table_view_helpers.ExtractUniqueValues(
+        mr.col_spec.lower().split(), sorted_issues, issues_users_by_id,
+        harmonized_config, related_issues,
+        hotlist_context_dict=context_for_all_issues)
+    unshown_columns = table_view_helpers.ComputeUnshownColumns(
+        sorted_issues, mr.col_spec.split(), harmonized_config,
+        features_constants.OTHER_BUILT_IN_COLS)
+    url_params = [(name, mr.GetParam(name)) for name in
+                  framework_helpers.RECOGNIZED_PARAMS]
+    # We are passing in None for the project_name because we are not operating
+    # under any project.
+    pagination = paginate.ArtifactPagination(
+        sorted_issues, mr.num, mr.GetPositiveIntParam('start'),
+        None, GetURLOfHotlist(mr.cnxn, mr.hotlist, services.user),
+        total_count=len(sorted_issues), url_params=url_params)
+
+    sort_spec = '%s %s %s' % (
+        mr.group_by_spec, mr.sort_spec, harmonized_config.default_sort_spec)
+
+    table_data = _MakeTableData(
+        pagination.visible_results, starred_iid_set,
+        mr.col_spec.lower().split(), mr.group_by_spec.lower().split(),
+        issues_users_by_id, tablecell.CELL_FACTORIES, related_issues,
+        viewable_iids_set, harmonized_config, context_for_all_issues,
+        mr.hotlist_id, sort_spec)
+
+  table_related_dict = {
+      'column_values': column_values, 'unshown_columns': unshown_columns,
+      'pagination': pagination, 'is_cross_project': is_cross_project }
+  return table_data, table_related_dict
+
+
+def _MakeTableData(issues, starred_iid_set, lower_columns,
+                   lower_group_by, users_by_id, cell_factories,
+                   related_issues, viewable_iids_set, config,
+                   context_for_all_issues,
+                   hotlist_id, sort_spec):
+  """Returns data from MakeTableData after adding additional information."""
+  table_data = table_view_helpers.MakeTableData(
+      issues, starred_iid_set, lower_columns, lower_group_by,
+      users_by_id, cell_factories, lambda issue: issue.issue_id,
+      related_issues, viewable_iids_set, config, context_for_all_issues)
+
+  for row, art in zip(table_data, issues):
+    row.issue_id = art.issue_id
+    row.local_id = art.local_id
+    row.project_name = art.project_name
+    row.project_url = framework_helpers.FormatURL(
+        None, '/p/%s' % row.project_name)
+    row.issue_ref = '%s:%d' % (art.project_name, art.local_id)
+    row.issue_clean_url = tracker_helpers.FormatRelativeIssueURL(
+        art.project_name, urls.ISSUE_DETAIL, id=art.local_id)
+    row.issue_ctx_url = tracker_helpers.FormatRelativeIssueURL(
+        art.project_name, urls.ISSUE_DETAIL,
+        id=art.local_id, sort=sort_spec, hotlist_id=hotlist_id)
+
+  return table_data
+
+
+def FilterIssues(cnxn, auth, can, issues, services):
+  # (MonorailConnection, AuthData, int, List[Issue], Services) -> List[Issue]
+  """Return a list of issues that the user is allowed to view.
+
+  Args:
+    cnxn: MonorailConnection for connection to the SQL database.
+    auth: AuthData object that identifies the logged in user.
+    can: in "canned_query" number to scope the visible issues.
+    issues: list of Issues to be filtered.
+    services: Services object for connections to backend services.
+
+  Returns:
+    A list of Issues that the user has permissions to view.
+  """
+  allowed_issues = []
+  project_ids = GetAllProjectsOfIssues(issues)
+  issue_projects = services.project.GetProjects(cnxn, project_ids)
+  configs_by_project_id = services.config.GetProjectConfigs(cnxn, project_ids)
+  perms_by_project_id = {
+      pid: permissions.GetPermissions(auth.user_pb, auth.effective_ids, p)
+      for pid, p in issue_projects.items()}
+  for issue in issues:
+    if (can == 1) or not issue.closed_timestamp:
+      issue_project = issue_projects[issue.project_id]
+      config = configs_by_project_id[issue.project_id]
+      perms = perms_by_project_id[issue.project_id]
+      granted_perms = tracker_bizobj.GetGrantedPerms(
+          issue, auth.effective_ids, config)
+      permit_view = permissions.CanViewIssue(
+          auth.effective_ids, perms,
+          issue_project, issue, granted_perms=granted_perms)
+      if permit_view:
+        allowed_issues.append(issue)
+
+  return allowed_issues
+
+
+def GetAllConfigsOfProjects(cnxn, project_ids, services):
+  """Returns a list of configs for the given list of projects."""
+  config_dict = services.config.GetProjectConfigs(cnxn, project_ids)
+  config_list = [config_dict[project_id] for project_id in project_ids]
+  return config_list
+
+
+def GetAllProjectsOfIssues(issues):
+  """Returns a list of all projects that the given issues are in."""
+  project_ids = set()
+  for issue in issues:
+    project_ids.add(issue.project_id)
+  return project_ids
+
+
+def MembersWithoutGivenIDs(hotlist, exclude_ids):
+  """Return three lists of member user IDs, with exclude_ids not in them."""
+  owner_ids = [user_id for user_id in hotlist.owner_ids
+               if user_id not in exclude_ids]
+  editor_ids = [user_id for user_id in hotlist.editor_ids
+                   if user_id not in exclude_ids]
+  follower_ids = [user_id for user_id in hotlist.follower_ids
+                     if user_id not in exclude_ids]
+
+  return owner_ids, editor_ids, follower_ids
+
+
+def MembersWithGivenIDs(hotlist, new_member_ids, role):
+  """Return three lists of member IDs with the new IDs in the right one.
+
+  Args:
+    hotlist: Hotlist PB for the project to get current members from.
+    new_member_ids: set of user IDs for members being added.
+    role: string name of the role that new_member_ids should be granted.
+
+  Returns:
+    Three lists of member IDs with new_member_ids added to the appropriate
+    list and removed from any other role.
+
+  Raises:
+    ValueError: if the role is not one of owner, committer, or contributor.
+  """
+  owner_ids, editor_ids, follower_ids = MembersWithoutGivenIDs(
+      hotlist, new_member_ids)
+
+  if role == 'owner':
+    owner_ids.extend(new_member_ids)
+  elif role == 'editor':
+    editor_ids.extend(new_member_ids)
+  elif role == 'follower':
+    follower_ids.extend(new_member_ids)
+  else:
+    raise ValueError()
+
+  return owner_ids, editor_ids, follower_ids
+
+
+def GetURLOfHotlist(cnxn, hotlist, user_service, url_for_token=False):
+    """Determines the url to be used to access the given hotlist.
+
+    Args:
+      cnxn: connection to SQL database
+      hotlist: the hotlist_pb
+      user_service: interface to user data storage
+      url_for_token: if true, url returned will use user's id
+        regardless of their user settings, for tokenization.
+
+    Returns:
+      The string url to be used when accessing this hotlist.
+    """
+    if not hotlist.owner_ids:  # Should never happen.
+      logging.error('Unowned Hotlist: id:%r, name:%r', hotlist.hotlist_id,
+                                                       hotlist.name)
+      return ''
+    owner_id = hotlist.owner_ids[0]  # only one owner allowed
+    owner = user_service.GetUser(cnxn, owner_id)
+    if owner.obscure_email or url_for_token:
+      return '/u/%d/hotlists/%s' % (owner_id, hotlist.name)
+    return (
+        '/u/%s/hotlists/%s' % (
+            owner.email, hotlist.name))
+
+
+def RemoveHotlist(cnxn, hotlist_id, services):
+  """Removes the given hotlist from the database.
+    Args:
+      hotlist_id: the id of the hotlist to be removed.
+      services: interfaces to data storage.
+  """
+  services.hotlist_star.ExpungeStars(cnxn, hotlist_id)
+  services.user.ExpungeHotlistsFromHistory(cnxn, [hotlist_id])
+  services.features.DeleteHotlist(cnxn, hotlist_id)
+
+
+# The following are used by issueentry.
+
+def InvalidParsedHotlistRefsNames(parsed_hotlist_refs, user_hotlist_pbs):
+  """Find and return all names without a corresponding hotlist so named.
+
+  Args:
+    parsed_hotlist_refs: a list of ParsedHotlistRef objects
+    user_hotlist_pbs: the hotlist protobuf objects of all hotlists
+      belonging to the user
+
+  Returns:
+    a list of invalid names; if none are found, the empty list
+  """
+  user_hotlist_names = {hotlist.name for hotlist in user_hotlist_pbs}
+  invalid_names = list()
+  for parsed_ref in parsed_hotlist_refs:
+    if parsed_ref.hotlist_name not in user_hotlist_names:
+      invalid_names.append(parsed_ref.hotlist_name)
+  return invalid_names
+
+
+def AmbiguousShortrefHotlistNames(short_refs, user_hotlist_pbs):
+  """Find and return ambiguous hotlist shortrefs' hotlist names.
+
+  A hotlist shortref is ambiguous iff there exists more than
+  hotlist with that name in the user's hotlists.
+
+  Args:
+    short_refs: a list of ParsedHotlistRef object specifying only
+      a hotlist name (user_email being none)
+    user_hotlist_pbs: the hotlist protobuf objects of all hotlists
+      belonging to the user
+
+  Returns:
+    a list of ambiguous hotlist names; if none are found, the empty list
+  """
+  ambiguous_names = set()
+  seen = set()
+  for hotlist in user_hotlist_pbs:
+    if hotlist.name in seen:
+      ambiguous_names.add(hotlist.name)
+    seen.add(hotlist.name)
+  ambiguous_from_refs = list()
+  for ref in short_refs:
+    if ref.hotlist_name in ambiguous_names:
+      ambiguous_from_refs.append(ref.hotlist_name)
+  return ambiguous_from_refs
+
+
+def InvalidParsedHotlistRefsEmails(full_refs, user_hotlist_emails_to_owners):
+  """Find and return invalid e-mails in hotlist full refs.
+
+  Args:
+    full_refs: a list of ParsedHotlistRef object specifying both
+      user_email and hotlist_name
+    user_hotlist_emails_to_owners: a dictionary having for its keys only
+      the e-mails of the owners of the hotlists the user had edit permission
+      over. (Could also be a set containing these e-mails.)
+
+  Returns:
+    A list of invalid e-mails; if none are found, the empty list.
+  """
+  parsed_emails = [pref.user_email for pref in full_refs]
+  invalid_emails = list()
+  for email in parsed_emails:
+    if email not in user_hotlist_emails_to_owners:
+      invalid_emails.append(email)
+  return invalid_emails
+
+
+def GetHotlistsOfParsedHotlistFullRefs(
+    full_refs, user_hotlist_emails_to_owners, user_hotlist_refs_to_pbs):
+  """Check that all full refs are valid.
+
+  A ref is 'invalid' if it doesn't specify one of the user's hotlists.
+
+  Args:
+    full_refs: a list of ParsedHotlistRef object specifying both
+      user_email and hotlist_name
+    user_hotlist_emails_to_owners: a dictionary having for its keys only
+      the e-mails of the owners of the hotlists the user had edit permission
+      over.
+    user_hotlist_refs_to_pbs: a dictionary mapping HotlistRefs
+      (owner_id, hotlist_name) to the corresponding hotlist protobuf object for
+      the user's hotlists
+
+  Returns:
+    A two-tuple: (list of valid refs' corresponding hotlist protobuf objects,
+                  list of invalid refs)
+
+  """
+  invalid_refs = list()
+  valid_pbs = list()
+  for parsed_ref in full_refs:
+    hotlist_ref = HotlistRef(
+        user_hotlist_emails_to_owners[parsed_ref.user_email],
+        parsed_ref.hotlist_name)
+    if hotlist_ref not in user_hotlist_refs_to_pbs:
+      invalid_refs.append(parsed_ref)
+    else:
+      valid_pbs.append(user_hotlist_refs_to_pbs[hotlist_ref])
+  return valid_pbs, invalid_refs
diff --git a/features/hotlist_views.py b/features/hotlist_views.py
new file mode 100644
index 0000000..8b17bbb
--- /dev/null
+++ b/features/hotlist_views.py
@@ -0,0 +1,92 @@
+# 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 to display hotlists in templates."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import ezt
+
+import logging
+
+from framework import framework_helpers
+from framework import permissions
+from framework import template_helpers
+
+
+class MemberView(object):
+  """EZT-view of details of how a person is participating in a project."""
+
+  def __init__(self, logged_in_user_id, member_id, user_view, hotlist,
+               effective_ids=None):
+    """Initialize a MemberView with the given information.
+
+    Args:
+      logged_in_user_id: int user ID of the viewing user, or 0 for anon.
+      member_id: int user ID of the hotlist member being viewed.
+      user_ivew: UserView object for this member
+      hotlist: Hotlist PB for the currently viewed hotlist
+      effective_ids: optional set of user IDs for this user, if supplied
+          we show the highest role that they have via any group membership.
+    """
+
+    self.viewing_self = ezt.boolean(logged_in_user_id == member_id)
+
+    self.user = user_view
+    member_qs_param = user_view.user_id
+    self.detail_url = '/u/%s/' % member_qs_param
+    self.role = framework_helpers.GetHotlistRoleName(
+        effective_ids or {member_id}, hotlist)
+
+
+class HotlistView(template_helpers.PBProxy):
+  """Wrapper class that makes it easier to display a hotlist via EZT."""
+
+  def __init__(
+      self, hotlist_pb, perms, user_auth=None,
+      viewed_user_id=None, users_by_id=None, is_starred=False):
+    super(HotlistView, self).__init__(hotlist_pb)
+
+    self.visible = permissions.CanViewHotlist(
+        user_auth.effective_ids, perms, hotlist_pb)
+
+    self.access_is_private = ezt.boolean(hotlist_pb.is_private)
+    if not hotlist_pb.owner_ids:  # Should never happen.
+      logging.error('Unowned Hotlist: id:%r, name:%r',
+        hotlist_pb.hotlist_id,
+        hotlist_pb.name)
+      self.url = ''
+      return
+    owner_id = hotlist_pb.owner_ids[0]  # only one owner allowed
+    owner = users_by_id[owner_id]
+    if owner.user.banned:
+      self.visible = False
+    if owner.obscure_email or not self.visible:
+      self.url = (
+          '/u/%d/hotlists/%s' % (owner_id, hotlist_pb.name))
+    else:
+      self.url = (
+          '/u/%s/hotlists/%s' % (
+              owner.email, hotlist_pb.name))
+
+    self.role_name = ''
+    if viewed_user_id in hotlist_pb.owner_ids:
+      self.role_name = 'owner'
+    elif any(effective_id in hotlist_pb.editor_ids for
+             effective_id in user_auth.effective_ids):
+      self.role_name = 'editor'
+
+    if users_by_id:
+      self.owners = [users_by_id[owner_id] for
+                     owner_id in hotlist_pb.owner_ids]
+      self.editors = [users_by_id[editor_id] for
+                      editor_id in hotlist_pb.editor_ids]
+    self.num_issues = len(hotlist_pb.items)
+    self.is_followed = ezt.boolean(user_auth.user_id in hotlist_pb.follower_ids)
+    # TODO(jojwang): if hotlist follower's will not be used, perhaps change
+    # from is_followed to is_member or just use is_starred
+    self.num_followers = len(hotlist_pb.follower_ids)
+    self.is_starred = ezt.boolean(is_starred)
diff --git a/features/hotlistcreate.py b/features/hotlistcreate.py
new file mode 100644
index 0000000..448697b
--- /dev/null
+++ b/features/hotlistcreate.py
@@ -0,0 +1,117 @@
+# 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 for creating new hotlists."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import time
+import re
+
+from features import features_constants
+from features import hotlist_helpers
+from framework import exceptions
+from framework import framework_bizobj
+from framework import framework_helpers
+from framework import permissions
+from framework import servlet
+from framework import urls
+from services import features_svc
+from proto import api_pb2_v1
+
+
+_MSG_HOTLIST_NAME_NOT_AVAIL = 'You already have a hotlist with that name.'
+_MSG_MISSING_HOTLIST_NAME = 'Missing hotlist name'
+_MSG_INVALID_HOTLIST_NAME = 'Invalid hotlist name'
+_MSG_MISSING_HOTLIST_SUMMARY = 'Missing hotlist summary'
+_MSG_INVALID_ISSUES_INPUT = 'Issues input is invalid'
+_MSG_INVALID_MEMBERS_INPUT = 'One or more editor emails is not valid.'
+
+
+class HotlistCreate(servlet.Servlet):
+  """HotlistCreate shows a simple page with a form to create a hotlist."""
+
+  _PAGE_TEMPLATE = 'features/hotlist-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(HotlistCreate, self).AssertBasePermission(mr)
+    if not permissions.CanCreateHotlist(mr.perms):
+      raise permissions.PermissionException(
+          'User is not allowed to create a hotlist.')
+
+  def GatherPageData(self, mr):
+    return {
+        'user_tab_mode': 'st6',
+        'initial_name': '',
+        'initial_summary': '',
+        'initial_description': '',
+        'initial_editors': '',
+        'initial_privacy': 'no',
+        }
+
+  def ProcessFormData(self, mr, post_data):
+    """Process the hotlist create 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.
+    """
+    hotlist_name = post_data.get('hotlistname')
+    if not hotlist_name:
+      mr.errors.hotlistname = _MSG_MISSING_HOTLIST_NAME
+    elif not framework_bizobj.IsValidHotlistName(hotlist_name):
+      mr.errors.hotlistname = _MSG_INVALID_HOTLIST_NAME
+
+    summary = post_data.get('summary')
+    if not summary:
+      mr.errors.summary = _MSG_MISSING_HOTLIST_SUMMARY
+
+    description = post_data.get('description', '')
+
+    editors = post_data.get('editors', '')
+    editor_ids = []
+    if editors:
+      editor_emails = [
+          email.strip() for email in editors.split(',')]
+      try:
+        editor_dict = self.services.user.LookupUserIDs(mr.cnxn, editor_emails)
+        editor_ids = list(editor_dict.values())
+      except exceptions.NoSuchUserException:
+        mr.errors.editors = _MSG_INVALID_MEMBERS_INPUT
+      # In case the logged-in user specifies themselves as an editor, ignore it.
+      editor_ids = [eid for eid in editor_ids if eid != mr.auth.user_id]
+
+    is_private = post_data.get('is_private')
+
+    if not mr.errors.AnyErrors():
+      try:
+        hotlist = self.services.features.CreateHotlist(
+            mr.cnxn, hotlist_name, summary, description,
+            owner_ids=[mr.auth.user_id], editor_ids=editor_ids,
+            is_private=(is_private == 'yes'),
+            ts=int(time.time()))
+      except features_svc.HotlistAlreadyExists:
+        mr.errors.hotlistname = _MSG_HOTLIST_NAME_NOT_AVAIL
+
+    if mr.errors.AnyErrors():
+      self.PleaseCorrect(
+          mr, initial_name=hotlist_name, initial_summary=summary,
+          initial_description=description,
+          initial_editors=editors, initial_privacy=is_private)
+    else:
+      return framework_helpers.FormatAbsoluteURL(
+          mr, hotlist_helpers.GetURLOfHotlist(
+              mr.cnxn, hotlist, self.services.user),
+          include_project=False)
diff --git a/features/hotlistdetails.py b/features/hotlistdetails.py
new file mode 100644
index 0000000..d3bf3b2
--- /dev/null
+++ b/features/hotlistdetails.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
+
+"""Servlets for hotlist details main subtab."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import time
+
+import ezt
+
+from features import hotlist_helpers
+from framework import framework_bizobj
+from framework import framework_helpers
+from framework import servlet
+from framework import permissions
+from framework import urls
+
+_MSG_DESCRIPTION_MISSING = 'Description is missing.'
+_MSG_SUMMARY_MISSING = 'Summary is missing.'
+_MSG_NAME_MISSING = 'Hotlist name is missing.'
+_MSG_COL_SPEC_MISSING = 'Hotlist default columns are missing.'
+_MSG_HOTLIST_NAME_NOT_AVAIL = 'You already have a hotlist with that name.'
+# pylint: disable=line-too-long
+_MSG_INVALID_HOTLIST_NAME = "Invalid hotlist name. Please make sure your hotlist name begins with a letter followed by any number of letters, numbers, -'s, and .'s"
+
+
+class HotlistDetails(servlet.Servlet):
+  """A page with hotlist details and editing options."""
+
+  _PAGE_TEMPLATE = 'features/hotlist-details-page.ezt'
+  _MAIN_TAB_MODE = servlet.Servlet.HOTLIST_TAB_DETAILS
+
+  def AssertBasePermission(self, mr):
+    super(HotlistDetails, self).AssertBasePermission(mr)
+    if not permissions.CanViewHotlist(
+        mr.auth.effective_ids, mr.perms, mr.hotlist):
+      raise permissions.PermissionException(
+          'User is not allowed to view the hotlist details')
+
+  def GatherPageData(self, mr):
+    """Buil up a dictionary of data values to use when rendering the page."""
+    if mr.auth.user_id:
+      self.services.user.AddVisitedHotlist(
+          mr.cnxn, mr.auth.user_id, mr.hotlist_id)
+    cant_administer_hotlist = not permissions.CanAdministerHotlist(
+        mr.auth.effective_ids, mr.perms, mr.hotlist)
+
+    return {
+        'initial_summary': mr.hotlist.summary,
+        'initial_description': mr.hotlist.description,
+        'initial_name': mr.hotlist.name,
+        'initial_default_col_spec': mr.hotlist.default_col_spec,
+        'initial_is_private': ezt.boolean(mr.hotlist.is_private),
+        'cant_administer_hotlist': ezt.boolean(cant_administer_hotlist),
+        'viewing_user_page': ezt.boolean(True),
+        'new_ui_url': '%s/%s/settings' % (urls.HOTLISTS, mr.hotlist_id),
+    }
+
+  def ProcessFormData(self, mr, post_data):
+    """Process the posted form."""
+    if not permissions.CanAdministerHotlist(
+        mr.auth.effective_ids, mr.perms, mr.hotlist):
+      raise permissions.PermissionException(
+          'User is not allowed to update hotlist settings.')
+
+    if post_data.get('deletestate') == 'true':
+      hotlist_helpers.RemoveHotlist(mr.cnxn, mr.hotlist_id, self.services)
+      return framework_helpers.FormatAbsoluteURL(
+          mr, '/u/%s/hotlists' % mr.auth.email,
+          saved=1, ts=int(time.time()), include_project=False)
+
+    (summary, description, name, default_col_spec) = self._ParseMetaData(
+        post_data, mr)
+    is_private = post_data.get('is_private') != 'no'
+
+    if not mr.errors.AnyErrors():
+      self.services.features.UpdateHotlist(
+          mr.cnxn, mr.hotlist.hotlist_id, name=name, summary=summary,
+          description=description, is_private=is_private,
+          default_col_spec=default_col_spec)
+
+    if mr.errors.AnyErrors():
+      self.PleaseCorrect(
+          mr, initial_summary=summary, initial_description=description,
+          initial_name=name, initial_default_col_spec=default_col_spec)
+    else:
+      return framework_helpers.FormatAbsoluteURL(
+          mr, '/u/%s/hotlists/%s%s' % (
+              mr.auth.user_id, mr.hotlist_id, urls.HOTLIST_DETAIL),
+          saved=1, ts=int(time.time()),
+          include_project=False)
+
+  def _ParseMetaData(self, post_data, mr):
+    """Process a POST on the hotlist metadata."""
+    summary = None
+    description = ''
+    name = None
+    default_col_spec = None
+
+    if 'summary' in post_data:
+      summary = post_data['summary']
+      if not summary:
+        mr.errors.summary = _MSG_SUMMARY_MISSING
+    if 'description' in post_data:
+      description = post_data['description']
+    if 'name' in post_data:
+      name = post_data['name']
+      if not name:
+        mr.errors.name = _MSG_NAME_MISSING
+      else:
+        if not framework_bizobj.IsValidHotlistName(name):
+          mr.errors.name = _MSG_INVALID_HOTLIST_NAME
+        elif self.services.features.LookupHotlistIDs(
+            mr.cnxn, [name], [mr.auth.user_id]) and (
+                mr.hotlist.name.lower() != name.lower()):
+          mr.errors.name = _MSG_HOTLIST_NAME_NOT_AVAIL
+    if 'default_col_spec' in post_data:
+      default_col_spec = post_data['default_col_spec']
+    return summary, description, name, default_col_spec
diff --git a/features/hotlistissues.py b/features/hotlistissues.py
new file mode 100644
index 0000000..8743772
--- /dev/null
+++ b/features/hotlistissues.py
@@ -0,0 +1,349 @@
+# 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 hotlistissues page and related forms."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import ezt
+
+import settings
+import time
+import re
+
+from businesslogic import work_env
+from features import features_bizobj
+from features import features_constants
+from features import hotlist_helpers
+from framework import exceptions
+from framework import servlet
+from framework import sorting
+from framework import permissions
+from framework import framework_helpers
+from framework import paginate
+from framework import framework_constants
+from framework import framework_views
+from framework import grid_view_helpers
+from framework import template_helpers
+from framework import timestr
+from framework import urls
+from framework import xsrf
+from services import features_svc
+from tracker import tracker_bizobj
+
+_INITIAL_ADD_ISSUES_MESSAGE = 'projectname:localID, projectname:localID, etc.'
+_MSG_INVALID_ISSUES_INPUT = (
+    'Please follow project_name:issue_id, project_name:issue_id..')
+_MSG_ISSUES_NOT_FOUND = 'One or more of your issues were not found.'
+_MSG_ISSUES_NOT_VIEWABLE = 'You lack permission to view one or more issues.'
+
+
+class HotlistIssues(servlet.Servlet):
+  """HotlistIssues is a page that shows the issues of one hotlist."""
+
+  _PAGE_TEMPLATE = 'features/hotlist-issues-page.ezt'
+  _MAIN_TAB_MODE = servlet.Servlet.HOTLIST_TAB_ISSUES
+
+  def AssertBasePermission(self, mr):
+    """Check that the user has permission to even visit this page."""
+    super(HotlistIssues, self).AssertBasePermission(mr)
+    try:
+      hotlist = self._GetHotlist(mr)
+    except features_svc.NoSuchHotlistException:
+      return
+    permit_view = permissions.CanViewHotlist(
+        mr.auth.effective_ids, mr.perms, hotlist)
+    if not permit_view:
+      raise permissions.PermissionException(
+        'User is not allowed to view this hotlist')
+
+  def GatherPageData(self, mr):
+    """Build up a dictionary of data values to use when rendering the page.
+
+    Args:
+      mr: commonly usef info parsed from the request.
+
+    Returns:
+      Dict of values used by EZT for rendering the page.
+    """
+    with mr.profiler.Phase('getting hotlist'):
+      if mr.hotlist_id is None:
+        self.abort(404, 'no hotlist specified')
+    if mr.auth.user_id:
+      self.services.user.AddVisitedHotlist(
+          mr.cnxn, mr.auth.user_id, mr.hotlist_id)
+
+    if mr.mode == 'grid':
+      page_data = self.GetGridViewData(mr)
+    else:
+      page_data = self.GetTableViewData(mr)
+
+    with mr.profiler.Phase('making page perms'):
+      owner_permissions = permissions.CanAdministerHotlist(
+          mr.auth.effective_ids, mr.perms, mr.hotlist)
+      editor_permissions = permissions.CanEditHotlist(
+          mr.auth.effective_ids, mr.perms, mr.hotlist)
+      # TODO(jojwang): each issue should have an individual
+      # SetStar status based on its project to indicate whether or not
+      # the star icon should be shown to the user.
+      page_perms = template_helpers.EZTItem(
+          EditIssue=None, SetStar=mr.auth.user_id)
+
+    allow_rerank = (not mr.group_by_spec and mr.sort_spec.startswith(
+        'rank') and (owner_permissions or editor_permissions))
+
+    user_hotlists = self.services.features.GetHotlistsByUserID(
+        mr.cnxn, mr.auth.user_id)
+    try:
+      user_hotlists.remove(self.services.features.GetHotlist(
+          mr.cnxn, mr.hotlist_id))
+    except ValueError:
+      pass
+
+    new_ui_url = '%s/%s/issues' % (urls.HOTLISTS, mr.hotlist_id)
+
+    # Note: The HotlistView is created and returned in servlet.py
+    page_data.update(
+        {
+            'owner_permissions':
+                ezt.boolean(owner_permissions),
+            'editor_permissions':
+                ezt.boolean(editor_permissions),
+            'issue_tab_mode':
+                'issueList',
+            'grid_mode':
+                ezt.boolean(mr.mode == 'grid'),
+            'list_mode':
+                ezt.boolean(mr.mode == 'list'),
+            'chart_mode':
+                ezt.boolean(mr.mode == 'chart'),
+            'page_perms':
+                page_perms,
+            'colspec':
+                mr.col_spec,
+            # monorail:6336, used in <ezt-show-columns-connector>
+            'phasespec':
+                "",
+            'allow_rerank':
+                ezt.boolean(allow_rerank),
+            'csv_link':
+                framework_helpers.FormatURL(
+                    [
+                        (name, mr.GetParam(name))
+                        for name in framework_helpers.RECOGNIZED_PARAMS
+                    ],
+                    '%d/csv' % mr.hotlist_id,
+                    num=100),
+            'is_hotlist':
+                ezt.boolean(True),
+            'col_spec':
+                mr.col_spec.lower(),
+            'viewing_user_page':
+                ezt.boolean(True),
+            # for update-issues-hotlists-dialog in
+            # issue-list-controls-top.
+            'user_issue_hotlists': [],
+            'user_remaining_hotlists':
+                user_hotlists,
+            'new_ui_url':
+                new_ui_url,
+        })
+    return page_data
+  # TODO(jojwang): implement peek issue on hover, implement starring issues
+
+  def _GetHotlist(self, mr):
+    """Retrieve the current hotlist."""
+    if mr.hotlist_id is None:
+      return None
+    try:
+      hotlist = self.services.features.GetHotlist(mr.cnxn, mr.hotlist_id)
+    except features_svc.NoSuchHotlistException:
+      self.abort(404, 'hotlist not found')
+    return hotlist
+
+  def GetTableViewData(self, mr):
+    """EZT template values to render a Table View of issues.
+
+    Args:
+      mr: commonly used info parsed from the request.
+
+    Returns:
+      Dictionary of page data for rendering of the Table View.
+    """
+    table_data, table_related_dict = hotlist_helpers.CreateHotlistTableData(
+        mr, mr.hotlist.items, self.services)
+    columns = mr.col_spec.split()
+    ordered_columns = [template_helpers.EZTItem(col_index=i, name=col)
+                       for i, col in enumerate(columns)]
+    table_view_data = {
+        'table_data': table_data,
+        'panels': [template_helpers.EZTItem(ordered_columns=ordered_columns)],
+        'cursor': mr.cursor or mr.preview,
+        'preview': mr.preview,
+        'default_colspec': features_constants.DEFAULT_COL_SPEC,
+        'default_results_per_page': 10,
+        'preview_on_hover': (
+            settings.enable_quick_edit and mr.auth.user_pb.preview_on_hover),
+        # token must be generated using url with userid to accommodate
+        # multiple urls for one hotlist
+        'edit_hotlist_token': xsrf.GenerateToken(
+            mr.auth.user_id,
+            hotlist_helpers.GetURLOfHotlist(
+                mr.cnxn, mr.hotlist, self.services.user,
+                url_for_token=True) + '.do'),
+        'add_local_ids': '',
+        'placeholder': _INITIAL_ADD_ISSUES_MESSAGE,
+        'add_issues_selected': ezt.boolean(False),
+        'col_spec': ''
+        }
+    table_view_data.update(table_related_dict)
+
+    return table_view_data
+
+  def ProcessFormData(self, mr, post_data):
+    if not permissions.CanEditHotlist(
+        mr.auth.effective_ids, mr.perms, mr.hotlist):
+      raise permissions.PermissionException(
+          'User is not allowed to edit this hotlist.')
+
+    hotlist_view_url = hotlist_helpers.GetURLOfHotlist(
+        mr.cnxn, mr.hotlist, self.services.user)
+    current_col_spec = post_data.get('current_col_spec')
+    default_url = framework_helpers.FormatAbsoluteURL(
+        mr, hotlist_view_url,
+        include_project=False, colspec=current_col_spec)
+    sorting.InvalidateArtValuesKeys(
+        mr.cnxn,
+        [hotlist_item.issue_id for hotlist_item
+         in mr.hotlist.items])
+
+    if post_data.get('remove') == 'true':
+      project_and_local_ids = post_data.get('remove_local_ids')
+    else:
+      project_and_local_ids = post_data.get('add_local_ids')
+      if not project_and_local_ids:
+        return default_url
+
+    selected_iids = []
+    if project_and_local_ids:
+      pattern = re.compile(features_constants.ISSUE_INPUT_REGEX)
+      if pattern.match(project_and_local_ids):
+        issue_refs_tuples = [(pair.split(':')[0].strip(),
+                          int(pair.split(':')[1].strip()))
+                             for pair in project_and_local_ids.split(',')
+                             if pair.strip()]
+        project_names = {project_name for (project_name, _) in
+                         issue_refs_tuples}
+        projects_dict = self.services.project.GetProjectsByName(
+            mr.cnxn, project_names)
+        selected_iids, _misses = self.services.issue.ResolveIssueRefs(
+            mr.cnxn, projects_dict, mr.project_name, issue_refs_tuples)
+        if (not selected_iids) or len(issue_refs_tuples) > len(selected_iids):
+          mr.errors.issues = _MSG_ISSUES_NOT_FOUND
+          # TODO(jojwang): give issues that were not found.
+      else:
+        mr.errors.issues = _MSG_INVALID_ISSUES_INPUT
+
+    try:
+      with work_env.WorkEnv(mr, self.services) as we:
+        we.GetIssuesDict(selected_iids)
+    except exceptions.NoSuchIssueException:
+      mr.errors.issues = _MSG_ISSUES_NOT_FOUND
+    except permissions.PermissionException:
+      mr.errors.issues = _MSG_ISSUES_NOT_VIEWABLE
+
+    # TODO(jojwang): fix: when there are errors, hidden column come back on
+    # the .do page but go away once the errors are fixed and the form
+    # is submitted again
+    if mr.errors.AnyErrors():
+      self.PleaseCorrect(
+          mr, add_local_ids=project_and_local_ids,
+          add_issues_selected=ezt.boolean(True), col_spec=current_col_spec)
+
+    else:
+      with work_env.WorkEnv(mr, self.services) as we:
+        if post_data.get('remove') == 'true':
+          we.RemoveIssuesFromHotlists([mr.hotlist_id], selected_iids)
+        else:
+          we.AddIssuesToHotlists([mr.hotlist_id], selected_iids, '')
+      return framework_helpers.FormatAbsoluteURL(
+          mr, hotlist_view_url, saved=1, ts=int(time.time()),
+          include_project=False, colspec=current_col_spec)
+
+  def GetGridViewData(self, mr):
+    """EZT template values to render a Table View of issues.
+
+    Args:
+      mr: commonly used info parsed from the request.
+
+    Returns:
+      Dictionary of page data for rendering of the Table View.
+    """
+    mr.ComputeColSpec(mr.hotlist)
+    starred_iid_set = set(self.services.issue_star.LookupStarredItemIDs(
+        mr.cnxn, mr.auth.user_id))
+    issues_list = self.services.issue.GetIssues(
+        mr.cnxn,
+        [hotlist_issue.issue_id for hotlist_issue
+         in mr.hotlist.items])
+    allowed_issues = hotlist_helpers.FilterIssues(
+        mr.cnxn, mr.auth, mr.can, issues_list, self.services)
+    issue_and_hotlist_users = tracker_bizobj.UsersInvolvedInIssues(
+        allowed_issues or []).union(features_bizobj.UsersInvolvedInHotlists(
+            [mr.hotlist]))
+    users_by_id = framework_views.MakeAllUserViews(
+        mr.cnxn, self.services.user,
+        issue_and_hotlist_users)
+    hotlist_issues_project_ids = hotlist_helpers.GetAllProjectsOfIssues(
+        [issue for issue in issues_list])
+    config_list = hotlist_helpers.GetAllConfigsOfProjects(
+        mr.cnxn, hotlist_issues_project_ids, self.services)
+    harmonized_config = tracker_bizobj.HarmonizeConfigs(config_list)
+    limit = settings.max_issues_in_grid
+    grid_limited = len(allowed_issues) > limit
+    lower_cols = mr.col_spec.lower().split()
+    grid_x = (mr.x or harmonized_config.default_x_attr or '--').lower()
+    grid_y = (mr.y or harmonized_config.default_y_attr or '--').lower()
+    lower_cols.append(grid_x)
+    lower_cols.append(grid_y)
+    related_iids = set()
+    for issue in allowed_issues:
+      if 'blockedon' in lower_cols:
+        related_iids.update(issue.blocked_on_iids)
+      if 'blocking' in lower_cols:
+        related_iids.update(issue.blocking_iids)
+      if 'mergedinto' in lower_cols:
+        related_iids.add(issue.merged_into)
+    related_issues_list = self.services.issue.GetIssues(
+        mr.cnxn, list(related_iids))
+    related_issues = {issue.issue_id: issue for issue in related_issues_list}
+
+    hotlist_context_dict = {
+        hotlist_issue.issue_id: {'adder_id': hotlist_issue.adder_id,
+                                 'date_added': timestr.FormatRelativeDate(
+                                     hotlist_issue.date_added),
+                                 'note': hotlist_issue.note}
+        for hotlist_issue in mr.hotlist.items}
+
+    grid_view_data = grid_view_helpers.GetGridViewData(
+        mr, allowed_issues, harmonized_config,
+        users_by_id, starred_iid_set, grid_limited, related_issues,
+        hotlist_context_dict=hotlist_context_dict)
+
+    url_params = [(name, mr.GetParam(name)) for name in
+                  framework_helpers.RECOGNIZED_PARAMS]
+    # We are passing in None for the project_name in ArtifactPagination
+    # because we are not operating under any project.
+    grid_view_data.update({'pagination': paginate.ArtifactPagination(
+          allowed_issues,
+          mr.GetPositiveIntParam(
+              'num', features_constants.DEFAULT_RESULTS_PER_PAGE),
+          mr.GetPositiveIntParam('start'), None,
+          urls.HOTLIST_ISSUES, total_count=len(allowed_issues),
+          url_params=url_params)})
+
+    return grid_view_data
diff --git a/features/hotlistissuescsv.py b/features/hotlistissuescsv.py
new file mode 100644
index 0000000..3ae3f3b
--- /dev/null
+++ b/features/hotlistissuescsv.py
@@ -0,0 +1,62 @@
+# 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
+
+"""Implemention of the hotlist issues list output as a CSV file."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+from features import hotlistissues
+from framework import framework_views
+from framework import csv_helpers
+from framework import permissions
+from framework import xsrf
+
+
+# TODO(jojwang): can be refactored even more, see similarities with
+# IssueListCsv
+class HotlistIssuesCsv(hotlistissues.HotlistIssues):
+  """HotlistIssuesCsv provides to the user a list of issues as a CSV document.
+
+  Overrides the standard HotlistIssues servlet but uses a different EZT template
+  to provide the same content as the HotlistIssues only as CSV. Adds the HTTP
+  header to offer the result as a download.
+  """
+
+  _PAGE_TEMPLATE = 'tracker/issue-list-csv.ezt'
+
+  def GatherPageData(self, mr):
+    if not mr.auth.user_id:
+      raise permissions.PermissionException(
+          'Anonymous users are not allowed to download hotlist CSV')
+
+    owner_id = mr.hotlist.owner_ids[0]  # only one owner allowed
+    users_by_id = framework_views.MakeAllUserViews(
+        mr.cnxn, self.services.user,
+        [owner_id])
+    owner = users_by_id[owner_id]
+
+    # Try to validate XSRF by either user email or user ID.
+    try:
+      xsrf.ValidateToken(
+          mr.token, mr.auth.user_id,
+          '/u/%s/hotlists/%s.do' % (owner.email, mr.hotlist.name))
+    except xsrf.TokenIncorrect:
+      xsrf.ValidateToken(
+          mr.token, mr.auth.user_id,
+          '/u/%s/hotlists/%s.do' % (owner.user_id, mr.hotlist.name))
+
+    # Sets headers to allow the response to be downloaded.
+    self.content_type = 'text/csv; charset=UTF-8'
+    download_filename = 'hotlist_%d-issues.csv' % mr.hotlist_id
+    self.response.headers.add(
+        'Content-Disposition', 'attachment; filename=%s' % download_filename)
+    self.response.headers.add('X-Content-Type-Options', 'nosniff')
+
+    mr.ComputeColSpec(mr.hotlist)
+    mr.col_spec = csv_helpers.RewriteColspec(mr.col_spec)
+    page_data = hotlistissues.HotlistIssues.GatherPageData(self, mr)
+    return csv_helpers.ReformatRowsForCSV(
+        mr, page_data, '%d/csv' % mr.hotlist_id)
diff --git a/features/hotlistpeople.py b/features/hotlistpeople.py
new file mode 100644
index 0000000..1eb00ff
--- /dev/null
+++ b/features/hotlistpeople.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
+
+"""Classes to implement the hotlistpeople page and related forms."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import time
+
+import ezt
+
+from features import hotlist_helpers
+from features import hotlist_views
+from framework import framework_helpers
+from framework import framework_views
+from framework import paginate
+from framework import permissions
+from framework import servlet
+from framework import urls
+from project import project_helpers
+
+MEMBERS_PER_PAGE = 50
+
+
+class HotlistPeopleList(servlet.Servlet):
+  _PAGE_TEMPLATE = 'project/people-list-page.ezt'
+  # Note: using the project's peoplelist page template. minor edits were
+  # to make it compatible with HotlistPeopleList
+  _MAIN_TAB_MODE = servlet.Servlet.HOTLIST_TAB_PEOPLE
+
+  def AssertBasePermission(self, mr):
+    super(HotlistPeopleList, self).AssertBasePermission(mr)
+    if not permissions.CanViewHotlist(
+        mr.auth.effective_ids, mr.perms, mr.hotlist):
+      raise permissions.PermissionException(
+          'User is now allowed to view the hotlist people list')
+
+  def GatherPageData(self, mr):
+    """Build up a dictionary of data values to use when rendering the page."""
+    if mr.auth.user_id:
+      self.services.user.AddVisitedHotlist(
+          mr.cnxn, mr.auth.user_id, mr.hotlist_id)
+
+    all_members = (mr.hotlist.owner_ids +
+                   mr.hotlist.editor_ids + mr.hotlist.follower_ids)
+
+    hotlist_url = hotlist_helpers.GetURLOfHotlist(
+        mr.cnxn, mr.hotlist, self.services.user)
+
+    with mr.profiler.Phase('gathering members on this page'):
+      users_by_id = framework_views.MakeAllUserViews(
+          mr.cnxn, self.services.user, all_members)
+      framework_views.RevealAllEmailsToMembers(
+          mr.cnxn, self.services, mr.auth, users_by_id, mr.project)
+
+    untrusted_user_group_proxies = []
+    # TODO(jojwang): implement FindUntrustedGroups()
+
+    with mr.profiler.Phase('making member views'):
+      owner_views = self._MakeMemberViews(mr, mr.hotlist.owner_ids, users_by_id)
+      editor_views = self._MakeMemberViews(mr, mr.hotlist.editor_ids,
+                                           users_by_id)
+      follower_views = self._MakeMemberViews(mr, mr.hotlist.follower_ids,
+                                             users_by_id)
+      all_member_views = owner_views + editor_views + follower_views
+
+    url_params = [(name, mr.GetParam(name)) for name in
+                  framework_helpers.RECOGNIZED_PARAMS]
+    # We are passing in None for the project_name because we are not operating
+    # under any project.
+    pagination = paginate.ArtifactPagination(
+        all_member_views, mr.GetPositiveIntParam('num', MEMBERS_PER_PAGE),
+        mr.GetPositiveIntParam('start'), None,
+        '%s%s' % (hotlist_url, urls.HOTLIST_PEOPLE), url_params=url_params)
+
+    offer_membership_editing = permissions.CanAdministerHotlist(
+        mr.auth.effective_ids, mr.perms, mr.hotlist)
+
+    offer_remove_self = (
+        not offer_membership_editing and
+        mr.auth.user_id and
+        mr.auth.user_id in mr.hotlist.editor_ids)
+
+    newly_added_views = [mv for mv in all_member_views
+                         if str(mv.user.user_id) in mr.GetParam('new', [])]
+
+    return {
+        'is_hotlist': ezt.boolean(True),
+        'untrusted_user_groups': untrusted_user_group_proxies,
+        'pagination': pagination,
+        'initial_add_members': '',
+        'subtab_mode': None,
+        'initially_expand_form': ezt.boolean(False),
+        'newly_added_views': newly_added_views,
+        'offer_membership_editing': ezt.boolean(offer_membership_editing),
+        'offer_remove_self': ezt.boolean(offer_remove_self),
+        'total_num_owners': len(mr.hotlist.owner_ids),
+        'check_abandonment': ezt.boolean(True),
+        'initial_new_owner_username': '',
+        'placeholder': 'new-owner-username',
+        'open_dialog': ezt.boolean(False),
+        'viewing_user_page': ezt.boolean(True),
+        'new_ui_url': '%s/%s/people' % (urls.HOTLISTS, mr.hotlist_id),
+    }
+
+  def ProcessFormData(self, mr, post_data):
+    """Process the posted form."""
+    permit_edit = permissions.CanAdministerHotlist(
+        mr.auth.effective_ids, mr.perms, mr.hotlist)
+    can_remove_self = (
+        not permit_edit and
+        mr.auth.user_id and
+        mr.auth.user_id in mr.hotlist.editor_ids)
+    if not can_remove_self and not permit_edit:
+      raise permissions.PermissionException(
+          'User is not permitted to edit hotlist membership')
+    hotlist_url = hotlist_helpers.GetURLOfHotlist(
+        mr.cnxn, mr.hotlist, self.services.user)
+    if permit_edit:
+      if 'addbtn' in post_data:
+        return self.ProcessAddMembers(mr, post_data, hotlist_url)
+      elif 'removebtn' in post_data:
+        return self.ProcessRemoveMembers(mr, post_data, hotlist_url)
+      elif 'changeowners' in post_data:
+        return self.ProcessChangeOwnership(mr, post_data)
+    if can_remove_self:
+      if 'removeself' in post_data:
+        return self.ProcessRemoveSelf(mr, hotlist_url)
+
+  def _MakeMemberViews(self, mr, member_ids, users_by_id):
+    """Return a sorted list of MemberViews for display by EZT."""
+    member_views = [hotlist_views.MemberView(
+        mr.auth.user_id, member_id, users_by_id[member_id],
+        mr.hotlist) for member_id in member_ids]
+    member_views.sort(key=lambda mv: mv.user.email)
+    return member_views
+
+  def ProcessChangeOwnership(self, mr, post_data):
+    new_owner_id_set = project_helpers.ParseUsernames(
+        mr.cnxn, self.services.user, post_data.get('changeowners'))
+    remain_as_editor = post_data.get('becomeeditor') == 'on'
+    if len(new_owner_id_set) != 1:
+      mr.errors.transfer_ownership = (
+          'Please add one valid user email.')
+    else:
+      new_owner_id = new_owner_id_set.pop()
+      if self.services.features.LookupHotlistIDs(
+          mr.cnxn, [mr.hotlist.name], [new_owner_id]):
+        mr.errors.transfer_ownership = (
+            'This user already owns a hotlist with the same name')
+
+    if mr.errors.AnyErrors():
+      self.PleaseCorrect(
+          mr, initial_new_owner_username=post_data.get('changeowners'),
+          open_dialog=ezt.boolean(True))
+    else:
+      old_and_new_owner_ids = [new_owner_id] + mr.hotlist.owner_ids
+      (_, editor_ids, follower_ids) = hotlist_helpers.MembersWithoutGivenIDs(
+          mr.hotlist, old_and_new_owner_ids)
+      if remain_as_editor and mr.hotlist.owner_ids:
+        editor_ids.append(mr.hotlist.owner_ids[0])
+
+      self.services.features.UpdateHotlistRoles(
+          mr.cnxn, mr.hotlist_id, [new_owner_id], editor_ids, follower_ids)
+
+      hotlist = self.services.features.GetHotlist(mr.cnxn, mr.hotlist_id)
+      hotlist_url = hotlist_helpers.GetURLOfHotlist(
+        mr.cnxn, hotlist, self.services.user)
+      return framework_helpers.FormatAbsoluteURL(
+          mr,'%s%s' % (hotlist_url, urls.HOTLIST_PEOPLE),
+          saved=1, ts=int(time.time()),
+          include_project=False)
+
+  def ProcessAddMembers(self, mr, post_data, hotlist_url):
+    """Process the user's request to add members.
+
+    Args:
+      mr: common information parsed from the HTTP request.
+      post_data: dictionary of form data
+      hotlist_url: hotlist_url to return to after data has been processed.
+
+    Returns:
+      String URL to redirect the user to after processing
+    """
+    # NOTE: using project_helpers function
+    new_member_ids = project_helpers.ParseUsernames(
+        mr.cnxn, self.services.user, post_data.get('addmembers'))
+    if not new_member_ids or not post_data.get('addmembers'):
+      mr.errors.incorrect_email_input = (
+          'Please give full emails seperated by commas.')
+    role = post_data['role']
+
+    (owner_ids, editor_ids, follower_ids) = hotlist_helpers.MembersWithGivenIDs(
+        mr.hotlist, new_member_ids, role)
+    # TODO(jojwang): implement MAX_HOTLIST_PEOPLE
+
+    if not owner_ids:
+      mr.errors.addmembers = (
+          'Cannot have a hotlist without an owner; please leave at least one.')
+
+    if mr.errors.AnyErrors():
+      add_members_str = post_data.get('addmembers', '')
+      self.PleaseCorrect(
+          mr, initial_add_members=add_members_str, initially_expand_form=True)
+    else:
+      self.services.features.UpdateHotlistRoles(
+          mr.cnxn, mr.hotlist_id, owner_ids, editor_ids, follower_ids)
+      return framework_helpers.FormatAbsoluteURL(
+          mr, '%s%s' % (
+              hotlist_url, urls.HOTLIST_PEOPLE),
+          saved=1, ts=int(time.time()),
+          new=','.join([str(u) for u in new_member_ids]),
+          include_project=False)
+
+  def ProcessRemoveMembers(self, mr, post_data, hotlist_url):
+    """Process the user's request to remove members."""
+    remove_strs = post_data.getall('remove')
+    logging.info('remove_strs = %r', remove_strs)
+    remove_ids = set(
+        self.services.user.LookupUserIDs(mr.cnxn, remove_strs).values())
+    (owner_ids, editor_ids,
+     follower_ids) = hotlist_helpers.MembersWithoutGivenIDs(
+         mr.hotlist, remove_ids)
+
+    self.services.features.UpdateHotlistRoles(
+        mr.cnxn, mr.hotlist_id, owner_ids, editor_ids, follower_ids)
+
+    return framework_helpers.FormatAbsoluteURL(
+        mr, '%s%s' % (
+              hotlist_url, urls.HOTLIST_PEOPLE),
+          saved=1, ts=int(time.time()), include_project=False)
+
+  def ProcessRemoveSelf(self, mr, hotlist_url):
+    """Process the request to remove the logged-in user."""
+    remove_ids = [mr.auth.user_id]
+
+    # This function does no permission checking; that's done by the caller.
+    (owner_ids, editor_ids,
+        follower_ids) = hotlist_helpers.MembersWithoutGivenIDs(
+            mr.hotlist, remove_ids)
+
+    self.services.features.UpdateHotlistRoles(
+        mr.cnxn, mr.hotlist_id, owner_ids, editor_ids, follower_ids)
+
+    return framework_helpers.FormatAbsoluteURL(
+        mr, '%s%s' % (
+              hotlist_url, urls.HOTLIST_PEOPLE),
+          saved=1, ts=int(time.time()), include_project=False)
diff --git a/features/inboundemail.py b/features/inboundemail.py
new file mode 100644
index 0000000..8ae095e
--- /dev/null
+++ b/features/inboundemail.py
@@ -0,0 +1,339 @@
+# 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
+
+"""Handler to process inbound email with issue comments and commands."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import email
+import logging
+import os
+import re
+import time
+import urllib
+
+import ezt
+
+from google.appengine.api import mail
+from google.appengine.ext.webapp.mail_handlers import BounceNotificationHandler
+
+import webapp2
+
+import settings
+from businesslogic import work_env
+from features import alert2issue
+from features import commitlogcommands
+from features import notify_helpers
+from framework import authdata
+from framework import emailfmt
+from framework import exceptions
+from framework import framework_constants
+from framework import monorailcontext
+from framework import permissions
+from framework import sql
+from framework import template_helpers
+from proto import project_pb2
+from tracker import tracker_helpers
+
+
+TEMPLATE_PATH_BASE = framework_constants.TEMPLATE_PATH
+
+MSG_TEMPLATES = {
+    'banned': 'features/inboundemail-banned.ezt',
+    'body_too_long': 'features/inboundemail-body-too-long.ezt',
+    'project_not_found': 'features/inboundemail-project-not-found.ezt',
+    'not_a_reply': 'features/inboundemail-not-a-reply.ezt',
+    'no_account': 'features/inboundemail-no-account.ezt',
+    'no_artifact': 'features/inboundemail-no-artifact.ezt',
+    'no_perms': 'features/inboundemail-no-perms.ezt',
+    'replies_disabled': 'features/inboundemail-replies-disabled.ezt',
+    }
+
+
+class InboundEmail(webapp2.RequestHandler):
+  """Servlet to handle inbound email messages."""
+
+  def __init__(self, request, response, services=None, *args, **kwargs):
+    super(InboundEmail, self).__init__(request, response, *args, **kwargs)
+    self.services = services or self.app.config.get('services')
+    self._templates = {}
+    for name, template_path in MSG_TEMPLATES.items():
+      self._templates[name] = template_helpers.MonorailTemplate(
+          TEMPLATE_PATH_BASE + template_path,
+          compress_whitespace=False, base_format=ezt.FORMAT_RAW)
+
+  def get(self, project_addr=None):
+    logging.info('\n\n\nGET for InboundEmail and project_addr is %r',
+                 project_addr)
+    self.Handler(mail.InboundEmailMessage(self.request.body),
+                 urllib.unquote(project_addr))
+
+  def post(self, project_addr=None):
+    logging.info('\n\n\nPOST for InboundEmail and project_addr is %r',
+                 project_addr)
+    self.Handler(mail.InboundEmailMessage(self.request.body),
+                 urllib.unquote(project_addr))
+
+  def Handler(self, inbound_email_message, project_addr):
+    """Process an inbound email message."""
+    msg = inbound_email_message.original
+    email_tasks = self.ProcessMail(msg, project_addr)
+
+    if email_tasks:
+      notify_helpers.AddAllEmailTasks(email_tasks)
+
+  def ProcessMail(self, msg, project_addr):
+    """Process an inbound email message."""
+    # TODO(jrobbins): If the message is HUGE, don't even try to parse
+    # it. Silently give up.
+
+    (from_addr, to_addrs, cc_addrs, references, incident_id, subject,
+     body) = emailfmt.ParseEmailMessage(msg)
+
+    logging.info('Proj addr:   %r', project_addr)
+    logging.info('From addr:   %r', from_addr)
+    logging.info('Subject:     %r', subject)
+    logging.info('To:          %r', to_addrs)
+    logging.info('Cc:          %r', cc_addrs)
+    logging.info('References:  %r', references)
+    logging.info('Incident Id: %r', incident_id)
+    logging.info('Body:        %r', body)
+
+    # If message body is very large, reject it and send an error email.
+    if emailfmt.IsBodyTooBigToParse(body):
+      return _MakeErrorMessageReplyTask(
+          project_addr, from_addr, self._templates['body_too_long'])
+
+    # Make sure that the project reply-to address is in the To: line.
+    if not emailfmt.IsProjectAddressOnToLine(project_addr, to_addrs):
+      return None
+
+    project_name, verb, trooper_queue = emailfmt.IdentifyProjectVerbAndLabel(
+        project_addr)
+
+    is_alert = bool(verb and verb.lower() == 'alert')
+    error_addr = from_addr
+    local_id = None
+    author_addr = from_addr
+
+    if is_alert:
+      error_addr = settings.alert_escalation_email
+      author_addr = settings.alert_service_account
+    else:
+      local_id = emailfmt.IdentifyIssue(project_name, subject)
+      if not local_id:
+        logging.info('Could not identify issue: %s %s', project_addr, subject)
+        # No error message, because message was probably not intended for us.
+        return None
+
+    cnxn = sql.MonorailConnection()
+    if self.services.cache_manager:
+      self.services.cache_manager.DoDistributedInvalidation(cnxn)
+
+    project = self.services.project.GetProjectByName(cnxn, project_name)
+    # Authenticate the author_addr and perm check.
+    try:
+      mc = monorailcontext.MonorailContext(
+          self.services, cnxn=cnxn, requester=author_addr, autocreate=is_alert)
+      mc.LookupLoggedInUserPerms(project)
+    except exceptions.NoSuchUserException:
+      return _MakeErrorMessageReplyTask(
+          project_addr, error_addr, self._templates['no_account'])
+
+    # TODO(zhangtiff): Add separate email templates for alert error cases.
+    if not project or project.state != project_pb2.ProjectState.LIVE:
+      return _MakeErrorMessageReplyTask(
+          project_addr, error_addr, self._templates['project_not_found'])
+
+    if not project.process_inbound_email:
+      return _MakeErrorMessageReplyTask(
+          project_addr, error_addr, self._templates['replies_disabled'],
+          project_name=project_name)
+
+    # Verify that this is a reply to a notification that we could have sent.
+    is_development = os.environ['SERVER_SOFTWARE'].startswith('Development')
+    if not (is_alert or is_development):
+      for ref in references:
+        if emailfmt.ValidateReferencesHeader(ref, project, from_addr, subject):
+          break  # Found a message ID that we could have sent.
+        if emailfmt.ValidateReferencesHeader(
+            ref, project, from_addr.lower(), subject):
+          break  # Also match all-lowercase from-address.
+      else: # for-else: if loop completes with no valid reference found.
+        return _MakeErrorMessageReplyTask(
+            project_addr, from_addr, self._templates['not_a_reply'])
+
+    # Note: If the issue summary line is changed, a new thread is created,
+    # and replies to the old thread will no longer work because the subject
+    # line hash will not match, which seems reasonable.
+
+    if mc.auth.user_pb.banned:
+      logging.info('Banned user %s tried to post to %s',
+                   from_addr, project_addr)
+      return _MakeErrorMessageReplyTask(
+          project_addr, error_addr, self._templates['banned'])
+
+    # If the email is an alert, switch to the alert handling path.
+    if is_alert:
+      alert2issue.ProcessEmailNotification(
+          self.services, cnxn, project, project_addr, from_addr,
+          mc.auth, subject, body, incident_id, msg, trooper_queue)
+      return None
+
+    # This email is a response to an email about a comment.
+    self.ProcessIssueReply(
+        mc, project, local_id, project_addr, body)
+
+    return None
+
+  def ProcessIssueReply(
+      self, mc, project, local_id, project_addr, body):
+    """Examine an issue reply email body and add a comment to the issue.
+
+    Args:
+      mc: MonorailContext with cnxn and the requester email, user_id, perms.
+      project: Project PB for the project containing the issue.
+      local_id: int ID of the issue being replied to.
+      project_addr: string email address used for outbound emails from
+          that project.
+      body: string email body text of the reply email.
+
+    Returns:
+      A list of follow-up work items, e.g., to notify other users of
+      the new comment, or to notify the user that their reply was not
+      processed.
+
+    Side-effect:
+      Adds a new comment to the issue, if no error is reported.
+    """
+    try:
+      issue = self.services.issue.GetIssueByLocalID(
+          mc.cnxn, project.project_id, local_id)
+    except exceptions.NoSuchIssueException:
+      issue = None
+
+    if not issue or issue.deleted:
+      # The referenced issue was not found, e.g., it might have been
+      # deleted, or someone messed with the subject line.  Reject it.
+      return _MakeErrorMessageReplyTask(
+          project_addr, mc.auth.email, self._templates['no_artifact'],
+          artifact_phrase='issue %d' % local_id,
+          project_name=project.project_name)
+
+    can_view = mc.perms.CanUsePerm(
+        permissions.VIEW, mc.auth.effective_ids, project,
+        permissions.GetRestrictions(issue))
+    can_comment = mc.perms.CanUsePerm(
+        permissions.ADD_ISSUE_COMMENT, mc.auth.effective_ids, project,
+        permissions.GetRestrictions(issue))
+    if not can_view or not can_comment:
+      return _MakeErrorMessageReplyTask(
+          project_addr, mc.auth.email, self._templates['no_perms'],
+          artifact_phrase='issue %d' % local_id,
+          project_name=project.project_name)
+
+    # TODO(jrobbins): if the user does not have EDIT_ISSUE and the inbound
+    # email tries to make an edit, send back an error message.
+
+    lines = body.strip().split('\n')
+    uia = commitlogcommands.UpdateIssueAction(local_id)
+    uia.Parse(mc.cnxn, project.project_name, mc.auth.user_id, lines,
+              self.services, strip_quoted_lines=True)
+    uia.Run(mc, self.services)
+
+
+def _MakeErrorMessageReplyTask(
+    project_addr, sender_addr, template, **callers_page_data):
+  """Return a new task to send an error message email.
+
+  Args:
+    project_addr: string email address that the inbound email was delivered to.
+    sender_addr: string email address of user who sent the email that we could
+        not process.
+    template: EZT template used to generate the email error message.  The
+        first line of this generated text will be used as the subject line.
+    callers_page_data: template data dict for body of the message.
+
+  Returns:
+    A list with a single Email task that can be enqueued to
+    actually send the email.
+
+  Raises:
+    ValueError: if the template does begin with a "Subject:" line.
+  """
+  email_data = {
+      'project_addr': project_addr,
+      'sender_addr': sender_addr
+      }
+  email_data.update(callers_page_data)
+
+  generated_lines = template.GetResponse(email_data)
+  subject, body = generated_lines.split('\n', 1)
+  if subject.startswith('Subject: '):
+    subject = subject[len('Subject: '):]
+  else:
+    raise ValueError('Email template does not begin with "Subject:" line.')
+
+  email_task = dict(to=sender_addr, subject=subject, body=body,
+                    from_addr=emailfmt.NoReplyAddress())
+  logging.info('sending email error reply: %r', email_task)
+
+  return [email_task]
+
+
+BAD_WRAP_RE = re.compile('=\r\n')
+BAD_EQ_RE = re.compile('=3D')
+
+class BouncedEmail(BounceNotificationHandler):
+  """Handler to notice when email to given user is bouncing."""
+
+  # For docs on AppEngine's bounce email handling, see:
+  # https://cloud.google.com/appengine/docs/python/mail/bounce
+  # Source code is in file:
+  # google_appengine/google/appengine/ext/webapp/mail_handlers.py
+
+  def post(self):
+    try:
+      super(BouncedEmail, self).post()
+    except AttributeError:
+      # Work-around for
+      # https://code.google.com/p/googleappengine/issues/detail?id=13512
+      raw_message = self.request.POST.get('raw-message')
+      logging.info('raw_message %r', raw_message)
+      raw_message = BAD_WRAP_RE.sub('', raw_message)
+      raw_message = BAD_EQ_RE.sub('=', raw_message)
+      logging.info('fixed raw_message %r', raw_message)
+      mime_message = email.message_from_string(raw_message)
+      logging.info('get_payload gives %r', mime_message.get_payload())
+      self.request.POST['raw-message'] = mime_message
+      super(BouncedEmail, self).post()  # Retry with mime_message
+
+
+  def receive(self, bounce_message):
+    email_addr = bounce_message.original.get('to')
+    logging.info('Bounce was sent to: %r', email_addr)
+
+    # TODO(crbug.com/monorail/8727): The problem is likely no longer happening.
+    # but we are adding permanent logging so we don't have to keep adding
+    # expriring logpoints.
+    if '@intel' in email_addr:  # both intel.com and intel-partner.
+      logging.info(
+          'bounce message: %s', bounce_message.notification.get('text'))
+
+    app_config = webapp2.WSGIApplication.app.config
+    services = app_config['services']
+    cnxn = sql.MonorailConnection()
+
+    try:
+      user_id = services.user.LookupUserID(cnxn, email_addr)
+      user = services.user.GetUser(cnxn, user_id)
+      user.email_bounce_timestamp = int(time.time())
+      services.user.UpdateUser(cnxn, user_id, user)
+    except exceptions.NoSuchUserException:
+      logging.info('User %r not found, ignoring', email_addr)
+      logging.info('Received bounce post ... [%s]', self.request)
+      logging.info('Bounce original: %s', bounce_message.original)
+      logging.info('Bounce notification: %s', bounce_message.notification)
diff --git a/features/notify.py b/features/notify.py
new file mode 100644
index 0000000..c285c76
--- /dev/null
+++ b/features/notify.py
@@ -0,0 +1,1055 @@
+# 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
+
+"""Task handlers for email notifications of issue changes.
+
+Email notificatons are sent when an issue changes, an issue that is blocking
+another issue changes, or a bulk edit is done.  The users notified include
+the project-wide mailing list, issue owners, cc'd users, starrers,
+also-notify addresses, and users who have saved queries with email notification
+set.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import collections
+import json
+import logging
+import os
+
+import ezt
+
+from google.appengine.api import mail
+from google.appengine.runtime import apiproxy_errors
+
+import settings
+from features import autolink
+from features import notify_helpers
+from features import notify_reasons
+from framework import authdata
+from framework import emailfmt
+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 monorailrequest
+from framework import permissions
+from framework import template_helpers
+from framework import urls
+from tracker import tracker_bizobj
+from tracker import tracker_helpers
+from tracker import tracker_views
+from proto import tracker_pb2
+
+
+class NotifyIssueChangeTask(notify_helpers.NotifyTaskBase):
+  """JSON servlet that notifies appropriate users after an issue change."""
+
+  _EMAIL_TEMPLATE = 'tracker/issue-change-notification-email.ezt'
+  _LINK_ONLY_EMAIL_TEMPLATE = (
+      'tracker/issue-change-notification-email-link-only.ezt')
+
+  def HandleRequest(self, mr):
+    """Process the task to notify users after an issue change.
+
+    Args:
+      mr: common information parsed from the HTTP request.
+
+    Returns:
+      Results dictionary in JSON format which is useful just for debugging.
+      The main goal is the side-effect of sending emails.
+    """
+    issue_id = mr.GetPositiveIntParam('issue_id')
+    if not issue_id:
+      return {
+          'params': {},
+          'notified': [],
+          'message': 'Cannot proceed without a valid issue ID.',
+      }
+    commenter_id = mr.GetPositiveIntParam('commenter_id')
+    seq_num = mr.seq
+    omit_ids = [commenter_id]
+    hostport = mr.GetParam('hostport')
+    try:
+      old_owner_id = mr.GetPositiveIntParam('old_owner_id')
+    except Exception:
+      old_owner_id = framework_constants.NO_USER_SPECIFIED
+    send_email = bool(mr.GetIntParam('send_email'))
+    comment_id = mr.GetPositiveIntParam('comment_id')
+    params = dict(
+        issue_id=issue_id, commenter_id=commenter_id,
+        seq_num=seq_num, hostport=hostport, old_owner_id=old_owner_id,
+        omit_ids=omit_ids, send_email=send_email, comment_id=comment_id)
+
+    logging.info('issue change params are %r', params)
+    # TODO(jrobbins): Re-enable the issue cache for notifications after
+    # the stale issue defect (monorail:2514) is 100% resolved.
+    issue = self.services.issue.GetIssue(mr.cnxn, issue_id, use_cache=False)
+    project = self.services.project.GetProject(mr.cnxn, issue.project_id)
+    config = self.services.config.GetProjectConfig(mr.cnxn, issue.project_id)
+
+    if issue.is_spam:
+      # Don't send email for spam issues.
+      return {
+          'params': params,
+          'notified': [],
+      }
+
+    all_comments = self.services.issue.GetCommentsForIssue(
+        mr.cnxn, issue.issue_id)
+    if comment_id:
+      logging.info('Looking up comment by comment_id')
+      for c in all_comments:
+        if c.id == comment_id:
+          comment = c
+          logging.info('Comment was found by comment_id')
+          break
+      else:
+        raise ValueError('Comment %r was not found' % comment_id)
+    else:
+      logging.info('Looking up comment by seq_num')
+      comment = all_comments[seq_num]
+
+    # Only issues that any contributor could view sent to mailing lists.
+    contributor_could_view = permissions.CanViewIssue(
+        set(), permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET,
+        project, issue)
+    starrer_ids = self.services.issue_star.LookupItemStarrers(
+        mr.cnxn, issue.issue_id)
+    users_by_id = framework_views.MakeAllUserViews(
+        mr.cnxn, self.services.user,
+        tracker_bizobj.UsersInvolvedInIssues([issue]), [old_owner_id],
+        tracker_bizobj.UsersInvolvedInComment(comment),
+        issue.cc_ids, issue.derived_cc_ids, starrer_ids, omit_ids)
+
+    # Make followup tasks to send emails
+    tasks = []
+    if send_email:
+      tasks = self._MakeEmailTasks(
+          mr.cnxn, project, issue, config, old_owner_id, users_by_id,
+          all_comments, comment, starrer_ids, contributor_could_view,
+          hostport, omit_ids, mr.perms)
+
+    notified = notify_helpers.AddAllEmailTasks(tasks)
+
+    return {
+        'params': params,
+        'notified': notified,
+        }
+
+  def _MakeEmailTasks(
+      self, cnxn, project, issue, config, old_owner_id,
+      users_by_id, all_comments, comment, starrer_ids,
+      contributor_could_view, hostport, omit_ids, perms):
+    """Formulate emails to be sent."""
+    detail_url = framework_helpers.IssueCommentURL(
+        hostport, project, issue.local_id, seq_num=comment.sequence)
+
+    # TODO(jrobbins): avoid the need to make a MonorailRequest object.
+    mr = monorailrequest.MonorailRequest(self.services)
+    mr.project_name = project.project_name
+    mr.project = project
+    mr.perms = perms
+
+    # We do not autolink in the emails, so just use an empty
+    # registry of autolink rules.
+    # TODO(jrobbins): offer users an HTML email option w/ autolinks.
+    autolinker = autolink.Autolink()
+    was_created = ezt.boolean(comment.sequence == 0)
+
+    email_data = {
+        # Pass open_related and closed_related into this method and to
+        # the issue view so that we can show it on new issue email.
+        'issue': tracker_views.IssueView(issue, users_by_id, config),
+        'summary': issue.summary,
+        'comment': tracker_views.IssueCommentView(
+            project.project_name, comment, users_by_id,
+            autolinker, {}, mr, issue),
+        'comment_text': comment.content,
+        'detail_url': detail_url,
+        'was_created': was_created,
+        }
+
+    # Generate three versions of email body: link-only is just the link,
+    # non-members see some obscured email addresses, and members version has
+    # all full email addresses exposed.
+    body_link_only = self.link_only_email_template.GetResponse(
+      {'detail_url': detail_url, 'was_created': was_created})
+    body_for_non_members = self.email_template.GetResponse(email_data)
+    framework_views.RevealAllEmails(users_by_id)
+    email_data['comment'] = tracker_views.IssueCommentView(
+        project.project_name, comment, users_by_id,
+        autolinker, {}, mr, issue)
+    body_for_members = self.email_template.GetResponse(email_data)
+
+    logging.info('link-only body is:\n%r' % body_link_only)
+    logging.info('body for non-members is:\n%r' % body_for_non_members)
+    logging.info('body for members is:\n%r' % body_for_members)
+
+    commenter_email = users_by_id[comment.user_id].email
+    omit_addrs = set([commenter_email] +
+                     [users_by_id[omit_id].email for omit_id in omit_ids])
+
+    auth = authdata.AuthData.FromUserID(
+        cnxn, comment.user_id, self.services)
+    commenter_in_project = framework_bizobj.UserIsInProject(
+        project, auth.effective_ids)
+    noisy = tracker_helpers.IsNoisy(len(all_comments) - 1, len(starrer_ids))
+
+    # Give each user a bullet-list of all the reasons that apply for that user.
+    group_reason_list = notify_reasons.ComputeGroupReasonList(
+        cnxn, self.services, project, issue, config, users_by_id,
+        omit_addrs, contributor_could_view, noisy=noisy,
+        starrer_ids=starrer_ids, old_owner_id=old_owner_id,
+        commenter_in_project=commenter_in_project)
+
+    commenter_view = users_by_id[comment.user_id]
+    detail_url = framework_helpers.FormatAbsoluteURLForDomain(
+        hostport, issue.project_name, urls.ISSUE_DETAIL,
+        id=issue.local_id)
+    email_tasks = notify_helpers.MakeBulletedEmailWorkItems(
+        group_reason_list, issue, body_link_only, body_for_non_members,
+        body_for_members, project, hostport, commenter_view, detail_url,
+        seq_num=comment.sequence)
+
+    return email_tasks
+
+
+class NotifyBlockingChangeTask(notify_helpers.NotifyTaskBase):
+  """JSON servlet that notifies appropriate users after a blocking change."""
+
+  _EMAIL_TEMPLATE = 'tracker/issue-blocking-change-notification-email.ezt'
+  _LINK_ONLY_EMAIL_TEMPLATE = (
+      'tracker/issue-change-notification-email-link-only.ezt')
+
+  def HandleRequest(self, mr):
+    """Process the task to notify users after an issue blocking change.
+
+    Args:
+      mr: common information parsed from the HTTP request.
+
+    Returns:
+      Results dictionary in JSON format which is useful just for debugging.
+      The main goal is the side-effect of sending emails.
+    """
+    issue_id = mr.GetPositiveIntParam('issue_id')
+    if not issue_id:
+      return {
+          'params': {},
+          'notified': [],
+          'message': 'Cannot proceed without a valid issue ID.',
+      }
+    commenter_id = mr.GetPositiveIntParam('commenter_id')
+    omit_ids = [commenter_id]
+    hostport = mr.GetParam('hostport')
+    delta_blocker_iids = mr.GetIntListParam('delta_blocker_iids')
+    send_email = bool(mr.GetIntParam('send_email'))
+    params = dict(
+        issue_id=issue_id, commenter_id=commenter_id,
+        hostport=hostport, delta_blocker_iids=delta_blocker_iids,
+        omit_ids=omit_ids, send_email=send_email)
+
+    logging.info('blocking change params are %r', params)
+    issue = self.services.issue.GetIssue(mr.cnxn, issue_id)
+    if issue.is_spam:
+      return {
+        'params': params,
+        'notified': [],
+        }
+
+    upstream_issues = self.services.issue.GetIssues(
+        mr.cnxn, delta_blocker_iids)
+    logging.info('updating ids %r', [up.local_id for up in upstream_issues])
+    upstream_projects = tracker_helpers.GetAllIssueProjects(
+        mr.cnxn, upstream_issues, self.services.project)
+    upstream_configs = self.services.config.GetProjectConfigs(
+        mr.cnxn, list(upstream_projects.keys()))
+
+    users_by_id = framework_views.MakeAllUserViews(
+        mr.cnxn, self.services.user, [commenter_id])
+    commenter_view = users_by_id[commenter_id]
+
+    tasks = []
+    if send_email:
+      for upstream_issue in upstream_issues:
+        one_issue_email_tasks = self._ProcessUpstreamIssue(
+            mr.cnxn, upstream_issue,
+            upstream_projects[upstream_issue.project_id],
+            upstream_configs[upstream_issue.project_id],
+            issue, omit_ids, hostport, commenter_view)
+        tasks.extend(one_issue_email_tasks)
+
+    notified = notify_helpers.AddAllEmailTasks(tasks)
+
+    return {
+        'params': params,
+        'notified': notified,
+        }
+
+  def _ProcessUpstreamIssue(
+      self, cnxn, upstream_issue, upstream_project, upstream_config,
+      issue, omit_ids, hostport, commenter_view):
+    """Compute notifications for one upstream issue that is now blocking."""
+    upstream_detail_url = framework_helpers.FormatAbsoluteURLForDomain(
+        hostport, upstream_issue.project_name, urls.ISSUE_DETAIL,
+        id=upstream_issue.local_id)
+    logging.info('upstream_detail_url = %r', upstream_detail_url)
+    detail_url = framework_helpers.FormatAbsoluteURLForDomain(
+        hostport, issue.project_name, urls.ISSUE_DETAIL,
+        id=issue.local_id)
+
+    # Only issues that any contributor could view are sent to mailing lists.
+    contributor_could_view = permissions.CanViewIssue(
+        set(), permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET,
+        upstream_project, upstream_issue)
+
+    # Now construct the e-mail to send
+
+    # Note: we purposely do not notify users who starred an issue
+    # about changes in blocking.
+    users_by_id = framework_views.MakeAllUserViews(
+        cnxn, self.services.user,
+        tracker_bizobj.UsersInvolvedInIssues([upstream_issue]), omit_ids)
+
+    is_blocking = upstream_issue.issue_id in issue.blocked_on_iids
+
+    email_data = {
+        'issue': tracker_views.IssueView(
+            upstream_issue, users_by_id, upstream_config),
+        'summary': upstream_issue.summary,
+        'detail_url': upstream_detail_url,
+        'is_blocking': ezt.boolean(is_blocking),
+        'downstream_issue_ref': tracker_bizobj.FormatIssueRef(
+            (None, issue.local_id)),
+        'downstream_issue_url': detail_url,
+        }
+
+    # TODO(jrobbins): Generate two versions of email body: members
+    # vesion has other member full email addresses exposed.  But, don't
+    # expose too many as we iterate through upstream projects.
+    body_link_only = self.link_only_email_template.GetResponse(
+        {'detail_url': upstream_detail_url, 'was_created': ezt.boolean(False)})
+    body = self.email_template.GetResponse(email_data)
+
+    omit_addrs = {users_by_id[omit_id].email for omit_id in omit_ids}
+
+    # Get the transitive set of owners and Cc'd users, and their UserView's.
+    # Give each user a bullet-list of all the reasons that apply for that user.
+    # Starrers are not notified of blocking changes to reduce noise.
+    group_reason_list = notify_reasons.ComputeGroupReasonList(
+        cnxn, self.services, upstream_project, upstream_issue,
+        upstream_config, users_by_id, omit_addrs, contributor_could_view)
+    one_issue_email_tasks = notify_helpers.MakeBulletedEmailWorkItems(
+        group_reason_list, upstream_issue, body_link_only, body, body,
+        upstream_project, hostport, commenter_view, detail_url)
+
+    return one_issue_email_tasks
+
+
+class NotifyBulkChangeTask(notify_helpers.NotifyTaskBase):
+  """JSON servlet that notifies appropriate users after a bulk edit."""
+
+  _EMAIL_TEMPLATE = 'tracker/issue-bulk-change-notification-email.ezt'
+
+  def HandleRequest(self, mr):
+    """Process the task to notify users after an issue blocking change.
+
+    Args:
+      mr: common information parsed from the HTTP request.
+
+    Returns:
+      Results dictionary in JSON format which is useful just for debugging.
+      The main goal is the side-effect of sending emails.
+    """
+    issue_ids = mr.GetIntListParam('issue_ids')
+    hostport = mr.GetParam('hostport')
+    if not issue_ids:
+      return {
+          'params': {},
+          'notified': [],
+          'message': 'Cannot proceed without a valid issue IDs.',
+      }
+
+    old_owner_ids = mr.GetIntListParam('old_owner_ids')
+    comment_text = mr.GetParam('comment_text')
+    commenter_id = mr.GetPositiveIntParam('commenter_id')
+    amendments = mr.GetParam('amendments')
+    send_email = bool(mr.GetIntParam('send_email'))
+    params = dict(
+        issue_ids=issue_ids, commenter_id=commenter_id, hostport=hostport,
+        old_owner_ids=old_owner_ids, comment_text=comment_text,
+        send_email=send_email, amendments=amendments)
+
+    logging.info('bulk edit params are %r', params)
+    issues = self.services.issue.GetIssues(mr.cnxn, issue_ids)
+    # TODO(jrobbins): For cross-project bulk edits, prefetch all relevant
+    # projects and configs and pass a dict of them to subroutines.  For
+    # now, all issue must be in the same project.
+    project_id = issues[0].project_id
+    project = self.services.project.GetProject(mr.cnxn, project_id)
+    config = self.services.config.GetProjectConfig(mr.cnxn, project_id)
+    issues = [issue for issue in issues if not issue.is_spam]
+    anon_perms = permissions.GetPermissions(None, set(), project)
+
+    users_by_id = framework_views.MakeAllUserViews(
+        mr.cnxn, self.services.user, [commenter_id])
+    ids_in_issues = {}
+    starrers = {}
+
+    non_private_issues = []
+    for issue, old_owner_id in zip(issues, old_owner_ids):
+      # TODO(jrobbins): use issue_id consistently rather than local_id.
+      starrers[issue.local_id] = self.services.issue_star.LookupItemStarrers(
+          mr.cnxn, issue.issue_id)
+      named_ids = set()  # users named in user-value fields that notify.
+      for fd in config.field_defs:
+        named_ids.update(notify_reasons.ComputeNamedUserIDsToNotify(
+            issue.field_values, fd))
+      direct, indirect = self.services.usergroup.ExpandAnyGroupEmailRecipients(
+          mr.cnxn,
+          list(issue.cc_ids) + list(issue.derived_cc_ids) +
+          [issue.owner_id, old_owner_id, issue.derived_owner_id] +
+          list(named_ids))
+      ids_in_issues[issue.local_id] = set(starrers[issue.local_id])
+      ids_in_issues[issue.local_id].update(direct)
+      ids_in_issues[issue.local_id].update(indirect)
+      ids_in_issue_needing_views = (
+          ids_in_issues[issue.local_id] |
+          tracker_bizobj.UsersInvolvedInIssues([issue]))
+      new_ids_in_issue = [user_id for user_id in ids_in_issue_needing_views
+                          if user_id not in users_by_id]
+      users_by_id.update(
+          framework_views.MakeAllUserViews(
+              mr.cnxn, self.services.user, new_ids_in_issue))
+
+      anon_can_view = permissions.CanViewIssue(
+          set(), anon_perms, project, issue)
+      if anon_can_view:
+        non_private_issues.append(issue)
+
+    commenter_view = users_by_id[commenter_id]
+    omit_addrs = {commenter_view.email}
+
+    tasks = []
+    if send_email:
+      email_tasks = self._BulkEditEmailTasks(
+          mr.cnxn, issues, old_owner_ids, omit_addrs, project,
+          non_private_issues, users_by_id, ids_in_issues, starrers,
+          commenter_view, hostport, comment_text, amendments, config)
+      tasks = email_tasks
+
+    notified = notify_helpers.AddAllEmailTasks(tasks)
+    return {
+        'params': params,
+        'notified': notified,
+        }
+
+  def _BulkEditEmailTasks(
+      self, cnxn, issues, old_owner_ids, omit_addrs, project,
+      non_private_issues, users_by_id, ids_in_issues, starrers,
+      commenter_view, hostport, comment_text, amendments, config):
+    """Generate Email PBs to notify interested users after a bulk edit."""
+    # 1. Get the user IDs of everyone who could be notified,
+    # and make all their user proxies. Also, build a dictionary
+    # of all the users to notify and the issues that they are
+    # interested in.  Also, build a dictionary of additional email
+    # addresses to notify and the issues to notify them of.
+    users_by_id = {}
+    ids_to_notify_of_issue = {}
+    additional_addrs_to_notify_of_issue = collections.defaultdict(list)
+
+    users_to_queries = notify_reasons.GetNonOmittedSubscriptions(
+        cnxn, self.services, [project.project_id], {})
+    config = self.services.config.GetProjectConfig(
+        cnxn, project.project_id)
+    for issue, old_owner_id in zip(issues, old_owner_ids):
+      issue_participants = set(
+          [tracker_bizobj.GetOwnerId(issue), old_owner_id] +
+          tracker_bizobj.GetCcIds(issue))
+      # users named in user-value fields that notify.
+      for fd in config.field_defs:
+        issue_participants.update(
+            notify_reasons.ComputeNamedUserIDsToNotify(issue.field_values, fd))
+      for user_id in ids_in_issues[issue.local_id]:
+        # TODO(jrobbins): implement batch GetUser() for speed.
+        if not user_id:
+          continue
+        auth = authdata.AuthData.FromUserID(
+            cnxn, user_id, self.services)
+        if (auth.user_pb.notify_issue_change and
+            not auth.effective_ids.isdisjoint(issue_participants)):
+          ids_to_notify_of_issue.setdefault(user_id, []).append(issue)
+        elif (auth.user_pb.notify_starred_issue_change and
+              user_id in starrers[issue.local_id]):
+          # Skip users who have starred issues that they can no longer view.
+          starrer_perms = permissions.GetPermissions(
+              auth.user_pb, auth.effective_ids, project)
+          granted_perms = tracker_bizobj.GetGrantedPerms(
+              issue, auth.effective_ids, config)
+          starrer_can_view = permissions.CanViewIssue(
+              auth.effective_ids, starrer_perms, project, issue,
+              granted_perms=granted_perms)
+          if starrer_can_view:
+            ids_to_notify_of_issue.setdefault(user_id, []).append(issue)
+        logging.info(
+            'ids_to_notify_of_issue[%s] = %s',
+            user_id,
+            [i.local_id for i in ids_to_notify_of_issue.get(user_id, [])])
+
+      # Find all subscribers that should be notified.
+      subscribers_to_consider = notify_reasons.EvaluateSubscriptions(
+          cnxn, issue, users_to_queries, self.services, config)
+      for sub_id in subscribers_to_consider:
+        auth = authdata.AuthData.FromUserID(cnxn, sub_id, self.services)
+        sub_perms = permissions.GetPermissions(
+            auth.user_pb, auth.effective_ids, project)
+        granted_perms = tracker_bizobj.GetGrantedPerms(
+            issue, auth.effective_ids, config)
+        sub_can_view = permissions.CanViewIssue(
+            auth.effective_ids, sub_perms, project, issue,
+            granted_perms=granted_perms)
+        if sub_can_view:
+          ids_to_notify_of_issue.setdefault(sub_id, [])
+          if issue not in ids_to_notify_of_issue[sub_id]:
+            ids_to_notify_of_issue[sub_id].append(issue)
+
+      if issue in non_private_issues:
+        for notify_addr in issue.derived_notify_addrs:
+          additional_addrs_to_notify_of_issue[notify_addr].append(issue)
+
+    # 2. Compose an email specifically for each user, and one email to each
+    # notify_addr with all the issues that it.
+    # Start from non-members first, then members to reveal email addresses.
+    email_tasks = []
+    needed_user_view_ids = [uid for uid in ids_to_notify_of_issue
+                            if uid not in users_by_id]
+    users_by_id.update(framework_views.MakeAllUserViews(
+        cnxn, self.services.user, needed_user_view_ids))
+    member_ids_to_notify_of_issue = {}
+    non_member_ids_to_notify_of_issue = {}
+    member_additional_addrs = {}
+    non_member_additional_addrs = {}
+    addr_to_addrperm = {}  # {email_address: AddrPerm object}
+    all_user_prefs = self.services.user.GetUsersPrefs(
+        cnxn, ids_to_notify_of_issue)
+
+    # TODO(jrobbins): Merge ids_to_notify_of_issue entries for linked accounts.
+
+    for user_id in ids_to_notify_of_issue:
+      if not user_id:
+        continue  # Don't try to notify NO_USER_SPECIFIED
+      if users_by_id[user_id].email in omit_addrs:
+        logging.info('Omitting %s', user_id)
+        continue
+      user_issues = ids_to_notify_of_issue[user_id]
+      if not user_issues:
+        continue  # user's prefs indicate they don't want these notifications
+      auth = authdata.AuthData.FromUserID(
+          cnxn, user_id, self.services)
+      is_member = bool(framework_bizobj.UserIsInProject(
+          project, auth.effective_ids))
+      if is_member:
+        member_ids_to_notify_of_issue[user_id] = user_issues
+      else:
+        non_member_ids_to_notify_of_issue[user_id] = user_issues
+      addr = users_by_id[user_id].email
+      omit_addrs.add(addr)
+      addr_to_addrperm[addr] = notify_reasons.AddrPerm(
+          is_member, addr, users_by_id[user_id].user,
+          notify_reasons.REPLY_NOT_ALLOWED, all_user_prefs[user_id])
+
+    for addr, addr_issues in additional_addrs_to_notify_of_issue.items():
+      auth = None
+      try:
+        auth = authdata.AuthData.FromEmail(cnxn, addr, self.services)
+      except:  # pylint: disable=bare-except
+        logging.warning('Cannot find user of email %s ', addr)
+      if auth:
+        is_member = bool(framework_bizobj.UserIsInProject(
+            project, auth.effective_ids))
+      else:
+        is_member = False
+      if is_member:
+        member_additional_addrs[addr] = addr_issues
+      else:
+        non_member_additional_addrs[addr] = addr_issues
+      omit_addrs.add(addr)
+      addr_to_addrperm[addr] = notify_reasons.AddrPerm(
+          is_member, addr, None, notify_reasons.REPLY_NOT_ALLOWED, None)
+
+    for user_id, user_issues in non_member_ids_to_notify_of_issue.items():
+      addr = users_by_id[user_id].email
+      email = self._FormatBulkIssuesEmail(
+          addr_to_addrperm[addr], user_issues, users_by_id,
+          commenter_view, hostport, comment_text, amendments, config, project)
+      email_tasks.append(email)
+      logging.info('about to bulk notify non-member %s (%s) of %s',
+                   users_by_id[user_id].email, user_id,
+                   [issue.local_id for issue in user_issues])
+
+    for addr, addr_issues in non_member_additional_addrs.items():
+      email = self._FormatBulkIssuesEmail(
+          addr_to_addrperm[addr], addr_issues, users_by_id, commenter_view,
+          hostport, comment_text, amendments, config, project)
+      email_tasks.append(email)
+      logging.info('about to bulk notify non-member additional addr %s of %s',
+                   addr, [addr_issue.local_id for addr_issue in addr_issues])
+
+    framework_views.RevealAllEmails(users_by_id)
+    commenter_view.RevealEmail()
+
+    for user_id, user_issues in member_ids_to_notify_of_issue.items():
+      addr = users_by_id[user_id].email
+      email = self._FormatBulkIssuesEmail(
+          addr_to_addrperm[addr], user_issues, users_by_id,
+          commenter_view, hostport, comment_text, amendments, config, project)
+      email_tasks.append(email)
+      logging.info('about to bulk notify member %s (%s) of %s',
+                   addr, user_id, [issue.local_id for issue in user_issues])
+
+    for addr, addr_issues in member_additional_addrs.items():
+      email = self._FormatBulkIssuesEmail(
+          addr_to_addrperm[addr], addr_issues, users_by_id, commenter_view,
+          hostport, comment_text, amendments, config, project)
+      email_tasks.append(email)
+      logging.info('about to bulk notify member additional addr %s of %s',
+                   addr, [addr_issue.local_id for addr_issue in addr_issues])
+
+    # 4. Add in the project's issue_notify_address.  This happens even if it
+    # is the same as the commenter's email address (which would be an unusual
+    # but valid project configuration).  Only issues that any contributor could
+    # view are included in emails to the all-issue-activity mailing lists.
+    if (project.issue_notify_address
+        and project.issue_notify_address not in omit_addrs):
+      non_private_issues_live = []
+      for issue in issues:
+        contributor_could_view = permissions.CanViewIssue(
+            set(), permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET,
+            project, issue)
+        if contributor_could_view:
+          non_private_issues_live.append(issue)
+
+      if non_private_issues_live:
+        project_notify_addrperm = notify_reasons.AddrPerm(
+            True, project.issue_notify_address, None,
+            notify_reasons.REPLY_NOT_ALLOWED, None)
+        email = self._FormatBulkIssuesEmail(
+            project_notify_addrperm, non_private_issues_live,
+            users_by_id, commenter_view, hostport, comment_text, amendments,
+            config, project)
+        email_tasks.append(email)
+        omit_addrs.add(project.issue_notify_address)
+        logging.info('about to bulk notify all-issues %s of %s',
+                     project.issue_notify_address,
+                     [issue.local_id for issue in non_private_issues])
+
+    return email_tasks
+
+  def _FormatBulkIssuesEmail(
+      self, addr_perm, issues, users_by_id, commenter_view,
+      hostport, comment_text, amendments, config, project):
+    """Format an email to one user listing many issues."""
+
+    from_addr = emailfmt.FormatFromAddr(
+        project, commenter_view=commenter_view, reveal_addr=addr_perm.is_member,
+        can_reply_to=False)
+
+    subject, body = self._FormatBulkIssues(
+        issues, users_by_id, commenter_view, hostport, comment_text,
+        amendments, config, addr_perm)
+    body = notify_helpers._TruncateBody(body)
+
+    return dict(from_addr=from_addr, to=addr_perm.address, subject=subject,
+                body=body)
+
+  def _FormatBulkIssues(
+      self, issues, users_by_id, commenter_view, hostport, comment_text,
+      amendments, config, addr_perm):
+    """Format a subject and body for a bulk issue edit."""
+    project_name = issues[0].project_name
+
+    any_link_only = False
+    issue_views = []
+    for issue in issues:
+      # TODO(jrobbins): choose config from dict of prefetched configs.
+      issue_view = tracker_views.IssueView(issue, users_by_id, config)
+      issue_view.link_only = ezt.boolean(False)
+      if addr_perm and notify_helpers.ShouldUseLinkOnly(addr_perm, issue):
+        issue_view.link_only = ezt.boolean(True)
+        any_link_only = True
+      issue_views.append(issue_view)
+
+    email_data = {
+        'any_link_only': ezt.boolean(any_link_only),
+        'hostport': hostport,
+        'num_issues': len(issues),
+        'issues': issue_views,
+        'comment_text': comment_text,
+        'commenter': commenter_view,
+        'amendments': amendments,
+    }
+
+    if len(issues) == 1:
+      # TODO(jrobbins): use compact email subject lines based on user pref.
+      if addr_perm and notify_helpers.ShouldUseLinkOnly(addr_perm, issues[0]):
+        subject = 'issue %s in %s' % (issues[0].local_id, project_name)
+      else:
+        subject = 'issue %s in %s: %s' % (
+            issues[0].local_id, project_name, issues[0].summary)
+      # TODO(jrobbins): Look up the sequence number instead and treat this
+      # more like an individual change for email threading.  For now, just
+      # add "Re:" because bulk edits are always replies.
+      subject = 'Re: ' + subject
+    else:
+      subject = '%d issues changed in %s' % (len(issues), project_name)
+
+    body = self.email_template.GetResponse(email_data)
+
+    return subject, body
+
+
+# For now, this class will not be used to send approval comment notifications
+# TODO(jojwang): monorail:3588, it might make sense for this class to handle
+# sending comment notifications for approval custom_subfield changes.
+class NotifyApprovalChangeTask(notify_helpers.NotifyTaskBase):
+  """JSON servlet that notifies appropriate users after an approval change."""
+
+  _EMAIL_TEMPLATE = 'tracker/approval-change-notification-email.ezt'
+
+  def HandleRequest(self, mr):
+    """Process the task to notify users after an approval change.
+
+    Args:
+      mr: common information parsed from the HTTP request.
+
+    Returns:
+      Results dictionary in JSON format which is useful just for debugging.
+      The main goal is the side-effect of sending emails.
+    """
+
+    send_email = bool(mr.GetIntParam('send_email'))
+    issue_id = mr.GetPositiveIntParam('issue_id')
+    approval_id = mr.GetPositiveIntParam('approval_id')
+    comment_id = mr.GetPositiveIntParam('comment_id')
+    hostport = mr.GetParam('hostport')
+
+    params = dict(
+        temporary='',
+        hostport=hostport,
+        issue_id=issue_id
+        )
+    logging.info('approval change params are %r', params)
+
+    issue, approval_value = self.services.issue.GetIssueApproval(
+        mr.cnxn, issue_id, approval_id, use_cache=False)
+    project = self.services.project.GetProject(mr.cnxn, issue.project_id)
+    config = self.services.config.GetProjectConfig(mr.cnxn, issue.project_id)
+
+    approval_fd = tracker_bizobj.FindFieldDefByID(approval_id, config)
+    if approval_fd is None:
+      raise exceptions.NoSuchFieldDefException()
+
+    # GetCommentsForIssue will fill the sequence for all comments, while
+    # other method for getting a single comment will not.
+    # The comment sequence is especially useful for Approval issues with
+    # many comment sections.
+    comment = None
+    all_comments = self.services.issue.GetCommentsForIssue(mr.cnxn, issue_id)
+    for c in all_comments:
+      if c.id == comment_id:
+        comment = c
+        break
+    if not comment:
+      raise exceptions.NoSuchCommentException()
+
+    field_user_ids = set()
+    relevant_fds = [fd for fd in config.field_defs if
+                    not fd.approval_id or
+                    fd.approval_id is approval_value.approval_id]
+    for fd in relevant_fds:
+      field_user_ids.update(
+          notify_reasons.ComputeNamedUserIDsToNotify(issue.field_values, fd))
+    users_by_id = framework_views.MakeAllUserViews(
+        mr.cnxn, self.services.user, [issue.owner_id],
+        approval_value.approver_ids,
+        tracker_bizobj.UsersInvolvedInComment(comment),
+        list(field_user_ids))
+
+    tasks = []
+    if send_email:
+      tasks = self._MakeApprovalEmailTasks(
+          hostport, issue, project, approval_value, approval_fd.field_name,
+          comment, users_by_id, list(field_user_ids), mr.perms)
+
+    notified = notify_helpers.AddAllEmailTasks(tasks)
+
+    return {
+        'params': params,
+        'notified': notified,
+        'tasks': tasks,
+        }
+
+  def _MakeApprovalEmailTasks(
+      self, hostport, issue, project, approval_value, approval_name,
+      comment, users_by_id, user_ids_from_fields, perms):
+    """Formulate emails to be sent."""
+
+    # TODO(jojwang): avoid need to make MonorailRequest and autolinker
+    # for comment_view OR make make tracker_views._ParseTextRuns public
+    # and only pass text_runs to email_data.
+    mr = monorailrequest.MonorailRequest(self.services)
+    mr.project_name = project.project_name
+    mr.project = project
+    mr.perms = perms
+    autolinker = autolink.Autolink()
+
+    approval_url = framework_helpers.IssueCommentURL(
+        hostport, project, issue.local_id, seq_num=comment.sequence)
+
+    comment_view = tracker_views.IssueCommentView(
+        project.project_name, comment, users_by_id, autolinker, {}, mr, issue)
+    domain_url = framework_helpers.FormatAbsoluteURLForDomain(
+        hostport, project.project_name, '/issues/')
+
+    commenter_view = users_by_id[comment.user_id]
+    email_data = {
+        'domain_url': domain_url,
+        'approval_url': approval_url,
+        'comment': comment_view,
+        'issue_local_id': issue.local_id,
+        'summary': issue.summary,
+        }
+    subject = '%s Approval: %s (Issue %s)' % (
+        approval_name, issue.summary, issue.local_id)
+    email_body = self.email_template.GetResponse(email_data)
+    body = notify_helpers._TruncateBody(email_body)
+
+    recipient_ids = self._GetApprovalEmailRecipients(
+        approval_value, comment, issue, user_ids_from_fields,
+        omit_ids=[comment.user_id])
+    direct, indirect = self.services.usergroup.ExpandAnyGroupEmailRecipients(
+        mr.cnxn, recipient_ids)
+    # group ids were found in recipient_ids.
+    # Re-set recipient_ids to remove group_ids
+    if indirect:
+      recipient_ids = set(direct + indirect)
+      users_by_id.update(framework_views.MakeAllUserViews(
+          mr.cnxn, self.services.user, indirect))  # already contains direct
+
+    # TODO(crbug.com/monorail/9104): Compute notify_reasons.AddrPerms based on
+    # project settings and recipient permissions so `reply_to` can be accurately
+    # set.
+
+    email_tasks = []
+    for rid in recipient_ids:
+      from_addr = emailfmt.FormatFromAddr(
+          project, commenter_view=commenter_view, can_reply_to=False)
+      dest_email = users_by_id[rid].email
+
+      refs = emailfmt.GetReferences(
+          dest_email, subject, comment.sequence,
+          '%s@%s' % (project.project_name, emailfmt.MailDomain()))
+      reply_to = emailfmt.NoReplyAddress()
+      email_tasks.append(
+          dict(
+              from_addr=from_addr,
+              to=dest_email,
+              subject=subject,
+              body=body,
+              reply_to=reply_to,
+              references=refs))
+
+    return email_tasks
+
+  def _GetApprovalEmailRecipients(
+      self, approval_value, comment, issue, user_ids_from_fields,
+      omit_ids=None):
+    # TODO(jojwang): monorail:3588, reorganize this, since now, comment_content
+    # and approval amendments happen in the same comment.
+    # NOTE: user_ids_from_fields are all the notify_on=ANY_COMMENT users.
+    # However, these users will still be excluded from notifications
+    # meant for approvers only eg. (status changed to REVIEW_REQUESTED).
+    recipient_ids = []
+    if comment.amendments:
+      for amendment in comment.amendments:
+        if amendment.custom_field_name == 'Status':
+          if (approval_value.status is
+              tracker_pb2.ApprovalStatus.REVIEW_REQUESTED):
+            recipient_ids = approval_value.approver_ids
+          else:
+            recipient_ids.extend([issue.owner_id])
+            recipient_ids.extend(user_ids_from_fields)
+
+        elif amendment.custom_field_name == 'Approvers':
+          recipient_ids.extend(approval_value.approver_ids)
+          recipient_ids.append(issue.owner_id)
+          recipient_ids.extend(user_ids_from_fields)
+          recipient_ids.extend(amendment.removed_user_ids)
+    else:
+      # No amendments, just a comment.
+      recipient_ids.extend(approval_value.approver_ids)
+      recipient_ids.append(issue.owner_id)
+      recipient_ids.extend(user_ids_from_fields)
+
+    if omit_ids:
+      recipient_ids = [rid for rid in recipient_ids if rid not in omit_ids]
+
+    return list(set(recipient_ids))
+
+
+class NotifyRulesDeletedTask(notify_helpers.NotifyTaskBase):
+  """JSON servlet that sends one email."""
+
+  _EMAIL_TEMPLATE = 'project/rules-deleted-notification-email.ezt'
+
+  def HandleRequest(self, mr):
+    """Process the task to notify project owners after a filter rule
+      has been deleted.
+
+    Args:
+      mr: common information parsed from the HTTP request.
+
+    Returns:
+      Results dictionary in JSON format which is useful for debugging.
+    """
+    project_id = mr.GetPositiveIntParam('project_id')
+    rules = mr.GetListParam('filter_rules')
+    hostport = mr.GetParam('hostport')
+
+    params = dict(
+        project_id=project_id,
+        rules=rules,
+        hostport=hostport,
+        )
+    logging.info('deleted filter rules params are %r', params)
+
+    project = self.services.project.GetProject(mr.cnxn, project_id)
+    emails_by_id = self.services.user.LookupUserEmails(
+        mr.cnxn, project.owner_ids, ignore_missed=True)
+
+    tasks = self._MakeRulesDeletedEmailTasks(
+        hostport, project, emails_by_id, rules)
+    notified = notify_helpers.AddAllEmailTasks(tasks)
+
+    return {
+        'params': params,
+        'notified': notified,
+        'tasks': tasks,
+        }
+
+  def _MakeRulesDeletedEmailTasks(self, hostport, project, emails_by_id, rules):
+
+    rules_url = framework_helpers.FormatAbsoluteURLForDomain(
+        hostport, project.project_name, urls.ADMIN_RULES)
+
+    email_data = {
+        'project_name': project.project_name,
+        'rules': rules,
+        'rules_url': rules_url,
+    }
+    logging.info(email_data)
+    subject = '%s Project: Deleted Filter Rules' % project.project_name
+    email_body = self.email_template.GetResponse(email_data)
+    body = notify_helpers._TruncateBody(email_body)
+
+    email_tasks = []
+    for rid in project.owner_ids:
+      from_addr = emailfmt.FormatFromAddr(
+          project, reveal_addr=True, can_reply_to=False)
+      dest_email = emails_by_id.get(rid)
+      email_tasks.append(
+          dict(from_addr=from_addr, to=dest_email, subject=subject, body=body))
+
+    return email_tasks
+
+
+class OutboundEmailTask(jsonfeed.InternalTask):
+  """JSON servlet that sends one email.
+
+  Handles tasks enqueued from notify_helpers._EnqueueOutboundEmail.
+  """
+
+  def HandleRequest(self, mr):
+    """Process the task to send one email message.
+
+    Args:
+      mr: common information parsed from the HTTP request.
+
+    Returns:
+      Results dictionary in JSON format which is useful just for debugging.
+      The main goal is the side-effect of sending emails.
+    """
+    # To avoid urlencoding the email body, the most salient parameters to this
+    # method are passed as a json-encoded POST body.
+    try:
+      email_params = json.loads(self.request.body)
+    except ValueError:
+      logging.error(self.request.body)
+      raise
+    # If running on a GAFYD domain, you must define an app alias on the
+    # Application Settings admin web page.
+    sender = email_params.get('from_addr')
+    reply_to = email_params.get('reply_to')
+    to = email_params.get('to')
+    if not to:
+      # Cannot proceed if we cannot create a valid EmailMessage.
+      return {'note': 'Skipping because no "to" address found.'}
+
+    # Don't send emails to any banned users.
+    try:
+      user_id = self.services.user.LookupUserID(mr.cnxn, to)
+      user = self.services.user.GetUser(mr.cnxn, user_id)
+      if user.banned:
+        logging.info('Not notifying banned user %r', user.email)
+        return {'note': 'Skipping because user is banned.'}
+    except exceptions.NoSuchUserException:
+      pass
+
+    references = email_params.get('references')
+    subject = email_params.get('subject')
+    body = email_params.get('body')
+    html_body = email_params.get('html_body')
+
+    if settings.local_mode:
+      to_format = settings.send_local_email_to
+    else:
+      to_format = settings.send_all_email_to
+
+    if to_format:
+      to_user, to_domain = to.split('@')
+      to = to_format % {'user': to_user, 'domain': to_domain}
+
+    logging.info(
+        'Email:\n sender: %s\n reply_to: %s\n to: %s\n references: %s\n '
+        'subject: %s\n body: %s\n html body: %s',
+        sender, reply_to, to, references, subject, body, html_body)
+    if html_body:
+      logging.info('Readable HTML:\n%s', html_body.replace('<br/>', '<br/>\n'))
+    message = mail.EmailMessage(
+        sender=sender, to=to, subject=subject, body=body)
+    if html_body:
+      message.html = html_body
+    if reply_to:
+      message.reply_to = reply_to
+    if references:
+      message.headers = {'References': references}
+    if settings.unit_test_mode:
+      logging.info('Sending message "%s" in test mode.', message.subject)
+    else:
+      retry_count = 3
+      for i in range(retry_count):
+        try:
+          message.send()
+          break
+        except apiproxy_errors.DeadlineExceededError as ex:
+          logging.warning('Sending email timed out on try: %d', i)
+          logging.warning(str(ex))
+
+    return dict(
+        sender=sender, to=to, subject=subject, body=body, html_body=html_body,
+        reply_to=reply_to, references=references)
diff --git a/features/notify_helpers.py b/features/notify_helpers.py
new file mode 100644
index 0000000..f22ed38
--- /dev/null
+++ b/features/notify_helpers.py
@@ -0,0 +1,440 @@
+# 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 email notifications of issue changes."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import json
+import logging
+
+import ezt
+import six
+
+from features import autolink
+from features import autolink_constants
+from features import features_constants
+from features import filterrules_helpers
+from features import savedqueries_helpers
+from features import notify_reasons
+from framework import cloud_tasks_helpers
+from framework import emailfmt
+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 monorailrequest
+from framework import permissions
+from framework import template_helpers
+from framework import urls
+from proto import tracker_pb2
+from search import query2ast
+from search import searchpipeline
+from tracker import tracker_bizobj
+
+
+# Email tasks can get too large for AppEngine to handle. In order to prevent
+# that, we set a maximum body size, and may truncate messages to that length.
+# We set this value to 35k so that the total of 35k body + 35k html_body +
+# metadata does not exceed AppEngine's limit of 100k.
+MAX_EMAIL_BODY_SIZE = 35 * 1024
+
+# This HTML template adds mark up which enables Gmail/Inbox to display a
+# convenient link that takes users to the CL directly from the inbox without
+# having to click on the email.
+# Documentation for this schema.org markup is here:
+#   https://developers.google.com/gmail/markup/reference/go-to-action
+HTML_BODY_WITH_GMAIL_ACTION_TEMPLATE = """
+<html>
+<body>
+<script type="application/ld+json">
+{
+  "@context": "http://schema.org",
+  "@type": "EmailMessage",
+  "potentialAction": {
+    "@type": "ViewAction",
+    "name": "View Issue",
+    "url": "%(url)s"
+  },
+  "description": ""
+}
+</script>
+
+<div style="font-family: arial, sans-serif; white-space:pre">%(body)s</div>
+</body>
+</html>
+"""
+
+HTML_BODY_WITHOUT_GMAIL_ACTION_TEMPLATE = """
+<html>
+<body>
+<div style="font-family: arial, sans-serif; white-space:pre">%(body)s</div>
+</body>
+</html>
+"""
+
+
+NOTIFY_RESTRICTED_ISSUES_PREF_NAME = 'notify_restricted_issues'
+NOTIFY_WITH_DETAILS = 'notify with details'
+NOTIFY_WITH_DETAILS_GOOGLE = 'notify with details: Google'
+NOTIFY_WITH_LINK_ONLY = 'notify with link only'
+
+
+def _EnqueueOutboundEmail(message_dict):
+  """Create a task to send one email message, all fields are in the dict.
+
+  We use a separate task for each outbound email to isolate errors.
+
+  Args:
+    message_dict: dict with all needed info for the task.
+  """
+  # We use a JSON-encoded payload because it ensures that the task size is
+  # effectively the same as the sum of the email bodies. Using params results
+  # in the dict being urlencoded, which can (worst case) triple the size of
+  # an email body containing many characters which need to be escaped.
+  payload = json.dumps(message_dict)
+  task = {
+      'app_engine_http_request':
+          {
+              'relative_uri': urls.OUTBOUND_EMAIL_TASK + '.do',
+              # Cloud Tasks expects body to be in bytes.
+              'body': payload.encode(),
+              # Cloud tasks default body content type is octet-stream.
+              'headers': {
+                  'Content-type': 'application/json'
+              }
+          }
+  }
+  cloud_tasks_helpers.create_task(
+      task, queue=features_constants.QUEUE_OUTBOUND_EMAIL)
+
+
+def AddAllEmailTasks(tasks):
+  """Add one GAE task for each email to be sent."""
+  notified = []
+  for task in tasks:
+    _EnqueueOutboundEmail(task)
+    notified.append(task['to'])
+
+  return notified
+
+
+class NotifyTaskBase(jsonfeed.InternalTask):
+  """Abstract base class for notification task handler."""
+
+  _EMAIL_TEMPLATE = None  # Subclasses must override this.
+  _LINK_ONLY_EMAIL_TEMPLATE = None  # Subclasses may override this.
+
+  CHECK_SECURITY_TOKEN = False
+
+  def __init__(self, *args, **kwargs):
+    super(NotifyTaskBase, self).__init__(*args, **kwargs)
+
+    if not self._EMAIL_TEMPLATE:
+      raise Exception('Subclasses must override _EMAIL_TEMPLATE.'
+                      ' This class must not be called directly.')
+    # We use FORMAT_RAW for emails because they are plain text, not HTML.
+    # TODO(jrobbins): consider sending HTML formatted emails someday.
+    self.email_template = template_helpers.MonorailTemplate(
+        framework_constants.TEMPLATE_PATH + self._EMAIL_TEMPLATE,
+        compress_whitespace=False, base_format=ezt.FORMAT_RAW)
+
+    if self._LINK_ONLY_EMAIL_TEMPLATE:
+      self.link_only_email_template = template_helpers.MonorailTemplate(
+          framework_constants.TEMPLATE_PATH + self._LINK_ONLY_EMAIL_TEMPLATE,
+          compress_whitespace=False, base_format=ezt.FORMAT_RAW)
+
+
+def _MergeLinkedAccountReasons(addr_to_addrperm, addr_to_reasons):
+  """Return an addr_reasons_dict where parents omit child accounts."""
+  all_ids = set(addr_perm.user.user_id
+                for addr_perm in addr_to_addrperm.values()
+                if addr_perm.user)
+  merged_ids = set()
+
+  result = {}
+  for addr, reasons in addr_to_reasons.items():
+    addr_perm = addr_to_addrperm[addr]
+    parent_id = addr_perm.user.linked_parent_id if addr_perm.user else None
+    if parent_id and parent_id in all_ids:
+      # The current user is a child account and the parent would be notified,
+      # so only notify the parent.
+      merged_ids.add(parent_id)
+    else:
+      result[addr] = reasons
+
+  for addr, reasons in result.items():
+    addr_perm = addr_to_addrperm[addr]
+    if addr_perm.user and addr_perm.user.user_id in merged_ids:
+      reasons.append(notify_reasons.REASON_LINKED_ACCOUNT)
+
+  return result
+
+
+def MakeBulletedEmailWorkItems(
+    group_reason_list, issue, body_link_only, body_for_non_members,
+    body_for_members, project, hostport, commenter_view, detail_url,
+    seq_num=None, subject_prefix=None, compact_subject_prefix=None):
+  """Make a list of dicts describing email-sending tasks to notify users.
+
+  Args:
+    group_reason_list: list of (addr_perm_list, reason) tuples.
+    issue: Issue that was updated.
+    body_link_only: string body of email with minimal information.
+    body_for_non_members: string body of email to send to non-members.
+    body_for_members: string body of email to send to members.
+    project: Project that contains the issue.
+    hostport: string hostname and port number for links to the site.
+    commenter_view: UserView for the user who made the comment.
+    detail_url: str direct link to the issue.
+    seq_num: optional int sequence number of the comment.
+    subject_prefix: optional string to customize the email subject line.
+    compact_subject_prefix: optional string to customize the email subject line.
+
+  Returns:
+    A list of dictionaries, each with all needed info to send an individual
+    email to one user.  Each email contains a footer that lists all the
+    reasons why that user received the email.
+  """
+  logging.info('group_reason_list is %r', group_reason_list)
+  addr_to_addrperm = {}  # {email_address: AddrPerm object}
+  addr_to_reasons = {}  # {email_address: [reason, ...]}
+  for group, reason in group_reason_list:
+    for memb_addr_perm in group:
+      addr = memb_addr_perm.address
+      addr_to_addrperm[addr] = memb_addr_perm
+      addr_to_reasons.setdefault(addr, []).append(reason)
+
+  addr_to_reasons = _MergeLinkedAccountReasons(
+      addr_to_addrperm, addr_to_reasons)
+  logging.info('addr_to_reasons is %r', addr_to_reasons)
+
+  email_tasks = []
+  for addr, reasons in addr_to_reasons.items():
+    memb_addr_perm = addr_to_addrperm[addr]
+    email_tasks.append(_MakeEmailWorkItem(
+        memb_addr_perm, reasons, issue, body_link_only, body_for_non_members,
+        body_for_members, project, hostport, commenter_view, detail_url,
+        seq_num=seq_num, subject_prefix=subject_prefix,
+        compact_subject_prefix=compact_subject_prefix))
+
+  return email_tasks
+
+
+def _TruncateBody(body):
+  """Truncate body string if it exceeds size limit."""
+  if len(body) > MAX_EMAIL_BODY_SIZE:
+    logging.info('Truncate body since its size %d exceeds limit', len(body))
+    return body[:MAX_EMAIL_BODY_SIZE] + '...'
+  return body
+
+
+def _GetNotifyRestrictedIssues(user_prefs, email, user):
+  """Return the notify_restricted_issues pref or a calculated default value."""
+  # If we explicitly set a pref for this address, use it.
+  if user_prefs:
+    for pref in user_prefs.prefs:
+      if pref.name == NOTIFY_RESTRICTED_ISSUES_PREF_NAME:
+        return pref.value
+
+  # Mailing lists cannot visit the site, so if it visited, it is a person.
+  if user and user.last_visit_timestamp:
+    return NOTIFY_WITH_DETAILS
+
+  # If it is a google.com mailing list, allow details for R-V-G issues.
+  if email.endswith('@google.com'):
+    return NOTIFY_WITH_DETAILS_GOOGLE
+
+  # It might be a public mailing list, so don't risk leaking any details.
+  return NOTIFY_WITH_LINK_ONLY
+
+
+def ShouldUseLinkOnly(addr_perm, issue, always_detailed=False):
+  """Return true when there is a risk of leaking a restricted issue.
+
+  We send notifications that contain only a link to the issue with no other
+  details about the change when:
+  - The issue is R-V-G and the address may be a non-google.com mailing list, or
+  - The issue is restricted with something other than R-V-G, and the user
+     may be a mailing list, or
+  - The user has a preference set.
+  """
+  if always_detailed:
+    return False
+
+  restrictions = permissions.GetRestrictions(issue, perm=permissions.VIEW)
+  if not restrictions:
+    return False
+
+  pref = _GetNotifyRestrictedIssues(
+      addr_perm.user_prefs, addr_perm.address, addr_perm.user)
+  if pref == NOTIFY_WITH_DETAILS:
+    return False
+  if (pref == NOTIFY_WITH_DETAILS_GOOGLE and
+      restrictions == ['restrict-view-google']):
+    return False
+
+  # If NOTIFY_WITH_LINK_ONLY or any unexpected value:
+  return True
+
+
+def _MakeEmailWorkItem(
+    addr_perm, reasons, issue, body_link_only,
+    body_for_non_members, body_for_members, project, hostport, commenter_view,
+    detail_url, seq_num=None, subject_prefix=None, compact_subject_prefix=None):
+  """Make one email task dict for one user, includes a detailed reason."""
+  should_use_link_only = ShouldUseLinkOnly(
+      addr_perm, issue, always_detailed=project.issue_notify_always_detailed)
+  subject_format = (
+      (subject_prefix or 'Issue ') +
+      '%(local_id)d in %(project_name)s')
+  if addr_perm.user and addr_perm.user.email_compact_subject:
+    subject_format = (
+        (compact_subject_prefix or '') +
+        '%(project_name)s:%(local_id)d')
+
+  subject = subject_format % {
+    'local_id': issue.local_id,
+    'project_name': issue.project_name,
+    }
+  if not should_use_link_only:
+    subject += ': ' + issue.summary
+
+  footer = _MakeNotificationFooter(reasons, addr_perm.reply_perm, hostport)
+  if isinstance(footer, six.text_type):
+    footer = footer.encode('utf-8')
+  if should_use_link_only:
+    body = _TruncateBody(body_link_only) + footer
+  elif addr_perm.is_member:
+    logging.info('got member %r, sending body for members', addr_perm.address)
+    body = _TruncateBody(body_for_members) + footer
+  else:
+    logging.info(
+        'got non-member %r, sending body for non-members', addr_perm.address)
+    body = _TruncateBody(body_for_non_members) + footer
+  logging.info('sending message footer:\n%r', footer)
+
+  can_reply_to = (
+      addr_perm.reply_perm != notify_reasons.REPLY_NOT_ALLOWED and
+      project.process_inbound_email)
+  from_addr = emailfmt.FormatFromAddr(
+    project, commenter_view=commenter_view, reveal_addr=addr_perm.is_member,
+    can_reply_to=can_reply_to)
+  if can_reply_to:
+    reply_to = '%s@%s' % (project.project_name, emailfmt.MailDomain())
+  else:
+    reply_to = emailfmt.NoReplyAddress()
+  refs = emailfmt.GetReferences(
+    addr_perm.address, subject, seq_num,
+    '%s@%s' % (project.project_name, emailfmt.MailDomain()))
+  # We use markup to display a convenient link that takes users directly to the
+  # issue without clicking on the email.
+  html_body = None
+  template = HTML_BODY_WITH_GMAIL_ACTION_TEMPLATE
+  if addr_perm.user and not addr_perm.user.email_view_widget:
+    template = HTML_BODY_WITHOUT_GMAIL_ACTION_TEMPLATE
+  body_with_tags = _AddHTMLTags(body.decode('utf-8'))
+  # Escape single quotes which are occasionally used to contain HTML
+  # attributes and event handler definitions.
+  body_with_tags = body_with_tags.replace("'", '&#39;')
+  html_body = template % {
+      'url': detail_url,
+      'body': body_with_tags,
+      }
+  return dict(
+    to=addr_perm.address, subject=subject, body=body, html_body=html_body,
+    from_addr=from_addr, reply_to=reply_to, references=refs)
+
+
+def _AddHTMLTags(body):
+  """Adds HMTL tags in the specified email body.
+
+  Specifically does the following:
+  * Detects links and adds <a href>s around the links.
+  * Substitutes <br/> for all occurrences of "\n".
+
+  See crbug.com/582463 for context.
+  """
+  # Convert all URLs into clickable links.
+  body = _AutolinkBody(body)
+
+  # Convert all "\n"s into "<br/>"s.
+  body = body.replace('\r\n', '<br/>')
+  body = body.replace('\n', '<br/>')
+  return body
+
+
+def _AutolinkBody(body):
+  """Convert text that looks like URLs into <a href=...>.
+
+  This uses autolink.py, but it does not register all the autolink components
+  because some of them depend on the current user's permissions which would
+  not make sense for an email body that will be sent to several different users.
+  """
+  email_autolink = autolink.Autolink()
+  email_autolink.RegisterComponent(
+      '01-linkify-user-profiles-or-mailto',
+      lambda request, mr: None,
+      lambda _mr, match: [match.group(0)],
+      {autolink_constants.IS_IMPLIED_EMAIL_RE: autolink.LinkifyEmail})
+  email_autolink.RegisterComponent(
+      '02-linkify-full-urls',
+      lambda request, mr: None,
+      lambda mr, match: None,
+      {autolink_constants.IS_A_LINK_RE: autolink.Linkify})
+  email_autolink.RegisterComponent(
+      '03-linkify-shorthand',
+      lambda request, mr: None,
+      lambda mr, match: None,
+      {autolink_constants.IS_A_SHORT_LINK_RE: autolink.Linkify,
+       autolink_constants.IS_A_NUMERIC_SHORT_LINK_RE: autolink.Linkify,
+       autolink_constants.IS_IMPLIED_LINK_RE: autolink.Linkify,
+       })
+
+  input_run = template_helpers.TextRun(body)
+  output_runs = email_autolink.MarkupAutolinks(
+      None, [input_run], autolink.SKIP_LOOKUPS)
+  output_strings = [run.FormatForHTMLEmail() for run in output_runs]
+  return ''.join(output_strings)
+
+
+def _MakeNotificationFooter(reasons, reply_perm, hostport):
+  """Make an informative footer for a notification email.
+
+  Args:
+    reasons: a list of strings to be used as the explanation.  Empty if no
+        reason is to be given.
+    reply_perm: string which is one of REPLY_NOT_ALLOWED, REPLY_MAY_COMMENT,
+        REPLY_MAY_UPDATE.
+    hostport: string with domain_name:port_number to be used in linking to
+        the user preferences page.
+
+  Returns:
+    A string to be used as the email footer.
+  """
+  if not reasons:
+    return ''
+
+  domain_port = hostport.split(':')
+  domain_port[0] = framework_helpers.GetPreferredDomain(domain_port[0])
+  hostport = ':'.join(domain_port)
+
+  prefs_url = 'https://%s%s' % (hostport, urls.USER_SETTINGS)
+  lines = ['-- ']
+  lines.append('You received this message because:')
+  lines.extend('  %d. %s' % (idx + 1, reason)
+               for idx, reason in enumerate(reasons))
+
+  lines.extend(['', 'You may adjust your notification preferences at:',
+                prefs_url])
+
+  if reply_perm == notify_reasons.REPLY_MAY_COMMENT:
+    lines.extend(['', 'Reply to this email to add a comment.'])
+  elif reply_perm == notify_reasons.REPLY_MAY_UPDATE:
+    lines.extend(['', 'Reply to this email to add a comment or make updates.'])
+
+  return '\n'.join(lines)
diff --git a/features/notify_reasons.py b/features/notify_reasons.py
new file mode 100644
index 0000000..436f975
--- /dev/null
+++ b/features/notify_reasons.py
@@ -0,0 +1,438 @@
+# Copyright 2017 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 deciding who to notify and why.."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import collections
+import logging
+
+import settings
+from features import filterrules_helpers
+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 permissions
+from proto import tracker_pb2
+from search import query2ast
+from search import searchpipeline
+from tracker import component_helpers
+from tracker import tracker_bizobj
+
+# When sending change notification emails, choose the reply-to header and
+# footer message based on three levels of the recipient's permissions
+# for that issue.
+REPLY_NOT_ALLOWED = 'REPLY_NOT_ALLOWED'
+REPLY_MAY_COMMENT = 'REPLY_MAY_COMMENT'
+REPLY_MAY_UPDATE = 'REPLY_MAY_UPDATE'
+
+# These are strings describing the various reasons that we send notifications.
+REASON_REPORTER = 'You reported this issue'
+REASON_OWNER = 'You are the owner of the issue'
+REASON_OLD_OWNER = 'You were the issue owner before this change'
+REASON_DEFAULT_OWNER = 'A rule made you owner of the issue'
+REASON_CCD = 'You were specifically CC\'d on the issue'
+REASON_DEFAULT_CCD = 'A rule CC\'d you on the issue'
+# TODO(crbug.com/monorail/2857): separate reasons for notification to group
+# members resulting from component and rules derived ccs.
+REASON_GROUP_CCD = (
+    'A group you\'re a member of was specifically CC\'d on the issue')
+REASON_STARRER = 'You starred the issue'
+REASON_SUBSCRIBER = 'Your saved query matched the issue'
+REASON_ALSO_NOTIFY = 'A rule was set up to notify you'
+REASON_ALL_NOTIFICATIONS = (
+    'The project was configured to send all issue notifications '
+    'to this address')
+REASON_LINKED_ACCOUNT = 'Your linked account would have been notified'
+
+# An AddrPerm is how we represent our decision to notify a given
+# email address, which version of the email body to send to them, and
+# whether to offer them the option to reply to the notification.  Many
+# of the functions in this file pass around AddrPerm lists (an "APL").
+# is_member is a boolean
+# address is a string email address
+# user is a User PB, including built-in user preference fields.
+# reply_perm is one of REPLY_NOT_ALLOWED, REPLY_MAY_COMMENT,
+# REPLY_MAY_UPDATE.
+# user_prefs is a UserPrefs object with string->string user prefs.
+AddrPerm = collections.namedtuple(
+    'AddrPerm', 'is_member, address, user, reply_perm, user_prefs')
+
+
+
+def ComputeIssueChangeAddressPermList(
+    cnxn, ids_to_consider, project, issue, services, omit_addrs,
+    users_by_id, pref_check_function=lambda u: u.notify_issue_change):
+  """Return a list of user email addresses to notify of an issue change.
+
+  User email addresses are determined by looking up the given user IDs
+  in the given users_by_id dict.
+
+  Args:
+    cnxn: connection to SQL database.
+    ids_to_consider: list of user IDs for users interested in this issue.
+    project: Project PB for the project containing this issue.
+    issue: Issue PB for the issue that was updated.
+    services: Services.
+    omit_addrs: set of strings for email addresses to not notify because
+        they already know.
+    users_by_id: dict {user_id: user_view} user info.
+    pref_check_function: optional function to use to check if a certain
+        User PB has a preference set to receive the email being sent.  It
+        defaults to "If I am in the issue's owner or cc field", but it
+        can be set to check "If I starred the issue."
+
+  Returns:
+    A list of AddrPerm objects.
+  """
+  memb_addr_perm_list = []
+  logging.info('Considering %r ', ids_to_consider)
+  all_user_prefs = services.user.GetUsersPrefs(cnxn, ids_to_consider)
+  for user_id in ids_to_consider:
+    if user_id == framework_constants.NO_USER_SPECIFIED:
+      continue
+    user = services.user.GetUser(cnxn, user_id)
+    # Notify people who have a pref set, or if they have no User PB
+    # because the pref defaults to True.
+    if user and not pref_check_function(user):
+      logging.info('Not notifying %r: user preference', user.email)
+      continue
+    # TODO(jrobbins): doing a bulk operation would reduce DB load.
+    auth = authdata.AuthData.FromUserID(cnxn, user_id, services)
+    perms = permissions.GetPermissions(user, auth.effective_ids, project)
+    config = services.config.GetProjectConfig(cnxn, project.project_id)
+    granted_perms = tracker_bizobj.GetGrantedPerms(
+        issue, auth.effective_ids, config)
+
+    if not permissions.CanViewIssue(
+        auth.effective_ids, perms, project, issue,
+        granted_perms=granted_perms):
+      logging.info('Not notifying %r: user cannot view issue', user.email)
+      continue
+
+    addr = users_by_id[user_id].email
+    if addr in omit_addrs:
+      logging.info('Not notifying %r: user already knows', user.email)
+      continue
+
+    recipient_is_member = bool(framework_bizobj.UserIsInProject(
+        project, auth.effective_ids))
+
+    reply_perm = REPLY_NOT_ALLOWED
+    if project.process_inbound_email:
+      if permissions.CanEditIssue(auth.effective_ids, perms, project, issue):
+        reply_perm = REPLY_MAY_UPDATE
+      elif permissions.CanCommentIssue(
+          auth.effective_ids, perms, project, issue):
+        reply_perm = REPLY_MAY_COMMENT
+
+    memb_addr_perm_list.append(
+      AddrPerm(recipient_is_member, addr, user, reply_perm,
+               all_user_prefs[user_id]))
+
+  logging.info('For %s %s, will notify: %r',
+               project.project_name, issue.local_id,
+               [ap.address for ap in memb_addr_perm_list])
+
+  return memb_addr_perm_list
+
+
+def ComputeProjectNotificationAddrList(
+    cnxn, services, project, contributor_could_view, omit_addrs):
+  """Return a list of non-user addresses to notify of an issue change.
+
+  The non-user addresses are specified by email address strings, not
+  user IDs.  One such address can be specified in the project PB.
+  It is not assumed to have permission to see all issues.
+
+  Args:
+    cnxn: connection to SQL database.
+    services: A Services object.
+    project: Project PB containing the issue that was updated.
+    contributor_could_view: True if any project contributor should be able to
+        see the notification email, e.g., in a mailing list archive or feed.
+    omit_addrs: set of strings for email addresses to not notify because
+        they already know.
+
+  Returns:
+    A list of tuples: [(False, email_addr, None, reply_permission_level), ...],
+    where reply_permission_level is always REPLY_NOT_ALLOWED for now.
+  """
+  memb_addr_perm_list = []
+  if contributor_could_view:
+    ml_addr = project.issue_notify_address
+    ml_user_prefs = services.user.GetUserPrefsByEmail(cnxn, ml_addr)
+
+    if ml_addr and ml_addr not in omit_addrs:
+      memb_addr_perm_list.append(
+          AddrPerm(False, ml_addr, None, REPLY_NOT_ALLOWED, ml_user_prefs))
+
+  return memb_addr_perm_list
+
+
+def ComputeIssueNotificationAddrList(cnxn, services, issue, omit_addrs):
+  """Return a list of non-user addresses to notify of an issue change.
+
+  The non-user addresses are specified by email address strings, not
+  user IDs.  They can be set by filter rules with the "Also notify" action.
+  "Also notify" addresses are assumed to have permission to see any issue,
+  even a restricted one.
+
+  Args:
+    cnxn: connection to SQL database.
+    services: A Services object.
+    issue: Issue PB for the issue that was updated.
+    omit_addrs: set of strings for email addresses to not notify because
+        they already know.
+
+  Returns:
+    A list of tuples: [(False, email_addr, None, reply_permission_level), ...],
+    where reply_permission_level is always REPLY_NOT_ALLOWED for now.
+  """
+  addr_perm_list = []
+  for addr in issue.derived_notify_addrs:
+    if addr not in omit_addrs:
+      notify_user_prefs = services.user.GetUserPrefsByEmail(cnxn, addr)
+      addr_perm_list.append(
+          AddrPerm(False, addr, None, REPLY_NOT_ALLOWED, notify_user_prefs))
+
+  return addr_perm_list
+
+
+def _GetSubscribersAddrPermList(
+    cnxn, services, issue, project, config, omit_addrs, users_by_id):
+  """Lookup subscribers, evaluate their saved queries, and decide to notify."""
+  users_to_queries = GetNonOmittedSubscriptions(
+      cnxn, services, [project.project_id], omit_addrs)
+  # TODO(jrobbins): need to pass through the user_id to use for "me".
+  subscribers_to_notify = EvaluateSubscriptions(
+      cnxn, issue, users_to_queries, services, config)
+  # TODO(jrobbins): expand any subscribers that are user groups.
+  subs_needing_user_views = [
+      uid for uid in subscribers_to_notify if uid not in users_by_id]
+  users_by_id.update(framework_views.MakeAllUserViews(
+      cnxn, services.user, subs_needing_user_views))
+  sub_addr_perm_list = ComputeIssueChangeAddressPermList(
+      cnxn, subscribers_to_notify, project, issue, services, omit_addrs,
+      users_by_id, pref_check_function=lambda *args: True)
+
+  return sub_addr_perm_list
+
+
+def EvaluateSubscriptions(
+    cnxn, issue, users_to_queries, services, config):
+  """Determine subscribers who have subs that match the given issue."""
+  # Note: unlike filter rule, subscriptions see explicit & derived values.
+  lower_labels = [lab.lower() for lab in tracker_bizobj.GetLabels(issue)]
+  label_set = set(lower_labels)
+
+  subscribers_to_notify = []
+  for uid, saved_queries in users_to_queries.items():
+    for sq in saved_queries:
+      if sq.subscription_mode != 'immediate':
+        continue
+      if issue.project_id not in sq.executes_in_project_ids:
+        continue
+      cond = savedqueries_helpers.SavedQueryToCond(sq)
+      # TODO(jrobbins): Support linked accounts me_user_ids.
+      cond, _warnings = searchpipeline.ReplaceKeywordsWithUserIDs([uid], cond)
+      cond_ast = query2ast.ParseUserQuery(
+        cond, '', query2ast.BUILTIN_ISSUE_FIELDS, config)
+
+      if filterrules_helpers.EvalPredicate(
+          cnxn, services, cond_ast, issue, label_set, config,
+          tracker_bizobj.GetOwnerId(issue), tracker_bizobj.GetCcIds(issue),
+          tracker_bizobj.GetStatus(issue)):
+        subscribers_to_notify.append(uid)
+        break  # Don't bother looking at the user's other saved quereies.
+
+  return subscribers_to_notify
+
+
+def GetNonOmittedSubscriptions(cnxn, services, project_ids, omit_addrs):
+  """Get a dict of users w/ subscriptions in those projects."""
+  users_to_queries = services.features.GetSubscriptionsInProjects(
+      cnxn, project_ids)
+  user_emails = services.user.LookupUserEmails(
+      cnxn, list(users_to_queries.keys()))
+  for user_id, email in user_emails.items():
+    if email in omit_addrs:
+      del users_to_queries[user_id]
+  return users_to_queries
+
+
+def ComputeCustomFieldAddrPerms(
+    cnxn, config, issue, project, services, omit_addrs, users_by_id):
+  """Check the reasons to notify users named in custom fields."""
+  group_reason_list = []
+  for fd in config.field_defs:
+    (direct_named_ids,
+     transitive_named_ids) = services.usergroup.ExpandAnyGroupEmailRecipients(
+         cnxn, ComputeNamedUserIDsToNotify(issue.field_values, fd))
+    named_user_ids = direct_named_ids + transitive_named_ids
+    if named_user_ids:
+      named_addr_perms = ComputeIssueChangeAddressPermList(
+          cnxn, named_user_ids, project, issue, services, omit_addrs,
+          users_by_id, pref_check_function=lambda u: True)
+      group_reason_list.append(
+          (named_addr_perms, 'You are named in the %s field' % fd.field_name))
+
+  return group_reason_list
+
+
+def ComputeNamedUserIDsToNotify(field_values, fd):
+  """Give a list of user IDs to notify because they're in a field."""
+  if (fd.field_type == tracker_pb2.FieldTypes.USER_TYPE and
+      fd.notify_on == tracker_pb2.NotifyTriggers.ANY_COMMENT):
+    return [fv.user_id for fv in field_values
+            if fv.field_id == fd.field_id]
+
+  return []
+
+
+def ComputeComponentFieldAddrPerms(
+    cnxn, config, issue, project, services, omit_addrs, users_by_id):
+  """Return [(addr_perm_list, reason),...] for users auto-cc'd by components."""
+  component_ids = set(issue.component_ids)
+  group_reason_list = []
+  for cd in config.component_defs:
+    if cd.component_id in component_ids:
+      (direct_ccs,
+       transitive_ccs) = services.usergroup.ExpandAnyGroupEmailRecipients(
+           cnxn, component_helpers.GetCcIDsForComponentAndAncestors(config, cd))
+      cc_ids = direct_ccs + transitive_ccs
+      comp_addr_perms = ComputeIssueChangeAddressPermList(
+          cnxn, cc_ids, project, issue, services, omit_addrs,
+          users_by_id, pref_check_function=lambda u: True)
+      group_reason_list.append(
+          (comp_addr_perms,
+           'You are auto-CC\'d on all issues in component %s' % cd.path))
+
+  return group_reason_list
+
+
+def ComputeGroupReasonList(
+    cnxn, services, project, issue, config, users_by_id, omit_addrs,
+    contributor_could_view, starrer_ids=None, noisy=False,
+    old_owner_id=None, commenter_in_project=True, include_subscribers=True,
+    include_notify_all=True,
+    starrer_pref_check_function=lambda u: u.notify_starred_issue_change):
+  """Return a list [(addr_perm_list, reason),...] of addrs to notify."""
+  # Get the transitive set of owners and Cc'd users, and their UserViews.
+  starrer_ids = starrer_ids or []
+  reporter = [issue.reporter_id] if issue.reporter_id in starrer_ids else []
+  if old_owner_id:
+    old_direct_owners, old_transitive_owners = (
+        services.usergroup.ExpandAnyGroupEmailRecipients(cnxn, [old_owner_id]))
+  else:
+    old_direct_owners, old_transitive_owners = [], []
+
+  direct_owners, transitive_owners = (
+      services.usergroup.ExpandAnyGroupEmailRecipients(cnxn, [issue.owner_id]))
+  der_direct_owners, der_transitive_owners = (
+      services.usergroup.ExpandAnyGroupEmailRecipients(
+          cnxn, [issue.derived_owner_id]))
+  direct_comp, trans_comp = services.usergroup.ExpandAnyGroupEmailRecipients(
+      cnxn, component_helpers.GetComponentCcIDs(issue, config))
+  direct_ccs, transitive_ccs = services.usergroup.ExpandAnyGroupEmailRecipients(
+      cnxn, list(issue.cc_ids))
+  der_direct_ccs, der_transitive_ccs = (
+      services.usergroup.ExpandAnyGroupEmailRecipients(
+          cnxn, list(issue.derived_cc_ids)))
+  # Remove cc's derived from components, which are grouped into their own
+  # notify-reason-group in ComputeComponentFieldAddrPerms().
+  # This means that an exact email cc'd by both a component and a rule will
+  # get an email that says they are only being notified because of the
+  # component.
+  # Note that a user directly cc'd due to a rule who is also part of a
+  # group cc'd due to a component, will get a message saying they're cc'd for
+  # both the rule and the component.
+  der_direct_ccs = list(set(der_direct_ccs).difference(set(direct_comp)))
+  der_transitive_ccs = list(set(der_transitive_ccs).difference(set(trans_comp)))
+
+  users_by_id.update(framework_views.MakeAllUserViews(
+      cnxn, services.user, transitive_owners, der_transitive_owners,
+      direct_comp, trans_comp, transitive_ccs, der_transitive_ccs))
+
+  # Notify interested people according to the reason for their interest:
+  # owners, component auto-cc'd users, cc'd users, starrers, and
+  # other notification addresses.
+  reporter_addr_perm_list = ComputeIssueChangeAddressPermList(
+      cnxn, reporter, project, issue, services, omit_addrs, users_by_id)
+  owner_addr_perm_list = ComputeIssueChangeAddressPermList(
+      cnxn, direct_owners + transitive_owners, project, issue,
+      services, omit_addrs, users_by_id)
+  old_owner_addr_perm_list = ComputeIssueChangeAddressPermList(
+      cnxn, old_direct_owners + old_transitive_owners, project, issue,
+      services, omit_addrs, users_by_id)
+  owner_addr_perm_set = set(owner_addr_perm_list)
+  old_owner_addr_perm_list = [ap for ap in old_owner_addr_perm_list
+                              if ap not in owner_addr_perm_set]
+  der_owner_addr_perm_list = ComputeIssueChangeAddressPermList(
+      cnxn, der_direct_owners + der_transitive_owners, project, issue,
+      services, omit_addrs, users_by_id)
+  cc_addr_perm_list = ComputeIssueChangeAddressPermList(
+      cnxn, direct_ccs, project, issue, services, omit_addrs, users_by_id)
+  transitive_cc_addr_perm_list = ComputeIssueChangeAddressPermList(
+      cnxn, transitive_ccs, project, issue, services, omit_addrs, users_by_id)
+  der_cc_addr_perm_list = ComputeIssueChangeAddressPermList(
+      cnxn, der_direct_ccs + der_transitive_ccs, project, issue,
+      services, omit_addrs, users_by_id)
+
+  starrer_addr_perm_list = []
+  sub_addr_perm_list = []
+  if not noisy or commenter_in_project:
+    # Avoid an OOM by only notifying a number of starrers that we can handle.
+    # And, we really should limit the number of emails that we send anyway.
+    max_starrers = settings.max_starrers_to_notify
+    starrer_ids = starrer_ids[-max_starrers:]
+    # Note: starrers can never be user groups.
+    starrer_addr_perm_list = (
+        ComputeIssueChangeAddressPermList(
+            cnxn, starrer_ids, project, issue,
+            services, omit_addrs, users_by_id,
+            pref_check_function=starrer_pref_check_function))
+
+    if include_subscribers:
+      sub_addr_perm_list = _GetSubscribersAddrPermList(
+          cnxn, services, issue, project, config, omit_addrs,
+          users_by_id)
+
+  # Get the list of addresses to notify based on filter rules.
+  issue_notify_addr_list = ComputeIssueNotificationAddrList(
+      cnxn, services, issue, omit_addrs)
+  # Get the list of addresses to notify based on project settings.
+  proj_notify_addr_list = []
+  if include_notify_all:
+    proj_notify_addr_list = ComputeProjectNotificationAddrList(
+        cnxn, services, project, contributor_could_view, omit_addrs)
+
+  group_reason_list = [
+      (reporter_addr_perm_list, REASON_REPORTER),
+      (owner_addr_perm_list, REASON_OWNER),
+      (old_owner_addr_perm_list, REASON_OLD_OWNER),
+      (der_owner_addr_perm_list, REASON_DEFAULT_OWNER),
+      (cc_addr_perm_list, REASON_CCD),
+      (transitive_cc_addr_perm_list, REASON_GROUP_CCD),
+      (der_cc_addr_perm_list, REASON_DEFAULT_CCD),
+  ]
+  group_reason_list.extend(ComputeComponentFieldAddrPerms(
+      cnxn, config, issue, project, services, omit_addrs,
+      users_by_id))
+  group_reason_list.extend(ComputeCustomFieldAddrPerms(
+      cnxn, config, issue, project, services, omit_addrs,
+      users_by_id))
+  group_reason_list.extend([
+      (starrer_addr_perm_list, REASON_STARRER),
+      (sub_addr_perm_list, REASON_SUBSCRIBER),
+      (issue_notify_addr_list, REASON_ALSO_NOTIFY),
+      (proj_notify_addr_list, REASON_ALL_NOTIFICATIONS),
+      ])
+  return group_reason_list
diff --git a/features/prettify.py b/features/prettify.py
new file mode 100644
index 0000000..bc64282
--- /dev/null
+++ b/features/prettify.py
@@ -0,0 +1,76 @@
+# 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 source code syntax highlighting."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import ezt
+
+from framework import framework_constants
+
+
+# We only attempt to do client-side syntax highlighting on files that we
+# expect to be source code in languages that we support, and that are
+# reasonably sized.
+MAX_PRETTIFY_LINES = 3000
+
+
+def PrepareSourceLinesForHighlighting(file_contents):
+  """Parse a file into lines for highlighting.
+
+  Args:
+    file_contents: string contents of the source code file.
+
+  Returns:
+    A list of _SourceLine objects, one for each line in the source file.
+  """
+  return [_SourceLine(num + 1, line) for num, line
+          in enumerate(file_contents.splitlines())]
+
+
+class _SourceLine(object):
+  """Convenience class to represent one line of the source code display.
+
+  Attributes:
+      num: The line's location in the source file.
+      line: String source code line to display.
+  """
+
+  def __init__(self, num, line):
+    self.num = num
+    self.line = line
+
+  def __repr__(self):
+    return '%d: %s' % (self.num, self.line)
+
+
+def BuildPrettifyData(num_lines, path):
+  """Return page data to help configure google-code-prettify.
+
+  Args:
+    num_lines: int number of lines of source code in the file.
+    path: string path to the file, or just the filename.
+
+  Returns:
+    Dictionary that can be passed to EZT to render a page.
+  """
+  reasonable_size = num_lines < MAX_PRETTIFY_LINES
+
+  filename_lower = path[path.rfind('/') + 1:].lower()
+  ext = filename_lower[filename_lower.rfind('.') + 1:]
+
+  # Note that '' might be a valid entry in these maps.
+  prettify_class = framework_constants.PRETTIFY_CLASS_MAP.get(ext)
+  if prettify_class is None:
+    prettify_class = framework_constants.PRETTIFY_FILENAME_CLASS_MAP.get(
+        filename_lower)
+  supported_lang = prettify_class is not None
+
+  return {
+      'should_prettify': ezt.boolean(supported_lang and reasonable_size),
+      'prettify_class': prettify_class,
+      }
diff --git a/features/pubsub.py b/features/pubsub.py
new file mode 100644
index 0000000..a74ff22
--- /dev/null
+++ b/features/pubsub.py
@@ -0,0 +1,81 @@
+# Copyright 2019 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
+
+"""Task handlers for publishing issue updates onto a pub/sub topic.
+
+The pub/sub topic name is: `projects/{project-id}/topics/issue-updates`.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import httplib2
+import logging
+import sys
+
+import settings
+
+from googleapiclient.discovery import build
+from apiclient.errors import Error as ApiClientError
+from oauth2client.client import GoogleCredentials
+from oauth2client.client import Error as Oauth2ClientError
+
+from framework import exceptions
+from framework import jsonfeed
+
+
+class PublishPubsubIssueChangeTask(jsonfeed.InternalTask):
+  """JSON servlet that pushes issue update messages onto a pub/sub topic."""
+
+  def HandleRequest(self, mr):
+    """Push a message onto a pub/sub queue.
+
+    Args:
+      mr: common information parsed from the HTTP request.
+    Returns:
+      A dictionary. If an error occurred, the 'error' field will be a string
+      containing the error message.
+    """
+    pubsub_client = set_up_pubsub_api()
+    if not pubsub_client:
+      return {
+        'error': 'Pub/Sub API init failure.',
+      }
+
+    issue_id = mr.GetPositiveIntParam('issue_id')
+    if not issue_id:
+      return {
+        'error': 'Cannot proceed without a valid issue ID.',
+      }
+    try:
+      issue = self.services.issue.GetIssue(mr.cnxn, issue_id, use_cache=False)
+    except exceptions.NoSuchIssueException:
+      return {
+        'error': 'Could not find issue with ID %s' % issue_id,
+      }
+
+    pubsub_client.projects().topics().publish(
+        topic=settings.pubsub_topic_id,
+        body={
+          'messages': [{
+            'attributes': {
+              'local_id': str(issue.local_id),
+              'project_name': str(issue.project_name),
+            },
+          }],
+        },
+      ).execute()
+
+    return {}
+
+
+def set_up_pubsub_api():
+  """Attempts to build and return a pub/sub API client."""
+  try:
+    return build('pubsub', 'v1', http=httplib2.Http(),
+        credentials=GoogleCredentials.get_application_default())
+  except (Oauth2ClientError, ApiClientError):
+    logging.error("Error setting up Pub/Sub API: %s" % sys.exc_info()[0])
+    return None
diff --git a/features/rerankhotlist.py b/features/rerankhotlist.py
new file mode 100644
index 0000000..fe235db
--- /dev/null
+++ b/features/rerankhotlist.py
@@ -0,0 +1,136 @@
+# 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
+
+"""Class that implements the reranking on the hotlistissues table page."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+
+from features import features_bizobj
+from features import hotlist_helpers
+from framework import jsonfeed
+from framework import permissions
+from framework import sorting
+from services import features_svc
+from tracker import rerank_helpers
+
+
+class RerankHotlistIssue(jsonfeed.JsonFeed):
+  """Rerank an issue in a hotlist."""
+
+  def AssertBasePermission(self, mr):
+    super(RerankHotlistIssue, self).AssertBasePermission(mr)
+    if mr.target_id and mr.moved_ids and mr.split_above:
+      try:
+        hotlist = self._GetHotlist(mr)
+      except features_svc.NoSuchHotlistException:
+        return
+      edit_perm = permissions.CanEditHotlist(
+          mr.auth.effective_ids, mr.perms, hotlist)
+      if not edit_perm:
+        raise permissions.PermissionException(
+            'User is not allowed to re-rank this hotlist')
+
+  def HandleRequest(self, mr):
+    changed_ranks = self._GetNewRankings(mr)
+
+    if changed_ranks:
+      relations_to_change = dict(
+          (issue_id, rank) for issue_id, rank in changed_ranks)
+
+      self.services.features.UpdateHotlistItemsFields(
+          mr.cnxn, mr.hotlist_id, new_ranks=relations_to_change)
+
+      hotlist_items = self.services.features.GetHotlist(
+          mr.cnxn, mr.hotlist_id).items
+
+      # Note: Cannot use mr.hotlist because hotlist_issues
+      # of mr.hotlist is not updated
+
+      sorting.InvalidateArtValuesKeys(
+          mr.cnxn, [hotlist_item.issue_id for hotlist_item in hotlist_items])
+      (table_data, _) = hotlist_helpers.CreateHotlistTableData(
+          mr, hotlist_items, self.services)
+
+      json_table_data = [{
+          'cells': [{
+              'type': cell.type,
+              'values': [{
+                  'item': value.item,
+                  'isDerived': value.is_derived,
+              } for value in cell.values],
+              'colIndex': cell.col_index,
+              'align': cell.align,
+              'noWrap': cell.NOWRAP,
+              'nonColLabels': [{
+                  'value': label.value,
+                  'isDerived': label.is_derived,
+              } for label in cell.non_column_labels],
+          } for cell in table_row.cells],
+          'issueRef': table_row.issue_ref,
+          'idx': table_row.idx,
+          'projectName': table_row.project_name,
+          'projectURL': table_row.project_url,
+          'localID': table_row.local_id,
+          'issueID': table_row.issue_id,
+          'isStarred': table_row.starred,
+          'issueCleanURL': table_row.issue_clean_url,
+          'issueContextURL': table_row.issue_ctx_url,
+      } for table_row in table_data]
+
+      for row, json_row in zip(
+          [table_row for table_row in table_data], json_table_data):
+        if (row.group and row.group.cells):
+          json_row.update({'group': {
+              'rowsInGroup': row.group.rows_in_group,
+              'cells': [{'groupName': cell.group_name,
+                         'values': [{
+                          # TODO(jojwang): check if this gives error when there
+                          # is no value.item
+                             'item': value.item if value.item else 'None',
+                         } for value in cell.values],
+              } for cell in row.group.cells],
+          }})
+        else:
+          json_row['group'] = 'no'
+
+      return {'table_data': json_table_data}
+    else:
+      return {'table_data': ''}
+
+  def _GetHotlist(self, mr):
+    """Retrieve the current hotlist."""
+    if mr.hotlist_id is None:
+      return None
+    try:
+      hotlist = self.services.features.GetHotlist( mr.cnxn, mr.hotlist_id)
+    except features_svc.NoSuchHotlistException:
+      self.abort(404, 'hotlist not found')
+    return hotlist
+
+  def _GetNewRankings(self, mr):
+    """Compute new issue reference rankings."""
+    missing = False
+    if not (mr.target_id):
+      logging.info('No target_id.')
+      missing = True
+    if not (mr.moved_ids):
+      logging.info('No moved_ids.')
+      missing = True
+    if mr.split_above is None:
+      logging.info('No split_above.')
+      missing = True
+    if missing:
+      return
+
+    untouched_items = [
+        (item.issue_id, item.rank) for item in
+        mr.hotlist.items if item.issue_id not in mr.moved_ids]
+
+    lower, higher = features_bizobj.SplitHotlistIssueRanks(
+        mr.target_id, mr.split_above, untouched_items)
+    return rerank_helpers.GetInsertRankings(lower, higher, mr.moved_ids)
diff --git a/features/savedqueries.py b/features/savedqueries.py
new file mode 100644
index 0000000..5cc1bc8
--- /dev/null
+++ b/features/savedqueries.py
@@ -0,0 +1,76 @@
+# 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
+
+"""Page for showing a user's saved queries and subscription options."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import time
+
+import ezt
+
+from features import savedqueries_helpers
+from framework import framework_helpers
+from framework import permissions
+from framework import servlet
+from framework import urls
+
+
+class SavedQueries(servlet.Servlet):
+  """A page class that shows the user's saved queries."""
+
+  _PAGE_TEMPLATE = 'features/saved-queries-page.ezt'
+
+  def AssertBasePermission(self, mr):
+    super(SavedQueries, self).AssertBasePermission(mr)
+    viewing_self = mr.viewed_user_auth.user_id == mr.auth.user_id
+    if not mr.auth.user_pb.is_site_admin and not viewing_self:
+      raise permissions.PermissionException(
+          'User not allowed to edit this user\'s saved queries')
+
+  def GatherPageData(self, mr):
+    """Build up a dictionary of data values to use when rendering the page."""
+    saved_queries = self.services.features.GetSavedQueriesByUserID(
+        mr.cnxn, mr.viewed_user_auth.user_id)
+    saved_query_views = [
+        savedqueries_helpers.SavedQueryView(
+            sq, idx + 1, mr.cnxn, self.services.project)
+        for idx, sq in enumerate(saved_queries)]
+
+    page_data = {
+        'canned_queries': saved_query_views,
+        'new_query_indexes': (
+            list(range(len(saved_queries) + 1,
+                  savedqueries_helpers.MAX_QUERIES + 1))),
+        'max_queries': savedqueries_helpers.MAX_QUERIES,
+        'user_tab_mode': 'st4',
+        'viewing_user_page': ezt.boolean(True),
+        }
+    return page_data
+
+  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.
+    """
+    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_')
+    saved_queries = existing_queries + added_queries
+
+    self.services.features.UpdateUserSavedQueries(
+        mr.cnxn, mr.viewed_user_auth.user_id, saved_queries)
+
+    return framework_helpers.FormatAbsoluteURL(
+        mr, '/u/%s%s' % (mr.viewed_username, urls.SAVED_QUERIES),
+        include_project=False, saved=1, ts=int(time.time()))
diff --git a/features/savedqueries_helpers.py b/features/savedqueries_helpers.py
new file mode 100644
index 0000000..a6cb46f
--- /dev/null
+++ b/features/savedqueries_helpers.py
@@ -0,0 +1,116 @@
+# 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
+
+"""Utility functions and classes for dealing with saved queries.
+
+Saved queries can be part of the project issue config, where they are
+called "canned queries".  Or, they can be personal saved queries that
+may appear in the search scope drop-down, on the user's dashboard, or
+in the user's subscription.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import re
+
+from framework import template_helpers
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+
+
+MAX_QUERIES = 100
+
+
+def ParseSavedQueries(cnxn, post_data, project_service, prefix=''):
+  """Parse form data for the Saved Queries part of an admin form."""
+  saved_queries = []
+  for i in range(1, MAX_QUERIES + 1):
+    if ('%ssavedquery_name_%s' % (prefix, i)) not in post_data:
+      continue  # skip any entries that are blank or have no predicate.
+
+    name = post_data['%ssavedquery_name_%s' % (prefix, i)].strip()
+    if not name:
+      continue  # skip any blank entries
+
+    if '%ssavedquery_id_%s' % (prefix, i) in post_data:
+      query_id = int(post_data['%ssavedquery_id_%s' % (prefix, i)])
+    else:
+      query_id = None  # a new query_id will be generated by the DB.
+
+    project_names_str = post_data.get(
+        '%ssavedquery_projects_%s' % (prefix, i), '')
+    project_names = [pn.strip().lower()
+                     for pn in re.split('[],;\s]+', project_names_str)
+                     if pn.strip()]
+    project_ids = list(project_service.LookupProjectIDs(
+        cnxn, project_names).values())
+
+    base_id = int(post_data['%ssavedquery_base_%s' % (prefix, i)])
+    query = post_data['%ssavedquery_query_%s' % (prefix, i)].strip()
+
+    subscription_mode_field = '%ssavedquery_sub_mode_%s' % (prefix, i)
+    if subscription_mode_field in post_data:
+      subscription_mode = post_data[subscription_mode_field].strip()
+    else:
+      subscription_mode = None
+
+    saved_queries.append(tracker_bizobj.MakeSavedQuery(
+        query_id, name, base_id, query, subscription_mode=subscription_mode,
+        executes_in_project_ids=project_ids))
+
+  return saved_queries
+
+
+class SavedQueryView(template_helpers.PBProxy):
+  """Wrapper class that makes it easier to display SavedQuery via EZT."""
+
+  def __init__(self, sq, idx, cnxn, project_service):
+    """Store relevant values for later display by EZT.
+
+    Args:
+      sq: A SavedQuery protocol buffer.
+      idx: Int index of this saved query in the list.
+      cnxn: connection to SQL database.
+      project_service: persistence layer for project data.
+    """
+    super(SavedQueryView, self).__init__(sq)
+
+    self.idx = idx
+    base_query_name = 'All issues'
+    for canned in tracker_constants.DEFAULT_CANNED_QUERIES:
+      qid, name, _base_id, _query = canned
+      if qid == sq.base_query_id:
+        base_query_name = name
+
+    if cnxn:
+      project_names = sorted(project_service.LookupProjectNames(
+          cnxn, sq.executes_in_project_ids).values())
+      self.projects = ', '.join(project_names)
+    else:
+      self.projects = ''
+
+    self.docstring = '[%s] %s' % (base_query_name, sq.query)
+
+
+def SavedQueryToCond(saved_query):
+  """Convert a SavedQuery PB to a user query condition string."""
+  if saved_query is None:
+    return ''
+
+  base_cond = tracker_bizobj.GetBuiltInQuery(saved_query.base_query_id)
+  cond = '%s %s' % (base_cond, saved_query.query)
+  return cond.strip()
+
+
+def SavedQueryIDToCond(cnxn, features_service, query_id):
+  """Convert a can/query ID to a user query condition string."""
+  built_in = tracker_bizobj.GetBuiltInQuery(query_id)
+  if built_in:
+    return built_in
+
+  saved_query = features_service.GetSavedQuery(cnxn, query_id)
+  return SavedQueryToCond(saved_query)
diff --git a/features/send_notifications.py b/features/send_notifications.py
new file mode 100644
index 0000000..e7ee4d4
--- /dev/null
+++ b/features/send_notifications.py
@@ -0,0 +1,118 @@
+# 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
+
+"""Functions that prepare and send email notifications of issue changes."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+
+import logging
+
+from features import features_constants
+from framework import cloud_tasks_helpers
+from framework import framework_constants
+from framework import urls
+from tracker import tracker_bizobj
+
+
+def PrepareAndSendIssueChangeNotification(
+    issue_id, hostport, commenter_id, send_email=True,
+    old_owner_id=framework_constants.NO_USER_SPECIFIED, comment_id=None):
+  """Create a task to notify users that an issue has changed.
+
+  Args:
+    issue_id: int ID of the issue that was changed.
+    hostport: string domain name and port number from the HTTP request.
+    commenter_id: int user ID of the user who made the comment.
+    send_email: True if email notifications should be sent.
+    old_owner_id: optional user ID of owner before the current change took
+      effect. They will also be notified.
+    comment_id: int Comment ID of the comment that was entered.
+
+  Returns nothing.
+  """
+  if old_owner_id is None:
+    old_owner_id = framework_constants.NO_USER_SPECIFIED
+  params = dict(
+      issue_id=issue_id, commenter_id=commenter_id, comment_id=comment_id,
+      hostport=hostport, old_owner_id=old_owner_id, send_email=int(send_email))
+  task = cloud_tasks_helpers.generate_simple_task(
+      urls.NOTIFY_ISSUE_CHANGE_TASK + '.do', params)
+  cloud_tasks_helpers.create_task(
+      task, queue=features_constants.QUEUE_NOTIFICATIONS)
+
+  task = cloud_tasks_helpers.generate_simple_task(
+      urls.PUBLISH_PUBSUB_ISSUE_CHANGE_TASK + '.do', params)
+  cloud_tasks_helpers.create_task(task, queue=features_constants.QUEUE_PUBSUB)
+
+
+def PrepareAndSendIssueBlockingNotification(
+    issue_id, hostport, delta_blocker_iids, commenter_id, send_email=True):
+  """Create a task to follow up on an issue blocked_on change."""
+  if not delta_blocker_iids:
+    return  # No notification is needed
+
+  params = dict(
+      issue_id=issue_id, commenter_id=commenter_id, hostport=hostport,
+      send_email=int(send_email),
+      delta_blocker_iids=','.join(str(iid) for iid in delta_blocker_iids))
+
+  task = cloud_tasks_helpers.generate_simple_task(
+      urls.NOTIFY_BLOCKING_CHANGE_TASK + '.do', params)
+  cloud_tasks_helpers.create_task(
+      task, queue=features_constants.QUEUE_NOTIFICATIONS)
+
+
+def PrepareAndSendApprovalChangeNotification(
+    issue_id, approval_id, hostport, comment_id, send_email=True):
+  """Create a task to follow up on an approval change."""
+
+  params = dict(
+      issue_id=issue_id, approval_id=approval_id, hostport=hostport,
+      comment_id=comment_id, send_email=int(send_email))
+
+  task = cloud_tasks_helpers.generate_simple_task(
+      urls.NOTIFY_APPROVAL_CHANGE_TASK + '.do', params)
+  cloud_tasks_helpers.create_task(
+      task, queue=features_constants.QUEUE_NOTIFICATIONS)
+
+
+def SendIssueBulkChangeNotification(
+    issue_ids, hostport, old_owner_ids, comment_text, commenter_id,
+    amendments, send_email, users_by_id):
+  """Create a task to follow up on an issue blocked_on change."""
+  amendment_lines = []
+  for up in amendments:
+    line = '    %s: %s' % (
+        tracker_bizobj.GetAmendmentFieldName(up),
+        tracker_bizobj.AmendmentString(up, users_by_id))
+    if line not in amendment_lines:
+      amendment_lines.append(line)
+
+  params = dict(
+      issue_ids=','.join(str(iid) for iid in issue_ids),
+      commenter_id=commenter_id, hostport=hostport, send_email=int(send_email),
+      old_owner_ids=','.join(str(uid) for uid in old_owner_ids),
+      comment_text=comment_text, amendments='\n'.join(amendment_lines))
+
+  task = cloud_tasks_helpers.generate_simple_task(
+      urls.NOTIFY_BULK_CHANGE_TASK + '.do', params)
+  cloud_tasks_helpers.create_task(
+      task, queue=features_constants.QUEUE_NOTIFICATIONS)
+
+
+def PrepareAndSendDeletedFilterRulesNotification(
+    project_id, hostport, filter_rule_strs):
+  """Create a task to notify project owners of deleted filter rules."""
+
+  params = dict(
+      project_id=project_id, filter_rules=','.join(filter_rule_strs),
+      hostport=hostport)
+
+  task = cloud_tasks_helpers.generate_simple_task(
+      urls.NOTIFY_RULES_DELETED_TASK + '.do', params)
+  cloud_tasks_helpers.create_task(
+      task, queue=features_constants.QUEUE_NOTIFICATIONS)
diff --git a/features/spammodel.py b/features/spammodel.py
new file mode 100644
index 0000000..dc5e715
--- /dev/null
+++ b/features/spammodel.py
@@ -0,0 +1,92 @@
+# 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
+""" Tasks and handlers for maintaining the spam classifier model. These
+    should be run via cron and task queue rather than manually.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import csv
+import logging
+import webapp2
+import cloudstorage
+import json
+
+from datetime import date
+from datetime import datetime
+from datetime import timedelta
+from google.appengine.api import app_identity
+
+from framework import cloud_tasks_helpers
+from framework import gcs_helpers
+from framework import servlet
+from framework import urls
+
+class TrainingDataExport(webapp2.RequestHandler):
+  """Trigger a training data export task"""
+  def get(self):
+    task = cloud_tasks_helpers.generate_simple_task(
+        urls.SPAM_DATA_EXPORT_TASK + '.do', {})
+    cloud_tasks_helpers.create_task(task)
+
+
+BATCH_SIZE = 1000
+
+class TrainingDataExportTask(servlet.Servlet):
+  """Export any human-labeled ham or spam from the previous day. These
+     records will be used by a subsequent task to create an updated model.
+  """
+  CHECK_SECURITY_TOKEN = False
+
+  def ProcessFormData(self, mr, post_data):
+    logging.info("Training data export initiated.")
+
+    bucket_name = app_identity.get_default_gcs_bucket_name()
+    date_str = date.today().isoformat()
+    export_target_path = '/' + bucket_name + '/spam_training_data/' + date_str
+    total_issues = 0
+
+    with cloudstorage.open(export_target_path, mode='w',
+        content_type=None, options=None, retry_params=None) as gcs_file:
+
+      csv_writer = csv.writer(gcs_file, delimiter=',', quotechar='"',
+          quoting=csv.QUOTE_ALL, lineterminator='\n')
+
+      since = datetime.now() - timedelta(days=7)
+
+      # TODO: Further pagination.
+      issues, first_comments, _count = (
+          self.services.spam.GetTrainingIssues(
+              mr.cnxn, self.services.issue, since, offset=0, limit=BATCH_SIZE))
+      total_issues += len(issues)
+      for issue in issues:
+        # Cloud Prediction API doesn't allow newlines in the training data.
+        fixed_summary = issue.summary.replace('\r\n', ' ')
+        fixed_comment = first_comments[issue.issue_id].replace('\r\n', ' ')
+        email = self.services.user.LookupUserEmail(mr.cnxn, issue.reporter_id)
+        csv_writer.writerow([
+            'spam' if issue.is_spam else 'ham',
+            fixed_summary.encode('utf-8'), fixed_comment.encode('utf-8'), email,
+        ])
+
+      comments = (
+          self.services.spam.GetTrainingComments(
+              mr.cnxn, self.services.issue, since, offset=0, limit=BATCH_SIZE))
+      total_comments = len(comments)
+      for comment in comments:
+        # Cloud Prediction API doesn't allow newlines in the training data.
+        fixed_comment = comment.content.replace('\r\n', ' ')
+        email = self.services.user.LookupUserEmail(mr.cnxn, comment.user_id)
+        csv_writer.writerow([
+            'spam' if comment.is_spam else 'ham',
+            # Comments don't have summaries, so it's blank:
+            '', fixed_comment.encode('utf-8'), email
+        ])
+
+    self.response.body = json.dumps({
+        "exported_issue_count": total_issues,
+        "exported_comment_count": total_comments,
+    })
diff --git a/features/spamtraining.py b/features/spamtraining.py
new file mode 100644
index 0000000..625fa53
--- /dev/null
+++ b/features/spamtraining.py
@@ -0,0 +1,63 @@
+"""Cron job to train spam model with all spam data."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import settings
+import time
+
+from googleapiclient import discovery
+from googleapiclient import errors
+from google.appengine.api import app_identity
+from oauth2client.client import GoogleCredentials
+import webapp2
+
+class TrainSpamModelCron(webapp2.RequestHandler):
+
+  """Submit a job to ML Engine which uploads a spam classification model by
+     training on an already packaged trainer.
+  """
+  def get(self):
+
+    credentials = GoogleCredentials.get_application_default()
+    ml = discovery.build('ml', 'v1', credentials=credentials)
+
+    app_id = app_identity.get_application_id()
+    project_id = 'projects/%s' % (app_id)
+    job_id = 'spam_trainer_%d' % time.time()
+    training_input = {
+        'scaleTier': 'BASIC',
+        'packageUris': [
+            settings.trainer_staging
+            if app_id == "monorail-staging" else
+            settings.trainer_prod
+        ],
+        'pythonModule': 'trainer.task',
+        'args': [
+            '--train-steps',
+            '1000',
+            '--verbosity',
+            'DEBUG',
+            '--gcs-bucket',
+            'monorail-prod.appspot.com',
+            '--gcs-prefix',
+            'spam_training_data',
+            '--trainer-type',
+            'spam'
+        ],
+        'region': 'us-central1',
+        'jobDir': 'gs://%s-mlengine/%s' % (app_id, job_id),
+        'runtimeVersion': '1.2'
+    }
+    job_info = {
+        'jobId': job_id,
+        'trainingInput': training_input
+    }
+    request = ml.projects().jobs().create(parent=project_id, body=job_info)
+
+    try:
+      response = request.execute()
+      logging.info(response)
+    except errors.HttpError, err:
+      logging.error(err._get_reason())
diff --git a/features/test/__init__.py b/features/test/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/features/test/__init__.py
diff --git a/features/test/activities_test.py b/features/test/activities_test.py
new file mode 100644
index 0000000..4eae1ab
--- /dev/null
+++ b/features/test/activities_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
+
+"""Unittests for monorail.feature.activities."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+import mox
+
+from features import activities
+from framework import framework_views
+from framework import profiler
+from proto import tracker_pb2
+from proto import user_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+
+
+class ActivitiesTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        config=fake.ConfigService(),
+        issue=fake.IssueService(),
+        user=fake.UserService(),
+        usergroup=fake.UserGroupService(),
+        project=fake.ProjectService(),
+    )
+
+    self.project_name = 'proj'
+    self.project_id = 987
+    self.project = self.services.project.TestAddProject(
+        self.project_name, project_id=self.project_id,
+        process_inbound_email=True)
+
+    self.issue_id = 11
+    self.issue_local_id = 100
+    self.issue = tracker_pb2.Issue()
+    self.issue.issue_id = self.issue_id
+    self.issue.project_id = self.project_id
+    self.issue.local_id = self.issue_local_id
+    self.services.issue.TestAddIssue(self.issue)
+
+    self.comment_id = 123
+    self.comment_timestamp = 120
+    self.user = self.services.user.TestAddUser('testuser@example.com', 2)
+    self.user_id = self.user.user_id
+    self.mr_after = 1234
+
+    self.mox = mox.Mox()
+
+  def tearDown(self):
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def testActivities_NoUpdates(self):
+    mr = testing_helpers.MakeMonorailRequest()
+    updates_data = activities.GatherUpdatesData(
+        self.services, mr, project_ids=[256],
+        user_ids=None, ending=None, updates_page_url=None, autolink=None,
+        highlight=None)
+
+    self.assertIsNone(updates_data['pagination'])
+    self.assertIsNone(updates_data['no_stars'])
+    self.assertIsNone(updates_data['updates_data'])
+    self.assertEqual('yes', updates_data['no_activities'])
+    self.assertIsNone(updates_data['ending_type'])
+
+  def createAndAssertUpdates(self, project_ids=None, user_ids=None,
+                             ascending=True):
+    comment_1 = tracker_pb2.IssueComment(
+        id=self.comment_id, issue_id=self.issue_id,
+        project_id=self.project_id, user_id=self.user_id,
+        content='this is the 1st comment',
+        timestamp=self.comment_timestamp)
+    self.mox.StubOutWithMock(self.services.issue, 'GetIssueActivity')
+
+    after = 0
+    if ascending:
+      after = self.mr_after
+    self.services.issue.GetIssueActivity(
+        mox.IgnoreArg(), num=50, before=0, after=after, project_ids=project_ids,
+        user_ids=user_ids, ascending=ascending).AndReturn([comment_1])
+
+    self.mox.ReplayAll()
+
+    mr = testing_helpers.MakeMonorailRequest()
+    if ascending:
+      mr.after = self.mr_after
+
+    updates_page_url='testing/testing'
+    updates_data = activities.GatherUpdatesData(
+        self.services, mr, project_ids=project_ids,
+        user_ids=user_ids, ending=None, autolink=None,
+        highlight='highlightme', updates_page_url=updates_page_url)
+    self.mox.VerifyAll()
+
+    if mr.after:
+      pagination = updates_data['pagination']
+      self.assertIsNone(pagination.last)
+      self.assertEqual(
+          '%s?before=%d' %
+          (updates_page_url.split('/')[-1], self.comment_timestamp),
+          pagination.next_url)
+      self.assertEqual(
+          '%s?after=%d' %
+          (updates_page_url.split('/')[-1], self.comment_timestamp),
+          pagination.prev_url)
+
+    activity_view = updates_data['updates_data'].older[0]
+    self.assertEqual(
+        '<a class="ot-issue-link"\n \n '
+        'href="/p//issues/detail?id=%s#c_ts%s"\n >'
+        'issue %s</a>\n\n()\n\n\n\n\n \n commented on' % (
+            self.issue_local_id, self.comment_timestamp, self.issue_local_id),
+        activity_view.escaped_title)
+    self.assertEqual(
+        '<span class="ot-issue-comment">\n this is the 1st comment\n</span>',
+        activity_view.escaped_body)
+    self.assertEqual('highlightme', activity_view.highlight)
+    self.assertEqual(self.project_name, activity_view.project_name)
+
+  def testActivities_AscendingProjectUpdates(self):
+    self.createAndAssertUpdates(project_ids=[self.project_id], ascending=True)
+
+  def testActivities_DescendingProjectUpdates(self):
+    self.createAndAssertUpdates(project_ids=[self.project_id], ascending=False)
+
+  def testActivities_AscendingUserUpdates(self):
+    self.createAndAssertUpdates(user_ids=[self.user_id], ascending=True)
+
+  def testActivities_DescendingUserUpdates(self):
+    self.createAndAssertUpdates(user_ids=[self.user_id], ascending=False)
+
+  def testActivities_SpecifyProjectAndUser(self):
+    self.createAndAssertUpdates(
+        project_ids=[self.project_id], user_ids=[self.user_id], ascending=False)
diff --git a/features/test/alert2issue_test.py b/features/test/alert2issue_test.py
new file mode 100644
index 0000000..3b1b6d1
--- /dev/null
+++ b/features/test/alert2issue_test.py
@@ -0,0 +1,677 @@
+# Copyright 2019 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.feature.alert2issue."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import email
+import unittest
+from mock import patch
+import mox
+from parameterized import parameterized
+
+from features import alert2issue
+from framework import authdata
+from framework import emailfmt
+from proto import tracker_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+from tracker import tracker_helpers
+
+AlertEmailHeader = emailfmt.AlertEmailHeader
+
+
+class TestData(object):
+  """Contains constants or such objects that are intended to be read-only."""
+  cnxn = 'fake cnxn'
+  test_issue_local_id = 100
+  component_id = 123
+  trooper_queue = 'my-trooper-bug-queue'
+
+  project_name = 'proj'
+  project_addr = '%s+ALERT+%s@monorail.example.com' % (
+      project_name, trooper_queue)
+  project_id = 987
+
+  from_addr = 'user@monorail.example.com'
+  user_id = 111
+
+  msg_body = 'this is the body'
+  msg_subject = 'this is the subject'
+  msg = testing_helpers.MakeMessage(
+      testing_helpers.ALERT_EMAIL_HEADER_LINES, msg_body)
+
+  incident_id = msg.get(AlertEmailHeader.INCIDENT_ID)
+  incident_label = alert2issue._GetIncidentLabel(incident_id)
+
+  # All the tests in this class use the following alert properties, and
+  # the generator functions/logic should be tested in a separate class.
+  alert_props = {
+      'owner_id': 0,
+      'cc_ids': [],
+      'status': 'Available',
+      'incident_label': incident_label,
+      'priority': 'Pri-0',
+      'trooper_queue': trooper_queue,
+      'field_values': [],
+      'labels': [
+          'Restrict-View-Google', 'Pri-0', incident_label, trooper_queue
+      ],
+      'component_ids': [component_id],
+  }
+
+
+class ProcessEmailNotificationTests(unittest.TestCase, TestData):
+  """Implements unit tests for alert2issue.ProcessEmailNotification."""
+  def setUp(self):
+    # services
+    self.services = service_manager.Services(
+        config=fake.ConfigService(),
+        issue=fake.IssueService(),
+        user=fake.UserService(),
+        usergroup=fake.UserGroupService(),
+        project=fake.ProjectService(),
+        features=fake.FeaturesService())
+
+    # project
+    self.project = self.services.project.TestAddProject(
+        self.project_name, project_id=self.project_id,
+        process_inbound_email=True, contrib_ids=[self.user_id])
+
+    # config
+    proj_config = fake.MakeTestConfig(self.project_id, [], ['Available'])
+    comp_def_1 = tracker_pb2.ComponentDef(
+        component_id=123, project_id=987, path='FOO', docstring='foo docstring')
+    proj_config.component_defs = [comp_def_1]
+    self.services.config.StoreConfig(self.cnxn, proj_config)
+
+    # sender
+    self.auth = authdata.AuthData(user_id=self.user_id, email=self.from_addr)
+
+    # issue
+    self.issue = tracker_pb2.Issue(
+        project_id=self.project_id,
+        local_id=self.test_issue_local_id,
+        summary=self.msg_subject,
+        reporter_id=self.user_id,
+        component_ids=[self.component_id],
+        status=self.alert_props['status'],
+        labels=self.alert_props['labels'],
+    )
+    self.services.issue.TestAddIssue(self.issue)
+
+    # Patch send_notifications functions.
+    self.notification_patchers = [
+        patch('features.send_notifications.%s' % func, spec=True)
+        for func in [
+            'PrepareAndSendIssueBlockingNotification',
+            'PrepareAndSendIssueChangeNotification',
+        ]
+    ]
+    self.blocking_notification = self.notification_patchers[0].start()
+    self.blocking_notification = self.notification_patchers[1].start()
+
+    self.mox = mox.Mox()
+
+  def tearDown(self):
+    self.notification_patchers[0].stop()
+    self.notification_patchers[1].stop()
+
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def testGoogleAddrsAreAllowlistedSender(self):
+    self.assertTrue(alert2issue.IsAllowlisted('test@google.com'))
+    self.assertFalse(alert2issue.IsAllowlisted('test@notgoogle.com'))
+
+  def testSkipNotification_IfFromNonAllowlistedSender(self):
+    self.mox.StubOutWithMock(alert2issue, 'IsAllowlisted')
+    alert2issue.IsAllowlisted(self.from_addr).AndReturn(False)
+
+    # None of them should be called, if the sender has not been allowlisted.
+    self.mox.StubOutWithMock(self.services.issue, 'CreateIssueComment')
+    self.mox.StubOutWithMock(self.services.issue, 'CreateIssue')
+    self.mox.ReplayAll()
+
+    alert2issue.ProcessEmailNotification(
+        self.services, self.cnxn, self.project, self.project_addr,
+        self.from_addr, self.auth, self.msg_subject, self.msg_body,
+        self.incident_label, self.msg, self.trooper_queue)
+    self.mox.VerifyAll()
+
+  def testSkipNotification_TooLongComment(self):
+    self.mox.StubOutWithMock(alert2issue, 'IsAllowlisted')
+    alert2issue.IsAllowlisted(self.from_addr).AndReturn(True)
+    self.mox.StubOutWithMock(alert2issue, 'IsCommentSizeReasonable')
+    alert2issue.IsCommentSizeReasonable(
+        'Filed by %s on behalf of %s\n\n%s' %
+        (self.auth.email, self.from_addr, self.msg_body)).AndReturn(False)
+
+    # None of them should be called, if the comment is too long.
+    self.mox.StubOutWithMock(self.services.issue, 'CreateIssueComment')
+    self.mox.StubOutWithMock(self.services.issue, 'CreateIssue')
+    self.mox.ReplayAll()
+
+    alert2issue.ProcessEmailNotification(
+        self.services, self.cnxn, self.project, self.project_addr,
+        self.from_addr, self.auth, self.msg_subject, self.msg_body,
+        self.incident_label, self.msg, self.trooper_queue)
+    self.mox.VerifyAll()
+
+  def testProcessNotification_IfFromAllowlistedSender(self):
+    self.mox.StubOutWithMock(alert2issue, 'IsAllowlisted')
+    alert2issue.IsAllowlisted(self.from_addr).AndReturn(True)
+
+    self.mox.StubOutWithMock(tracker_helpers, 'LookupComponentIDs')
+    tracker_helpers.LookupComponentIDs(
+        ['Infra'],
+        mox.IgnoreArg()).AndReturn([1])
+    self.mox.StubOutWithMock(self.services.issue, 'CreateIssueComment')
+    self.mox.StubOutWithMock(self.services.issue, 'CreateIssue')
+    self.mox.ReplayAll()
+
+    # Either of the methods should be called, if the sender is allowlisted.
+    with self.assertRaises(mox.UnexpectedMethodCallError):
+      alert2issue.ProcessEmailNotification(
+          self.services, self.cnxn, self.project, self.project_addr,
+          self.from_addr, self.auth, self.msg_subject, self.msg_body,
+          self.incident_label, self.msg, self.trooper_queue)
+
+    self.mox.VerifyAll()
+
+  def testIssueCreated_ForNewIncident(self):
+    """Tests if a new issue is created for a new incident."""
+    self.mox.StubOutWithMock(alert2issue, 'IsAllowlisted')
+    alert2issue.IsAllowlisted(self.from_addr).AndReturn(True)
+
+    # FindAlertIssue() returns None for a new incident.
+    self.mox.StubOutWithMock(alert2issue, 'FindAlertIssue')
+    alert2issue.FindAlertIssue(
+        self.services, self.cnxn, self.project.project_id,
+        self.incident_label).AndReturn(None)
+
+    # Mock GetAlertProperties() to create the issue with the expected
+    # properties.
+    self.mox.StubOutWithMock(alert2issue, 'GetAlertProperties')
+    alert2issue.GetAlertProperties(
+        self.services, self.cnxn, self.project_id, self.incident_id,
+        self.trooper_queue, self.msg).AndReturn(self.alert_props)
+
+    self.mox.ReplayAll()
+    alert2issue.ProcessEmailNotification(
+        self.services, self.cnxn, self.project, self.project_addr,
+        self.from_addr, self.auth, self.msg_subject, self.msg_body,
+        self.incident_id, self.msg, self.trooper_queue)
+
+    # the local ID of the newly created issue should be +1 from the highest ID
+    # in the existing issues.
+    comments = self._verifyIssue(self.test_issue_local_id + 1, self.alert_props)
+    self.assertEqual(comments[0].content,
+                     'Filed by %s on behalf of %s\n\n%s' % (
+                         self.from_addr, self.from_addr, self.msg_body))
+
+    self.mox.VerifyAll()
+
+  def testProcessEmailNotification_ExistingIssue(self):
+    """When an alert for an ongoing incident comes in, add a comment."""
+    self.mox.StubOutWithMock(alert2issue, 'IsAllowlisted')
+    alert2issue.IsAllowlisted(self.from_addr).AndReturn(True)
+
+    # FindAlertIssue() returns None for a new incident.
+    self.mox.StubOutWithMock(alert2issue, 'FindAlertIssue')
+    alert2issue.FindAlertIssue(
+        self.services, self.cnxn, self.project.project_id,
+        self.incident_label).AndReturn(self.issue)
+
+    # Mock GetAlertProperties() to create the issue with the expected
+    # properties.
+    self.mox.StubOutWithMock(alert2issue, 'GetAlertProperties')
+    alert2issue.GetAlertProperties(
+        self.services, self.cnxn, self.project_id, self.incident_id,
+        self.trooper_queue, self.msg).AndReturn(self.alert_props)
+
+    self.mox.ReplayAll()
+
+    # Before processing the notification, ensures that there is only 1 comment
+    # in the test issue.
+    comments = self._verifyIssue(self.test_issue_local_id, self.alert_props)
+    self.assertEqual(len(comments), 1)
+
+    # Process
+    alert2issue.ProcessEmailNotification(
+        self.services, self.cnxn, self.project, self.project_addr,
+        self.from_addr, self.auth, self.msg_subject, self.msg_body,
+        self.incident_id, self.msg, self.trooper_queue)
+
+    # Now, it should have a new comment added.
+    comments = self._verifyIssue(self.test_issue_local_id, self.alert_props)
+    self.assertEqual(len(comments), 2)
+    self.assertEqual(comments[1].content,
+                     'Filed by %s on behalf of %s\n\n%s' % (
+                         self.from_addr, self.from_addr, self.msg_body))
+
+    self.mox.VerifyAll()
+
+  def _verifyIssue(self, local_issue_id, alert_props):
+    actual_issue = self.services.issue.GetIssueByLocalID(
+        self.cnxn, self.project.project_id, local_issue_id)
+    actual_comments = self.services.issue.GetCommentsForIssue(
+        self.cnxn, actual_issue.issue_id)
+
+    self.assertEqual(actual_issue.summary, self.msg_subject)
+    self.assertEqual(actual_issue.status, alert_props['status'])
+    self.assertEqual(actual_issue.reporter_id, self.user_id)
+    self.assertEqual(actual_issue.component_ids, [self.component_id])
+    if alert_props['owner_id']:
+      self.assertEqual(actual_issue.owner_id, alert_props['owner_id'])
+    self.assertEqual(sorted(actual_issue.labels), sorted(alert_props['labels']))
+    return actual_comments
+
+
+class GetAlertPropertiesTests(unittest.TestCase, TestData):
+  """Implements unit tests for alert2issue.GetAlertProperties."""
+  def assertSubset(self, lhs, rhs):
+    if not (lhs <= rhs):
+      raise AssertionError('%s not a subset of %s' % (lhs, rhs))
+
+  def assertCaseInsensitiveEqual(self, lhs, rhs):
+    self.assertEqual(lhs if lhs is None else lhs.lower(),
+                     rhs if lhs is None else rhs.lower())
+
+  def setUp(self):
+    # services
+    self.services = service_manager.Services(
+        config=fake.ConfigService(),
+        issue=fake.IssueService(),
+        user=fake.UserService(),
+        usergroup=fake.UserGroupService(),
+        project=fake.ProjectService())
+
+    # project
+    self.project = self.services.project.TestAddProject(
+        self.project_name, project_id=self.project_id,
+        process_inbound_email=True, contrib_ids=[self.user_id])
+
+    proj_config = fake.MakeTestConfig(
+        self.project_id,
+        [
+            # test labels for Pri field
+            'Pri-0', 'Pri-1', 'Pri-2', 'Pri-3',
+            # test labels for OS field
+            'OS-Android', 'OS-Windows',
+            # test labels for Type field
+            'Type-Bug', 'Type-Bug-Regression', 'Type-Bug-Security', 'Type-Task',
+        ],
+        ['Assigned', 'Available', 'Unconfirmed']
+    )
+    self.services.config.StoreConfig(self.cnxn, proj_config)
+
+    # create a test email message, which tests can alternate the header values
+    # to verify the behaviour of a given parser function.
+    self.test_msg = email.Message.Message()
+    for key, value in self.msg.items():
+      self.test_msg[key] = value
+
+    self.mox = mox.Mox()
+
+  @parameterized.expand([
+      (None,),
+      ('',),
+      (' ',),
+  ])
+  def testDefaultComponent(self, header_value):
+    """Checks if the default component is Infra."""
+    self.test_msg.replace_header(AlertEmailHeader.COMPONENT, header_value)
+    self.mox.StubOutWithMock(tracker_helpers, 'LookupComponentIDs')
+    tracker_helpers.LookupComponentIDs(
+        ['Infra'],
+        mox.IgnoreArg()).AndReturn([self.component_id])
+
+    self.mox.ReplayAll()
+    props = alert2issue.GetAlertProperties(
+        self.services, self.cnxn, self.project_id, self.incident_id,
+        self.trooper_queue, self.test_msg)
+    self.assertEqual(props['component_ids'], [self.component_id])
+    self.mox.VerifyAll()
+
+  @parameterized.expand([
+      # an existing single component with componentID 1
+      ({'Infra': 1}, [1]),
+      # 3 of existing components
+      ({'Infra': 1, 'Foo': 2, 'Bar': 3}, [1, 2, 3]),
+      # a non-existing component
+      ({'Infra': None}, []),
+      # 3 of non-existing components
+      ({'Infra': None, 'Foo': None, 'Bar': None}, []),
+      # a mix of existing and non-existing components
+      ({'Infra': 1, 'Foo': None, 'Bar': 2}, [1, 2]),
+  ])
+  def testGetComponentIDs(self, components, expected_component_ids):
+    """Tests _GetComponentIDs."""
+    self.test_msg.replace_header(
+        AlertEmailHeader.COMPONENT, ','.join(sorted(components.keys())))
+
+    self.mox.StubOutWithMock(tracker_helpers, 'LookupComponentIDs')
+    tracker_helpers.LookupComponentIDs(
+        sorted(components.keys()),
+        mox.IgnoreArg()).AndReturn(
+            [components[key] for key in sorted(components.keys())
+             if components[key]]
+        )
+
+    self.mox.ReplayAll()
+    props = alert2issue.GetAlertProperties(
+        self.services, self.cnxn, self.project_id, self.incident_id,
+        self.trooper_queue, self.test_msg)
+    self.assertEqual(sorted(props['component_ids']),
+                     sorted(expected_component_ids))
+    self.mox.VerifyAll()
+
+
+  def testLabelsWithNecessaryValues(self):
+    """Checks if the labels contain all the necessary values."""
+    props = alert2issue.GetAlertProperties(
+        self.services, self.cnxn, self.project_id, self.incident_id,
+        self.trooper_queue, self.test_msg)
+
+    # This test assumes that the test message contains non-empty values for
+    # all the headers.
+    self.assertTrue(props['incident_label'])
+    self.assertTrue(props['priority'])
+    self.assertTrue(props['issue_type'])
+    self.assertTrue(props['oses'])
+
+    # Here are a list of the labels that props['labels'] should contain
+    self.assertIn('Restrict-View-Google'.lower(), props['labels'])
+    self.assertIn(self.trooper_queue, props['labels'])
+    self.assertIn(props['incident_label'], props['labels'])
+    self.assertIn(props['priority'], props['labels'])
+    self.assertIn(props['issue_type'], props['labels'])
+    for os in props['oses']:
+      self.assertIn(os, props['labels'])
+
+  @parameterized.expand([
+      (None, 0),
+      ('', 0),
+      (' ', 0),
+  ])
+  def testDefaultOwnerID(self, header_value, expected_owner_id):
+    """Checks if _GetOwnerID returns None in default."""
+    self.test_msg.replace_header(AlertEmailHeader.OWNER, header_value)
+    props = alert2issue.GetAlertProperties(
+        self.services, self.cnxn, self.project_id, self.incident_id,
+        self.trooper_queue, self.test_msg)
+    self.assertEqual(props['owner_id'], expected_owner_id)
+
+  @parameterized.expand(
+      [
+          # an existing user with userID 1.
+          ('owner@example.org', 1),
+          # a non-existing user.
+          ('owner@example.org', 0),
+      ])
+  def testGetOwnerID(self, owner, expected_owner_id):
+    """Tests _GetOwnerID returns the ID of the owner."""
+    self.test_msg.replace_header(AlertEmailHeader.CC, '')
+    self.test_msg.replace_header(AlertEmailHeader.OWNER, owner)
+
+    self.mox.StubOutWithMock(self.services.user, 'LookupExistingUserIDs')
+    self.services.user.LookupExistingUserIDs(self.cnxn, [owner]).AndReturn(
+        {owner: expected_owner_id})
+
+    self.mox.ReplayAll()
+    props = alert2issue.GetAlertProperties(
+        self.services, self.cnxn, self.project_id, self.incident_id,
+        self.trooper_queue, self.test_msg)
+    self.mox.VerifyAll()
+    self.assertEqual(props['owner_id'], expected_owner_id)
+
+  @parameterized.expand([
+      (None, []),
+      ('', []),
+      (' ', []),
+  ])
+  def testDefaultCCIDs(self, header_value, expected_cc_ids):
+    """Checks if _GetCCIDs returns an empty list in default."""
+    self.test_msg.replace_header(AlertEmailHeader.CC, header_value)
+    props = alert2issue.GetAlertProperties(
+        self.services, self.cnxn, self.project_id, self.incident_id,
+        self.trooper_queue, self.test_msg)
+    self.assertEqual(props['cc_ids'], expected_cc_ids)
+
+  @parameterized.expand([
+      # with one existing user cc-ed.
+      ({'user1@example.org': 1}, [1]),
+      # with two of existing users.
+      ({'user1@example.org': 1, 'user2@example.org': 2}, [1, 2]),
+      # with one non-existing user.
+      ({'user1@example.org': None}, []),
+      # with two of non-existing users.
+      ({'user1@example.org': None, 'user2@example.org': None}, []),
+      # with a mix of existing and non-existing users.
+      ({'user1@example.org': 1, 'user2@example.org': None}, [1]),
+  ])
+  def testGetCCIDs(self, ccers, expected_cc_ids):
+    """Tests _GetCCIDs returns the IDs of the email addresses to be cc-ed."""
+    self.test_msg.replace_header(
+        AlertEmailHeader.CC, ','.join(sorted(ccers.keys())))
+    self.test_msg.replace_header(AlertEmailHeader.OWNER, '')
+
+    self.mox.StubOutWithMock(self.services.user, 'LookupExistingUserIDs')
+    self.services.user.LookupExistingUserIDs(
+        self.cnxn, sorted(ccers.keys())).AndReturn(ccers)
+
+    self.mox.ReplayAll()
+    props = alert2issue.GetAlertProperties(
+        self.services, self.cnxn, self.project_id, self.incident_id,
+        self.trooper_queue, self.test_msg)
+    self.mox.VerifyAll()
+    self.assertEqual(sorted(props['cc_ids']), sorted(expected_cc_ids))
+
+  @parameterized.expand([
+      # None and '' should result in the default priority returned.
+      (None, 'Pri-2'),
+      ('', 'Pri-2'),
+      (' ', 'Pri-2'),
+
+      # Tests for valid priority values
+      ('0', 'Pri-0'),
+      ('1', 'Pri-1'),
+      ('2', 'Pri-2'),
+      ('3', 'Pri-3'),
+
+      # Tests for invalid priority values
+      ('test', 'Pri-2'),
+      ('foo', 'Pri-2'),
+      ('critical', 'Pri-2'),
+      ('4', 'Pri-2'),
+      ('3x', 'Pri-2'),
+      ('00', 'Pri-2'),
+      ('01', 'Pri-2'),
+  ])
+  def testGetPriority(self, header_value, expected_priority):
+    """Tests _GetPriority."""
+    self.test_msg.replace_header(AlertEmailHeader.PRIORITY, header_value)
+    props = alert2issue.GetAlertProperties(
+        self.services, self.cnxn, self.project_id, self.incident_id,
+        self.trooper_queue, self.test_msg)
+    self.assertCaseInsensitiveEqual(props['priority'], expected_priority)
+
+  @parameterized.expand([
+      (None, 'Available'),
+      ('', 'Available'),
+      (' ', 'Available'),
+  ])
+  def testDefaultStatus(self, header_value, expected_status):
+    """Checks if _GetStatus return Available in default."""
+    self.test_msg.replace_header(AlertEmailHeader.STATUS, header_value)
+    props = alert2issue.GetAlertProperties(
+        self.services, self.cnxn, self.project_id, self.incident_id,
+        self.trooper_queue, self.test_msg)
+    self.assertCaseInsensitiveEqual(props['status'], expected_status)
+
+  @parameterized.expand([
+      ('random_status', True, 'random_status'),
+      # If the status is not one of the open statuses, the default status
+      # should be returned instead.
+      ('random_status', False, 'Available'),
+  ])
+  def testGetStatusWithoutOwner(self, status, means_open, expected_status):
+    """Tests GetStatus without an owner."""
+    self.test_msg.replace_header(AlertEmailHeader.STATUS, status)
+    self.mox.StubOutWithMock(tracker_helpers, 'MeansOpenInProject')
+    tracker_helpers.MeansOpenInProject(status, mox.IgnoreArg()).AndReturn(
+        means_open)
+
+    self.mox.ReplayAll()
+    props = alert2issue.GetAlertProperties(
+        self.services, self.cnxn, self.project_id, self.incident_id,
+        self.trooper_queue, self.test_msg)
+    self.assertCaseInsensitiveEqual(props['status'], expected_status)
+    self.mox.VerifyAll()
+
+  @parameterized.expand([
+      # If there is an owner, the status should always be Assigned.
+      (None, 'Assigned'),
+      ('', 'Assigned'),
+      (' ', 'Assigned'),
+
+      ('random_status', 'Assigned'),
+      ('Available', 'Assigned'),
+      ('Unconfirmed', 'Assigned'),
+      ('Fixed', 'Assigned'),
+  ])
+  def testGetStatusWithOwner(self, status, expected_status):
+    """Tests GetStatus with an owner."""
+    owner = 'owner@example.org'
+    self.test_msg.replace_header(AlertEmailHeader.OWNER, owner)
+    self.test_msg.replace_header(AlertEmailHeader.CC, '')
+    self.test_msg.replace_header(AlertEmailHeader.STATUS, status)
+
+    self.mox.StubOutWithMock(self.services.user, 'LookupExistingUserIDs')
+    self.services.user.LookupExistingUserIDs(self.cnxn, [owner]).AndReturn(
+        {owner: 1})
+
+    self.mox.ReplayAll()
+    props = alert2issue.GetAlertProperties(
+        self.services, self.cnxn, self.project_id, self.incident_id,
+        self.trooper_queue, self.test_msg)
+    self.assertCaseInsensitiveEqual(props['status'], expected_status)
+    self.mox.VerifyAll()
+
+  @parameterized.expand(
+      [
+          # None and '' should result in None returned.
+          (None, None),
+          ('', None),
+          (' ', None),
+
+          # allowlisted issue types
+          ('Bug', 'Type-Bug'),
+          ('Bug-Regression', 'Type-Bug-Regression'),
+          ('Bug-Security', 'Type-Bug-Security'),
+          ('Task', 'Type-Task'),
+
+          # non-allowlisted issue types
+          ('foo', None),
+          ('bar', None),
+          ('Bug,Bug-Regression', None),
+          ('Bug,', None),
+          (',Task', None),
+      ])
+  def testGetIssueType(self, header_value, expected_issue_type):
+    """Tests _GetIssueType."""
+    self.test_msg.replace_header(AlertEmailHeader.TYPE, header_value)
+    props = alert2issue.GetAlertProperties(
+        self.services, self.cnxn, self.project_id, self.incident_id,
+        self.trooper_queue, self.test_msg)
+    self.assertCaseInsensitiveEqual(props['issue_type'], expected_issue_type)
+
+  @parameterized.expand(
+      [
+          # None and '' should result in an empty list returned.
+          (None, []),
+          ('', []),
+          (' ', []),
+
+          # a single, allowlisted os
+          ('Android', ['OS-Android']),
+          # a single, non-allowlisted OS
+          ('Bendroid', []),
+          # multiple, allowlisted oses
+          ('Android,Windows', ['OS-Android', 'OS-Windows']),
+          # multiple, non-allowlisted oses
+          ('Bendroid,Findows', []),
+          # a mix of allowlisted and non-allowlisted oses
+          ('Android,Findows,Windows,Bendroid', ['OS-Android', 'OS-Windows']),
+          # a mix of allowlisted and non-allowlisted oses with trailing commas.
+          ('Android,Findows,Windows,Bendroid,,', ['OS-Android', 'OS-Windows']),
+          # a mix of allowlisted and non-allowlisted oses with commas at the
+          # beginning.
+          (
+              ',,Android,Findows,Windows,Bendroid,,',
+              ['OS-Android', 'OS-Windows']),
+      ])
+  def testGetOSes(self, header_value, expected_oses):
+    """Tests _GetOSes."""
+    self.test_msg.replace_header(AlertEmailHeader.OS, header_value)
+    props = alert2issue.GetAlertProperties(
+        self.services, self.cnxn, self.project_id, self.incident_id,
+        self.trooper_queue, self.test_msg)
+    self.assertEqual(sorted(os if os is None else os.lower()
+                            for os in props['oses']),
+                     sorted(os if os is None else os.lower()
+                            for os in expected_oses))
+
+  @parameterized.expand([
+      # None and '' should result in an empty list + RSVG returned.
+      (None, []),
+      ('', []),
+      (' ', []),
+
+      ('Label-1', ['label-1']),
+      ('Label-1,Label-2', ['label-1', 'label-2',]),
+      ('Label-1,Label-2,Label-3', ['label-1', 'label-2', 'label-3']),
+
+      # Duplicates should be removed.
+      ('Label-1,Label-1', ['label-1']),
+      ('Label-1,label-1', ['label-1']),
+      (',Label-1,label-1,', ['label-1']),
+      ('Label-1,label-1,', ['label-1']),
+      (',Label-1,,label-1,,,', ['label-1']),
+      ('Label-1,Label-2,Label-1', ['label-1', 'label-2']),
+
+      # Whitespaces should be removed from labels.
+      ('La bel - 1 ', ['label-1']),
+      ('La bel - 1 , Label- 1', ['label-1']),
+      ('La bel- 1 , Label - 2', ['label-1', 'label-2']),
+
+      # RSVG should be set always.
+      ('Label-1,Label-1,Restrict-View-Google', ['label-1']),
+  ])
+  def testGetLabels(self, header_value, expected_labels):
+    """Tests _GetLabels."""
+    self.test_msg.replace_header(AlertEmailHeader.LABEL, header_value)
+    props = alert2issue.GetAlertProperties(
+        self.services, self.cnxn, self.project_id, self.incident_id,
+        self.trooper_queue, self.test_msg)
+
+    # Check if there are any duplicates
+    labels = set(props['labels'])
+    self.assertEqual(sorted(props['labels']), sorted(list(labels)))
+
+    # Check the labels that shouldb always be included
+    self.assertIn('Restrict-View-Google'.lower(), labels)
+    self.assertIn(props['trooper_queue'], labels)
+    self.assertIn(props['incident_label'], labels)
+    self.assertIn(props['priority'], labels)
+    self.assertIn(props['issue_type'], labels)
+    self.assertSubset(set(props['oses']), labels)
+
+    # All the custom labels should be present.
+    self.assertSubset(set(expected_labels), labels)
diff --git a/features/test/autolink_test.py b/features/test/autolink_test.py
new file mode 100644
index 0000000..a779014
--- /dev/null
+++ b/features/test/autolink_test.py
@@ -0,0 +1,808 @@
+# 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 autolink feature."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import re
+import unittest
+
+from features import autolink
+from features import autolink_constants
+from framework import template_helpers
+from proto import tracker_pb2
+from testing import fake
+from testing import testing_helpers
+
+
+SIMPLE_EMAIL_RE = re.compile(r'([a-z]+)@([a-z]+)\.com')
+OVER_AMBITIOUS_DOMAIN_RE = re.compile(r'([a-z]+)\.(com|net|org)')
+
+
+class AutolinkTest(unittest.TestCase):
+
+  def RegisterEmailCallbacks(self, aa):
+
+    def LookupUsers(_mr, all_addresses):
+      """Return user objects for only users who are at trusted domains."""
+      return [addr for addr in all_addresses
+              if addr.endswith('@example.com')]
+
+    def Match2Addresses(_mr, match):
+      return [match.group(0)]
+
+    def MakeMailtoLink(_mr, match, comp_ref_artifacts):
+      email = match.group(0)
+      if comp_ref_artifacts and email in comp_ref_artifacts:
+        return [template_helpers.TextRun(
+            tag='a', href='mailto:%s' % email, content=email)]
+      else:
+        return [template_helpers.TextRun('%s AT %s.com' % match.group(1, 2))]
+
+    aa.RegisterComponent('testcomp',
+                         LookupUsers,
+                         Match2Addresses,
+                         {SIMPLE_EMAIL_RE: MakeMailtoLink})
+
+  def RegisterDomainCallbacks(self, aa):
+
+    def LookupDomains(_mr, _all_refs):
+      """Return business objects for only real domains. Always just True."""
+      return True  # We don't have domain business objects, accept anything.
+
+    def Match2Domains(_mr, match):
+      return [match.group(0)]
+
+    def MakeHyperLink(_mr, match, _comp_ref_artifacts):
+      domain = match.group(0)
+      return [template_helpers.TextRun(tag='a', href=domain, content=domain)]
+
+    aa.RegisterComponent('testcomp2',
+                         LookupDomains,
+                         Match2Domains,
+                         {OVER_AMBITIOUS_DOMAIN_RE: MakeHyperLink})
+
+  def setUp(self):
+    self.aa = autolink.Autolink()
+    self.RegisterEmailCallbacks(self.aa)
+    self.comment1 = ('Feel free to contact me at a@other.com, '
+                     'or b@example.com, or c@example.org.')
+    self.comment2 = 'no matches in this comment'
+    self.comment3 = 'just matches with no ref: a@other.com, c@example.org'
+    self.comments = [self.comment1, self.comment2, self.comment3]
+
+  def testRegisterComponent(self):
+    self.assertIn('testcomp', self.aa.registry)
+
+  def testGetAllReferencedArtifacts(self):
+    all_ref_artifacts = self.aa.GetAllReferencedArtifacts(
+        None, self.comments)
+
+    self.assertIn('testcomp', all_ref_artifacts)
+    comp_refs = all_ref_artifacts['testcomp']
+    self.assertIn('b@example.com', comp_refs)
+    self.assertTrue(len(comp_refs) == 1)
+
+  def testGetAllReferencedArtifacts_TooBig(self):
+    all_ref_artifacts = self.aa.GetAllReferencedArtifacts(
+        None, self.comments, max_total_length=10)
+
+    self.assertEqual(autolink.SKIP_LOOKUPS, all_ref_artifacts)
+
+  def testMarkupAutolinks(self):
+    all_ref_artifacts = self.aa.GetAllReferencedArtifacts(None, self.comments)
+    result = self.aa.MarkupAutolinks(
+        None, [template_helpers.TextRun(self.comment1)], all_ref_artifacts)
+    self.assertEqual('Feel free to contact me at ', result[0].content)
+    self.assertEqual('a AT other.com', result[1].content)
+    self.assertEqual(', or ', result[2].content)
+    self.assertEqual('b@example.com', result[3].content)
+    self.assertEqual('mailto:b@example.com', result[3].href)
+    self.assertEqual(', or c@example.org.', result[4].content)
+
+    result = self.aa.MarkupAutolinks(
+        None, [template_helpers.TextRun(self.comment2)], all_ref_artifacts)
+    self.assertEqual('no matches in this comment', result[0].content)
+
+    result = self.aa.MarkupAutolinks(
+        None, [template_helpers.TextRun(self.comment3)], all_ref_artifacts)
+    self.assertEqual('just matches with no ref: ', result[0].content)
+    self.assertEqual('a AT other.com', result[1].content)
+    self.assertEqual(', c@example.org', result[2].content)
+
+  def testNonnestedAutolinks(self):
+    """Test that when a substitution yields plain text, others are applied."""
+    self.RegisterDomainCallbacks(self.aa)
+    all_ref_artifacts = self.aa.GetAllReferencedArtifacts(None, self.comments)
+    result = self.aa.MarkupAutolinks(
+        None, [template_helpers.TextRun(self.comment1)], all_ref_artifacts)
+    self.assertEqual('Feel free to contact me at ', result[0].content)
+    self.assertEqual('a AT ', result[1].content)
+    self.assertEqual('other.com', result[2].content)
+    self.assertEqual('other.com', result[2].href)
+    self.assertEqual(', or ', result[3].content)
+    self.assertEqual('b@example.com', result[4].content)
+    self.assertEqual('mailto:b@example.com', result[4].href)
+    self.assertEqual(', or c@', result[5].content)
+    self.assertEqual('example.org', result[6].content)
+    self.assertEqual('example.org', result[6].href)
+    self.assertEqual('.', result[7].content)
+
+    result = self.aa.MarkupAutolinks(
+        None, [template_helpers.TextRun(self.comment2)], all_ref_artifacts)
+    self.assertEqual('no matches in this comment', result[0].content)
+    result = self.aa.MarkupAutolinks(
+        None, [template_helpers.TextRun(self.comment3)], all_ref_artifacts)
+    self.assertEqual('just matches with no ref: ', result[0].content)
+    self.assertEqual('a AT ', result[1].content)
+    self.assertEqual('other.com', result[2].content)
+    self.assertEqual('other.com', result[2].href)
+    self.assertEqual(', c@', result[3].content)
+    self.assertEqual('example.org', result[4].content)
+    self.assertEqual('example.org', result[4].href)
+
+  def testMarkupAutolinks_TooBig(self):
+    """If the issue has too much text, we just do regex-based autolinking."""
+    all_ref_artifacts = self.aa.GetAllReferencedArtifacts(
+        None, self.comments, max_total_length=10)
+    result = self.aa.MarkupAutolinks(
+        None, [template_helpers.TextRun(self.comment1)], all_ref_artifacts)
+    self.assertEqual(5, len(result))
+    self.assertEqual('Feel free to contact me at ', result[0].content)
+    # The test autolink handlers in this file do not link email addresses.
+    self.assertEqual('a AT other.com', result[1].content)
+    self.assertIsNone(result[1].href)
+
+class EmailAutolinkTest(unittest.TestCase):
+
+  def setUp(self):
+    self.user_1 = 'fake user'  # Note: no User fields are accessed.
+
+  def DoLinkify(
+      self, content, filter_re=autolink_constants.IS_IMPLIED_EMAIL_RE):
+    """Calls the LinkifyEmail method and returns the result.
+
+    Args:
+      content: string with a hyperlink.
+
+    Returns:
+      A list of TextRuns with some runs having the embedded email hyperlinked.
+      Or, None if no link was detected.
+    """
+    match = filter_re.search(content)
+    if not match:
+      return None
+
+    return autolink.LinkifyEmail(None, match, {'one@example.com': self.user_1})
+
+  def testLinkifyEmail(self):
+    """Test that an address is autolinked when put in the given context."""
+    test = 'one@ or @one'
+    result = self.DoLinkify('Have you met %s' % test)
+    self.assertEqual(None, result)
+
+    test = 'one@example.com'
+    result = self.DoLinkify('Have you met %s' % test)
+    self.assertEqual('/u/' + test, result[0].href)
+    self.assertEqual(test, result[0].content)
+
+    test = 'alias@example.com'
+    result = self.DoLinkify('Please also CC %s' % test)
+    self.assertEqual('mailto:' + test, result[0].href)
+    self.assertEqual(test, result[0].content)
+
+    result = self.DoLinkify('Reviewed-By: Test Person <%s>' % test)
+    self.assertEqual('mailto:' + test, result[0].href)
+    self.assertEqual(test, result[0].content)
+
+
+class URLAutolinkTest(unittest.TestCase):
+
+  def DoLinkify(self, content, filter_re=autolink_constants.IS_A_LINK_RE):
+    """Calls the linkify method and returns the result.
+
+    Args:
+      content: string with a hyperlink.
+
+    Returns:
+      A list of TextRuns with some runs will have the embedded URL hyperlinked.
+      Or, None if no link was detected.
+    """
+    match = filter_re.search(content)
+    if not match:
+      return None
+
+    return autolink.Linkify(None, match, None)
+
+  def testLinkify(self):
+    """Test that given url is autolinked when put in the given context."""
+    # Disallow the linking of URLs with user names and passwords.
+    test = 'http://user:pass@www.yahoo.com'
+    result = self.DoLinkify('What about %s' % test)
+    self.assertEqual(None, result[0].tag)
+    self.assertEqual(None, result[0].href)
+    self.assertEqual(test, result[0].content)
+
+    # Disallow the linking of non-HTTP(S) links
+    test = 'nntp://news.google.com'
+    result = self.DoLinkify('%s' % test)
+    self.assertEqual(None, result)
+
+    # Disallow the linking of file links
+    test = 'file://C:/Windows/System32/cmd.exe'
+    result = self.DoLinkify('%s' % test)
+    self.assertEqual(None, result)
+
+    # Test some known URLs
+    test = 'http://www.example.com'
+    result = self.DoLinkify('What about %s' % test)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+
+  def testLinkify_FTP(self):
+    """Test that FTP urls are linked."""
+    # Check for a standard ftp link
+    test = 'ftp://ftp.example.com'
+    result = self.DoLinkify('%s' % test)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+
+  def testLinkify_Email(self):
+    """Test that mailto: urls are linked."""
+    test = 'mailto:user@example.com'
+    result = self.DoLinkify('%s' % test)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+
+  def testLinkify_ShortLink(self):
+    """Test that shortlinks are linked."""
+    test = 'http://go/monorail'
+    result = self.DoLinkify(
+        '%s' % test, filter_re=autolink_constants.IS_A_SHORT_LINK_RE)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+
+    test = 'go/monorail'
+    result = self.DoLinkify(
+        '%s' % test, filter_re=autolink_constants.IS_A_SHORT_LINK_RE)
+    self.assertEqual('http://' + test, result[0].href)
+    self.assertEqual(test, result[0].content)
+
+    test = 'b/12345'
+    result = self.DoLinkify(
+      '%s' % test, filter_re=autolink_constants.IS_A_NUMERIC_SHORT_LINK_RE)
+    self.assertEqual('http://' + test, result[0].href)
+    self.assertEqual(test, result[0].content)
+
+    test = 'http://b/12345'
+    result = self.DoLinkify(
+      '%s' % test, filter_re=autolink_constants.IS_A_NUMERIC_SHORT_LINK_RE)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+
+    test = '/b/12345'
+    result = self.DoLinkify(
+        '%s' % test, filter_re=autolink_constants.IS_A_SHORT_LINK_RE)
+    self.assertIsNone(result)
+
+    test = '/b/12345'
+    result = self.DoLinkify(
+      '%s' % test, filter_re=autolink_constants.IS_A_NUMERIC_SHORT_LINK_RE)
+    self.assertIsNone(result)
+
+    test = 'b/secondFileInDiff'
+    result = self.DoLinkify(
+      '%s' % test, filter_re=autolink_constants.IS_A_NUMERIC_SHORT_LINK_RE)
+    self.assertIsNone(result)
+
+  def testLinkify_ImpliedLink(self):
+    """Test that text with .com, .org, .net, and .edu are linked."""
+    test = 'google.org'
+    result = self.DoLinkify(
+        '%s' % test, filter_re=autolink_constants.IS_IMPLIED_LINK_RE)
+    self.assertEqual('http://' + test, result[0].href)
+    self.assertEqual(test, result[0].content)
+
+    test = 'code.google.com/p/chromium'
+    result = self.DoLinkify(
+        '%s' % test, filter_re=autolink_constants.IS_IMPLIED_LINK_RE)
+    self.assertEqual('http://' + test, result[0].href)
+    self.assertEqual(test, result[0].content)
+
+    # This is not a domain, it is a directory or something.
+    test = 'build.out/p/chromium'
+    result = self.DoLinkify(
+        '%s' % test, filter_re=autolink_constants.IS_IMPLIED_LINK_RE)
+    self.assertEqual(None, result)
+
+    # We do not link the NNTP scheme, and the domain name part of it will not
+    # be linked as an HTTP link because it is preceeded by "/".
+    test = 'nntp://news.google.com'
+    result = self.DoLinkify(
+        '%s' % test, filter_re=autolink_constants.IS_IMPLIED_LINK_RE)
+    self.assertIsNone(result)
+
+  def testLinkify_Context(self):
+    """Test that surrounding syntax is not considered part of the url."""
+    test = 'http://www.example.com'
+
+    # Check for a link followed by a comma at end of English phrase.
+    result = self.DoLinkify('The URL %s, points to a great website.' % test)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+    self.assertEqual(',', result[1].content)
+
+    # Check for a link followed by a period at end of English sentence.
+    result = self.DoLinkify('The best site ever, %s.' % test)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+    self.assertEqual('.', result[1].content)
+
+    # Check for a link in paranthesis (), [], or {}
+    result = self.DoLinkify('My fav site (%s).' % test)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+    self.assertEqual(').', result[1].content)
+
+    result = self.DoLinkify('My fav site [%s].' % test)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+    self.assertEqual('].', result[1].content)
+
+    result = self.DoLinkify('My fav site {%s}.' % test)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+    self.assertEqual('}.', result[1].content)
+
+    # Check for a link with trailing colon
+    result = self.DoLinkify('Hit %s: you will love it.' % test)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+    self.assertEqual(':', result[1].content)
+
+    # Check link with commas in query string, but don't include trailing comma.
+    test = 'http://www.example.com/?v=1,2,3'
+    result = self.DoLinkify('Try %s, ok?' % test)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+
+    # Check link surrounded by angle-brackets.
+    result = self.DoLinkify('<%s>' % test)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+    self.assertEqual('>', result[1].content)
+
+    # Check link surrounded by double-quotes.
+    result = self.DoLinkify('"%s"' % test)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+    self.assertEqual('"', result[1].content)
+
+    # Check link with embedded double-quotes.
+    test = 'http://www.example.com/?q="a+b+c"'
+    result = self.DoLinkify('Try %s, ok?' % test)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+    self.assertEqual(',', result[1].content)
+
+    # Check link surrounded by single-quotes.
+    result = self.DoLinkify("'%s'" % test)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+    self.assertEqual("'", result[1].content)
+
+    # Check link with embedded single-quotes.
+    test = "http://www.example.com/?q='a+b+c'"
+    result = self.DoLinkify('Try %s, ok?' % test)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+    self.assertEqual(',', result[1].content)
+
+    # Check link with embedded parens.
+    test = 'http://www.example.com/funky(foo)and(bar).asp'
+    result = self.DoLinkify('Try %s, ok?' % test)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+    self.assertEqual(',', result[1].content)
+
+    test = 'http://www.example.com/funky(foo)and(bar).asp'
+    result = self.DoLinkify('My fav site <%s>' % test)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+    self.assertEqual('>', result[1].content)
+
+    # Check link with embedded brackets and braces.
+    test = 'http://www.example.com/funky[foo]and{bar}.asp'
+    result = self.DoLinkify('My fav site <%s>' % test)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+    self.assertEqual('>', result[1].content)
+
+    # Check link with mismatched delimeters inside it or outside it.
+    test = 'http://www.example.com/funky"(foo]and>bar}.asp'
+    result = self.DoLinkify('My fav site <%s>' % test)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+    self.assertEqual('>', result[1].content)
+
+    test = 'http://www.example.com/funky"(foo]and>bar}.asp'
+    result = self.DoLinkify('My fav site {%s' % test)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+
+    test = 'http://www.example.com/funky"(foo]and>bar}.asp'
+    result = self.DoLinkify('My fav site %s}' % test)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+    self.assertEqual('}', result[1].content)
+
+    # Link as part of an HTML example.
+    test = 'http://www.example.com/'
+    result = self.DoLinkify('<a href="%s">' % test)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+    self.assertEqual('">', result[1].content)
+
+    # Link nested in an HTML tag.
+    result = self.DoLinkify('<span>%s</span>' % test)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+
+    # Link followed by HTML tag - same bug as above.
+    result = self.DoLinkify('%s<span>foo</span>' % test)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+
+    # Link followed by unescaped HTML tag.
+    result = self.DoLinkify('%s<span>foo</span>' % test)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+
+    # Link surrounded by multiple delimiters.
+    result = self.DoLinkify('(e.g. <%s>)' % test)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+    result = self.DoLinkify('(e.g. <%s>),' % test)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+
+  def testLinkify_ContextOnBadLink(self):
+    """Test that surrounding text retained in cases where we don't link url."""
+    test = 'http://bad=example'
+    result = self.DoLinkify('<a href="%s">' % test)
+    self.assertEqual(None, result[0].href)
+    self.assertEqual(test + '">', result[0].content)
+    self.assertEqual(1, len(result))
+
+  def testLinkify_UnicodeContext(self):
+    """Test that unicode context does not mess up the link."""
+    test = 'http://www.example.com'
+
+    # This string has a non-breaking space \xa0.
+    result = self.DoLinkify(u'The correct RFC link is\xa0%s' % test)
+    self.assertEqual(test, result[0].content)
+    self.assertEqual(test, result[0].href)
+
+  def testLinkify_UnicodeLink(self):
+    """Test that unicode in a link is OK."""
+    test = u'http://www.example.com?q=division\xc3\xb7sign'
+
+    # This string has a non-breaking space \xa0.
+    result = self.DoLinkify(u'The unicode link is %s' % test)
+    self.assertEqual(test, result[0].content)
+    self.assertEqual(test, result[0].href)
+
+  def testLinkify_LinkTextEscapingDisabled(self):
+    """Test that url-like things that miss validation aren't linked."""
+    # Link matched by the regex but not accepted by the validator.
+    test = 'http://bad_domain/reportdetail?reportid=35aa03e04772358b'
+    result = self.DoLinkify('<span>%s</span>' % test)
+    self.assertEqual(None, result[0].href)
+    self.assertEqual(test, result[0].content)
+
+
+def _Issue(project_name, local_id, summary, status):
+  issue = tracker_pb2.Issue()
+  issue.project_name = project_name
+  issue.local_id = local_id
+  issue.summary = summary
+  issue.status = status
+  return issue
+
+
+class TrackerAutolinkTest(unittest.TestCase):
+
+  COMMENT_TEXT = (
+    'This relates to issue 1, issue #2, and issue3 \n'
+    'as well as bug 4, bug #5, and bug6 \n'
+    'with issue other-project:12 and issue other-project#13. \n'
+    'Watch out for issues 21, 22, and 23 with oxford comma. \n'
+    'And also bugs 31, 32 and 33 with no oxford comma.\n'
+    'Here comes crbug.com/123 and crbug.com/monorail/456.\n'
+    'We do not match when an issue\n'
+    '999. Is split across lines.'
+    )
+
+  def testExtractProjectAndIssueIdNormal(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/detail?id=1')
+    ref_batches = []
+    for match in autolink._ISSUE_REF_RE.finditer(self.COMMENT_TEXT):
+      new_refs = autolink.ExtractProjectAndIssueIdsNormal(mr, match)
+      ref_batches.append(new_refs)
+
+    self.assertEqual(
+        ref_batches, [
+            [(None, 1)],
+            [(None, 2)],
+            [(None, 3)],
+            [(None, 4)],
+            [(None, 5)],
+            [(None, 6)],
+            [('other-project', 12)],
+            [('other-project', 13)],
+            [(None, 21), (None, 22), (None, 23)],
+            [(None, 31), (None, 32), (None, 33)],
+        ])
+
+
+  def testExtractProjectAndIssueIdCrbug(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/detail?id=1')
+    ref_batches = []
+    for match in autolink._CRBUG_REF_RE.finditer(self.COMMENT_TEXT):
+      new_refs = autolink.ExtractProjectAndIssueIdsCrBug(mr, match)
+      ref_batches.append(new_refs)
+
+    self.assertEqual(ref_batches, [
+        [('chromium', 123)],
+        [('monorail', 456)],
+    ])
+
+  def DoReplaceIssueRef(
+      self, content, regex=autolink._ISSUE_REF_RE,
+      single_issue_regex=autolink._SINGLE_ISSUE_REF_RE,
+      default_project_name=None):
+    """Calls the ReplaceIssueRef method and returns the result.
+
+    Args:
+      content: string that may have a textual reference to an issue.
+      regex: optional regex to use instead of _ISSUE_REF_RE.
+
+    Returns:
+      A list of TextRuns with some runs will have the reference hyperlinked.
+      Or, None if no reference detected.
+    """
+    match = regex.search(content)
+    if not match:
+      return None
+
+    open_dict = {'proj:1': _Issue('proj', 1, 'summary-PROJ-1', 'New'),
+                 # Assume there is no issue 3 in PROJ
+                 'proj:4': _Issue('proj', 4, 'summary-PROJ-4', 'New'),
+                 'proj:6': _Issue('proj', 6, 'summary-PROJ-6', 'New'),
+                 'other-project:12': _Issue('other-project', 12,
+                                            'summary-OP-12', 'Accepted'),
+                }
+    closed_dict = {'proj:2': _Issue('proj', 2, 'summary-PROJ-2', 'Fixed'),
+                   'proj:5': _Issue('proj', 5, 'summary-PROJ-5', 'Fixed'),
+                   'other-project:13': _Issue('other-project', 13,
+                                              'summary-OP-12', 'Invalid'),
+                   'chromium:13': _Issue('chromium', 13,
+                                         'summary-Cr-13', 'Invalid'),
+                  }
+    comp_ref_artifacts = (open_dict, closed_dict,)
+
+    replacement_runs = autolink._ReplaceIssueRef(
+        match, comp_ref_artifacts, single_issue_regex, default_project_name)
+    return replacement_runs
+
+  def testReplaceIssueRef_NoMatch(self):
+    result = self.DoReplaceIssueRef('What is this all about?')
+    self.assertIsNone(result)
+
+  def testReplaceIssueRef_Normal(self):
+    result = self.DoReplaceIssueRef(
+        'This relates to issue 1', default_project_name='proj')
+    self.assertEqual('/p/proj/issues/detail?id=1', result[0].href)
+    self.assertEqual('issue 1', result[0].content)
+    self.assertEqual(None, result[0].css_class)
+    self.assertEqual('summary-PROJ-1', result[0].title)
+    self.assertEqual('a', result[0].tag)
+
+    result = self.DoReplaceIssueRef(
+        ', issue #2', default_project_name='proj')
+    self.assertEqual('/p/proj/issues/detail?id=2', result[0].href)
+    self.assertEqual(' issue #2 ', result[0].content)
+    self.assertEqual('closed_ref', result[0].css_class)
+    self.assertEqual('summary-PROJ-2', result[0].title)
+    self.assertEqual('a', result[0].tag)
+
+    result = self.DoReplaceIssueRef(
+        ', and issue3 ', default_project_name='proj')
+    self.assertEqual(None, result[0].href)  # There is no issue 3
+    self.assertEqual('issue3', result[0].content)
+
+    result = self.DoReplaceIssueRef(
+        'as well as bug 4', default_project_name='proj')
+    self.assertEqual('/p/proj/issues/detail?id=4', result[0].href)
+    self.assertEqual('bug 4', result[0].content)
+
+    result = self.DoReplaceIssueRef(
+        ', bug #5, ', default_project_name='proj')
+    self.assertEqual('/p/proj/issues/detail?id=5', result[0].href)
+    self.assertEqual(' bug #5 ', result[0].content)
+
+    result = self.DoReplaceIssueRef(
+        'and bug6', default_project_name='proj')
+    self.assertEqual('/p/proj/issues/detail?id=6', result[0].href)
+    self.assertEqual('bug6', result[0].content)
+
+    result = self.DoReplaceIssueRef(
+        'with issue other-project:12', default_project_name='proj')
+    self.assertEqual('/p/other-project/issues/detail?id=12', result[0].href)
+    self.assertEqual('issue other-project:12', result[0].content)
+
+    result = self.DoReplaceIssueRef(
+        'and issue other-project#13', default_project_name='proj')
+    self.assertEqual('/p/other-project/issues/detail?id=13', result[0].href)
+    self.assertEqual(' issue other-project#13 ', result[0].content)
+
+  def testReplaceIssueRef_CrBug(self):
+    result = self.DoReplaceIssueRef(
+        'and crbug.com/other-project/13', regex=autolink._CRBUG_REF_RE,
+        single_issue_regex=autolink._CRBUG_REF_RE,
+        default_project_name='chromium')
+    self.assertEqual('/p/other-project/issues/detail?id=13', result[0].href)
+    self.assertEqual(' crbug.com/other-project/13 ', result[0].content)
+
+    result = self.DoReplaceIssueRef(
+        'and http://crbug.com/13', regex=autolink._CRBUG_REF_RE,
+        single_issue_regex=autolink._CRBUG_REF_RE,
+        default_project_name='chromium')
+    self.assertEqual('/p/chromium/issues/detail?id=13', result[0].href)
+    self.assertEqual(' http://crbug.com/13 ', result[0].content)
+
+    result = self.DoReplaceIssueRef(
+        'and http://crbug.com/13#c17', regex=autolink._CRBUG_REF_RE,
+        single_issue_regex=autolink._CRBUG_REF_RE,
+        default_project_name='chromium')
+    self.assertEqual('/p/chromium/issues/detail?id=13#c17', result[0].href)
+    self.assertEqual(' http://crbug.com/13#c17 ', result[0].content)
+
+  def testParseProjectNameMatch(self):
+    golden = 'project-name'
+    variations = ['%s', '  %s', '%s  ', '%s:', '%s#', '%s#:', '%s:#', '%s :#',
+                  '\t%s', '%s\t', '\t%s\t', '\t\t%s\t\t', '\n%s', '%s\n',
+                  '\n%s\n', '\n\n%s\n\n', '\t\n%s', '\n\t%s', '%s\t\n',
+                  '%s\n\t', '\t\n%s#', '\n\t%s#', '%s\t\n#', '%s\n\t#',
+                  '\t\n%s:', '\n\t%s:', '%s\t\n:', '%s\n\t:'
+                 ]
+
+    # First pass checks all valid project name results
+    for pattern in variations:
+      self.assertEqual(
+          golden, autolink._ParseProjectNameMatch(pattern % golden))
+
+    # Second pass tests all inputs that should result in None
+    for pattern in variations:
+      self.assertTrue(
+          autolink._ParseProjectNameMatch(pattern % '') in [None, ''])
+
+
+class VCAutolinkTest(unittest.TestCase):
+
+  GIT_HASH_1 = '1' * 40
+  GIT_HASH_2 = '2' * 40
+  GIT_HASH_3 = 'a1' * 20
+  GIT_COMMENT_TEXT = (
+      'This is a fix for r%s and R%s, by r2d2, who also authored revision %s, '
+      'revision #%s, revision %s, and revision %s' % (
+          GIT_HASH_1, GIT_HASH_2, GIT_HASH_3,
+          GIT_HASH_1.upper(), GIT_HASH_2.upper(), GIT_HASH_3.upper()))
+  SVN_COMMENT_TEXT = (
+      'This is a fix for r12 and R3400, by r2d2, who also authored '
+      'revision r4, '
+      'revision #1234567, revision 789, and revision 9025.  If you have '
+      'questions, call me at 18005551212')
+
+  def testGetReferencedRevisions(self):
+    refs = ['1', '2', '3']
+    # For now, we do not look up revision objects, result is always None
+    self.assertIsNone(autolink.GetReferencedRevisions(None, refs))
+
+  def testExtractGitHashes(self):
+    refs = []
+    for match in autolink._GIT_HASH_RE.finditer(self.GIT_COMMENT_TEXT):
+      new_refs = autolink.ExtractRevNums(None, match)
+      refs.extend(new_refs)
+
+    self.assertEqual(
+        refs, [
+            self.GIT_HASH_1, self.GIT_HASH_2, self.GIT_HASH_3,
+            self.GIT_HASH_1.upper(),
+            self.GIT_HASH_2.upper(),
+            self.GIT_HASH_3.upper()
+        ])
+
+  def testExtractRevNums(self):
+    refs = []
+    for match in autolink._SVN_REF_RE.finditer(self.SVN_COMMENT_TEXT):
+      new_refs = autolink.ExtractRevNums(None, match)
+      refs.extend(new_refs)
+
+    # Note that we only autolink rNNNN with at least 4 digits.
+    self.assertEqual(refs, ['3400', '1234567', '9025'])
+
+
+  def DoReplaceRevisionRef(self, content, project=None):
+    """Calls the ReplaceRevisionRef method and returns the result.
+
+    Args:
+      content: string with a hyperlink.
+      project: optional project.
+
+    Returns:
+      A list of TextRuns with some runs will have the embedded URL hyperlinked.
+      Or, None if no link was detected.
+    """
+    match = autolink._GIT_HASH_RE.search(content)
+    if not match:
+      return None
+
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/source/detail?r=1', project=project)
+    replacement_runs = autolink.ReplaceRevisionRef(mr, match, None)
+    return replacement_runs
+
+  def testReplaceRevisionRef(self):
+    result = self.DoReplaceRevisionRef(
+        'This is a fix for r%s' % self.GIT_HASH_1)
+    self.assertEqual('https://crrev.com/%s' % self.GIT_HASH_1, result[0].href)
+    self.assertEqual('r%s' % self.GIT_HASH_1, result[0].content)
+
+    result = self.DoReplaceRevisionRef(
+        'and R%s, by r2d2, who ' % self.GIT_HASH_2)
+    self.assertEqual('https://crrev.com/%s' % self.GIT_HASH_2, result[0].href)
+    self.assertEqual('R%s' % self.GIT_HASH_2, result[0].content)
+
+    result = self.DoReplaceRevisionRef('by r2d2, who ')
+    self.assertEqual(None, result)
+
+    result = self.DoReplaceRevisionRef(
+        'also authored revision %s, ' % self.GIT_HASH_3)
+    self.assertEqual('https://crrev.com/%s' % self.GIT_HASH_3, result[0].href)
+    self.assertEqual('revision %s' % self.GIT_HASH_3, result[0].content)
+
+    result = self.DoReplaceRevisionRef(
+        'revision #%s, ' % self.GIT_HASH_1.upper())
+    self.assertEqual(
+        'https://crrev.com/%s' % self.GIT_HASH_1.upper(), result[0].href)
+    self.assertEqual(
+        'revision #%s' % self.GIT_HASH_1.upper(), result[0].content)
+
+    result = self.DoReplaceRevisionRef(
+        'revision %s, ' % self.GIT_HASH_2.upper())
+    self.assertEqual(
+        'https://crrev.com/%s' % self.GIT_HASH_2.upper(), result[0].href)
+    self.assertEqual('revision %s' % self.GIT_HASH_2.upper(), result[0].content)
+
+    result = self.DoReplaceRevisionRef(
+        'and revision %s' % self.GIT_HASH_3.upper())
+    self.assertEqual(
+        'https://crrev.com/%s' % self.GIT_HASH_3.upper(), result[0].href)
+    self.assertEqual('revision %s' % self.GIT_HASH_3.upper(), result[0].content)
+
+  def testReplaceRevisionRef_CustomURL(self):
+    """A project can override the URL used for revision links."""
+    project = fake.Project()
+    project.revision_url_format = 'http://example.com/+/{revnum}'
+    result = self.DoReplaceRevisionRef(
+        'This is a fix for r%s' % self.GIT_HASH_1, project=project)
+    self.assertEqual(
+        'http://example.com/+/%s' % self.GIT_HASH_1, result[0].href)
+    self.assertEqual('r%s' % self.GIT_HASH_1, result[0].content)
diff --git a/features/test/banspammer_test.py b/features/test/banspammer_test.py
new file mode 100644
index 0000000..e6fceff
--- /dev/null
+++ b/features/test/banspammer_test.py
@@ -0,0 +1,141 @@
+# 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 ban spammer feature."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import json
+import mock
+import os
+import unittest
+import urllib
+import webapp2
+
+import settings
+from features import banspammer
+from framework import framework_views
+from framework import permissions
+from framework import urls
+from proto import tracker_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+
+class BanSpammerTest(unittest.TestCase):
+
+  def setUp(self):
+    self.cnxn = 'fake cnxn'
+    self.mr = testing_helpers.MakeMonorailRequest()
+    self.services = service_manager.Services(
+        issue=fake.IssueService(),
+        project=fake.ProjectService(),
+        spam=fake.SpamService(),
+        user=fake.UserService())
+    self.servlet = banspammer.BanSpammer('req', 'res', services=self.services)
+
+  @mock.patch('framework.cloud_tasks_helpers._get_client')
+  def testProcessFormData_noPermission(self, get_client_mock):
+    self.servlet.services.user.TestAddUser('member', 222)
+    self.servlet.services.user.TestAddUser('spammer@domain.com', 111)
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/u/spammer@domain.com/banSpammer.do',
+        perms=permissions.GetPermissions(None, {}, None))
+    mr.viewed_user_auth.user_view = framework_views.MakeUserView(mr.cnxn,
+        self.servlet.services.user, 111)
+    mr.auth.user_id = 222
+    self.assertRaises(permissions.PermissionException,
+        self.servlet.AssertBasePermission, mr)
+    try:
+      self.servlet.ProcessFormData(mr, {})
+    except permissions.PermissionException:
+      pass
+    self.assertEqual(get_client_mock().queue_path.call_count, 0)
+    self.assertEqual(get_client_mock().create_task.call_count, 0)
+
+  @mock.patch('framework.cloud_tasks_helpers._get_client')
+  def testProcessFormData_ok(self, get_client_mock):
+    self.servlet.services.user.TestAddUser('owner', 222)
+    self.servlet.services.user.TestAddUser('spammer@domain.com', 111)
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/u/spammer@domain.com/banSpammer.do',
+        perms=permissions.ADMIN_PERMISSIONSET)
+    mr.viewed_user_auth.user_view = framework_views.MakeUserView(mr.cnxn,
+        self.servlet.services.user, 111)
+    mr.viewed_user_auth.user_pb.user_id = 111
+    mr.auth.user_id = 222
+    self.servlet.ProcessFormData(mr, {'banned': 'non-empty'})
+
+    params = {'spammer_id': 111, 'reporter_id': 222, 'is_spammer': True}
+    task = {
+        'app_engine_http_request':
+            {
+                'relative_uri': urls.BAN_SPAMMER_TASK + '.do',
+                'body': urllib.urlencode(params),
+                'headers': {
+                    'Content-type': 'application/x-www-form-urlencoded'
+                }
+            }
+    }
+    get_client_mock().queue_path.assert_called_with(
+        settings.app_id, settings.CLOUD_TASKS_REGION, 'default')
+    get_client_mock().create_task.assert_called_once()
+    ((_parent, called_task), _kwargs) = get_client_mock().create_task.call_args
+    self.assertEqual(called_task, task)
+
+
+class BanSpammerTaskTest(unittest.TestCase):
+  def setUp(self):
+    self.services = service_manager.Services(
+        issue=fake.IssueService(),
+        spam=fake.SpamService())
+    self.res = webapp2.Response()
+    self.servlet = banspammer.BanSpammerTask('req', self.res,
+        services=self.services)
+
+  def testProcessFormData_okNoIssues(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        path=urls.BAN_SPAMMER_TASK + '.do', method='POST',
+        params={'spammer_id': 111, 'reporter_id': 222})
+
+    self.servlet.HandleRequest(mr)
+    self.assertEqual(self.res.body, json.dumps({'comments': 0, 'issues': 0}))
+
+  def testProcessFormData_okSomeIssues(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        path=urls.BAN_SPAMMER_TASK + '.do', method='POST',
+        params={'spammer_id': 111, 'reporter_id': 222})
+
+    for i in range(0, 10):
+      issue = fake.MakeTestIssue(
+          1, i, 'issue_summary', 'New', 111, project_name='project-name')
+      self.servlet.services.issue.TestAddIssue(issue)
+
+    self.servlet.HandleRequest(mr)
+    self.assertEqual(self.res.body, json.dumps({'comments': 0, 'issues': 10}))
+
+  def testProcessFormData_okSomeCommentsAndIssues(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        path=urls.BAN_SPAMMER_TASK + '.do', method='POST',
+        params={'spammer_id': 111, 'reporter_id': 222})
+
+    for i in range(0, 12):
+      issue = fake.MakeTestIssue(
+          1, i, 'issue_summary', 'New', 111, project_name='project-name')
+      self.servlet.services.issue.TestAddIssue(issue)
+
+    for i in range(10, 20):
+      issue = fake.MakeTestIssue(
+          1, i, 'issue_summary', 'New', 222, project_name='project-name')
+      self.servlet.services.issue.TestAddIssue(issue)
+      for _ in range(0, 5):
+        comment = tracker_pb2.IssueComment()
+        comment.project_id = 1
+        comment.user_id = 111
+        comment.issue_id = issue.issue_id
+        self.servlet.services.issue.TestAddComment(comment, issue.local_id)
+    self.servlet.HandleRequest(mr)
+    self.assertEqual(self.res.body, json.dumps({'comments': 50, 'issues': 10}))
diff --git a/features/test/commands_test.py b/features/test/commands_test.py
new file mode 100644
index 0000000..e8bc47b
--- /dev/null
+++ b/features/test/commands_test.py
@@ -0,0 +1,230 @@
+# 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 and functions that implement command-line-like issue updates."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import unittest
+
+from features import commands
+from framework import framework_constants
+from proto import tracker_pb2
+from services import service_manager
+from testing import fake
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+
+
+class CommandsTest(unittest.TestCase):
+
+  def VerifyParseQuickEditCommmand(
+      self, cmd, exp_summary='sum', exp_status='New', exp_owner_id=111,
+      exp_cc_ids=None, exp_labels=None):
+
+    issue = tracker_pb2.Issue()
+    issue.project_name = 'proj'
+    issue.local_id = 1
+    issue.summary = 'sum'
+    issue.status = 'New'
+    issue.owner_id = 111
+    issue.cc_ids.extend([222, 333])
+    issue.labels.extend(['Type-Defect', 'Priority-Medium', 'Hot'])
+
+    if exp_cc_ids is None:
+      exp_cc_ids = [222, 333]
+    if exp_labels is None:
+      exp_labels = ['Type-Defect', 'Priority-Medium', 'Hot']
+
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    logged_in_user_id = 999
+    services = service_manager.Services(
+        config=fake.ConfigService(),
+        issue=fake.IssueService(),
+        user=fake.UserService())
+    services.user.TestAddUser('jrobbins', 333)
+    services.user.TestAddUser('jrobbins@jrobbins.org', 888)
+
+    cnxn = 'fake cnxn'
+    (summary, status, owner_id, cc_ids,
+     labels) = commands.ParseQuickEditCommand(
+         cnxn, cmd, issue, config, logged_in_user_id, services)
+    self.assertEqual(exp_summary, summary)
+    self.assertEqual(exp_status, status)
+    self.assertEqual(exp_owner_id, owner_id)
+    self.assertListEqual(exp_cc_ids, cc_ids)
+    self.assertListEqual(exp_labels, labels)
+
+  def testParseQuickEditCommmand_Empty(self):
+    self.VerifyParseQuickEditCommmand('')  # Nothing should change.
+
+  def testParseQuickEditCommmand_BuiltInFields(self):
+    self.VerifyParseQuickEditCommmand(
+        'status=Fixed', exp_status='Fixed')
+    self.VerifyParseQuickEditCommmand(  # Normalized capitalization.
+        'status=fixed', exp_status='Fixed')
+    self.VerifyParseQuickEditCommmand(
+        'status=limbo', exp_status='limbo')
+
+    self.VerifyParseQuickEditCommmand(
+        'owner=me', exp_owner_id=999)
+    self.VerifyParseQuickEditCommmand(
+        'owner=jrobbins@jrobbins.org', exp_owner_id=888)
+    self.VerifyParseQuickEditCommmand(
+        'owner=----', exp_owner_id=framework_constants.NO_USER_SPECIFIED)
+
+    self.VerifyParseQuickEditCommmand(
+        'summary=JustOneWord', exp_summary='JustOneWord')
+    self.VerifyParseQuickEditCommmand(
+        'summary="quoted sentence"', exp_summary='quoted sentence')
+    self.VerifyParseQuickEditCommmand(
+        "summary='quoted sentence'", exp_summary='quoted sentence')
+
+    self.VerifyParseQuickEditCommmand(
+        'cc=me', exp_cc_ids=[222, 333, 999])
+    self.VerifyParseQuickEditCommmand(
+        'cc=jrobbins@jrobbins.org', exp_cc_ids=[222, 333, 888])
+    self.VerifyParseQuickEditCommmand(
+        'cc=me,jrobbins@jrobbins.org',
+        exp_cc_ids=[222, 333, 999, 888])
+    self.VerifyParseQuickEditCommmand(
+        'cc=-jrobbins,jrobbins@jrobbins.org',
+        exp_cc_ids=[222, 888])
+
+  def testParseQuickEditCommmand_Labels(self):
+    self.VerifyParseQuickEditCommmand(
+        'Priority=Low', exp_labels=['Type-Defect', 'Hot', 'Priority-Low'])
+    self.VerifyParseQuickEditCommmand(
+        'priority=low', exp_labels=['Type-Defect', 'Hot', 'Priority-Low'])
+    self.VerifyParseQuickEditCommmand(
+        'priority-low', exp_labels=['Type-Defect', 'Hot', 'Priority-Low'])
+    self.VerifyParseQuickEditCommmand(
+        '-priority-low', exp_labels=['Type-Defect', 'Priority-Medium', 'Hot'])
+    self.VerifyParseQuickEditCommmand(
+        '-priority-medium', exp_labels=['Type-Defect', 'Hot'])
+
+    self.VerifyParseQuickEditCommmand(
+        'Cold', exp_labels=['Type-Defect', 'Priority-Medium', 'Hot', 'Cold'])
+    self.VerifyParseQuickEditCommmand(
+        '+Cold', exp_labels=['Type-Defect', 'Priority-Medium', 'Hot', 'Cold'])
+    self.VerifyParseQuickEditCommmand(
+        '-Hot Cold', exp_labels=['Type-Defect', 'Priority-Medium', 'Cold'])
+    self.VerifyParseQuickEditCommmand(
+        '-Hot', exp_labels=['Type-Defect', 'Priority-Medium'])
+
+  def testParseQuickEditCommmand_Multiple(self):
+    self.VerifyParseQuickEditCommmand(
+        'Priority=Low -hot owner:me cc:-jrobbins summary="other summary"',
+        exp_summary='other summary', exp_owner_id=999,
+        exp_cc_ids=[222], exp_labels=['Type-Defect', 'Priority-Low'])
+
+  def testBreakCommandIntoParts_Empty(self):
+    self.assertListEqual(
+        [],
+        commands._BreakCommandIntoParts(''))
+
+  def testBreakCommandIntoParts_Single(self):
+    self.assertListEqual(
+        [('summary', 'new summary')],
+        commands._BreakCommandIntoParts('summary="new summary"'))
+    self.assertListEqual(
+        [('summary', 'OneWordSummary')],
+        commands._BreakCommandIntoParts('summary=OneWordSummary'))
+    self.assertListEqual(
+        [('key', 'value')],
+        commands._BreakCommandIntoParts('key=value'))
+    self.assertListEqual(
+        [('key', 'value-with-dashes')],
+        commands._BreakCommandIntoParts('key=value-with-dashes'))
+    self.assertListEqual(
+        [('key', 'value')],
+        commands._BreakCommandIntoParts('key:value'))
+    self.assertListEqual(
+        [('key', 'value')],
+        commands._BreakCommandIntoParts(' key:value '))
+    self.assertListEqual(
+        [('key', 'value')],
+        commands._BreakCommandIntoParts('key:"value"'))
+    self.assertListEqual(
+        [('key', 'user@dom.com')],
+        commands._BreakCommandIntoParts('key:user@dom.com'))
+    self.assertListEqual(
+        [('key', 'a@dom.com,-b@dom.com')],
+        commands._BreakCommandIntoParts('key:a@dom.com,-b@dom.com'))
+    self.assertListEqual(
+        [(None, 'label')],
+        commands._BreakCommandIntoParts('label'))
+    self.assertListEqual(
+        [(None, '-label')],
+        commands._BreakCommandIntoParts('-label'))
+    self.assertListEqual(
+        [(None, '+label')],
+        commands._BreakCommandIntoParts('+label'))
+
+  def testBreakCommandIntoParts_Multiple(self):
+    self.assertListEqual(
+        [('summary', 'new summary'), (None, 'Hot'), (None, '-Cold'),
+         ('owner', 'me'), ('cc', '+a,-b')],
+        commands._BreakCommandIntoParts(
+            'summary="new summary" Hot -Cold owner:me cc:+a,-b'))
+
+
+class CommandSyntaxParsingTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        project=fake.ProjectService(),
+        config=fake.ConfigService(),
+        user=fake.UserService())
+
+    self.services.project.TestAddProject('proj', owner_ids=[111])
+    self.services.user.TestAddUser('a@example.com', 222)
+
+    cnxn = 'fake connection'
+    config = self.services.config.GetProjectConfig(cnxn, 789)
+
+    for status in ['New', 'ReadyForReview']:
+      config.well_known_statuses.append(tracker_pb2.StatusDef(
+          status=status))
+
+    for label in ['Prioity-Low', 'Priority-High']:
+      config.well_known_labels.append(tracker_pb2.LabelDef(
+          label=label))
+
+    config.exclusive_label_prefixes.extend(
+        tracker_constants.DEFAULT_EXCL_LABEL_PREFIXES)
+
+    self.services.config.StoreConfig(cnxn, config)
+
+  def testStandardizeStatus(self):
+    config = self.services.config.GetProjectConfig('fake cnxn', 789)
+    self.assertEqual('New',
+                     commands._StandardizeStatus('NEW', config))
+    self.assertEqual('New',
+                     commands._StandardizeStatus('n$Ew ', config))
+    self.assertEqual(
+        'custom-label',
+        commands._StandardizeLabel('custom=label ', config))
+
+  def testStandardizeLabel(self):
+    config = self.services.config.GetProjectConfig('fake cnxn', 789)
+    self.assertEqual(
+        'Priority-High',
+        commands._StandardizeLabel('priority-high', config))
+    self.assertEqual(
+        'Priority-High',
+        commands._StandardizeLabel('PRIORITY=HIGH', config))
+
+  def testLookupMeOrUsername(self):
+    self.assertEqual(
+        123,
+        commands._LookupMeOrUsername('fake cnxn', 'me', self.services, 123))
+
+    self.assertEqual(
+        222,
+        commands._LookupMeOrUsername(
+            'fake cnxn', 'a@example.com', self.services, 0))
diff --git a/features/test/commitlogcommands_test.py b/features/test/commitlogcommands_test.py
new file mode 100644
index 0000000..7e5d566
--- /dev/null
+++ b/features/test/commitlogcommands_test.py
@@ -0,0 +1,111 @@
+# 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.features.commitlogcommands."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import mock
+import unittest
+
+from features import commitlogcommands
+from features import send_notifications
+from framework import monorailcontext
+from proto import tracker_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+
+
+class InboundEmailTest(unittest.TestCase):
+
+  def setUp(self):
+    self.cnxn = 'fake cnxn'
+    self.services = service_manager.Services(
+        issue=fake.IssueService(),
+        user=fake.UserService(),
+        usergroup=fake.UserGroupService(),
+        project=fake.ProjectService(),
+        config=fake.ConfigService())
+
+    self.member = self.services.user.TestAddUser('member@example.com', 111)
+    self.outsider = self.services.user.TestAddUser('outsider@example.com', 222)
+    self.project = self.services.project.TestAddProject(
+        'proj', project_id=987, process_inbound_email=True,
+        committer_ids=[self.member.user_id])
+    self.issue = tracker_pb2.Issue()
+    self.issue.issue_id = 98701
+    self.issue.project_id = 987
+    self.issue.local_id = 1
+    self.issue.owner_id = 0
+    self.issue.summary = 'summary'
+    self.issue.status = 'Assigned'
+    self.services.issue.TestAddIssue(self.issue)
+
+    self.uia = commitlogcommands.UpdateIssueAction(self.issue.local_id)
+
+  def testParse_NoCommandLines(self):
+    commands_found = self.uia.Parse(self.cnxn, self.project.project_name, 111,
+                   ['line 1'], self.services,
+                   hostport='testing-app.appspot.com', strip_quoted_lines=True)
+    self.assertEqual(False, commands_found)
+    self.assertEqual('line 1', self.uia.description)
+    self.assertEqual('line 1', self.uia.inbound_message)
+
+  def testParse_StripQuotedLines(self):
+    commands_found = self.uia.Parse(self.cnxn, self.project.project_name, 111,
+                   ['summary:something', '> line 1', 'line 2'], self.services,
+                   hostport='testing-app.appspot.com', strip_quoted_lines=True)
+    self.assertEqual(True, commands_found)
+    self.assertEqual('line 2', self.uia.description)
+    self.assertEqual(
+        'summary:something\n> line 1\nline 2', self.uia.inbound_message)
+
+  def testParse_NoStripQuotedLines(self):
+    commands_found = self.uia.Parse(self.cnxn, self.project.project_name, 111,
+                   ['summary:something', '> line 1', 'line 2'], self.services,
+                   hostport='testing-app.appspot.com')
+    self.assertEqual(True, commands_found)
+    self.assertEqual('> line 1\nline 2', self.uia.description)
+    self.assertIsNone(self.uia.inbound_message)
+
+  def setupAndCallRun(self, mc, commenter_id, mock_pasicn):
+    self.uia.Parse(self.cnxn, self.project.project_name, 111,
+                   ['summary:something', 'status:New', '> line 1', '> line 2'],
+                   self.services, hostport='testing-app.appspot.com')
+    self.uia.Run(mc, self.services)
+
+    mock_pasicn.assert_called_once_with(
+        self.issue.issue_id, 'testing-app.appspot.com', commenter_id,
+        old_owner_id=self.issue.owner_id, comment_id=1, send_email=True)
+
+  @mock.patch(
+      'features.send_notifications.PrepareAndSendIssueChangeNotification')
+  def testRun_AllowEdit(self, mock_pasicn):
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='member@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+
+    self.setupAndCallRun(mc, 111, mock_pasicn)
+
+    self.assertEqual('> line 1\n> line 2', self.uia.description)
+    # Assert that amendments were made to the issue.
+    self.assertEqual('something', self.issue.summary)
+    self.assertEqual('New', self.issue.status)
+
+  @mock.patch(
+      'features.send_notifications.PrepareAndSendIssueChangeNotification')
+  def testRun_NoAllowEdit(self, mock_pasicn):
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='outsider@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+
+    self.setupAndCallRun(mc, 222, mock_pasicn)
+
+    self.assertEqual('> line 1\n> line 2', self.uia.description)
+    # Assert that amendments were *not* made to the issue.
+    self.assertEqual('summary', self.issue.summary)
+    self.assertEqual('Assigned', self.issue.status)
diff --git a/features/test/component_helpers_test.py b/features/test/component_helpers_test.py
new file mode 100644
index 0000000..aa6c761
--- /dev/null
+++ b/features/test/component_helpers_test.py
@@ -0,0 +1,145 @@
+# 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 component prediction endpoints."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import json
+import mock
+import sys
+import unittest
+
+from services import service_manager
+from testing import fake
+
+# Mock cloudstorage before it's imported by component_helpers
+sys.modules['cloudstorage'] = mock.Mock()
+from features import component_helpers
+
+
+class FakeMLEngine(object):
+  def __init__(self, test):
+    self.test = test
+    self.expected_features = None
+    self.scores = None
+    self._execute_response = None
+
+  def projects(self):
+    return self
+
+  def models(self):
+    return self
+
+  def predict(self, name, body):
+    self.test.assertEqual(component_helpers.MODEL_NAME, name)
+    self.test.assertEqual(
+        {'instances': [{'inputs': self.expected_features}]}, body)
+    self._execute_response = {'predictions': [{'scores': self.scores}]}
+    return self
+
+  def get(self, name):
+    self.test.assertEqual(component_helpers.MODEL_NAME, name)
+    self._execute_response = {'defaultVersion': {'name': 'v_1234'}}
+    return self
+
+  def execute(self):
+    response = self._execute_response
+    self._execute_response = None
+    return response
+
+
+class ComponentHelpersTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        config=fake.ConfigService(),
+        user=fake.UserService())
+    self.project = fake.Project(project_name='proj')
+
+    self._ml_engine = FakeMLEngine(self)
+    self._top_words = None
+    self._components_by_index = None
+
+    mock.patch(
+        'services.ml_helpers.setup_ml_engine', lambda: self._ml_engine).start()
+    mock.patch(
+        'features.component_helpers._GetTopWords',
+        lambda _: self._top_words).start()
+    mock.patch('cloudstorage.open', self.cloudstorageOpen).start()
+    mock.patch('settings.component_features', 5).start()
+
+    self.addCleanup(mock.patch.stopall)
+
+  def cloudstorageOpen(self, name, mode):
+    """Create a file mock that returns self._components_by_index when read."""
+    open_fn = mock.mock_open(read_data=json.dumps(self._components_by_index))
+    return open_fn(name, mode)
+
+  def testPredict_Normal(self):
+    """Test normal case when predicted component exists."""
+    component_id = self.services.config.CreateComponentDef(
+        cnxn=None, project_id=self.project.project_id, path='Ruta>Baga',
+        docstring='', deprecated=False, admin_ids=[], cc_ids=[], created=None,
+        creator_id=None, label_ids=[])
+    config = self.services.config.GetProjectConfig(
+        None, self.project.project_id)
+
+    self._top_words = {
+        'foo': 0,
+        'bar': 1,
+        'baz': 2}
+    self._components_by_index = {
+        '0': '123',
+        '1': str(component_id),
+        '2': '789'}
+    self._ml_engine.expected_features = [3, 0, 1, 0, 0]
+    self._ml_engine.scores = [5, 10, 3]
+
+    text = 'foo baz foo foo'
+
+    self.assertEqual(
+        component_id, component_helpers.PredictComponent(text, config))
+
+  def testPredict_UnknownComponentIndex(self):
+    """Test case where the prediction is not in components_by_index."""
+    config = self.services.config.GetProjectConfig(
+        None, self.project.project_id)
+
+    self._top_words = {
+        'foo': 0,
+        'bar': 1,
+        'baz': 2}
+    self._components_by_index = {
+        '0': '123',
+        '1': '456',
+        '2': '789'}
+    self._ml_engine.expected_features = [3, 0, 1, 0, 0]
+    self._ml_engine.scores = [5, 10, 3, 1000]
+
+    text = 'foo baz foo foo'
+
+    self.assertIsNone(component_helpers.PredictComponent(text, config))
+
+  def testPredict_InvalidComponentIndex(self):
+    """Test case where the prediction is not a valid component id."""
+    config = self.services.config.GetProjectConfig(
+        None, self.project.project_id)
+
+    self._top_words = {
+        'foo': 0,
+        'bar': 1,
+        'baz': 2}
+    self._components_by_index = {
+        '0': '123',
+        '1': '456',
+        '2': '789'}
+    self._ml_engine.expected_features = [3, 0, 1, 0, 0]
+    self._ml_engine.scores = [5, 10, 3]
+
+    text = 'foo baz foo foo'
+
+    self.assertIsNone(component_helpers.PredictComponent(text, config))
diff --git a/features/test/componentexport_test.py b/features/test/componentexport_test.py
new file mode 100644
index 0000000..0e5fbf8
--- /dev/null
+++ b/features/test/componentexport_test.py
@@ -0,0 +1,42 @@
+# 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.
+"""Tests for the componentexport module."""
+
+from __future__ import absolute_import
+from __future__ import division
+from __future__ import print_function
+
+import mock
+import unittest
+import webapp2
+
+import settings
+from features import componentexport
+from framework import urls
+
+
+class ComponentTrainingDataExportTest(unittest.TestCase):
+
+  def test_handler_definition(self):
+    instance = componentexport.ComponentTrainingDataExport()
+    self.assertIsInstance(instance, webapp2.RequestHandler)
+
+  @mock.patch('framework.cloud_tasks_helpers._get_client')
+  def test_enqueues_task(self, get_client_mock):
+    componentexport.ComponentTrainingDataExport().get()
+
+    queue = 'componentexport'
+    task = {
+        'app_engine_http_request':
+            {
+                'http_method': 'GET',
+                'relative_uri': urls.COMPONENT_DATA_EXPORT_TASK
+            }
+    }
+
+    get_client_mock().queue_path.assert_called_with(
+        settings.app_id, settings.CLOUD_TASKS_REGION, queue)
+    get_client_mock().create_task.assert_called_once()
+    ((_parent, called_task), _kwargs) = get_client_mock().create_task.call_args
+    self.assertEqual(called_task, task)
diff --git a/features/test/dateaction_test.py b/features/test/dateaction_test.py
new file mode 100644
index 0000000..09e5c5c
--- /dev/null
+++ b/features/test/dateaction_test.py
@@ -0,0 +1,323 @@
+# 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 dateaction module."""
+
+from __future__ import division
+from __future__ import print_function
+from __future__ import absolute_import
+
+import logging
+import mock
+import time
+import unittest
+
+from features import dateaction
+from framework import cloud_tasks_helpers
+from framework import framework_constants
+from framework import framework_views
+from framework import timestr
+from framework import urls
+from proto import tracker_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+from tracker import tracker_bizobj
+
+
+NOW = 1492120863
+
+
+class DateActionCronTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        user=fake.UserService(),
+        issue=fake.IssueService())
+    self.servlet = dateaction.DateActionCron(
+        'req', 'res', services=self.services)
+    self.TIMESTAMP_MIN = (
+        NOW // framework_constants.SECS_PER_DAY *
+        framework_constants.SECS_PER_DAY)
+    self.TIMESTAMP_MAX = self.TIMESTAMP_MIN + framework_constants.SECS_PER_DAY
+    self.left_joins = [
+        ('Issue2FieldValue ON Issue.id = Issue2FieldValue.issue_id', []),
+        ('FieldDef ON Issue2FieldValue.field_id = FieldDef.id', []),
+    ]
+    self.where = [
+        ('FieldDef.field_type = %s', ['date_type']),
+        (
+            'FieldDef.date_action IN (%s,%s)',
+            ['ping_owner_only', 'ping_participants']),
+        ('Issue2FieldValue.date_value >= %s', [self.TIMESTAMP_MIN]),
+        ('Issue2FieldValue.date_value < %s', [self.TIMESTAMP_MAX]),
+    ]
+    self.order_by = [
+        ('Issue.id', []),
+    ]
+
+  @mock.patch('time.time', return_value=NOW)
+  def testHandleRequest_NoMatches(self, _mock_time):
+    _request, mr = testing_helpers.GetRequestObjects(
+        path=urls.DATE_ACTION_CRON)
+    self.services.issue.RunIssueQuery = mock.MagicMock(return_value=([], False))
+
+    self.servlet.HandleRequest(mr)
+
+    self.services.issue.RunIssueQuery.assert_called_with(
+        mr.cnxn, self.left_joins, self.where + [('Issue.id > %s', [0])],
+        self.order_by)
+
+  @mock.patch('framework.cloud_tasks_helpers._get_client')
+  @mock.patch('time.time', return_value=NOW)
+  def testHandleRequest_OneMatche(self, _mock_time, get_client_mock):
+    _request, mr = testing_helpers.GetRequestObjects(
+        path=urls.DATE_ACTION_CRON)
+    self.services.issue.RunIssueQuery = mock.MagicMock(
+        return_value=([78901], False))
+
+    self.servlet.HandleRequest(mr)
+
+    self.services.issue.RunIssueQuery.assert_called_with(
+        mr.cnxn, self.left_joins, self.where + [('Issue.id > %s', [0])],
+        self.order_by)
+    expected_task = {
+        'app_engine_http_request':
+            {
+                'relative_uri': urls.ISSUE_DATE_ACTION_TASK + '.do',
+                'body': 'issue_id=78901',
+                'headers': {
+                    'Content-type': 'application/x-www-form-urlencoded'
+                }
+            }
+    }
+    get_client_mock().create_task.assert_any_call(
+        get_client_mock().queue_path(),
+        expected_task,
+        retry=cloud_tasks_helpers._DEFAULT_RETRY)
+
+  @mock.patch('framework.cloud_tasks_helpers._get_client')
+  def testEnqueueDateAction(self, get_client_mock):
+    self.servlet.EnqueueDateAction(78901)
+    expected_task = {
+        'app_engine_http_request':
+            {
+                'relative_uri': urls.ISSUE_DATE_ACTION_TASK + '.do',
+                'body': 'issue_id=78901',
+                'headers': {
+                    'Content-type': 'application/x-www-form-urlencoded'
+                }
+            }
+    }
+    get_client_mock().create_task.assert_any_call(
+        get_client_mock().queue_path(),
+        expected_task,
+        retry=cloud_tasks_helpers._DEFAULT_RETRY)
+
+
+class IssueDateActionTaskTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        user=fake.UserService(),
+        usergroup=fake.UserGroupService(),
+        features=fake.FeaturesService(),
+        issue=fake.IssueService(),
+        project=fake.ProjectService(),
+        config=fake.ConfigService(),
+        issue_star=fake.IssueStarService())
+    self.servlet = dateaction.IssueDateActionTask(
+        'req', 'res', services=self.services)
+
+    self.config = self.services.config.GetProjectConfig('cnxn', 789)
+    self.config.field_defs = [
+        tracker_bizobj.MakeFieldDef(
+            123, 789, 'NextAction', tracker_pb2.FieldTypes.DATE_TYPE,
+            '', '', False, False, False, None, None, None, False, '',
+            None, None, tracker_pb2.DateAction.PING_OWNER_ONLY,
+            'Date of next expected progress update', False),
+        tracker_bizobj.MakeFieldDef(
+            124, 789, 'EoL', tracker_pb2.FieldTypes.DATE_TYPE,
+            '', '', False, False, False, None, None, None, False, '',
+            None, None, tracker_pb2.DateAction.PING_OWNER_ONLY, 'doc', False),
+        tracker_bizobj.MakeFieldDef(
+            125, 789, 'TLsBirthday', tracker_pb2.FieldTypes.DATE_TYPE,
+            '', '', False, False, False, None, None, None, False, '',
+            None, None, tracker_pb2.DateAction.NO_ACTION, 'doc', False),
+        ]
+    self.services.config.StoreConfig('cnxn', self.config)
+    self.project = self.services.project.TestAddProject('proj', project_id=789)
+    self.owner = self.services.user.TestAddUser('owner@example.com', 111)
+    self.date_action_user = self.services.user.TestAddUser(
+        'date-action-user@example.com', 555)
+
+  def testHandleRequest_IssueHasNoArrivedDates(self):
+    _request, mr = testing_helpers.GetRequestObjects(
+        path=urls.ISSUE_DATE_ACTION_TASK + '.do?issue_id=78901')
+    self.services.issue.TestAddIssue(fake.MakeTestIssue(
+        789, 1, 'summary', 'New', 111, issue_id=78901))
+    self.assertEqual(1, len(self.services.issue.GetCommentsForIssue(
+        mr.cnxn, 78901)))
+
+    self.servlet.HandleRequest(mr)
+    self.assertEqual(1, len(self.services.issue.GetCommentsForIssue(
+        mr.cnxn, 78901)))
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testHandleRequest_IssueHasOneArriveDate(self, create_task_mock):
+    _request, mr = testing_helpers.GetRequestObjects(
+        path=urls.ISSUE_DATE_ACTION_TASK + '.do?issue_id=78901')
+
+    now = int(time.time())
+    date_str = timestr.TimestampToDateWidgetStr(now)
+    issue = fake.MakeTestIssue(789, 1, 'summary', 'New', 111, issue_id=78901)
+    self.services.issue.TestAddIssue(issue)
+    issue.field_values = [
+        tracker_bizobj.MakeFieldValue(123, None, None, None, now, None, False)]
+    self.assertEqual(1, len(self.services.issue.GetCommentsForIssue(
+        mr.cnxn, 78901)))
+
+    self.servlet.HandleRequest(mr)
+    comments = self.services.issue.GetCommentsForIssue(mr.cnxn, 78901)
+    self.assertEqual(2, len(comments))
+    self.assertEqual(
+      'The NextAction date has arrived: %s' % date_str,
+      comments[1].content)
+
+    self.assertEqual(create_task_mock.call_count, 1)
+
+    (args, kwargs) = create_task_mock.call_args
+    self.assertEqual(
+        args[0]['app_engine_http_request']['relative_uri'],
+        urls.OUTBOUND_EMAIL_TASK + '.do')
+    self.assertEqual(kwargs['queue'], 'outboundemail')
+
+  def SetUpFieldValues(self, issue, now):
+    issue.field_values = [
+        tracker_bizobj.MakeFieldValue(123, None, None, None, now, None, False),
+        tracker_bizobj.MakeFieldValue(124, None, None, None, now, None, False),
+        tracker_bizobj.MakeFieldValue(125, None, None, None, now, None, False),
+        ]
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testHandleRequest_IssueHasTwoArriveDates(self, create_task_mock):
+    _request, mr = testing_helpers.GetRequestObjects(
+        path=urls.ISSUE_DATE_ACTION_TASK + '.do?issue_id=78901')
+
+    now = int(time.time())
+    date_str = timestr.TimestampToDateWidgetStr(now)
+    issue = fake.MakeTestIssue(789, 1, 'summary', 'New', 111, issue_id=78901)
+    self.services.issue.TestAddIssue(issue)
+    self.SetUpFieldValues(issue, now)
+    self.assertEqual(1, len(self.services.issue.GetCommentsForIssue(
+        mr.cnxn, 78901)))
+
+    self.servlet.HandleRequest(mr)
+    comments = self.services.issue.GetCommentsForIssue(mr.cnxn, 78901)
+    self.assertEqual(2, len(comments))
+    self.assertEqual(
+      'The EoL date has arrived: %s\n'
+      'The NextAction date has arrived: %s' % (date_str, date_str),
+      comments[1].content)
+
+    self.assertEqual(create_task_mock.call_count, 1)
+
+    (args, kwargs) = create_task_mock.call_args
+    self.assertEqual(
+        args[0]['app_engine_http_request']['relative_uri'],
+        urls.OUTBOUND_EMAIL_TASK + '.do')
+    self.assertEqual(kwargs['queue'], 'outboundemail')
+
+  def MakePingComment(self):
+    comment = tracker_pb2.IssueComment()
+    comment.project_id = self.project.project_id
+    comment.user_id = self.date_action_user.user_id
+    comment.content = 'Some date(s) arrived...'
+    return comment
+
+  def testMakeEmailTasks_Owner(self):
+    """The issue owner gets pinged and the email has expected content."""
+    issue = fake.MakeTestIssue(
+        789, 1, 'summary', 'New', self.owner.user_id, issue_id=78901)
+    self.services.issue.TestAddIssue(issue)
+    now = int(time.time())
+    self.SetUpFieldValues(issue, now)
+    issue.project_name = 'proj'
+    comment = self.MakePingComment()
+    next_action_field_def = self.config.field_defs[0]
+    pings = [(next_action_field_def, now)]
+    users_by_id = framework_views.MakeAllUserViews(
+        'fake cnxn', self.services.user,
+        [self.owner.user_id, self.date_action_user.user_id])
+
+    tasks = self.servlet._MakeEmailTasks(
+        'fake cnxn', issue, self.project, self.config, comment,
+        [], 'example-app.appspot.com', users_by_id, pings)
+    self.assertEqual(1, len(tasks))
+    notify_owner_task = tasks[0]
+    self.assertEqual('owner@example.com', notify_owner_task['to'])
+    self.assertEqual(
+        'Follow up on issue 1 in proj: summary',
+        notify_owner_task['subject'])
+    body = notify_owner_task['body']
+    self.assertIn(comment.content, body)
+    self.assertIn(next_action_field_def.docstring, body)
+
+  def testMakeEmailTasks_Starrer(self):
+    """Users who starred the issue are notified iff they opt in."""
+    issue = fake.MakeTestIssue(
+        789, 1, 'summary', 'New', 0, issue_id=78901)
+    self.services.issue.TestAddIssue(issue)
+    now = int(time.time())
+    self.SetUpFieldValues(issue, now)
+    issue.project_name = 'proj'
+    comment = self.MakePingComment()
+    next_action_field_def = self.config.field_defs[0]
+    pings = [(next_action_field_def, now)]
+
+    starrer_333 = self.services.user.TestAddUser('starrer333@example.com', 333)
+    starrer_333.notify_starred_ping = True
+    self.services.user.TestAddUser('starrer444@example.com', 444)
+    starrer_ids = [333, 444]
+    users_by_id = framework_views.MakeAllUserViews(
+        'fake cnxn', self.services.user,
+        [self.owner.user_id, self.date_action_user.user_id],
+        starrer_ids)
+
+    tasks = self.servlet._MakeEmailTasks(
+        'fake cnxn', issue, self.project, self.config, comment,
+        starrer_ids, 'example-app.appspot.com', users_by_id, pings)
+    self.assertEqual(1, len(tasks))
+    notify_owner_task = tasks[0]
+    self.assertEqual('starrer333@example.com', notify_owner_task['to'])
+
+  def testCalculateIssuePings_Normal(self):
+    """Return a ping for an issue that has a date that happened today."""
+    issue = fake.MakeTestIssue(
+        789, 1, 'summary', 'New', 0, issue_id=78901)
+    self.services.issue.TestAddIssue(issue)
+    now = int(time.time())
+    self.SetUpFieldValues(issue, now)
+    issue.project_name = 'proj'
+
+    pings = self.servlet._CalculateIssuePings(issue, self.config)
+
+    self.assertEqual(
+        [(self.config.field_defs[1], now),
+         (self.config.field_defs[0], now)],
+        pings)
+
+  def testCalculateIssuePings_Closed(self):
+    """Don't ping for a closed issue."""
+    issue = fake.MakeTestIssue(
+        789, 1, 'summary', 'Fixed', 0, issue_id=78901)
+    self.services.issue.TestAddIssue(issue)
+    now = int(time.time())
+    self.SetUpFieldValues(issue, now)
+    issue.project_name = 'proj'
+
+    pings = self.servlet._CalculateIssuePings(issue, self.config)
+
+    self.assertEqual([], pings)
diff --git a/features/test/features_bizobj_test.py b/features/test/features_bizobj_test.py
new file mode 100644
index 0000000..1814ae2
--- /dev/null
+++ b/features/test/features_bizobj_test.py
@@ -0,0 +1,134 @@
+# 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 features bizobj functions."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from proto import features_pb2
+from features import features_bizobj
+from testing import fake
+
+class FeaturesBizobjTest(unittest.TestCase):
+
+  def setUp(self):
+    self.local_ids = [1, 2, 3, 4, 5]
+    self.issues = [fake.MakeTestIssue(1000, local_id, '', 'New', 111)
+                   for local_id in self.local_ids]
+    self.hotlistitems = [features_pb2.MakeHotlistItem(
+        issue.issue_id, rank=rank*10, adder_id=111, date_added=3) for
+                           rank, issue in enumerate(self.issues)]
+    self.iids = [item.issue_id for item in self.hotlistitems]
+
+  def testIssueIsInHotlist(self):
+    hotlist = features_pb2.Hotlist(items=self.hotlistitems)
+    for issue in self.issues:
+      self.assertTrue(features_bizobj.IssueIsInHotlist(hotlist, issue.issue_id))
+
+    self.assertFalse(features_bizobj.IssueIsInHotlist(
+        hotlist, fake.MakeTestIssue(1000, 9, '', 'New', 111)))
+
+  def testSplitHotlistIssueRanks(self):
+    iid_rank_tuples = [(issue.issue_id, issue.rank)
+                       for issue in self.hotlistitems]
+    iid_rank_tuples.reverse()
+    ret = features_bizobj.SplitHotlistIssueRanks(
+        100003, True, iid_rank_tuples)
+    self.assertEqual(ret, (iid_rank_tuples[:2], iid_rank_tuples[2:]))
+
+    iid_rank_tuples.reverse()
+    ret = features_bizobj.SplitHotlistIssueRanks(
+        100003, False, iid_rank_tuples)
+    self.assertEqual(ret, (iid_rank_tuples[:3], iid_rank_tuples[3:]))
+
+    # target issue not found
+    first_pairs, second_pairs = features_bizobj.SplitHotlistIssueRanks(
+        100009, True, iid_rank_tuples)
+    self.assertEqual(iid_rank_tuples, first_pairs)
+    self.assertEqual(second_pairs, [])
+
+  def testGetOwnerIds(self):
+    hotlist = features_pb2.Hotlist(owner_ids=[111])
+    self.assertEqual(features_bizobj.GetOwnerIds(hotlist), [111])
+
+  def testUsersInvolvedInHotlists_Empty(self):
+    self.assertEqual(set(), features_bizobj.UsersInvolvedInHotlists([]))
+
+  def testUsersInvolvedInHotlists_Normal(self):
+    hotlist1 = features_pb2.Hotlist(
+        owner_ids=[111, 222], editor_ids=[333, 444, 555],
+        follower_ids=[123])
+    hotlist2 = features_pb2.Hotlist(
+        owner_ids=[111], editor_ids=[222, 123])
+    self.assertEqual(set([111, 222, 333, 444, 555, 123]),
+                     features_bizobj.UsersInvolvedInHotlists([hotlist1,
+                                                              hotlist2]))
+
+  def testUserIsInHotlist(self):
+    h = features_pb2.Hotlist()
+    self.assertFalse(features_bizobj.UserIsInHotlist(h, {9}))
+    self.assertFalse(features_bizobj.UserIsInHotlist(h, set()))
+
+    h.owner_ids.extend([1, 2, 3])
+    h.editor_ids.extend([4, 5, 6])
+    h.follower_ids.extend([7, 8, 9])
+    self.assertTrue(features_bizobj.UserIsInHotlist(h, {1}))
+    self.assertTrue(features_bizobj.UserIsInHotlist(h, {4}))
+    self.assertTrue(features_bizobj.UserIsInHotlist(h, {7}))
+    self.assertFalse(features_bizobj.UserIsInHotlist(h, {10}))
+
+    # Membership via group membership
+    self.assertTrue(features_bizobj.UserIsInHotlist(h, {10, 4}))
+
+    # Membership via several group memberships
+    self.assertTrue(features_bizobj.UserIsInHotlist(h, {1, 4}))
+
+    # Several irrelevant group memberships
+    self.assertFalse(features_bizobj.UserIsInHotlist(h, {10, 11, 12}))
+
+  def testDetermineHotlistIssuePosition(self):
+    # normal
+    prev_iid, index, next_iid = features_bizobj.DetermineHotlistIssuePosition(
+        self.issues[2], self.iids)
+    self.assertEqual(prev_iid, self.hotlistitems[1].issue_id)
+    self.assertEqual(index, 2)
+    self.assertEqual(next_iid, self.hotlistitems[3].issue_id)
+
+    # end of list
+    prev_iid, index, next_iid = features_bizobj.DetermineHotlistIssuePosition(
+        self.issues[4], self.iids)
+    self.assertEqual(prev_iid, self.hotlistitems[3].issue_id)
+    self.assertEqual(index, 4)
+    self.assertEqual(next_iid, None)
+
+    # beginning of list
+    prev_iid, index, next_iid = features_bizobj.DetermineHotlistIssuePosition(
+        self.issues[0], self.iids)
+    self.assertEqual(prev_iid, None)
+    self.assertEqual(index, 0)
+    self.assertEqual(next_iid, self.hotlistitems[1].issue_id)
+
+    # one item in list
+    prev_iid, index, next_iid = features_bizobj.DetermineHotlistIssuePosition(
+        self.issues[2], [self.iids[2]])
+    self.assertEqual(prev_iid, None)
+    self.assertEqual(index, 0)
+    self.assertEqual(next_iid, None)
+
+    prev_iid, index, next_iid = features_bizobj.DetermineHotlistIssuePosition(
+        self.issues[2], [self.iids[3]])
+    self.assertEqual(prev_iid, None)
+    self.assertEqual(index, None)
+    self.assertEqual(next_iid, None)
+
+    #none
+    prev_iid, index, next_iid = features_bizobj.DetermineHotlistIssuePosition(
+        self.issues[2], [])
+    self.assertEqual(prev_iid, None)
+    self.assertEqual(index, None)
+    self.assertEqual(next_iid, None)
diff --git a/features/test/federated_test.py b/features/test/federated_test.py
new file mode 100644
index 0000000..1ba088a
--- /dev/null
+++ b/features/test/federated_test.py
@@ -0,0 +1,114 @@
+# Copyright 2019 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 monorail.feature.federated."""
+
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from features import federated
+from framework.exceptions import InvalidExternalIssueReference
+
+
+# Schema: tracker, shortlink.
+VALID_SHORTLINKS = [
+    ('google', 'b/1'),
+    ('google', 'b/123456'),
+    ('google', 'b/1234567890123')]
+
+
+# Schema: tracker, shortlink.
+INVALID_SHORTLINKS = [
+   ('google', 'b'),
+   ('google', 'b/'),
+   ('google', 'b//123'),
+   ('google', 'b/123/123')]
+
+
+class FederatedTest(unittest.TestCase):
+  """Test public module methods."""
+
+  def testIsShortlinkValid_Valid(self):
+    for _, shortlink in VALID_SHORTLINKS:
+      self.assertTrue(federated.IsShortlinkValid(shortlink),
+        'Expected %s to be a valid shortlink for any tracker.'
+        % shortlink)
+
+  def testIsShortlinkValid_Invalid(self):
+    for _, shortlink in INVALID_SHORTLINKS:
+      self.assertFalse(federated.IsShortlinkValid(shortlink),
+        'Expected %s to be an invalid shortlink for any tracker.'
+        % shortlink)
+
+  def testFromShortlink_Valid(self):
+    for _, shortlink in VALID_SHORTLINKS:
+      issue = federated.FromShortlink(shortlink)
+      self.assertEqual(shortlink, issue.shortlink, (
+          'Expected %s to be converted into a valid tracker object '
+          'with shortlink %s' % (shortlink, issue.shortlink)))
+
+  def testFromShortlink_Invalid(self):
+    for _, shortlink in INVALID_SHORTLINKS:
+      self.assertIsNone(federated.FromShortlink(shortlink))
+
+
+class FederatedIssueTest(unittest.TestCase):
+
+  def testInit_NotImplemented(self):
+    """By default, __init__ raises NotImplementedError.
+
+    Because __init__ calls IsShortlinkValid. See test below.
+    """
+    with self.assertRaises(NotImplementedError):
+      federated.FederatedIssue('a')
+
+  def testIsShortlinkValid_NotImplemented(self):
+    """By default, IsShortlinkValid raises NotImplementedError."""
+    with self.assertRaises(NotImplementedError):
+      federated.FederatedIssue('a').IsShortlinkValid('rutabaga')
+
+
+class GoogleIssueTrackerIssueTest(unittest.TestCase):
+
+  def setUp(self):
+    self.valid_shortlinks = [s for tracker, s in VALID_SHORTLINKS
+      if tracker == 'google']
+    self.invalid_shortlinks = [s for tracker, s in INVALID_SHORTLINKS
+      if tracker == 'google']
+
+  def testInit_ValidatesValidShortlink(self):
+    for shortlink in self.valid_shortlinks:
+      issue = federated.GoogleIssueTrackerIssue(shortlink)
+      self.assertEqual(issue.shortlink, shortlink)
+
+  def testInit_ValidatesInvalidShortlink(self):
+    for shortlink in self.invalid_shortlinks:
+      with self.assertRaises(InvalidExternalIssueReference):
+        federated.GoogleIssueTrackerIssue(shortlink)
+
+  def testIsShortlinkValid_Valid(self):
+    for shortlink in self.valid_shortlinks:
+      self.assertTrue(
+        federated.GoogleIssueTrackerIssue.IsShortlinkValid(shortlink),
+        'Expected %s to be a valid shortlink for Google.'
+        % shortlink)
+
+  def testIsShortlinkValid_Invalid(self):
+    for shortlink in self.invalid_shortlinks:
+      self.assertFalse(
+        federated.GoogleIssueTrackerIssue.IsShortlinkValid(shortlink),
+        'Expected %s to be an invalid shortlink for Google.'
+        % shortlink)
+
+  def testToURL(self):
+    self.assertEqual('https://issuetracker.google.com/issues/123456',
+        federated.GoogleIssueTrackerIssue('b/123456').ToURL())
+
+  def testSummary(self):
+    self.assertEqual('Google Issue Tracker issue 123456.',
+        federated.GoogleIssueTrackerIssue('b/123456').Summary())
diff --git a/features/test/filterrules_helpers_test.py b/features/test/filterrules_helpers_test.py
new file mode 100644
index 0000000..99d22b7
--- /dev/null
+++ b/features/test/filterrules_helpers_test.py
@@ -0,0 +1,927 @@
+# 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 filterrules_helpers feature."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import mock
+import unittest
+import urllib
+import urlparse
+
+import settings
+from features import filterrules_helpers
+from framework import cloud_tasks_helpers
+from framework import framework_constants
+from framework import template_helpers
+from framework import urls
+from proto import ast_pb2
+from proto import tracker_pb2
+from search import query2ast
+from services import service_manager
+from testing import fake
+from tracker import tracker_bizobj
+
+
+ORIG_SUMMARY = 'this is the orginal summary'
+ORIG_LABELS = ['one', 'two']
+
+# Fake user id mapping
+TEST_ID_MAP = {
+    'mike.j.parent': 1,
+    'jrobbins': 2,
+    'ningerso': 3,
+    'ui@example.com': 4,
+    'db@example.com': 5,
+    'ui-db@example.com': 6,
+    }
+
+TEST_LABEL_IDS = {
+  'i18n': 1,
+  'l10n': 2,
+  'Priority-High': 3,
+  'Priority-Medium': 4,
+  }
+
+
+class RecomputeAllDerivedFieldsTest(unittest.TestCase):
+
+  BLOCK = filterrules_helpers.BLOCK
+
+  def setUp(self):
+    self.features = fake.FeaturesService()
+    self.user = fake.UserService()
+    self.services = service_manager.Services(
+        features=self.features,
+        user=self.user,
+        issue=fake.IssueService())
+    self.project = fake.Project(project_name='proj')
+    self.config = 'fake config'
+    self.cnxn = 'fake cnxn'
+
+
+  def testRecomputeDerivedFields_Disabled(self):
+    """Servlet should just call RecomputeAllDerivedFieldsNow with no bounds."""
+    saved_flag = settings.recompute_derived_fields_in_worker
+    settings.recompute_derived_fields_in_worker = False
+
+    filterrules_helpers.RecomputeAllDerivedFields(
+        self.cnxn, self.services, self.project, self.config)
+    self.assertTrue(self.services.issue.get_all_issues_in_project_called)
+    self.assertTrue(self.services.issue.update_issues_called)
+    self.assertTrue(self.services.issue.enqueue_issues_called)
+
+    settings.recompute_derived_fields_in_worker = saved_flag
+
+  def testRecomputeDerivedFields_DisabledNextIDSet(self):
+    """Servlet should just call RecomputeAllDerivedFields with no bounds."""
+    saved_flag = settings.recompute_derived_fields_in_worker
+    settings.recompute_derived_fields_in_worker = False
+    self.services.issue.next_id = 1234
+
+    filterrules_helpers.RecomputeAllDerivedFields(
+        self.cnxn, self.services, self.project, self.config)
+    self.assertTrue(self.services.issue.get_all_issues_in_project_called)
+    self.assertTrue(self.services.issue.enqueue_issues_called)
+
+    settings.recompute_derived_fields_in_worker = saved_flag
+
+  def testRecomputeDerivedFields_NoIssues(self):
+    """Servlet should not call because there is no work to do."""
+    saved_flag = settings.recompute_derived_fields_in_worker
+    settings.recompute_derived_fields_in_worker = True
+
+    filterrules_helpers.RecomputeAllDerivedFields(
+        self.cnxn, self.services, self.project, self.config)
+    self.assertFalse(self.services.issue.get_all_issues_in_project_called)
+    self.assertFalse(self.services.issue.update_issues_called)
+    self.assertFalse(self.services.issue.enqueue_issues_called)
+
+    settings.recompute_derived_fields_in_worker = saved_flag
+
+  @mock.patch('framework.cloud_tasks_helpers._get_client')
+  def testRecomputeDerivedFields_SomeIssues(self, get_client_mock):
+    """Servlet should enqueue one work item rather than call directly."""
+    saved_flag = settings.recompute_derived_fields_in_worker
+    settings.recompute_derived_fields_in_worker = True
+    self.services.issue.next_id = 1234
+    num_calls = (self.services.issue.next_id // self.BLOCK + 1)
+
+    filterrules_helpers.RecomputeAllDerivedFields(
+        self.cnxn, self.services, self.project, self.config)
+    self.assertFalse(self.services.issue.get_all_issues_in_project_called)
+    self.assertFalse(self.services.issue.update_issues_called)
+    self.assertFalse(self.services.issue.enqueue_issues_called)
+
+    get_client_mock().queue_path.assert_any_call(
+        settings.app_id, settings.CLOUD_TASKS_REGION, 'recomputederivedfields')
+    self.assertEqual(get_client_mock().queue_path.call_count, num_calls)
+    self.assertEqual(get_client_mock().create_task.call_count, num_calls)
+
+    parent = get_client_mock().queue_path()
+    highest_id = self.services.issue.GetHighestLocalID(
+        self.cnxn, self.project.project_id)
+    steps = list(range(1, highest_id + 1, self.BLOCK))
+    steps.reverse()
+    shard_id = 0
+    for step in steps:
+      params = {
+          'project_id': self.project.project_id,
+          'lower_bound': step,
+          'upper_bound': min(step + self.BLOCK, highest_id + 1),
+          'shard_id': shard_id,
+      }
+      task = {
+          'app_engine_http_request':
+              {
+                  'relative_uri': urls.RECOMPUTE_DERIVED_FIELDS_TASK + '.do',
+                  'body': urllib.urlencode(params),
+                  'headers':
+                      {
+                          'Content-type': 'application/x-www-form-urlencoded'
+                      }
+              }
+      }
+      get_client_mock().create_task.assert_any_call(
+          parent, task, retry=cloud_tasks_helpers._DEFAULT_RETRY)
+      shard_id = (shard_id + 1) % settings.num_logical_shards
+
+    settings.recompute_derived_fields_in_worker = saved_flag
+
+  @mock.patch('framework.cloud_tasks_helpers._get_client')
+  def testRecomputeDerivedFields_LotsOfIssues(self, get_client_mock):
+    """Servlet should enqueue multiple work items."""
+    saved_flag = settings.recompute_derived_fields_in_worker
+    settings.recompute_derived_fields_in_worker = True
+    self.services.issue.next_id = 12345
+
+    filterrules_helpers.RecomputeAllDerivedFields(
+        self.cnxn, self.services, self.project, self.config)
+
+    self.assertFalse(self.services.issue.get_all_issues_in_project_called)
+    self.assertFalse(self.services.issue.update_issues_called)
+    self.assertFalse(self.services.issue.enqueue_issues_called)
+    num_calls = (self.services.issue.next_id // self.BLOCK + 1)
+    get_client_mock().queue_path.assert_any_call(
+        settings.app_id, settings.CLOUD_TASKS_REGION, 'recomputederivedfields')
+    self.assertEqual(get_client_mock().queue_path.call_count, num_calls)
+    self.assertEqual(get_client_mock().create_task.call_count, num_calls)
+
+    ((_parent, called_task),
+     _kwargs) = get_client_mock().create_task.call_args_list[0]
+    relative_uri = called_task.get('app_engine_http_request').get(
+        'relative_uri')
+    self.assertEqual(relative_uri, urls.RECOMPUTE_DERIVED_FIELDS_TASK + '.do')
+    encoded_params = called_task.get('app_engine_http_request').get('body')
+    params = {k: v[0] for k, v in urlparse.parse_qs(encoded_params).items()}
+    self.assertEqual(params['project_id'], str(self.project.project_id))
+    self.assertEqual(
+        params['lower_bound'], str(12345 // self.BLOCK * self.BLOCK + 1))
+    self.assertEqual(params['upper_bound'], str(12345))
+
+    ((_parent, called_task), _kwargs) = get_client_mock().create_task.call_args
+    relative_uri = called_task.get('app_engine_http_request').get(
+        'relative_uri')
+    self.assertEqual(relative_uri, urls.RECOMPUTE_DERIVED_FIELDS_TASK + '.do')
+    encoded_params = called_task.get('app_engine_http_request').get('body')
+    params = {k: v[0] for k, v in urlparse.parse_qs(encoded_params).items()}
+    self.assertEqual(params['project_id'], str(self.project.project_id))
+    self.assertEqual(params['lower_bound'], str(1))
+    self.assertEqual(params['upper_bound'], str(self.BLOCK + 1))
+
+    settings.recompute_derived_fields_in_worker = saved_flag
+
+  @mock.patch(
+      'features.filterrules_helpers.ApplyGivenRules', return_value=(True, {}))
+  def testRecomputeAllDerivedFieldsNow(self, apply_mock):
+    """Servlet should reapply all filter rules to project's issues."""
+    self.services.issue.next_id = 12345
+    test_issue_1 = fake.MakeTestIssue(
+        project_id=self.project.project_id, local_id=1, issue_id=1001,
+        summary='sum1', owner_id=100, status='New')
+    test_issue_1.assume_stale = False  # We will store this issue.
+    test_issue_2 = fake.MakeTestIssue(
+        project_id=self.project.project_id, local_id=2, issue_id=1002,
+        summary='sum2', owner_id=100, status='New')
+    test_issue_2.assume_stale = False  # We will store this issue.
+    test_issues = [test_issue_1, test_issue_2]
+    self.services.issue.TestAddIssue(test_issue_1)
+    self.services.issue.TestAddIssue(test_issue_2)
+
+    filterrules_helpers.RecomputeAllDerivedFieldsNow(
+        self.cnxn, self.services, self.project, self.config)
+
+    self.assertTrue(self.services.issue.get_all_issues_in_project_called)
+    self.assertTrue(self.services.issue.update_issues_called)
+    self.assertTrue(self.services.issue.enqueue_issues_called)
+    self.assertEqual(test_issues, self.services.issue.updated_issues)
+    self.assertEqual([issue.issue_id for issue in test_issues],
+                     self.services.issue.enqueued_issues)
+    self.assertEqual(apply_mock.call_count, 2)
+    for test_issue in test_issues:
+      apply_mock.assert_any_call(
+          self.cnxn, self.services, test_issue, self.config, [], [])
+
+
+class FilterRulesHelpersTest(unittest.TestCase):
+
+  def setUp(self):
+    self.cnxn = 'fake cnxn'
+    self.services = service_manager.Services(
+        user=fake.UserService(),
+        project=fake.ProjectService(),
+        issue=fake.IssueService(),
+        config=fake.ConfigService())
+    self.project = self.services.project.TestAddProject('proj', project_id=789)
+    self.other_project = self.services.project.TestAddProject(
+        'otherproj', project_id=890)
+    for email, user_id in TEST_ID_MAP.items():
+      self.services.user.TestAddUser(email, user_id)
+    self.services.config.TestAddLabelsDict(TEST_LABEL_IDS)
+
+  def testApplyRule(self):
+    cnxn = 'fake sql connection'
+    issue = fake.MakeTestIssue(
+        789, 1, ORIG_SUMMARY, 'New', 111, labels=ORIG_LABELS)
+    config = tracker_pb2.ProjectIssueConfig(project_id=self.project.project_id)
+    # Empty label set cannot satisfy rule looking for labels.
+    pred = 'label:a label:b'
+    rule = filterrules_helpers.MakeRule(
+        pred, default_owner_id=1, default_status='S')
+    predicate_ast = query2ast.ParseUserQuery(
+        pred, '', query2ast.BUILTIN_ISSUE_FIELDS, config)
+    self.assertEqual(
+        (None, None, [], [], [], None, None),
+        filterrules_helpers._ApplyRule(
+            cnxn, self.services, rule, predicate_ast, issue, set(), config))
+
+    pred = 'label:a -label:b'
+    rule = filterrules_helpers.MakeRule(
+        pred, default_owner_id=1, default_status='S')
+    predicate_ast = query2ast.ParseUserQuery(
+        pred, '', query2ast.BUILTIN_ISSUE_FIELDS, config)
+    self.assertEqual(
+        (None, None, [], [], [], None, None),
+        filterrules_helpers._ApplyRule(
+            cnxn, self.services, rule, predicate_ast, issue, set(), config))
+
+    # Empty label set will satisfy rule looking for missing labels.
+    pred = '-label:a -label:b'
+    rule = filterrules_helpers.MakeRule(
+        pred, default_owner_id=1, default_status='S')
+    predicate_ast = query2ast.ParseUserQuery(
+        pred, '', query2ast.BUILTIN_ISSUE_FIELDS, config)
+    self.assertEqual(
+        (1, 'S', [], [], [], None, None),
+        filterrules_helpers._ApplyRule(
+            cnxn, self.services, rule, predicate_ast, issue, set(), config))
+
+    # Label set has the needed labels.
+    pred = 'label:a label:b'
+    rule = filterrules_helpers.MakeRule(
+        pred, default_owner_id=1, default_status='S')
+    predicate_ast = query2ast.ParseUserQuery(
+        pred, '', query2ast.BUILTIN_ISSUE_FIELDS, config)
+    self.assertEqual(
+        (1, 'S', [], [], [], None, None),
+        filterrules_helpers._ApplyRule(
+            cnxn, self.services, rule, predicate_ast, issue, {'a', 'b'},
+            config))
+
+    # Label set has the needed labels with test for unicode.
+    pred = 'label:a label:b'
+    rule = filterrules_helpers.MakeRule(
+        pred, default_owner_id=1, default_status='S')
+    predicate_ast = query2ast.ParseUserQuery(
+        pred, '', query2ast.BUILTIN_ISSUE_FIELDS, config)
+    self.assertEqual(
+        (1, 'S', [], [], [], None, None),
+        filterrules_helpers._ApplyRule(
+            cnxn, self.services, rule, predicate_ast, issue, {u'a', u'b'},
+            config))
+
+    # Label set has the needed labels, capitalization irrelevant.
+    pred = 'label:A label:B'
+    rule = filterrules_helpers.MakeRule(
+        pred, default_owner_id=1, default_status='S')
+    predicate_ast = query2ast.ParseUserQuery(
+        pred, '', query2ast.BUILTIN_ISSUE_FIELDS, config)
+    self.assertEqual(
+        (1, 'S', [], [], [], None, None),
+        filterrules_helpers._ApplyRule(
+            cnxn, self.services, rule, predicate_ast, issue, {'a', 'b'},
+            config))
+
+    # Label set has a label, the rule negates.
+    pred = 'label:a -label:b'
+    rule = filterrules_helpers.MakeRule(
+        pred, default_owner_id=1, default_status='S')
+    predicate_ast = query2ast.ParseUserQuery(
+        pred, '', query2ast.BUILTIN_ISSUE_FIELDS, config)
+    self.assertEqual(
+        (None, None, [], [], [], None, None),
+        filterrules_helpers._ApplyRule(
+            cnxn, self.services, rule, predicate_ast, issue, {'a', 'b'},
+            config))
+
+    # Consequence is to add a warning.
+    pred = 'label:a'
+    rule = filterrules_helpers.MakeRule(
+        pred, warning='Hey look out')
+    predicate_ast = query2ast.ParseUserQuery(
+        pred, '', query2ast.BUILTIN_ISSUE_FIELDS, config)
+    self.assertEqual(
+        (None, None, [], [], [], 'Hey look out', None),
+        filterrules_helpers._ApplyRule(
+            cnxn, self.services, rule, predicate_ast, issue, {'a', 'b'},
+            config))
+
+    # Consequence is to add an error.
+    pred = 'label:a'
+    rule = filterrules_helpers.MakeRule(
+        pred, error='We cannot allow that')
+    predicate_ast = query2ast.ParseUserQuery(
+        pred, '', query2ast.BUILTIN_ISSUE_FIELDS, config)
+    self.assertEqual(
+        (None, None, [], [], [], None, 'We cannot allow that'),
+        filterrules_helpers._ApplyRule(
+            cnxn, self.services, rule, predicate_ast, issue, {'a', 'b'},
+            config))
+
+  def testComputeDerivedFields_Components(self):
+    cnxn = 'fake sql connection'
+    rules = []
+    component_defs = [
+      tracker_bizobj.MakeComponentDef(
+        10, 789, 'DB', 'database', False, [],
+        [TEST_ID_MAP['db@example.com'],
+         TEST_ID_MAP['ui-db@example.com']],
+        0, 0,
+        label_ids=[TEST_LABEL_IDS['i18n'],
+                   TEST_LABEL_IDS['Priority-High']]),
+      tracker_bizobj.MakeComponentDef(
+        20, 789, 'Install', 'installer', False, [],
+        [], 0, 0),
+      tracker_bizobj.MakeComponentDef(
+        30, 789, 'UI', 'doc', False, [],
+        [TEST_ID_MAP['ui@example.com'],
+         TEST_ID_MAP['ui-db@example.com']],
+        0, 0,
+        label_ids=[TEST_LABEL_IDS['i18n'],
+                   TEST_LABEL_IDS['l10n'],
+                   TEST_LABEL_IDS['Priority-Medium']]),
+      ]
+    excl_prefixes = ['Priority', 'type', 'milestone']
+    config = tracker_pb2.ProjectIssueConfig(
+        exclusive_label_prefixes=excl_prefixes,
+        component_defs=component_defs)
+    predicate_asts = filterrules_helpers.ParsePredicateASTs(rules, config, [])
+
+    # No components.
+    issue = fake.MakeTestIssue(
+        789, 1, ORIG_SUMMARY, 'New', 0, labels=ORIG_LABELS)
+    self.assertEqual(
+        (0, '', [], [], [], {}, [], []),
+        filterrules_helpers._ComputeDerivedFields(
+            cnxn, self.services, issue, config, rules, predicate_asts))
+
+    # One component, no CCs or labels added
+    issue.component_ids = [20]
+    issue = fake.MakeTestIssue(
+        789, 1, ORIG_SUMMARY, 'New', 0, labels=ORIG_LABELS)
+    self.assertEqual(
+        (0, '', [], [], [], {}, [], []),
+        filterrules_helpers._ComputeDerivedFields(
+            cnxn, self.services, issue, config, rules, predicate_asts))
+
+    # One component, some CCs and labels added
+    issue = fake.MakeTestIssue(
+        789, 1, ORIG_SUMMARY, 'New', 0, labels=ORIG_LABELS,
+        component_ids=[10])
+    traces = {
+      (tracker_pb2.FieldID.CC, TEST_ID_MAP['db@example.com']):
+          'Added by component DB',
+      (tracker_pb2.FieldID.CC, TEST_ID_MAP['ui-db@example.com']):
+          'Added by component DB',
+      (tracker_pb2.FieldID.LABELS, 'i18n'):
+          'Added by component DB',
+      (tracker_pb2.FieldID.LABELS, 'Priority-High'):
+          'Added by component DB',
+      }
+    self.assertEqual(
+        (
+            0, '', [
+                TEST_ID_MAP['db@example.com'], TEST_ID_MAP['ui-db@example.com']
+            ], ['i18n', 'Priority-High'], [], traces, [], []),
+        filterrules_helpers._ComputeDerivedFields(
+            cnxn, self.services, issue, config, rules, predicate_asts))
+
+    # One component, CCs and labels not added because of labels on the issue.
+    issue = fake.MakeTestIssue(
+        789, 1, ORIG_SUMMARY, 'New', 0, labels=['Priority-Low', 'i18n'],
+        component_ids=[10])
+    issue.cc_ids = [TEST_ID_MAP['db@example.com']]
+    traces = {
+      (tracker_pb2.FieldID.CC, TEST_ID_MAP['ui-db@example.com']):
+          'Added by component DB',
+      }
+    self.assertEqual(
+        (0, '', [TEST_ID_MAP['ui-db@example.com']], [], [], traces, [], []),
+        filterrules_helpers._ComputeDerivedFields(
+            cnxn, self.services, issue, config, rules, predicate_asts))
+
+    # Multiple components, added CCs treated as a set, exclusive labels in later
+    # components take priority over earlier ones.
+    issue = fake.MakeTestIssue(
+        789, 1, ORIG_SUMMARY, 'New', 0, labels=ORIG_LABELS,
+        component_ids=[10, 30])
+    traces = {
+      (tracker_pb2.FieldID.CC, TEST_ID_MAP['db@example.com']):
+          'Added by component DB',
+      (tracker_pb2.FieldID.CC, TEST_ID_MAP['ui-db@example.com']):
+          'Added by component DB',
+      (tracker_pb2.FieldID.LABELS, 'i18n'):
+          'Added by component DB',
+      (tracker_pb2.FieldID.LABELS, 'Priority-High'):
+          'Added by component DB',
+      (tracker_pb2.FieldID.CC, TEST_ID_MAP['ui@example.com']):
+          'Added by component UI',
+      (tracker_pb2.FieldID.LABELS, 'Priority-Medium'):
+          'Added by component UI',
+      (tracker_pb2.FieldID.LABELS, 'l10n'):
+          'Added by component UI',
+      }
+    self.assertEqual(
+        (
+            0, '', [
+                TEST_ID_MAP['db@example.com'], TEST_ID_MAP['ui-db@example.com'],
+                TEST_ID_MAP['ui@example.com']
+            ], ['i18n', 'l10n', 'Priority-Medium'], [], traces, [], []),
+        filterrules_helpers._ComputeDerivedFields(
+            cnxn, self.services, issue, config, rules, predicate_asts))
+
+  def testComputeDerivedFields_Rules(self):
+    cnxn = 'fake sql connection'
+    rules = [
+        filterrules_helpers.MakeRule(
+            'label:HasWorkaround', add_labels=['Priority-Low']),
+        filterrules_helpers.MakeRule(
+            'label:Security', add_labels=['Private']),
+        filterrules_helpers.MakeRule(
+            'label:Security', add_labels=['Priority-High'],
+            add_notify=['jrobbins@chromium.org']),
+        filterrules_helpers.MakeRule(
+            'Priority=High label:Regression', add_labels=['Urgent']),
+        filterrules_helpers.MakeRule(
+            'Size=L', default_owner_id=444),
+        filterrules_helpers.MakeRule(
+            'Size=XL', warning='It will take too long'),
+        filterrules_helpers.MakeRule(
+            'Size=XL', warning='It will cost too much'),
+        ]
+    excl_prefixes = ['Priority', 'type', 'milestone']
+    config = tracker_pb2.ProjectIssueConfig(
+        exclusive_label_prefixes=excl_prefixes,
+        project_id=self.project.project_id)
+    predicate_asts = filterrules_helpers.ParsePredicateASTs(rules, config, [])
+
+    # No rules fire.
+    issue = fake.MakeTestIssue(
+        789, 1, ORIG_SUMMARY, 'New', 0, labels=ORIG_LABELS)
+    self.assertEqual(
+        (0, '', [], [], [], {}, [], []),
+        filterrules_helpers._ComputeDerivedFields(
+            cnxn, self.services, issue, config, rules, predicate_asts))
+
+    issue = fake.MakeTestIssue(
+        789, 1, ORIG_SUMMARY, 'New', 0, labels=['foo', 'bar'])
+    self.assertEqual(
+        (0, '', [], [], [], {}, [], []),
+        filterrules_helpers._ComputeDerivedFields(
+            cnxn, self.services, issue, config, rules, predicate_asts))
+
+    # One rule fires.
+    issue = fake.MakeTestIssue(
+        789, 1, ORIG_SUMMARY, 'New', 0, labels=['Size-L'])
+    traces = {
+        (tracker_pb2.FieldID.OWNER, 444):
+            'Added by rule: IF Size=L THEN SET DEFAULT OWNER',
+        }
+    self.assertEqual(
+        (444, '', [], [], [], traces, [], []),
+        filterrules_helpers._ComputeDerivedFields(
+            cnxn, self.services, issue, config, rules, predicate_asts))
+
+    # One rule fires, but no effect because of explicit fields.
+    issue = fake.MakeTestIssue(
+        789, 1, ORIG_SUMMARY, 'New', 0,
+        labels=['HasWorkaround', 'Priority-Critical'])
+    traces = {}
+    self.assertEqual(
+        (0, '', [], [], [], traces, [], []),
+        filterrules_helpers._ComputeDerivedFields(
+            cnxn, self.services, issue, config, rules, predicate_asts))
+
+    # One rule fires, another has no effect because of explicit exclusive label.
+    issue = fake.MakeTestIssue(
+        789, 1, ORIG_SUMMARY, 'New', 0,
+        labels=['Security', 'Priority-Critical'])
+    traces = {
+        (tracker_pb2.FieldID.LABELS, 'Private'):
+            'Added by rule: IF label:Security THEN ADD LABEL',
+        }
+    self.assertEqual(
+        (0, '', [], ['Private'], ['jrobbins@chromium.org'], traces, [], []),
+        filterrules_helpers._ComputeDerivedFields(
+            cnxn, self.services, issue, config, rules, predicate_asts))
+
+    # Multiple rules have cumulative effect.
+    issue = fake.MakeTestIssue(
+        789, 1, ORIG_SUMMARY, 'New', 0, labels=['HasWorkaround', 'Size-L'])
+    traces = {
+        (tracker_pb2.FieldID.LABELS, 'Priority-Low'):
+            'Added by rule: IF label:HasWorkaround THEN ADD LABEL',
+        (tracker_pb2.FieldID.OWNER, 444):
+            'Added by rule: IF Size=L THEN SET DEFAULT OWNER',
+        }
+    self.assertEqual(
+        (444, '', [], ['Priority-Low'], [], traces, [], []),
+        filterrules_helpers._ComputeDerivedFields(
+            cnxn, self.services, issue, config, rules, predicate_asts))
+
+    # Multiple rules have cumulative warnings.
+    issue = fake.MakeTestIssue(
+        789, 1, ORIG_SUMMARY, 'New', 0, labels=['Size-XL'])
+    traces = {
+        (tracker_pb2.FieldID.WARNING, 'It will take too long'):
+            'Added by rule: IF Size=XL THEN ADD WARNING',
+        (tracker_pb2.FieldID.WARNING, 'It will cost too much'):
+            'Added by rule: IF Size=XL THEN ADD WARNING',
+        }
+    self.assertEqual(
+        (
+            0, '', [], [], [], traces,
+            ['It will take too long', 'It will cost too much'], []),
+        filterrules_helpers._ComputeDerivedFields(
+            cnxn, self.services, issue, config, rules, predicate_asts))
+
+    # Two rules fire, second overwrites the first.
+    issue = fake.MakeTestIssue(
+        789, 1, ORIG_SUMMARY, 'New', 0, labels=['HasWorkaround', 'Security'])
+    traces = {
+        (tracker_pb2.FieldID.LABELS, 'Priority-Low'):
+            'Added by rule: IF label:HasWorkaround THEN ADD LABEL',
+        (tracker_pb2.FieldID.LABELS, 'Priority-High'):
+            'Added by rule: IF label:Security THEN ADD LABEL',
+        (tracker_pb2.FieldID.LABELS, 'Private'):
+            'Added by rule: IF label:Security THEN ADD LABEL',
+        }
+    self.assertEqual(
+        (
+            0, '', [], ['Private', 'Priority-High'], ['jrobbins@chromium.org'],
+            traces, [], []),
+        filterrules_helpers._ComputeDerivedFields(
+            cnxn, self.services, issue, config, rules, predicate_asts))
+
+    # Two rules fire, second triggered by the first.
+    issue = fake.MakeTestIssue(
+        789, 1, ORIG_SUMMARY, 'New', 0, labels=['Security', 'Regression'])
+    traces = {
+        (tracker_pb2.FieldID.LABELS, 'Priority-High'):
+            'Added by rule: IF label:Security THEN ADD LABEL',
+        (tracker_pb2.FieldID.LABELS, 'Urgent'):
+            'Added by rule: IF Priority=High label:Regression THEN ADD LABEL',
+        (tracker_pb2.FieldID.LABELS, 'Private'):
+            'Added by rule: IF label:Security THEN ADD LABEL',
+        }
+    self.assertEqual(
+        (
+            0, '', [], ['Private', 'Priority-High', 'Urgent'],
+            ['jrobbins@chromium.org'], traces, [], []),
+        filterrules_helpers._ComputeDerivedFields(
+            cnxn, self.services, issue, config, rules, predicate_asts))
+
+    # Two rules fire, each one wants to add the same CC: only add once.
+    rules.append(filterrules_helpers.MakeRule('Watch', add_cc_ids=[111]))
+    rules.append(filterrules_helpers.MakeRule('Monitor', add_cc_ids=[111]))
+    config = tracker_pb2.ProjectIssueConfig(
+        exclusive_label_prefixes=excl_prefixes,
+        project_id=self.project.project_id)
+    predicate_asts = filterrules_helpers.ParsePredicateASTs(rules, config, [])
+    traces = {
+        (tracker_pb2.FieldID.CC, 111):
+            'Added by rule: IF Watch THEN ADD CC',
+        }
+    issue = fake.MakeTestIssue(
+        789, 1, ORIG_SUMMARY, 'New', 111, labels=['Watch', 'Monitor'])
+    self.assertEqual(
+        (0, '', [111], [], [], traces, [], []),
+        filterrules_helpers._ComputeDerivedFields(
+            cnxn, self.services, issue, config, rules, predicate_asts))
+
+  def testCompareComponents_Trivial(self):
+    config = tracker_pb2.ProjectIssueConfig()
+    self.assertTrue(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.IS_DEFINED, [], [123]))
+    self.assertFalse(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.IS_NOT_DEFINED, [], [123]))
+    self.assertFalse(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.IS_DEFINED, [], []))
+    self.assertTrue(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.IS_NOT_DEFINED, [], []))
+    self.assertFalse(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.EQ, [123], []))
+
+  def testCompareComponents_Normal(self):
+    config = tracker_pb2.ProjectIssueConfig()
+    config.component_defs.append(tracker_bizobj.MakeComponentDef(
+        100, 789, 'UI', 'doc', False, [], [], 0, 0))
+    config.component_defs.append(tracker_bizobj.MakeComponentDef(
+        110, 789, 'UI>Help', 'doc', False, [], [], 0, 0))
+    config.component_defs.append(tracker_bizobj.MakeComponentDef(
+        200, 789, 'Networking', 'doc', False, [], [], 0, 0))
+
+    # Check if the issue is in a specified component or subcomponent.
+    self.assertTrue(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.EQ, ['UI'], [100]))
+    self.assertTrue(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.EQ, ['UI>Help'], [110]))
+    self.assertTrue(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.EQ, ['UI'], [100, 110]))
+    self.assertFalse(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.EQ, ['UI'], []))
+    self.assertFalse(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.EQ, ['UI'], [110]))
+    self.assertFalse(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.EQ, ['UI'], [200]))
+    self.assertFalse(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.EQ, ['UI>Help'], [100]))
+    self.assertFalse(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.EQ, ['Networking'], [100]))
+
+    self.assertTrue(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.NE, ['UI'], []))
+    self.assertFalse(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.NE, ['UI'], [100]))
+    self.assertTrue(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.NE, ['Networking'], [100]))
+
+    # Exact vs non-exact.
+    self.assertFalse(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.EQ, ['Help'], [110]))
+    self.assertTrue(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.TEXT_HAS, ['UI'], [110]))
+    self.assertFalse(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.TEXT_HAS, ['Help'], [110]))
+    self.assertFalse(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.NOT_TEXT_HAS, ['UI'], [110]))
+    self.assertTrue(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.NOT_TEXT_HAS, ['Help'], [110]))
+
+    # Multivalued issues and Quick-OR notation
+    self.assertTrue(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.EQ, ['Networking'], [200]))
+    self.assertFalse(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.EQ, ['Networking'], [100, 110]))
+    self.assertTrue(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.EQ, ['UI', 'Networking'], [100]))
+    self.assertFalse(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.EQ, ['UI', 'Networking'], [110]))
+    self.assertTrue(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.EQ, ['UI', 'Networking'], [200]))
+    self.assertTrue(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.EQ, ['UI', 'Networking'], [110, 200]))
+    self.assertTrue(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.TEXT_HAS, ['UI', 'Networking'], [110, 200]))
+    self.assertTrue(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.EQ, ['UI>Help', 'Networking'], [110, 200]))
+
+  def testCompareIssueRefs_Trivial(self):
+    self.assertTrue(filterrules_helpers._CompareIssueRefs(
+        self.cnxn, self.services, self.project,
+        ast_pb2.QueryOp.IS_DEFINED, [], [123]))
+    self.assertFalse(filterrules_helpers._CompareIssueRefs(
+        self.cnxn, self.services, self.project,
+        ast_pb2.QueryOp.IS_NOT_DEFINED, [], [123]))
+    self.assertFalse(filterrules_helpers._CompareIssueRefs(
+        self.cnxn, self.services, self.project,
+        ast_pb2.QueryOp.IS_DEFINED, [], []))
+    self.assertTrue(filterrules_helpers._CompareIssueRefs(
+        self.cnxn, self.services, self.project,
+        ast_pb2.QueryOp.IS_NOT_DEFINED, [], []))
+    self.assertFalse(filterrules_helpers._CompareIssueRefs(
+        self.cnxn, self.services, self.project,
+        ast_pb2.QueryOp.EQ, ['1'], []))
+
+  def testCompareIssueRefs_Normal(self):
+    self.services.issue.TestAddIssue(fake.MakeTestIssue(
+        789, 1, 'summary', 'New', 0, issue_id=123))
+    self.services.issue.TestAddIssue(fake.MakeTestIssue(
+        789, 2, 'summary', 'New', 0, issue_id=124))
+    self.services.issue.TestAddIssue(fake.MakeTestIssue(
+        890, 1, 'other summary', 'New', 0, issue_id=125))
+
+    # EQ and NE, implict references to the current project.
+    self.assertTrue(filterrules_helpers._CompareIssueRefs(
+        self.cnxn, self.services, self.project,
+        ast_pb2.QueryOp.EQ, ['1'], [123]))
+    self.assertFalse(filterrules_helpers._CompareIssueRefs(
+        self.cnxn, self.services, self.project,
+        ast_pb2.QueryOp.NE, ['1'], [123]))
+
+    # EQ and NE, explicit project references.
+    self.assertTrue(filterrules_helpers._CompareIssueRefs(
+        self.cnxn, self.services, self.project,
+        ast_pb2.QueryOp.EQ, ['proj:1'], [123]))
+    self.assertTrue(filterrules_helpers._CompareIssueRefs(
+        self.cnxn, self.services, self.project,
+        ast_pb2.QueryOp.EQ, ['otherproj:1'], [125]))
+
+    # Inequalities
+    self.assertTrue(filterrules_helpers._CompareIssueRefs(
+        self.cnxn, self.services, self.project,
+        ast_pb2.QueryOp.GE, ['1'], [123]))
+    self.assertTrue(filterrules_helpers._CompareIssueRefs(
+        self.cnxn, self.services, self.project,
+        ast_pb2.QueryOp.GE, ['1'], [124]))
+    self.assertTrue(filterrules_helpers._CompareIssueRefs(
+        self.cnxn, self.services, self.project,
+        ast_pb2.QueryOp.GE, ['2'], [124]))
+    self.assertFalse(filterrules_helpers._CompareIssueRefs(
+        self.cnxn, self.services, self.project,
+        ast_pb2.QueryOp.GT, ['2'], [124]))
+
+  def testCompareUsers(self):
+    pass  # TODO(jrobbins): Add this test.
+
+  def testCompareUserIDs(self):
+    pass  # TODO(jrobbins): Add this test.
+
+  def testCompareEmails(self):
+    pass  # TODO(jrobbins): Add this test.
+
+  def testCompare(self):
+    pass  # TODO(jrobbins): Add this test.
+
+  def testParseOneRuleAddLabels(self):
+    cnxn = 'fake SQL connection'
+    error_list = []
+    rule_pb = filterrules_helpers._ParseOneRule(
+        cnxn, 'label:lab1 label:lab2', 'add_labels', 'hot cOld, ', None, 1,
+        error_list)
+    self.assertEqual('label:lab1 label:lab2', rule_pb.predicate)
+    self.assertEqual(error_list, [])
+    self.assertEqual(len(rule_pb.add_labels), 2)
+    self.assertEqual(rule_pb.add_labels[0], 'hot')
+    self.assertEqual(rule_pb.add_labels[1], 'cOld')
+
+    rule_pb = filterrules_helpers._ParseOneRule(
+        cnxn, '', 'default_status', 'hot cold', None, 1, error_list)
+    self.assertEqual(len(rule_pb.predicate), 0)
+    self.assertEqual(error_list, [])
+
+  def testParseOneRuleDefaultOwner(self):
+    cnxn = 'fake SQL connection'
+    error_list = []
+    rule_pb = filterrules_helpers._ParseOneRule(
+        cnxn, 'label:lab1, label:lab2 ', 'default_owner', 'jrobbins',
+        self.services.user, 1, error_list)
+    self.assertEqual(error_list, [])
+    self.assertEqual(rule_pb.default_owner_id, TEST_ID_MAP['jrobbins'])
+
+  def testParseOneRuleDefaultStatus(self):
+    cnxn = 'fake SQL connection'
+    error_list = []
+    rule_pb = filterrules_helpers._ParseOneRule(
+        cnxn, 'label:lab1', 'default_status', 'InReview',
+        None, 1, error_list)
+    self.assertEqual(error_list, [])
+    self.assertEqual(rule_pb.default_status, 'InReview')
+
+  def testParseOneRuleAddCcs(self):
+    cnxn = 'fake SQL connection'
+    error_list = []
+    rule_pb = filterrules_helpers._ParseOneRule(
+        cnxn, 'label:lab1', 'add_ccs', 'jrobbins, mike.j.parent',
+        self.services.user, 1, error_list)
+    self.assertEqual(error_list, [])
+    self.assertEqual(rule_pb.add_cc_ids[0], TEST_ID_MAP['jrobbins'])
+    self.assertEqual(rule_pb.add_cc_ids[1], TEST_ID_MAP['mike.j.parent'])
+    self.assertEqual(len(rule_pb.add_cc_ids), 2)
+
+  def testParseRulesNone(self):
+    cnxn = 'fake SQL connection'
+    post_data = {}
+    rules = filterrules_helpers.ParseRules(
+        cnxn, post_data, None, template_helpers.EZTError())
+    self.assertEqual(rules, [])
+
+  def testParseRules(self):
+    cnxn = 'fake SQL connection'
+    post_data = {
+        'predicate1': 'a, b c',
+        'action_type1': 'default_status',
+        'action_value1': 'Reviewed',
+        'predicate2': 'a, b c',
+        'action_type2': 'default_owner',
+        'action_value2': 'jrobbins',
+        'predicate3': 'a, b c',
+        'action_type3': 'add_ccs',
+        'action_value3': 'jrobbins, mike.j.parent',
+        'predicate4': 'a, b c',
+        'action_type4': 'add_labels',
+        'action_value4': 'hot, cold',
+        }
+    errors = template_helpers.EZTError()
+    rules = filterrules_helpers.ParseRules(
+        cnxn, post_data, self.services.user, errors)
+    self.assertEqual(rules[0].predicate, 'a, b c')
+    self.assertEqual(rules[0].default_status, 'Reviewed')
+    self.assertEqual(rules[1].default_owner_id, TEST_ID_MAP['jrobbins'])
+    self.assertEqual(rules[2].add_cc_ids[0], TEST_ID_MAP['jrobbins'])
+    self.assertEqual(rules[2].add_cc_ids[1], TEST_ID_MAP['mike.j.parent'])
+    self.assertEqual(rules[3].add_labels[0], 'hot')
+    self.assertEqual(rules[3].add_labels[1], 'cold')
+    self.assertEqual(len(rules), 4)
+    self.assertFalse(errors.AnyErrors())
+
+  def testOwnerCcsInvolvedInFilterRules(self):
+    rules = [
+        tracker_pb2.FilterRule(add_cc_ids=[111, 333], default_owner_id=999),
+        tracker_pb2.FilterRule(default_owner_id=888),
+        tracker_pb2.FilterRule(add_cc_ids=[999, 777]),
+        tracker_pb2.FilterRule(),
+        ]
+    actual_user_ids = filterrules_helpers.OwnerCcsInvolvedInFilterRules(rules)
+    self.assertItemsEqual([111, 333, 777, 888, 999], actual_user_ids)
+
+  def testBuildFilterRuleStrings(self):
+    rules = [
+        tracker_pb2.FilterRule(
+            predicate='label:machu', add_cc_ids=[111, 333, 999]),
+        tracker_pb2.FilterRule(predicate='label:pichu', default_owner_id=222),
+        tracker_pb2.FilterRule(
+            predicate='owner:farmer@test.com',
+            add_labels=['cows-farting', 'chicken', 'machu-pichu']),
+        tracker_pb2.FilterRule(predicate='label:beach', default_status='New'),
+        tracker_pb2.FilterRule(
+            predicate='label:rainforest',
+            add_notify_addrs=['cake@test.com', 'pie@test.com']),
+    ]
+    emails_by_id = {
+        111: 'cow@test.com', 222: 'fox@test.com', 333: 'llama@test.com'}
+    rule_strs = filterrules_helpers.BuildFilterRuleStrings(rules, emails_by_id)
+
+    self.assertItemsEqual(
+        rule_strs, [
+            'if label:machu '
+            'then add cc(s): cow@test.com, llama@test.com, user not found',
+            'if label:pichu then set default owner: fox@test.com',
+            'if owner:farmer@test.com '
+            'then add label(s): cows-farting, chicken, machu-pichu',
+            'if label:beach then set default status: New',
+            'if label:rainforest then notify: cake@test.com, pie@test.com',
+        ])
+
+  def testBuildRedactedFilterRuleStrings(self):
+    rules_by_project = {
+        16: [
+            tracker_pb2.FilterRule(
+                predicate='label:machu', add_cc_ids=[111, 333, 999]),
+            tracker_pb2.FilterRule(
+                predicate='label:pichu', default_owner_id=222)],
+        19: [
+            tracker_pb2.FilterRule(
+                predicate='owner:farmer@test.com',
+                add_labels=['cows-farting', 'chicken', 'machu-pichu']),
+            tracker_pb2.FilterRule(
+                predicate='label:rainforest',
+                add_notify_addrs=['cake@test.com', 'pie@test.com'])],
+        }
+    deleted_emails = ['farmer@test.com', 'pie@test.com', 'fox@test.com']
+    self.services.user.TestAddUser('cow@test.com', 111)
+    self.services.user.TestAddUser('fox@test.com', 222)
+    self.services.user.TestAddUser('llama@test.com', 333)
+    actual = filterrules_helpers.BuildRedactedFilterRuleStrings(
+        self.cnxn, rules_by_project, self.services.user, deleted_emails)
+
+    self.assertItemsEqual(
+        actual,
+        {16: [
+            'if label:machu '
+            'then add cc(s): cow@test.com, llama@test.com, user not found',
+            'if label:pichu '
+            'then set default owner: %s' %
+            framework_constants.DELETED_USER_NAME],
+         19: [
+             'if owner:%s '
+             'then add label(s): cows-farting, chicken, machu-pichu' %
+             framework_constants.DELETED_USER_NAME,
+             'if label:rainforest '
+             'then notify: cake@test.com, %s' %
+             framework_constants.DELETED_USER_NAME],
+        })
diff --git a/features/test/filterrules_views_test.py b/features/test/filterrules_views_test.py
new file mode 100644
index 0000000..323b6c2
--- /dev/null
+++ b/features/test/filterrules_views_test.py
@@ -0,0 +1,75 @@
+# 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 unittest
+
+from features import filterrules_views
+from proto import tracker_pb2
+from testing import testing_helpers
+
+
+class RuleViewTest(unittest.TestCase):
+
+  def setUp(self):
+    self.rule = tracker_pb2.FilterRule()
+    self.rule.predicate = 'label:a label:b'
+
+  def testNone(self):
+    view = filterrules_views.RuleView(None, {})
+    self.assertEqual('', view.action_type)
+    self.assertEqual('', view.action_value)
+
+  def testEmpty(self):
+    view = filterrules_views.RuleView(self.rule, {})
+    self.rule.predicate = ''
+    self.assertEqual('', view.predicate)
+    self.assertEqual('', view.action_type)
+    self.assertEqual('', view.action_value)
+
+  def testDefaultStatus(self):
+    self.rule.default_status = 'Unknown'
+    view = filterrules_views.RuleView(self.rule, {})
+    self.assertEqual('label:a label:b', view.predicate)
+    self.assertEqual('default_status', view.action_type)
+    self.assertEqual('Unknown', view.action_value)
+
+  def testDefaultOwner(self):
+    self.rule.default_owner_id = 111
+    view = filterrules_views.RuleView(
+        self.rule, {
+            111: testing_helpers.Blank(email='jrobbins@chromium.org')})
+    self.assertEqual('label:a label:b', view.predicate)
+    self.assertEqual('default_owner', view.action_type)
+    self.assertEqual('jrobbins@chromium.org', view.action_value)
+
+  def testAddCCs(self):
+    self.rule.add_cc_ids.extend([111, 222])
+    view = filterrules_views.RuleView(
+        self.rule, {
+            111: testing_helpers.Blank(email='jrobbins@chromium.org'),
+            222: testing_helpers.Blank(email='jrobbins@gmail.com')})
+    self.assertEqual('label:a label:b', view.predicate)
+    self.assertEqual('add_ccs', view.action_type)
+    self.assertEqual(
+        'jrobbins@chromium.org, jrobbins@gmail.com', view.action_value)
+
+  def testAddLabels(self):
+    self.rule.add_labels.extend(['Hot', 'Cool'])
+    view = filterrules_views.RuleView(self.rule, {})
+    self.assertEqual('label:a label:b', view.predicate)
+    self.assertEqual('add_labels', view.action_type)
+    self.assertEqual('Hot, Cool', view.action_value)
+
+  def testAlsoNotify(self):
+    self.rule.add_notify_addrs.extend(['a@dom.com', 'b@dom.com'])
+    view = filterrules_views.RuleView(self.rule, {})
+    self.assertEqual('label:a label:b', view.predicate)
+    self.assertEqual('also_notify', view.action_type)
+    self.assertEqual('a@dom.com, b@dom.com', view.action_value)
diff --git a/features/test/generate_features_test.py b/features/test/generate_features_test.py
new file mode 100644
index 0000000..8b1664e
--- /dev/null
+++ b/features/test/generate_features_test.py
@@ -0,0 +1,25 @@
+# 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 generate_features."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from features import generate_dataset
+
+
+class GenerateFeaturesTest(unittest.TestCase):
+
+  def testCleanText(self):
+    sampleText = """Here's some sample text...$*IT should l00k much\n\n\t,
+                    _much_MUCH better \"cleaned\"!"""
+    self.assertEqual(generate_dataset.CleanText(sampleText),
+                     ("heres some sample text it should l00k much much much "
+                      "better cleaned"))
+    emptyText = ""
+    self.assertEqual(generate_dataset.CleanText(emptyText), "")
diff --git a/features/test/hotlist_helpers_test.py b/features/test/hotlist_helpers_test.py
new file mode 100644
index 0000000..800a913
--- /dev/null
+++ b/features/test/hotlist_helpers_test.py
@@ -0,0 +1,285 @@
+# 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 helpers module."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from features import hotlist_helpers
+from features import features_constants
+from framework import profiler
+from framework import table_view_helpers
+from framework import sorting
+from services import service_manager
+from testing import testing_helpers
+from testing import fake
+from tracker import tablecell
+from tracker import tracker_bizobj
+from proto import features_pb2
+from proto import tracker_pb2
+
+
+class HotlistTableDataTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        issue=fake.IssueService(),
+        features=fake.FeaturesService(),
+        issue_star=fake.AbstractStarService(),
+        config=fake.ConfigService(),
+        project=fake.ProjectService(),
+        user=fake.UserService(),
+        cache_manager=fake.CacheManager())
+    self.services.project.TestAddProject('ProjectName', project_id=1)
+
+    self.services.user.TestAddUser('annajowang@email.com', 111)
+    self.services.user.TestAddUser('claremont@email.com', 222)
+    issue1 = fake.MakeTestIssue(
+        1, 1, 'issue_summary', 'New', 111, project_name='ProjectName')
+    self.services.issue.TestAddIssue(issue1)
+    issue2 = fake.MakeTestIssue(
+        1, 2, 'issue_summary2', 'New', 111, project_name='ProjectName')
+    self.services.issue.TestAddIssue(issue2)
+    issue3 = fake.MakeTestIssue(
+        1, 3, 'issue_summary3', 'New', 222, project_name='ProjectName')
+    self.services.issue.TestAddIssue(issue3)
+    issues = [issue1, issue2, issue3]
+    hotlist_items = [
+        (issue.issue_id, rank, 222, None, '') for
+        rank, issue in enumerate(issues)]
+
+    self.hotlist_items_list = [
+        features_pb2.MakeHotlistItem(
+            issue_id, rank=rank, adder_id=adder_id,
+            date_added=date, note=note) for (
+                issue_id, rank, adder_id, date, note) in hotlist_items]
+    self.test_hotlist = self.services.features.TestAddHotlist(
+        'hotlist', hotlist_id=123, owner_ids=[111],
+        hotlist_item_fields=hotlist_items)
+    sorting.InitializeArtValues(self.services)
+    self.mr = None
+
+  def setUpCreateHotlistTableDataTestMR(self, **kwargs):
+    self.mr = testing_helpers.MakeMonorailRequest(**kwargs)
+    self.services.user.TestAddUser('annajo@email.com', 148)
+    self.mr.auth.effective_ids = {148}
+    self.mr.col_spec = 'ID Summary Modified'
+
+  def testCreateHotlistTableData(self):
+    self.setUpCreateHotlistTableDataTestMR(hotlist=self.test_hotlist)
+    table_data, table_related_dict = hotlist_helpers.CreateHotlistTableData(
+        self.mr, self.hotlist_items_list, self.services)
+    self.assertEqual(len(table_data), 3)
+    start_index = 100001
+    for row in table_data:
+      self.assertEqual(row.project_name, 'ProjectName')
+      self.assertEqual(row.issue_id, start_index)
+      start_index += 1
+    self.assertEqual(len(table_related_dict['column_values']), 3)
+
+    # test none of the shown columns show up in unshown_columns
+    self.assertTrue(
+        set(self.mr.col_spec.split()).isdisjoint(
+            table_related_dict['unshown_columns']))
+    self.assertEqual(table_related_dict['is_cross_project'], False)
+    self.assertEqual(len(table_related_dict['pagination'].visible_results), 3)
+
+  def testCreateHotlistTableData_Pagination(self):
+    self.setUpCreateHotlistTableDataTestMR(
+        hotlist=self.test_hotlist, path='/123?num=2')
+    table_data, _ = hotlist_helpers.CreateHotlistTableData(
+        self.mr, self.hotlist_items_list, self.services)
+    self.assertEqual(len(table_data), 2)
+
+  def testCreateHotlistTableData_EndPagination(self):
+    self.setUpCreateHotlistTableDataTestMR(
+        hotlist=self.test_hotlist, path='/123?num=2&start=2')
+    table_data, _ = hotlist_helpers.CreateHotlistTableData(
+        self.mr, self.hotlist_items_list, self.services)
+    self.assertEqual(len(table_data), 1)
+
+
+class MakeTableDataTest(unittest.TestCase):
+
+  def test_MakeTableData(self):
+    issues = [fake.MakeTestIssue(
+        789, 1, 'issue_summary', 'New', 111, project_name='ProjectName',
+        issue_id=1001)]
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    cell_factories = {
+        'summary': table_view_helpers.TableCellSummary}
+    table_data = hotlist_helpers._MakeTableData(
+        issues, [], ['summary'], [], {} , cell_factories,
+        {}, set(), config, None, 29, 'stars')
+    self.assertEqual(len(table_data), 1)
+    row = table_data[0]
+    self.assertEqual(row.issue_id, 1001)
+    self.assertEqual(row.local_id, 1)
+    self.assertEqual(row.project_name, 'ProjectName')
+    self.assertEqual(row.issue_ref, 'ProjectName:1')
+    self.assertTrue('hotlist_id=29' in row.issue_ctx_url)
+    self.assertTrue('sort=stars' in row.issue_ctx_url)
+
+
+class GetAllProjectsOfIssuesTest(unittest.TestCase):
+
+  issue_x_1 = tracker_pb2.Issue()
+  issue_x_1.project_id = 789
+
+  issue_x_2 = tracker_pb2.Issue()
+  issue_x_2.project_id = 789
+
+  issue_y_1 = tracker_pb2.Issue()
+  issue_y_1.project_id = 678
+
+  def testGetAllProjectsOfIssues_Normal(self):
+    issues = [self.issue_x_1, self.issue_x_2]
+    self.assertEqual(
+        hotlist_helpers.GetAllProjectsOfIssues(issues),
+        set([789]))
+    issues = [self.issue_x_1, self.issue_x_2, self.issue_y_1]
+    self.assertEqual(
+        hotlist_helpers.GetAllProjectsOfIssues(issues),
+        set([678, 789]))
+
+  def testGetAllProjectsOfIssues_Empty(self):
+    self.assertEqual(
+        hotlist_helpers.GetAllProjectsOfIssues([]),
+        set())
+
+
+class HelpersUnitTest(unittest.TestCase):
+
+  # TODO(jojwang): Write Tests for GetAllConfigsOfProjects
+  def setUp(self):
+    self.services = service_manager.Services(issue=fake.IssueService(),
+                                        config=fake.ConfigService(),
+                                        project=fake.ProjectService(),
+                                        features=fake.FeaturesService(),
+                                        user=fake.UserService())
+    self.project = self.services.project.TestAddProject(
+        'ProjectName', project_id=1, owner_ids=[111])
+
+    self.services.user.TestAddUser('annajowang@email.com', 111)
+    self.services.user.TestAddUser('claremont@email.com', 222)
+    self.issue1 = fake.MakeTestIssue(
+        1, 1, 'issue_summary', 'New', 111,
+        project_name='ProjectName', labels='restrict-view-Googler')
+    self.services.issue.TestAddIssue(self.issue1)
+    self.issue3 = fake.MakeTestIssue(
+        1, 3, 'issue_summary3', 'New', 222, project_name='ProjectName')
+    self.services.issue.TestAddIssue(self.issue3)
+    self.issue4 = fake.MakeTestIssue(
+        1, 4, 'issue_summary4', 'Fixed', 222, closed_timestamp=232423,
+        project_name='ProjectName')
+    self.services.issue.TestAddIssue(self.issue4)
+    self.issues = [self.issue1, self.issue3, self.issue4]
+    self.mr = testing_helpers.MakeMonorailRequest()
+
+  def testFilterIssues(self):
+    test_allowed_issues = hotlist_helpers.FilterIssues(
+        self.mr.cnxn, self.mr.auth, 2, self.issues, self.services)
+    self.assertEqual(len(test_allowed_issues), 1)
+    self.assertEqual(test_allowed_issues[0].local_id, 3)
+
+  def testFilterIssues_ShowClosed(self):
+    test_allowed_issues = hotlist_helpers.FilterIssues(
+        self.mr.cnxn, self.mr.auth, 1, self.issues, self.services)
+    self.assertEqual(len(test_allowed_issues), 2)
+    self.assertEqual(test_allowed_issues[0].local_id, 3)
+    self.assertEqual(test_allowed_issues[1].local_id, 4)
+
+  def testMembersWithoutGivenIDs(self):
+    h = features_pb2.Hotlist()
+    owners, editors, followers = hotlist_helpers.MembersWithoutGivenIDs(
+        h, set())
+    # Check lists are empty
+    self.assertFalse(owners)
+    self.assertFalse(editors)
+    self.assertFalse(followers)
+
+    h.owner_ids.extend([1, 2, 3])
+    h.editor_ids.extend([4, 5, 6])
+    h.follower_ids.extend([7, 8, 9])
+    owners, editors, followers = hotlist_helpers.MembersWithoutGivenIDs(
+        h, {10, 11, 12})
+    self.assertEqual(h.owner_ids, owners)
+    self.assertEqual(h.editor_ids, editors)
+    self.assertEqual(h.follower_ids, followers)
+
+    owners, editors, followers = hotlist_helpers.MembersWithoutGivenIDs(
+        h, set())
+    self.assertEqual(h.owner_ids, owners)
+    self.assertEqual(h.editor_ids, editors)
+    self.assertEqual(h.follower_ids, followers)
+
+    owners, editors, followers = hotlist_helpers.MembersWithoutGivenIDs(
+        h, {1, 4, 7})
+    self.assertEqual([2, 3], owners)
+    self.assertEqual([5, 6], editors)
+    self.assertEqual([8, 9], followers)
+
+  def testMembersWithGivenIDs(self):
+    h = features_pb2.Hotlist()
+
+    # empty GivenIDs give empty member lists from originally empty member lists
+    owners, editors, followers = hotlist_helpers.MembersWithGivenIDs(
+        h, set(), 'follower')
+    self.assertFalse(owners)
+    self.assertFalse(editors)
+    self.assertFalse(followers)
+
+    # empty GivenIDs return original non-empty member lists
+    h.owner_ids.extend([1, 2, 3])
+    h.editor_ids.extend([4, 5, 6])
+    h.follower_ids.extend([7, 8, 9])
+    owners, editors, followers = hotlist_helpers.MembersWithGivenIDs(
+        h, set(), 'editor')
+    self.assertEqual(owners, h.owner_ids)
+    self.assertEqual(editors, h.editor_ids)
+    self.assertEqual(followers, h.follower_ids)
+
+    # non-member GivenIDs return updated member lists
+    owners, editors, followers = hotlist_helpers.MembersWithGivenIDs(
+        h, {10, 11, 12}, 'owner')
+    self.assertEqual(owners, [1, 2, 3, 10, 11, 12])
+    self.assertEqual(editors, [4, 5, 6])
+    self.assertEqual(followers, [7, 8, 9])
+
+    # member GivenIDs return updated member lists
+    owners, editors, followers = hotlist_helpers.MembersWithGivenIDs(
+        h, {1, 4, 7}, 'editor')
+    self.assertEqual(owners, [2, 3])
+    self.assertEqual(editors, [5, 6, 1, 4, 7])
+    self.assertEqual(followers, [8, 9])
+
+  def testGetURLOfHotlist(self):
+    cnxn = 'fake cnxn'
+    user = self.services.user.TestAddUser('claremont@email.com', 432)
+    user.obscure_email = False
+    hotlist1 = self.services.features.TestAddHotlist(
+        'hotlist1', hotlist_id=123, owner_ids=[432])
+    url = hotlist_helpers.GetURLOfHotlist(
+        cnxn, hotlist1, self.services.user)
+    self.assertEqual('/u/claremont@email.com/hotlists/hotlist1', url)
+
+    url = hotlist_helpers.GetURLOfHotlist(
+        cnxn, hotlist1, self.services.user, url_for_token=True)
+    self.assertEqual('/u/432/hotlists/hotlist1', url)
+
+    user.obscure_email = True
+    url = hotlist_helpers.GetURLOfHotlist(
+        cnxn, hotlist1, self.services.user)
+    self.assertEqual('/u/432/hotlists/hotlist1', url)
+
+    # Test that a Hotlist without an owner has an empty URL.
+    hotlist_unowned = self.services.features.TestAddHotlist('hotlist2',
+        hotlist_id=234, owner_ids=[])
+    url = hotlist_helpers.GetURLOfHotlist(cnxn, hotlist_unowned,
+        self.services.user)
+    self.assertFalse(url)
diff --git a/features/test/hotlist_views_test.py b/features/test/hotlist_views_test.py
new file mode 100644
index 0000000..92369ba
--- /dev/null
+++ b/features/test/hotlist_views_test.py
@@ -0,0 +1,125 @@
+# 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 hotlist_views classes."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from features import hotlist_views
+from framework import authdata
+from framework import framework_views
+from framework import permissions
+from services import service_manager
+from testing import fake
+from proto import user_pb2
+
+
+class MemberViewTest(unittest.TestCase):
+
+  def setUp(self):
+    self.hotlist = fake.Hotlist('hotlistName', 123,
+                                hotlist_item_fields=[
+                                    (2, 0, None, None, ''),
+                                    (1, 0, None, None, ''),
+                                    (5, 0, None, None, '')],
+                                is_private=False, owner_ids=[111])
+    self.user1 = user_pb2.User(user_id=111)
+    self.user1_view = framework_views.UserView(self.user1)
+
+  def testMemberViewCorrect(self):
+    member_view = hotlist_views.MemberView(111, 111, self.user1_view,
+                                           self.hotlist)
+    self.assertEqual(member_view.user, self.user1_view)
+    self.assertEqual(member_view.detail_url, '/u/111/')
+    self.assertEqual(member_view.role, 'Owner')
+    self.assertTrue(member_view.viewing_self)
+
+
+class HotlistViewTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(user=fake.UserService(),
+    usergroup=fake.UserGroupService())
+    self.user1 = self.services.user.TestAddUser('user1', 111)
+    self.user1.obscure_email = True
+    self.user1_view = framework_views.UserView(self.user1)
+    self.user2 = self.services.user.TestAddUser('user2', 222)
+    self.user2.obscure_email = False
+    self.user2_view = framework_views.UserView(self.user2)
+    self.user3 = self.services.user.TestAddUser('user3', 333)
+    self.user3_view = framework_views.UserView(self.user3)
+    self.user4 = self.services.user.TestAddUser('user4', 444, banned=True)
+    self.user4_view = framework_views.UserView(self.user4)
+
+    self.user_auth = authdata.AuthData.FromEmail(
+        None, 'user3', self.services)
+    self.user_auth.effective_ids = {3}
+    self.user_auth.user_id = 3
+    self.users_by_id = {1: self.user1_view, 2: self.user2_view,
+        3: self.user3_view, 4: self.user4_view}
+    self.perms = permissions.EMPTY_PERMISSIONSET
+
+  def testNoOwner(self):
+    hotlist = fake.Hotlist('unowned', 500, owner_ids=[])
+    view = hotlist_views.HotlistView(hotlist, self.perms,
+                                     self.user_auth, 1, self.users_by_id)
+    self.assertFalse(view.url)
+
+  def testBanned(self):
+    # With a banned user
+    hotlist = fake.Hotlist('userBanned', 423, owner_ids=[4])
+    hotlist_view = hotlist_views.HotlistView(
+        hotlist, self.perms, self.user_auth, 1, self.users_by_id)
+    self.assertFalse(hotlist_view.visible)
+
+    # With a user not banned
+    hotlist = fake.Hotlist('userNotBanned', 453, owner_ids=[1])
+    hotlist_view = hotlist_views.HotlistView(
+        hotlist, self.perms, self.user_auth, 1, self.users_by_id)
+    self.assertTrue(hotlist_view.visible)
+
+  def testNoPermissions(self):
+    hotlist = fake.Hotlist(
+        'private', 333, is_private=True, owner_ids=[1], editor_ids=[2])
+    hotlist_view = hotlist_views.HotlistView(
+        hotlist, self.perms, self.user_auth, 1, self.users_by_id)
+    self.assertFalse(hotlist_view.visible)
+    self.assertEqual(hotlist_view.url, '/u/1/hotlists/private')
+
+  def testFriendlyURL(self):
+    # owner with obscure_email:false
+    hotlist = fake.Hotlist(
+        'noObscureHotlist', 133, owner_ids=[2], editor_ids=[3])
+    hotlist_view = hotlist_views.HotlistView(
+        hotlist, self.perms, self.user_auth,
+        viewed_user_id=3, users_by_id=self.users_by_id)
+    self.assertEqual(hotlist_view.url, '/u/user2/hotlists/noObscureHotlist')
+
+    #owner with obscure_email:true
+    hotlist = fake.Hotlist('ObscureHotlist', 133, owner_ids=[1], editor_ids=[3])
+    hotlist_view = hotlist_views.HotlistView(
+        hotlist, self.perms, self.user_auth, viewed_user_id=1,
+        users_by_id=self.users_by_id)
+    self.assertEqual(hotlist_view.url, '/u/1/hotlists/ObscureHotlist')
+
+  def testOtherAttributes(self):
+    hotlist = fake.Hotlist(
+        'hotlistName', 123, hotlist_item_fields=[(2, 0, None, None, ''),
+                                                (1, 0, None, None, ''),
+                                                 (5, 0, None, None, '')],
+                                is_private=False, owner_ids=[1],
+                                editor_ids=[2, 3])
+    hotlist_view = hotlist_views.HotlistView(
+        hotlist, self.perms, self.user_auth, viewed_user_id=2,
+        users_by_id=self.users_by_id, is_starred=True)
+    self.assertTrue(hotlist_view.visible, True)
+    self.assertEqual(hotlist_view.role_name, 'editor')
+    self.assertEqual(hotlist_view.owners, [self.user1_view])
+    self.assertEqual(hotlist_view.editors, [self.user2_view, self.user3_view])
+    self.assertEqual(hotlist_view.num_issues, 3)
+    self.assertTrue(hotlist_view.is_starred)
diff --git a/features/test/hotlistcreate_test.py b/features/test/hotlistcreate_test.py
new file mode 100644
index 0000000..8cf0012
--- /dev/null
+++ b/features/test/hotlistcreate_test.py
@@ -0,0 +1,148 @@
+# 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 test for Hotlist creation servlet."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import mox
+import unittest
+
+import settings
+from framework import permissions
+from features import hotlistcreate
+from proto import site_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+
+
+class HotlistCreateTest(unittest.TestCase):
+  """Tests for the HotlistCreate servlet."""
+
+  def setUp(self):
+    self.cnxn = 'fake cnxn'
+    self.mr = testing_helpers.MakeMonorailRequest()
+    self.services = service_manager.Services(project=fake.ProjectService(),
+                                        user=fake.UserService(),
+                                             issue=fake.IssueService(),
+                                             features=fake.FeaturesService())
+    self.servlet = hotlistcreate.HotlistCreate('req', 'res',
+                                               services=self.services)
+    self.mox = mox.Mox()
+
+  def tearDown(self):
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def CheckAssertBasePermissions(
+      self, restriction, expect_admin_ok, expect_nonadmin_ok):
+    old_hotlist_creation_restriction = settings.hotlist_creation_restriction
+    settings.hotlist_creation_restriction = restriction
+
+    mr = testing_helpers.MakeMonorailRequest(
+        perms=permissions.GetPermissions(None, {}, None))
+    self.assertRaises(
+        permissions.PermissionException,
+        self.servlet.AssertBasePermission, mr)
+
+    mr = testing_helpers.MakeMonorailRequest()
+    if expect_admin_ok:
+      self.servlet.AssertBasePermission(mr)
+    else:
+      self.assertRaises(
+          permissions.PermissionException,
+          self.servlet.AssertBasePermission, mr)
+
+    mr = testing_helpers.MakeMonorailRequest(
+        perms=permissions.GetPermissions(mr.auth.user_pb, {111}, None))
+    if expect_nonadmin_ok:
+      self.servlet.AssertBasePermission(mr)
+    else:
+      self.assertRaises(
+          permissions.PermissionException,
+          self.servlet.AssertBasePermission, mr)
+
+    settings.hotlist_creation_restriction = old_hotlist_creation_restriction
+
+  def testAssertBasePermission(self):
+    self.CheckAssertBasePermissions(
+        site_pb2.UserTypeRestriction.ANYONE, True, True)
+    self.CheckAssertBasePermissions(
+        site_pb2.UserTypeRestriction.ADMIN_ONLY, True, False)
+    self.CheckAssertBasePermissions(
+        site_pb2.UserTypeRestriction.NO_ONE, False, False)
+
+  def testGatherPageData(self):
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.assertEqual('st6', page_data['user_tab_mode'])
+    self.assertEqual('', page_data['initial_name'])
+    self.assertEqual('', page_data['initial_summary'])
+    self.assertEqual('', page_data['initial_description'])
+    self.assertEqual('', page_data['initial_editors'])
+    self.assertEqual('no', page_data['initial_privacy'])
+
+  def testProcessFormData(self):
+    self.servlet.services.user.TestAddUser('owner', 111)
+    self.mr.auth.user_id = 111
+    post_data = fake.PostData(hotlistname=['Hotlist'], summary=['summ'],
+                              description=['hey'],
+                              editors=[''], is_private=['yes'])
+    url = self.servlet.ProcessFormData(self.mr, post_data)
+    self.assertTrue('/u/111/hotlists/Hotlist' in url)
+
+  def testProcessFormData_OwnerInEditors(self):
+    self.servlet.services.user.TestAddUser('owner_editor', 222)
+    self.mr.auth.user_id = 222
+    self.mr.cnxn = 'fake cnxn'
+    post_data = fake.PostData(hotlistname=['Hotlist-owner-editor'],
+                              summary=['summ'],
+                              description=['hi'],
+                              editors=['owner_editor'], is_private=['yes'])
+    url = self.servlet.ProcessFormData(self.mr, post_data)
+    self.assertTrue('/u/222/hotlists/Hotlist-owner-editor' in url)
+    hotlists_by_id = self.servlet.services.features.LookupHotlistIDs(
+        self.mr.cnxn, ['Hotlist-owner-editor'], [222])
+    self.assertTrue(('hotlist-owner-editor', 222) in hotlists_by_id)
+    hotlist_id = hotlists_by_id[('hotlist-owner-editor', 222)]
+    hotlist = self.servlet.services.features.GetHotlist(
+        self.mr.cnxn, hotlist_id, use_cache=False)
+    self.assertEqual(hotlist.owner_ids, [222])
+    self.assertEqual(hotlist.editor_ids, [])
+
+  def testProcessFormData_RejectTemplateInvalid(self):
+    mr = testing_helpers.MakeMonorailRequest()
+    # invalid hotlist name and nonexistent editor
+    post_data = fake.PostData(hotlistname=['123BadName'], summary=['summ'],
+                              description=['hey'],
+                              editors=['test@email.com'], is_private=['yes'])
+    self.mox.StubOutWithMock(self.servlet, 'PleaseCorrect')
+    self.servlet.PleaseCorrect(
+        mr, initial_name = '123BadName', initial_summary='summ',
+        initial_description='hey',
+        initial_editors='test@email.com', initial_privacy='yes')
+    self.mox.ReplayAll()
+    url = self.servlet.ProcessFormData(mr, post_data)
+    self.mox.VerifyAll()
+    self.assertEqual(mr.errors.hotlistname, 'Invalid hotlist name')
+    self.assertEqual(mr.errors.editors,
+                     'One or more editor emails is not valid.')
+    self.assertIsNone(url)
+
+  def testProcessFormData_RejectTemplateMissing(self):
+    mr = testing_helpers.MakeMonorailRequest()
+    # missing name and summary
+    post_data = fake.PostData()
+    self.mox.StubOutWithMock(self.servlet, 'PleaseCorrect')
+    self.servlet.PleaseCorrect(mr, initial_name = None, initial_summary=None,
+                               initial_description='',
+                               initial_editors='', initial_privacy=None)
+    self.mox.ReplayAll()
+    url = self.servlet.ProcessFormData(mr, post_data)
+    self.mox.VerifyAll()
+    self.assertEqual(mr.errors.hotlistname, 'Missing hotlist name')
+    self.assertEqual(mr.errors.summary,'Missing hotlist summary')
+    self.assertIsNone(url)
diff --git a/features/test/hotlistdetails_test.py b/features/test/hotlistdetails_test.py
new file mode 100644
index 0000000..9a9e53f
--- /dev/null
+++ b/features/test/hotlistdetails_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
+
+"""Unit tests for hotlistdetails page."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import mox
+import unittest
+import mock
+
+import ezt
+
+from framework import permissions
+from features import features_constants
+from services import service_manager
+from features import hotlistdetails
+from proto import features_pb2
+from testing import fake
+from testing import testing_helpers
+
+class HotlistDetailsTest(unittest.TestCase):
+  """Unit tests for the HotlistDetails servlet class."""
+
+  def setUp(self):
+    self.user_service = fake.UserService()
+    self.user_1 = self.user_service.TestAddUser('111@test.com', 111)
+    self.user_2 = self.user_service.TestAddUser('user2@test.com', 222)
+    services = service_manager.Services(
+        features=fake.FeaturesService(), user=self.user_service)
+    self.servlet = hotlistdetails.HotlistDetails(
+        'req', 'res', services=services)
+    self.hotlist = self.servlet.services.features.TestAddHotlist(
+        'hotlist', summary='hotlist summary', description='hotlist description',
+        owner_ids=[111], editor_ids=[222])
+    self.request, self.mr = testing_helpers.GetRequestObjects(
+        hotlist=self.hotlist)
+    self.mr.auth.user_id = 111
+    self.private_hotlist = services.features.TestAddHotlist(
+        'private_hotlist', owner_ids=[111], editor_ids=[222], is_private=True)
+    self.mox = mox.Mox()
+
+  def tearDown(self):
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def testAssertBasePermission(self):
+    # non-members cannot view private hotlists
+    mr = testing_helpers.MakeMonorailRequest(
+        hotlist=self.private_hotlist, perms=permissions.EMPTY_PERMISSIONSET)
+    mr.auth.effective_ids = {333}
+    self.assertRaises(permissions.PermissionException,
+                      self.servlet.AssertBasePermission, mr)
+
+    # members can view private hotlists
+    mr = testing_helpers.MakeMonorailRequest(
+        hotlist=self.private_hotlist)
+    mr.auth.effective_ids = {222, 444}
+    self.servlet.AssertBasePermission(mr)
+
+    # non-members can view public hotlists
+    mr = testing_helpers.MakeMonorailRequest(
+        hotlist=self.hotlist)
+    mr.auth.effective_ids = {333, 444}
+    self.servlet.AssertBasePermission(mr)
+
+    # members can view public hotlists
+    mr = testing_helpers.MakeMonorailRequest(
+        hotlist=self.hotlist)
+    mr.auth.effective_ids = {111, 333}
+    self.servlet.AssertBasePermission(mr)
+
+  def testGatherPageData(self):
+    self.mr.auth.effective_ids = [222]
+    self.mr.perms = permissions.EMPTY_PERMISSIONSET
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.assertEqual('hotlist summary', page_data['initial_summary'])
+    self.assertEqual('hotlist description', page_data['initial_description'])
+    self.assertEqual('hotlist', page_data['initial_name'])
+    self.assertEqual(features_constants.DEFAULT_COL_SPEC,
+                     page_data['initial_default_col_spec'])
+    self.assertEqual(ezt.boolean(False), page_data['initial_is_private'])
+
+    # editor is viewing, so cant_administer_hotlist is True
+    self.assertEqual(ezt.boolean(True), page_data['cant_administer_hotlist'])
+
+    # owner is veiwing, so cant_administer_hotlist is False
+    self.mr.auth.effective_ids = [111]
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.assertEqual(ezt.boolean(False), page_data['cant_administer_hotlist'])
+
+  def testProcessFormData(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        hotlist=self.hotlist,
+        path='/u/111/hotlists/%s/details' % self.hotlist.hotlist_id,
+        services=service_manager.Services(user=self.user_service),
+        perms=permissions.EMPTY_PERMISSIONSET)
+    mr.auth.effective_ids = {111}
+    mr.auth.user_id = 111
+    post_data = fake.PostData(
+        name=['hotlist'],
+        summary = ['hotlist summary'],
+        description = ['hotlist description'],
+        default_col_spec = ['test default col spec'])
+    url = self.servlet.ProcessFormData(mr, post_data)
+    self.assertTrue((
+        '/u/111/hotlists/%d/details?saved=' % self.hotlist.hotlist_id) in url)
+
+  @mock.patch('features.hotlist_helpers.RemoveHotlist')
+  def testProcessFormData_DeleteHotlist(self, fake_rh):
+    mr = testing_helpers.MakeMonorailRequest(
+        hotlist=self.hotlist,
+        path='/u/111/hotlists/%s/details' % self.hotlist.hotlist_id,
+        services=service_manager.Services(user=self.user_service),
+        perms=permissions.EMPTY_PERMISSIONSET)
+    mr.auth.effective_ids = {self.user_1.user_id}
+    mr.auth.user_id = self.user_1.user_id
+    mr.auth.email = self.user_1.email
+
+    post_data = fake.PostData(deletestate=['true'])
+    url = self.servlet.ProcessFormData(mr, post_data)
+    fake_rh.assert_called_once_with(
+        mr.cnxn, mr.hotlist_id, self.servlet.services)
+    self.assertTrue(('/u/%s/hotlists?saved=' % self.user_1.email) in url)
+
+  def testProcessFormData_RejectTemplate(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        hotlist=self.hotlist,
+        path='/u/111/hotlists/%s/details' % self.hotlist.hotlist_id,
+        services=service_manager.Services(user=self.user_service),
+        perms=permissions.EMPTY_PERMISSIONSET)
+    mr.auth.user_id = 111
+    mr.auth.effective_ids = {111}
+    post_data = fake.PostData(
+        summary = [''],
+        name = [''],
+        description = ['fake description'],
+        default_col_spec = ['test default col spec'])
+    self.mox.StubOutWithMock(self.servlet, 'PleaseCorrect')
+    self.servlet.PleaseCorrect(
+        mr, initial_summary='',
+        initial_description='fake description', initial_name = '',
+        initial_default_col_spec = 'test default col spec')
+    self.mox.ReplayAll()
+
+    url = self.servlet.ProcessFormData(mr, post_data)
+    self.mox.VerifyAll()
+    self.assertEqual(hotlistdetails._MSG_NAME_MISSING, mr.errors.name)
+    self.assertEqual(hotlistdetails._MSG_SUMMARY_MISSING,
+                     mr.errors.summary)
+    self.assertIsNone(url)
+
+  def testProcessFormData_DuplicateName(self):
+    self.servlet.services.features.TestAddHotlist(
+        'FirstHotlist', summary='hotlist summary', description='description',
+        owner_ids=[111], editor_ids=[])
+    mr = testing_helpers.MakeMonorailRequest(
+        hotlist=self.hotlist,
+        path='/u/111/hotlists/%s/details' % (self.hotlist.hotlist_id),
+        services=service_manager.Services(user=self.user_service),
+        perms=permissions.EMPTY_PERMISSIONSET)
+    mr.auth.user_id = 111
+    mr.auth.effective_ids = {111}
+    post_data = fake.PostData(
+        summary = ['hotlist summary'],
+        name = ['FirstHotlist'],
+        description = ['description'],
+        default_col_spec = ['test default col spec'])
+    self.mox.StubOutWithMock(self.servlet, 'PleaseCorrect')
+    self.servlet.PleaseCorrect(
+        mr, initial_summary='hotlist summary',
+        initial_description='description', initial_name = 'FirstHotlist',
+        initial_default_col_spec = 'test default col spec')
+    self.mox.ReplayAll()
+
+    url = self.servlet.ProcessFormData(mr, post_data)
+    self.mox.VerifyAll()
+    self.assertEqual(hotlistdetails._MSG_HOTLIST_NAME_NOT_AVAIL,
+                     mr.errors.name)
+    self.assertIsNone(url)
+
+  def testProcessFormData_Bad(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        hotlist=self.hotlist,
+        path='/u/111/hotlists/%s/details' % (self.hotlist.hotlist_id),
+        services=service_manager.Services(user=self.user_service),
+         perms=permissions.EMPTY_PERMISSIONSET)
+    mr.auth.user_id = 111
+    mr.auth.effective_ids = {111}
+    post_data = fake.PostData(
+        summary = ['hotlist summary'],
+        name = ['2badName'],
+        description = ['fake description'],
+        default_col_spec = ['test default col spec'])
+    self.mox.StubOutWithMock(self.servlet, 'PleaseCorrect')
+    self.servlet.PleaseCorrect(
+        mr, initial_summary='hotlist summary',
+        initial_description='fake description', initial_name = '2badName',
+        initial_default_col_spec = 'test default col spec')
+    self.mox.ReplayAll()
+
+    url = self.servlet.ProcessFormData(mr, post_data)
+    self.mox.VerifyAll()
+    self.assertEqual(hotlistdetails._MSG_INVALID_HOTLIST_NAME,
+                     mr.errors.name)
+    self.assertIsNone(url)
+
+  def testProcessFormData_NoPermissions(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        hotlist=self.hotlist,
+        path='/u/111/hotlists/%s/details' % (self.hotlist.hotlist_id),
+        services=service_manager.Services(user=self.user_service),
+        perms=permissions.EMPTY_PERMISSIONSET)
+    mr.auth.user_id = self.user_2.user_id
+    mr.auth.effective_ids = {self.user_2.user_id}
+    post_data = fake.PostData(
+        summary = ['hotlist summary'],
+        name = ['hotlist'],
+        description = ['fake description'],
+        default_col_spec = ['test default col spec'])
+    with self.assertRaises(permissions.PermissionException):
+      self.servlet.ProcessFormData(mr, post_data)
diff --git a/features/test/hotlistissues_test.py b/features/test/hotlistissues_test.py
new file mode 100644
index 0000000..49c3270
--- /dev/null
+++ b/features/test/hotlistissues_test.py
@@ -0,0 +1,211 @@
+# 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 mox
+import mock
+import unittest
+import time
+
+from google.appengine.ext import testbed
+import ezt
+
+from features import hotlistissues
+from features import hotlist_helpers
+from framework import framework_views
+from framework import permissions
+from framework import sorting
+from framework import template_helpers
+from framework import xsrf
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+
+
+class HotlistIssuesUnitTest(unittest.TestCase):
+
+  def setUp(self):
+    self.testbed = testbed.Testbed()
+    self.testbed.activate()
+    self.testbed.init_memcache_stub()
+    self.testbed.init_datastore_v3_stub()
+    self.services = service_manager.Services(
+        issue_star=fake.IssueStarService(),
+        config=fake.ConfigService(),
+        user=fake.UserService(),
+        issue=fake.IssueService(),
+        project=fake.ProjectService(),
+        features=fake.FeaturesService(),
+        cache_manager=fake.CacheManager(),
+        hotlist_star=fake.HotlistStarService())
+    self.servlet = hotlistissues.HotlistIssues(
+        'req', 'res', services=self.services)
+    self.user1 = self.services.user.TestAddUser('testuser@gmail.com', 111)
+    self.user2 = self.services.user.TestAddUser('testuser2@gmail.com', 222, )
+    self.services.project.TestAddProject('project-name', project_id=1)
+    self.issue1 = fake.MakeTestIssue(
+        1, 1, 'issue_summary', 'New', 111, project_name='project-name')
+    self.services.issue.TestAddIssue(self.issue1)
+    self.issue2 = fake.MakeTestIssue(
+        1, 2, 'issue_summary2', 'New', 111, project_name='project-name')
+    self.services.issue.TestAddIssue(self.issue2)
+    self.issue3 = fake.MakeTestIssue(
+        1, 3, 'issue_summary3', 'New', 222, project_name='project-name')
+    self.services.issue.TestAddIssue(self.issue3)
+    self.issues = [self.issue1, self.issue2, self.issue3]
+    self.hotlist_item_fields = [
+        (issue.issue_id, rank, 111, 1205079300, '') for
+        rank, issue in enumerate(self.issues)]
+    self.test_hotlist = self.services.features.TestAddHotlist(
+        'hotlist', hotlist_id=123, owner_ids=[222], editor_ids=[111],
+        hotlist_item_fields=self.hotlist_item_fields)
+    self.hotlistissues = self.test_hotlist.items
+    # Unless perms is specified,
+    # MakeMonorailRequest will return an mr with admin permissions.
+    self.mr = testing_helpers.MakeMonorailRequest(
+        hotlist=self.test_hotlist, path='/u/222/hotlists/123',
+        services=self.services, perms=permissions.EMPTY_PERMISSIONSET)
+    self.mr.hotlist_id = self.test_hotlist.hotlist_id
+    self.mr.auth.user_id = 111
+    self.mr.auth.effective_ids = {111}
+    self.mr.viewed_user_auth.user_id = 111
+    sorting.InitializeArtValues(self.services)
+
+    self.mox = mox.Mox()
+
+  def tearDown(self):
+    self.mox.UnsetStubs()
+    self.testbed.deactivate()
+
+  def testAssertBasePermissions(self):
+    private_hotlist = self.services.features.TestAddHotlist(
+        'privateHotlist', hotlist_id=321, owner_ids=[222],
+        hotlist_item_fields=self.hotlist_item_fields, is_private=True)
+    # non-members cannot view private hotlists
+    mr = testing_helpers.MakeMonorailRequest(
+        hotlist=private_hotlist, perms=permissions.EMPTY_PERMISSIONSET)
+    mr.auth.effective_ids = {333}
+    mr.hotlist_id = private_hotlist.hotlist_id
+    self.assertRaises(permissions.PermissionException,
+                      self.servlet.AssertBasePermission, mr)
+
+    # members can view private hotlists
+    mr = testing_helpers.MakeMonorailRequest(
+        hotlist=private_hotlist, perms=permissions.EMPTY_PERMISSIONSET)
+    mr.auth.effective_ids = {222, 444}
+    mr.hotlist_id = private_hotlist.hotlist_id
+    self.servlet.AssertBasePermission(mr)
+
+    # non-members can view public hotlists
+    mr = testing_helpers.MakeMonorailRequest(
+        hotlist=self.test_hotlist, perms=permissions.EMPTY_PERMISSIONSET)
+    mr.auth.effective_ids = {333, 444}
+    mr.hotlist_id = self.test_hotlist.hotlist_id
+    self.servlet.AssertBasePermission(mr)
+
+    # members can view public hotlists
+    mr = testing_helpers.MakeMonorailRequest(
+        hotlist=self.test_hotlist, perms=permissions.EMPTY_PERMISSIONSET)
+    mr.auth.effective_ids = {111, 333}
+    mr.hotlist_id = self.test_hotlist.hotlist_id
+    self.servlet.AssertBasePermission(mr)
+
+  def testGatherPageData(self):
+    self.mr.mode = 'list'
+    self.mr.auth.effective_ids = {111}
+    self.mr.auth.user_id = 111
+    self.mr.sort_spec = 'rank stars'
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.assertEqual(ezt.boolean(False), page_data['owner_permissions'])
+    self.assertEqual(ezt.boolean(True), page_data['editor_permissions'])
+    self.assertEqual(ezt.boolean(False), page_data['grid_mode'])
+    self.assertEqual(ezt.boolean(True), page_data['allow_rerank'])
+
+    self.mr.sort_spec = 'stars ranks'
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.assertEqual(ezt.boolean(False), page_data['allow_rerank'])
+
+  def testGetTableViewData(self):
+    now = time.time()
+    self.mox.StubOutWithMock(time, 'time')
+    time.time().MultipleTimes().AndReturn(now)
+    self.mox.ReplayAll()
+
+    self.mr.auth.user_id = 222
+    self.mr.col_spec = 'Stars Projects Rank'
+    table_view_data = self.servlet.GetTableViewData(self.mr)
+    self.assertEqual(table_view_data['edit_hotlist_token'], xsrf.GenerateToken(
+        self.mr.auth.user_id, '/u/222/hotlists/hotlist.do'))
+    self.assertEqual(table_view_data['add_issues_selected'], ezt.boolean(False))
+
+    self.user2.obscure_email = False
+    table_view_data = self.servlet.GetTableViewData(self.mr)
+    self.assertEqual(table_view_data['edit_hotlist_token'], xsrf.GenerateToken(
+        self.mr.auth.user_id, '/u/222/hotlists/hotlist.do'))
+    self.mox.VerifyAll()
+
+  def testGetGridViewData(self):
+    # TODO(jojwang): Write this test
+    pass
+
+  def testProcessFormData_NoNewIssues(self):
+    post_data = fake.PostData(remove=['false'], add_local_ids=[''])
+    url = self.servlet.ProcessFormData(self.mr, post_data)
+    self.assertTrue(url.endswith('u/222/hotlists/hotlist'))
+    self.assertEqual(self.test_hotlist.items, self.hotlistissues)
+
+  def testProcessFormData_AddBadIssueRef(self):
+    self.servlet.PleaseCorrect = mock.Mock()
+    post_data = fake.PostData(
+        remove=['false'], add_local_ids=['no-such-project:999'])
+    url = self.servlet.ProcessFormData(self.mr, post_data)
+    self.assertIsNone(url)
+    self.servlet.PleaseCorrect.assert_called_once()
+
+  def testProcessFormData_RemoveBadIssueRef(self):
+    post_data = fake.PostData(
+        remove=['true'], add_local_ids=['no-such-project:999'])
+    url = self.servlet.ProcessFormData(self.mr, post_data)
+    self.assertIn('u/222/hotlists/hotlist', url)
+    self.assertEqual(self.test_hotlist.items, self.hotlistissues)
+
+  def testProcessFormData_NormalEditIssues(self):
+    issue4 = fake.MakeTestIssue(
+        1, 4, 'issue_summary4', 'New', 222, project_name='project-name')
+    self.services.issue.TestAddIssue(issue4)
+    issue5 = fake.MakeTestIssue(
+        1, 5, 'issue_summary5', 'New', 222, project_name='project-name')
+    self.services.issue.TestAddIssue(issue5)
+
+    post_data = fake.PostData(remove=['false'],
+                              add_local_ids=['project-name:4, project-name:5'])
+    url = self.servlet.ProcessFormData(self.mr, post_data)
+    self.assertTrue('u/222/hotlists/hotlist' in url)
+    self.assertEqual(len(self.test_hotlist.items), 5)
+    self.assertEqual(
+        self.test_hotlist.items[3].issue_id, issue4.issue_id)
+    self.assertEqual(
+        self.test_hotlist.items[4].issue_id, issue5.issue_id)
+
+    post_data = fake.PostData(remove=['true'], remove_local_ids=[
+        'project-name:4, project-name:1, project-name:2'])
+    url = self.servlet.ProcessFormData(self.mr, post_data)
+    self.assertTrue('u/222/hotlists/hotlist' in url)
+    self.assertTrue(len(self.test_hotlist.items), 2)
+    issue_ids = [issue.issue_id for issue in self.test_hotlist.items]
+    self.assertTrue(issue5.issue_id in issue_ids)
+    self.assertTrue(self.issue3.issue_id in issue_ids)
+
+  def testProcessFormData_NoPermissions(self):
+    post_data = fake.PostData(remove=['false'],
+                              add_local_ids=['project-name:4, project-name:5'])
+    self.mr.auth.effective_ids = {333}
+    self.mr.auth.user_id = 333
+    with self.assertRaises(permissions.PermissionException):
+      self.servlet.ProcessFormData(self.mr, post_data)
diff --git a/features/test/hotlistissuescsv_test.py b/features/test/hotlistissuescsv_test.py
new file mode 100644
index 0000000..afa53d5
--- /dev/null
+++ b/features/test/hotlistissuescsv_test.py
@@ -0,0 +1,97 @@
+# 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 issuelistcsv module."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from google.appengine.ext import testbed
+
+import webapp2
+
+from framework import permissions
+from framework import sorting
+from framework import xsrf
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+from features import hotlistissuescsv
+
+
+class HotlistIssuesCsvTest(unittest.TestCase):
+
+  def setUp(self):
+    self.testbed = testbed.Testbed()
+    self.testbed.activate()
+    self.testbed.init_memcache_stub()
+    self.testbed.init_datastore_v3_stub()
+    self.services = service_manager.Services(
+        issue_star=fake.IssueStarService(),
+        config=fake.ConfigService(),
+        user=fake.UserService(),
+        issue=fake.IssueService(),
+        project=fake.ProjectService(),
+        cache_manager=fake.CacheManager(),
+        features=fake.FeaturesService())
+    self.servlet = hotlistissuescsv.HotlistIssuesCsv(
+        'req', webapp2.Response(), services=self.services)
+    self.user1 = self.services.user.TestAddUser('testuser@gmail.com', 111)
+    self.user2 = self.services.user.TestAddUser('testuser2@gmail.com', 222)
+    self.services.project.TestAddProject('project-name', project_id=1)
+    self.issue1 = fake.MakeTestIssue(
+        1, 1, 'issue_summary', 'New', 111, project_name='project-name')
+    self.services.issue.TestAddIssue(self.issue1)
+    self.issues = [self.issue1]
+    self.hotlist_item_fields = [
+        (issue.issue_id, rank, 111, 1205079300, '') for
+        rank, issue in enumerate(self.issues)]
+    self.hotlist = self.services.features.TestAddHotlist(
+        'MyHotlist', hotlist_id=123, owner_ids=[222], editor_ids=[111],
+        hotlist_item_fields=self.hotlist_item_fields)
+    self._MakeMR('/u/222/hotlists/MyHotlist')
+    sorting.InitializeArtValues(self.services)
+
+  def _MakeMR(self, path):
+    self.mr = testing_helpers.MakeMonorailRequest(
+        hotlist=self.hotlist, path=path, services=self.services)
+    self.mr.hotlist_id = self.hotlist.hotlist_id
+    self.mr.hotlist = self.hotlist
+
+  def testGatherPageData_AnonUsers(self):
+    """Anonymous users cannot download the issue list."""
+    self.mr.auth.user_id = 0
+    self.assertRaises(permissions.PermissionException,
+                      self.servlet.GatherPageData, self.mr)
+
+  def testGatherPageData_NoXSRF(self):
+    """Users need a valid XSRF token to download the issue list."""
+    # Note no token query-string parameter is set.
+    self.mr.auth.user_id = self.user2.user_id
+    self.assertRaises(xsrf.TokenIncorrect,
+                      self.servlet.GatherPageData, self.mr)
+
+  def testGatherPageData_BadXSRF(self):
+    """Users need a valid XSRF token to download the issue list."""
+    for path in ('/u/222/hotlists/MyHotlist',
+                 '/u/testuser2@gmail.com/hotlists/MyHotlist'):
+      token = 'bad'
+      self._MakeMR(path + '?token=%s' % token)
+      self.mr.auth.user_id = self.user2.user_id
+      self.assertRaises(xsrf.TokenIncorrect,
+                        self.servlet.GatherPageData, self.mr)
+
+  def testGatherPageData_Normal(self):
+    """Users can get the hotlist issue list."""
+    for path in ('/u/222/hotlists/MyHotlist',
+                 '/u/testuser2@gmail.com/hotlists/MyHotlist'):
+      form_token_path = self.servlet._FormHandlerURL(path)
+      token = xsrf.GenerateToken(self.user1.user_id, form_token_path)
+      self._MakeMR(path + '?token=%s' % token)
+      self.mr.auth.email = self.user1.email
+      self.mr.auth.user_id = self.user1.user_id
+      self.servlet.GatherPageData(self.mr)
diff --git a/features/test/hotlistpeople_test.py b/features/test/hotlistpeople_test.py
new file mode 100644
index 0000000..74beec3
--- /dev/null
+++ b/features/test/hotlistpeople_test.py
@@ -0,0 +1,253 @@
+# 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 Hotlist People servlet."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import mox
+import unittest
+import logging
+
+import ezt
+
+from testing import fake
+from features import hotlistpeople
+from framework import permissions
+from services import service_manager
+from testing import testing_helpers
+
+class HotlistPeopleListTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        features=fake.FeaturesService(),
+        project=fake.ProjectService(),
+        user=fake.UserService(),
+        usergroup=fake.UserGroupService())
+    self.owner_user = self.services.user.TestAddUser('buzbuz@gmail.com', 111)
+    self.editor_user = self.services.user.TestAddUser('monica@gmail.com', 222)
+    self.non_member_user = self.services.user.TestAddUser(
+        'who-dis@gmail.com', 333)
+    self.private_hotlist = self.services.features.TestAddHotlist(
+        'PrivateHotlist', 'owner only', [111], [222], is_private=True)
+    self.public_hotlist = self.services.features.TestAddHotlist(
+        'PublicHotlist', 'everyone', [111], [222], is_private=False)
+    self.servlet = hotlistpeople.HotlistPeopleList(
+        'req', 'res', services=self.services)
+    self.mox = mox.Mox()
+
+  def tearDown(self):
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def testAssertBasePermission(self):
+    # owner can view people in private hotlist
+    mr = testing_helpers.MakeMonorailRequest(
+        hotlist=self.private_hotlist, perms=permissions.EMPTY_PERMISSIONSET)
+    mr.auth.effective_ids = {111, 444}
+    self.servlet.AssertBasePermission(mr)
+
+    # editor can view people in private hotlist
+    mr.auth.effective_ids = {222, 333}
+    self.servlet.AssertBasePermission(mr)
+
+    # non-members cannot view people in private hotlist
+    mr.auth.effective_ids = {444, 333}
+    self.assertRaises(permissions.PermissionException,
+                      self.servlet.AssertBasePermission, mr)
+
+    # owner can view people in public hotlist
+    mr = testing_helpers.MakeMonorailRequest(hotlist=self.public_hotlist)
+    mr.auth.effective_ids = {111, 444}
+    self.servlet.AssertBasePermission(mr)
+
+    # editor can view people in public hotlist
+    mr.auth.effective_ids = {222, 333}
+    self.servlet.AssertBasePermission(mr)
+
+    # non-members cannot view people in public hotlist
+    mr.auth.effective_ids = {444, 333}
+    self.servlet.AssertBasePermission(mr)
+
+  def testGatherPageData(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        hotlist=self.public_hotlist, perms=permissions.EMPTY_PERMISSIONSET)
+    mr.auth.user_id = 111
+    mr.auth.effective_ids = {111}
+    mr.cnxn = 'fake cnxn'
+    page_data = self.servlet.GatherPageData(mr)
+    self.assertEqual(ezt.boolean(True), page_data['offer_membership_editing'])
+    self.assertEqual(ezt.boolean(False), page_data['offer_remove_self'])
+    self.assertEqual(page_data['total_num_owners'], 1)
+    self.assertEqual(page_data['newly_added_views'], [])
+    self.assertEqual(len(page_data['pagination'].visible_results), 2)
+
+    # non-owners cannot edit people list
+    mr.auth.user_id = 222
+    mr.auth.effective_ids = {222}
+    page_data = self.servlet.GatherPageData(mr)
+    self.assertEqual(ezt.boolean(False), page_data['offer_membership_editing'])
+    self.assertEqual(ezt.boolean(True), page_data['offer_remove_self'])
+
+    mr.auth.user_id = 333
+    mr.auth.effective_ids = {333}
+    page_data = self.servlet.GatherPageData(mr)
+    self.assertEqual(ezt.boolean(False), page_data['offer_membership_editing'])
+    self.assertEqual(ezt.boolean(False), page_data['offer_remove_self'])
+
+  def testProcessFormData_Permission(self):
+    """Only owner can change member of hotlist."""
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/u/buzbuz@gmail.com/hotlists/PrivateHotlist/people',
+        hotlist=self.private_hotlist, perms=permissions.EMPTY_PERMISSIONSET)
+    mr.auth.effective_ids = {111, 444}
+    self.servlet.ProcessFormData(mr, {})
+
+    mr.auth.effective_ids = {222, 444}
+    self.assertRaises(permissions.PermissionException,
+                      self.servlet.ProcessFormData, mr, {})
+
+  def testProcessRemoveMembers(self):
+    hotlist = self.servlet.services.features.TestAddHotlist(
+        'HotlistName', 'removing 222, monica', [111], [222])
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/u/buzbuz@gmail.com/hotlists/HotlistName/people',
+        hotlist=hotlist)
+    mr.hotlist_id = hotlist.hotlist_id
+    post_data = fake.PostData(
+        remove = ['monica@gmail.com'])
+    url = self.servlet.ProcessRemoveMembers(
+        mr, post_data, '/u/111/hotlists/HotlistName')
+    self.assertTrue('/u/111/hotlists/HotlistName/people' in url)
+    self.assertEqual(hotlist.editor_ids, [])
+
+  def testProcessRemoveSelf(self):
+    hotlist = self.servlet.services.features.TestAddHotlist(
+        'HotlistName', 'self removing 222, monica', [111], [222])
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/u/buzbuz@gmail.com/hotlists/HotlistName/people',
+        hotlist=hotlist)
+    mr.hotlist_id = hotlist.hotlist_id
+    mr.cnxn = 'fake cnxn'
+    # The owner cannot be removed using ProcessRemoveSelf(); this is enforced
+    # by permission in ProcessFormData, not in the function itself;
+    # nor may a random user...
+    mr.auth.user_id = 333
+    mr.auth.effective_ids = {333}
+    url = self.servlet.ProcessRemoveSelf(mr, '/u/111/hotlists/HotlistName')
+    self.assertTrue('/u/111/hotlists/HotlistName/people' in url)
+    self.assertEqual(hotlist.owner_ids, [111])
+    self.assertEqual(hotlist.editor_ids, [222])
+    # ...but an editor can.
+    mr.auth.user_id = 222
+    mr.auth.effective_ids = {222}
+    url = self.servlet.ProcessRemoveSelf(mr, '/u/111/hotlists/HotlistName')
+    self.assertTrue('/u/111/hotlists/HotlistName/people' in url)
+    self.assertEqual(hotlist.owner_ids, [111])
+    self.assertEqual(hotlist.editor_ids, [])
+
+  def testProcessAddMembers(self):
+    hotlist = self.servlet.services.features.TestAddHotlist(
+        'HotlistName', 'adding 333, who-dis', [111], [222])
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/u/buzbuz@gmail.com/hotlists/HotlistName/people',
+        hotlist=hotlist)
+    mr.hotlist_id = hotlist.hotlist_id
+    post_data = fake.PostData(
+        addmembers = ['who-dis@gmail.com'],
+        role = ['editor'])
+    url = self.servlet.ProcessAddMembers(
+        mr, post_data, '/u/111/hotlists/HotlistName')
+    self.assertTrue('/u/111/hotlists/HotlistName/people' in url)
+    self.assertEqual(hotlist.editor_ids, [222, 333])
+
+  def testProcessAddMembers_OwnerToEditor(self):
+    hotlist = self.servlet.services.features.TestAddHotlist(
+        'HotlistName', 'adding owner 111, buzbuz as editor', [111], [222])
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/u/buzbuz@gmail.com/hotlists/HotlistName/people',
+        hotlist=hotlist)
+    mr.hotlist_id = hotlist.hotlist_id
+    addmembers_input = 'buzbuz@gmail.com'
+    post_data = fake.PostData(
+        addmembers = [addmembers_input],
+        role = ['editor'])
+    self.mox.StubOutWithMock(self.servlet, 'PleaseCorrect')
+    self.servlet.PleaseCorrect(
+        mr, initial_add_members=addmembers_input, initially_expand_form=True)
+    self.mox.ReplayAll()
+    url = self.servlet.ProcessAddMembers(
+        mr, post_data, '/u/111/hotlists/HotlistName')
+    self.mox.VerifyAll()
+    self.assertEqual(
+        'Cannot have a hotlist without an owner; please leave at least one.',
+        mr.errors.addmembers)
+    self.assertIsNone(url)
+    # Verify that no changes have actually occurred.
+    self.assertEqual(hotlist.owner_ids, [111])
+    self.assertEqual(hotlist.editor_ids, [222])
+
+  def testProcessChangeOwnership(self):
+    hotlist = self.servlet.services.features.TestAddHotlist(
+        'HotlistName', 'new owner 333, who-dis', [111], [222])
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/u/buzbuz@gmail.com/hotlists/HotlistName/people',
+        hotlist=hotlist)
+    mr.hotlist_id = hotlist.hotlist_id
+    post_data = fake.PostData(
+        changeowners = ['who-dis@gmail.com'],
+        becomeeditor = ['on'])
+    url = self.servlet.ProcessChangeOwnership(mr, post_data)
+    self.assertTrue('/u/333/hotlists/HotlistName/people' in url)
+    self.assertEqual(hotlist.owner_ids, [333])
+    self.assertEqual(hotlist.editor_ids, [222, 111])
+
+  def testProcessChangeOwnership_UnownedHotlist(self):
+    hotlist = self.services.features.TestAddHotlist(
+        'unowned', 'new owner 333, who-dis', [], [222])
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/whatever',
+        hotlist=hotlist)
+    mr.hotlist_id = hotlist.hotlist_id
+    post_data = fake.PostData(
+        changeowners = ['who-dis@gmail.com'],
+        becomeeditor = ['on'])
+    self.servlet.ProcessChangeOwnership(mr, post_data)
+    self.assertEqual([333], mr.hotlist.owner_ids)
+
+  def testProcessChangeOwnership_BadEmail(self):
+    hotlist = self.servlet.services.features.TestAddHotlist(
+        'HotlistName', 'new owner 333, who-dis', [111], [222])
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/u/buzbuz@gmail.com/hotlists/HotlistName/people',
+        hotlist=hotlist)
+    mr.hotlist_id = hotlist.hotlist_id
+    changeowners_input = 'who-dis@gmail.com, extra-email@gmail.com'
+    post_data = fake.PostData(
+        changeowners = [changeowners_input],
+        becomeeditor = ['on'])
+    self.mox.StubOutWithMock(self.servlet, 'PleaseCorrect')
+    self.servlet.PleaseCorrect(
+        mr, initial_new_owner_username=changeowners_input, open_dialog='yes')
+    self.mox.ReplayAll()
+    url = self.servlet.ProcessChangeOwnership(mr, post_data)
+    self.mox.VerifyAll()
+    self.assertEqual(
+        'Please add one valid user email.', mr.errors.transfer_ownership)
+    self.assertIsNone(url)
+
+  def testProcessChangeOwnership_DuplicateName(self):
+    # other_hotlist = self.servlet.services.features.TestAddHotlist(
+    #    'HotlistName', 'hotlist with same name', [333], [])
+    # hotlist = self.servlet.services.features.TestAddHotlist(
+    #     'HotlistName', 'new owner 333, who-dis', [111], [222])
+
+    # in the test_hotlists dict of features_service in testing/fake
+    # 'other_hotlist' is overwritten by 'hotlist'
+    # TODO(jojwang): edit the fake features_service to allow hotlists
+    # with the same name but different owners
+    pass
diff --git a/features/test/inboundemail_test.py b/features/test/inboundemail_test.py
new file mode 100644
index 0000000..6c13827
--- /dev/null
+++ b/features/test/inboundemail_test.py
@@ -0,0 +1,400 @@
+# 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.feature.inboundemail."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+import webapp2
+from mock import patch
+
+import mox
+import time
+
+from google.appengine.ext.webapp.mail_handlers import BounceNotificationHandler
+
+import settings
+from businesslogic import work_env
+from features import alert2issue
+from features import commitlogcommands
+from features import inboundemail
+from framework import authdata
+from framework import emailfmt
+from framework import monorailcontext
+from framework import permissions
+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_helpers
+
+
+class InboundEmailTest(unittest.TestCase):
+
+  def setUp(self):
+    self.cnxn = 'fake cnxn'
+    self.services = service_manager.Services(
+        config=fake.ConfigService(),
+        issue=fake.IssueService(),
+        user=fake.UserService(),
+        usergroup=fake.UserGroupService(),
+        project=fake.ProjectService())
+    self.project = self.services.project.TestAddProject(
+        'proj', project_id=987, process_inbound_email=True,
+        contrib_ids=[111])
+    self.project_addr = 'proj@monorail.example.com'
+
+    self.issue = tracker_pb2.Issue()
+    self.issue.project_id = 987
+    self.issue.local_id = 100
+    self.services.issue.TestAddIssue(self.issue)
+
+    self.msg = testing_helpers.MakeMessage(
+        testing_helpers.HEADER_LINES, 'awesome!')
+
+    request, _ = testing_helpers.GetRequestObjects()
+    self.inbound = inboundemail.InboundEmail(request, None, self.services)
+    self.mox = mox.Mox()
+
+  def tearDown(self):
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def testTemplates(self):
+    for name, template_path in self.inbound._templates.items():
+      assert(name in inboundemail.MSG_TEMPLATES)
+      assert(
+          template_path.GetTemplatePath().endswith(
+              inboundemail.MSG_TEMPLATES[name]))
+
+  def testProcessMail_MsgTooBig(self):
+    self.mox.StubOutWithMock(emailfmt, 'IsBodyTooBigToParse')
+    emailfmt.IsBodyTooBigToParse(mox.IgnoreArg()).AndReturn(True)
+    self.mox.ReplayAll()
+
+    email_tasks = self.inbound.ProcessMail(self.msg, self.project_addr)
+    self.mox.VerifyAll()
+    self.assertEqual(1, len(email_tasks))
+    email_task = email_tasks[0]
+    self.assertEqual('user@example.com', email_task['to'])
+    self.assertEqual('Email body too long', email_task['subject'])
+
+  def testProcessMail_NoProjectOnToLine(self):
+    self.mox.StubOutWithMock(emailfmt, 'IsProjectAddressOnToLine')
+    emailfmt.IsProjectAddressOnToLine(
+        self.project_addr, [self.project_addr]).AndReturn(False)
+    self.mox.ReplayAll()
+
+    ret = self.inbound.ProcessMail(self.msg, self.project_addr)
+    self.mox.VerifyAll()
+    self.assertIsNone(ret)
+
+  def testProcessMail_IssueUnidentified(self):
+    self.mox.StubOutWithMock(emailfmt, 'IdentifyProjectVerbAndLabel')
+    emailfmt.IdentifyProjectVerbAndLabel(self.project_addr).AndReturn(('proj',
+        None, None))
+
+    self.mox.StubOutWithMock(emailfmt, 'IdentifyIssue')
+    emailfmt.IdentifyIssue('proj', mox.IgnoreArg()).AndReturn((None))
+
+    self.mox.ReplayAll()
+
+    ret = self.inbound.ProcessMail(self.msg, self.project_addr)
+    self.mox.VerifyAll()
+    self.assertIsNone(ret)
+
+  def testProcessMail_ProjectNotLive(self):
+    self.services.user.TestAddUser('user@example.com', 111)
+    self.project.state = project_pb2.ProjectState.DELETABLE
+    email_tasks = self.inbound.ProcessMail(self.msg, self.project_addr)
+    email_task = email_tasks[0]
+    self.assertEqual('user@example.com', email_task['to'])
+    self.assertEqual('Project not found', email_task['subject'])
+
+  def testProcessMail_ProjectInboundEmailDisabled(self):
+    self.services.user.TestAddUser('user@example.com', 111)
+    self.project.process_inbound_email = False
+    email_tasks = self.inbound.ProcessMail(self.msg, self.project_addr)
+    email_task = email_tasks[0]
+    self.assertEqual('user@example.com', email_task['to'])
+    self.assertEqual(
+        'Email replies are not enabled in project proj', email_task['subject'])
+
+  def testProcessMail_NoRefHeader(self):
+    self.services.user.TestAddUser('user@example.com', 111)
+    self.mox.StubOutWithMock(emailfmt, 'ValidateReferencesHeader')
+    emailfmt.ValidateReferencesHeader(
+        mox.IgnoreArg(), self.project, mox.IgnoreArg(),
+        mox.IgnoreArg()).AndReturn(False)
+    emailfmt.ValidateReferencesHeader(
+        mox.IgnoreArg(), self.project, mox.IgnoreArg(),
+        mox.IgnoreArg()).AndReturn(False)
+    self.mox.ReplayAll()
+
+    email_tasks = self.inbound.ProcessMail(self.msg, self.project_addr)
+    self.mox.VerifyAll()
+    self.assertEqual(1, len(email_tasks))
+    email_task = email_tasks[0]
+    self.assertEqual('user@example.com', email_task['to'])
+    self.assertEqual(
+        'Your message is not a reply to a notification email',
+        email_task['subject'])
+
+  def testProcessMail_NoAccount(self):
+    # Note: not calling TestAddUser().
+    email_tasks = self.inbound.ProcessMail(self.msg, self.project_addr)
+    self.mox.VerifyAll()
+    self.assertEqual(1, len(email_tasks))
+    email_task = email_tasks[0]
+    self.assertEqual('user@example.com', email_task['to'])
+    self.assertEqual(
+        'Could not determine account of sender', email_task['subject'])
+
+  def testProcessMail_BannedAccount(self):
+    user_pb = self.services.user.TestAddUser('user@example.com', 111)
+    user_pb.banned = 'banned'
+
+    self.mox.StubOutWithMock(emailfmt, 'ValidateReferencesHeader')
+    emailfmt.ValidateReferencesHeader(
+        mox.IgnoreArg(), self.project, mox.IgnoreArg(),
+        mox.IgnoreArg()).AndReturn(True)
+    self.mox.ReplayAll()
+
+    email_tasks = self.inbound.ProcessMail(self.msg, self.project_addr)
+    self.mox.VerifyAll()
+    self.assertEqual(1, len(email_tasks))
+    email_task = email_tasks[0]
+    self.assertEqual('user@example.com', email_task['to'])
+    self.assertEqual(
+        'You are banned from using this issue tracker', email_task['subject'])
+
+  def testProcessMail_Success(self):
+    self.services.user.TestAddUser('user@example.com', 111)
+
+    self.mox.StubOutWithMock(emailfmt, 'ValidateReferencesHeader')
+    emailfmt.ValidateReferencesHeader(
+        mox.IgnoreArg(), self.project, mox.IgnoreArg(),
+        mox.IgnoreArg()).AndReturn(True)
+
+    self.mox.StubOutWithMock(self.inbound, 'ProcessIssueReply')
+    self.inbound.ProcessIssueReply(
+        mox.IgnoreArg(), self.project, 123, self.project_addr,
+        'awesome!')
+
+    self.mox.ReplayAll()
+
+    ret = self.inbound.ProcessMail(self.msg, self.project_addr)
+    self.mox.VerifyAll()
+    self.assertIsNone(ret)
+
+  def testProcessMail_Success_with_AlertNotification(self):
+    """Test ProcessMail with an alert notification message.
+
+    This is a sanity check for alert2issue.ProcessEmailNotification to ensure
+    that it can be successfully invoked in ProcessMail. Each function of
+    alert2issue module should be tested in aler2issue_test.
+    """
+    project_name = self.project.project_name
+    verb = 'alert'
+    trooper_queue = 'my-trooper'
+    project_addr = '%s+%s+%s@example.com' % (project_name, verb, trooper_queue)
+
+    self.mox.StubOutWithMock(emailfmt, 'IsProjectAddressOnToLine')
+    emailfmt.IsProjectAddressOnToLine(
+        project_addr, mox.IgnoreArg()).AndReturn(True)
+
+    class MockAuthData(object):
+      def __init__(self):
+        self.user_pb = user_pb2.MakeUser(111)
+        self.effective_ids = set([1, 2, 3])
+        self.user_id = 111
+        self.email = 'user@example.com'
+
+    mock_auth_data = MockAuthData()
+    self.mox.StubOutWithMock(authdata.AuthData, 'FromEmail')
+    authdata.AuthData.FromEmail(
+        mox.IgnoreArg(), settings.alert_service_account, self.services,
+        autocreate=True).AndReturn(mock_auth_data)
+
+    self.mox.StubOutWithMock(alert2issue, 'ProcessEmailNotification')
+    alert2issue.ProcessEmailNotification(
+        self.services, mox.IgnoreArg(), self.project, project_addr,
+        mox.IgnoreArg(), mock_auth_data, mox.IgnoreArg(), 'awesome!', '',
+        self.msg, trooper_queue)
+
+    self.mox.ReplayAll()
+    ret = self.inbound.ProcessMail(self.msg, project_addr)
+    self.mox.VerifyAll()
+    self.assertIsNone(ret)
+
+  def testProcessIssueReply_NoIssue(self):
+    nonexistant_local_id = 200
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='user@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+
+    email_tasks = self.inbound.ProcessIssueReply(
+        mc, self.project, nonexistant_local_id, self.project_addr,
+        'awesome!')
+    self.assertEqual(1, len(email_tasks))
+    email_task = email_tasks[0]
+    self.assertEqual('user@example.com', email_task['to'])
+    self.assertEqual(
+        'Could not find issue %d in project %s' %
+        (nonexistant_local_id, self.project.project_name),
+        email_task['subject'])
+
+  def testProcessIssueReply_DeletedIssue(self):
+    self.issue.deleted = True
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='user@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+
+    email_tasks = self.inbound.ProcessIssueReply(
+        mc, self.project, self.issue.local_id, self.project_addr,
+        'awesome!')
+    self.assertEqual(1, len(email_tasks))
+    email_task = email_tasks[0]
+    self.assertEqual('user@example.com', email_task['to'])
+    self.assertEqual(
+        'Could not find issue %d in project %s' %
+        (self.issue.local_id, self.project.project_name), email_task['subject'])
+
+  def VerifyUserHasNoPerm(self, perms):
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='user@example.com')
+    mc.perms = perms
+
+    email_tasks = self.inbound.ProcessIssueReply(
+        mc, self.project, self.issue.local_id, self.project_addr,
+        'awesome!')
+    self.assertEqual(1, len(email_tasks))
+    email_task = email_tasks[0]
+    self.assertEqual('user@example.com', email_task['to'])
+    self.assertEqual(
+        'User does not have permission to add a comment', email_task['subject'])
+
+  def testProcessIssueReply_NoViewPerm(self):
+    self.VerifyUserHasNoPerm(permissions.EMPTY_PERMISSIONSET)
+
+  def testProcessIssueReply_CantViewRestrictedIssue(self):
+    self.issue.labels.append('Restrict-View-CoreTeam')
+    self.VerifyUserHasNoPerm(permissions.USER_PERMISSIONSET)
+
+  def testProcessIssueReply_NoAddIssuePerm(self):
+    self.VerifyUserHasNoPerm(permissions.READ_ONLY_PERMISSIONSET)
+
+  def testProcessIssueReply_NoEditIssuePerm(self):
+    self.services.user.TestAddUser('user@example.com', 111)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='user@example.com')
+    mc.perms = permissions.USER_PERMISSIONSET
+    mock_uia = commitlogcommands.UpdateIssueAction(self.issue.local_id)
+
+    self.mox.StubOutWithMock(commitlogcommands, 'UpdateIssueAction')
+    commitlogcommands.UpdateIssueAction(self.issue.local_id).AndReturn(mock_uia)
+
+    self.mox.StubOutWithMock(mock_uia, 'Parse')
+    mock_uia.Parse(
+        self.cnxn, self.project.project_name, 111, ['awesome!'], self.services,
+        strip_quoted_lines=True)
+    self.mox.StubOutWithMock(mock_uia, 'Run')
+    # mc.perms does not contain permission EDIT_ISSUE.
+    mock_uia.Run(mc, self.services)
+
+    self.mox.ReplayAll()
+    ret = self.inbound.ProcessIssueReply(
+        mc, self.project, self.issue.local_id, self.project_addr,
+        'awesome!')
+    self.mox.VerifyAll()
+    self.assertIsNone(ret)
+
+  def testProcessIssueReply_Success(self):
+    self.services.user.TestAddUser('user@example.com', 111)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='user@example.com')
+    mc.perms = permissions.COMMITTER_ACTIVE_PERMISSIONSET
+    mock_uia = commitlogcommands.UpdateIssueAction(self.issue.local_id)
+
+    self.mox.StubOutWithMock(commitlogcommands, 'UpdateIssueAction')
+    commitlogcommands.UpdateIssueAction(self.issue.local_id).AndReturn(mock_uia)
+
+    self.mox.StubOutWithMock(mock_uia, 'Parse')
+    mock_uia.Parse(
+        self.cnxn, self.project.project_name, 111, ['awesome!'], self.services,
+        strip_quoted_lines=True)
+    self.mox.StubOutWithMock(mock_uia, 'Run')
+    mock_uia.Run(mc, self.services)
+
+    self.mox.ReplayAll()
+    ret = self.inbound.ProcessIssueReply(
+        mc, self.project, self.issue.local_id, self.project_addr,
+        'awesome!')
+    self.mox.VerifyAll()
+    self.assertIsNone(ret)
+
+
+class BouncedEmailTest(unittest.TestCase):
+
+  def setUp(self):
+    self.cnxn = 'fake cnxn'
+    self.services = service_manager.Services(
+        user=fake.UserService())
+    self.user = self.services.user.TestAddUser('user@example.com', 111)
+
+    app = webapp2.WSGIApplication(config={'services': self.services})
+    app.set_globals(app=app)
+
+    self.servlet = inboundemail.BouncedEmail()
+    self.mox = mox.Mox()
+
+  def tearDown(self):
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def testPost_Normal(self):
+    """Normally, our post() just calls BounceNotificationHandler post()."""
+    self.mox.StubOutWithMock(BounceNotificationHandler, 'post')
+    BounceNotificationHandler.post()
+    self.mox.ReplayAll()
+
+    self.servlet.post()
+    self.mox.VerifyAll()
+
+  def testPost_Exception(self):
+    """Our post() method works around an escaping bug."""
+    self.servlet.request = webapp2.Request.blank(
+        '/', POST={'raw-message': 'this is an email message'})
+
+    self.mox.StubOutWithMock(BounceNotificationHandler, 'post')
+    BounceNotificationHandler.post().AndRaise(AttributeError())
+    BounceNotificationHandler.post()
+    self.mox.ReplayAll()
+
+    self.servlet.post()
+    self.mox.VerifyAll()
+
+  def testReceive_Normal(self):
+    """Find the user that bounced and set email_bounce_timestamp."""
+    self.assertEqual(0, self.user.email_bounce_timestamp)
+
+    bounce_message = testing_helpers.Blank(original={'to': 'user@example.com'})
+    self.servlet.receive(bounce_message)
+
+    self.assertNotEqual(0, self.user.email_bounce_timestamp)
+
+  def testReceive_NoSuchUser(self):
+    """When not found, log it and ignore without creating a user record."""
+    self.servlet.request = webapp2.Request.blank(
+        '/', POST={'raw-message': 'this is an email message'})
+    bounce_message = testing_helpers.Blank(
+        original={'to': 'nope@example.com'},
+        notification='notification')
+    self.servlet.receive(bounce_message)
+    self.assertEqual(1, len(self.services.user.users_by_id))
diff --git a/features/test/notify_helpers_test.py b/features/test/notify_helpers_test.py
new file mode 100644
index 0000000..615da38
--- /dev/null
+++ b/features/test/notify_helpers_test.py
@@ -0,0 +1,617 @@
+# -*- coding: utf-8 -*-
+# 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 notify_helpers.py."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import json
+import mock
+import unittest
+import os
+
+from features import features_constants
+from features import notify_helpers
+from features import notify_reasons
+from framework import emailfmt
+from framework import framework_views
+from framework import urls
+from proto import user_pb2
+from services import service_manager
+from testing import fake
+
+
+REPLY_NOT_ALLOWED = notify_reasons.REPLY_NOT_ALLOWED
+REPLY_MAY_COMMENT = notify_reasons.REPLY_MAY_COMMENT
+REPLY_MAY_UPDATE = notify_reasons.REPLY_MAY_UPDATE
+
+
+class TaskQueueingFunctionsTest(unittest.TestCase):
+
+  @mock.patch('framework.cloud_tasks_helpers._get_client')
+  def testAddAllEmailTasks(self, get_client_mock):
+    notify_helpers.AddAllEmailTasks(
+      tasks=[{'to': 'user'}, {'to': 'user2'}])
+
+    self.assertEqual(get_client_mock().create_task.call_count, 2)
+
+    queue_call_args = get_client_mock().queue_path.call_args_list
+    ((_app_id, _region, queue), _kwargs) = queue_call_args[0]
+    self.assertEqual(queue, features_constants.QUEUE_OUTBOUND_EMAIL)
+    ((_app_id, _region, queue), _kwargs) = queue_call_args[1]
+    self.assertEqual(queue, features_constants.QUEUE_OUTBOUND_EMAIL)
+
+    task_call_args = get_client_mock().create_task.call_args_list
+    ((_parent, task), _kwargs) = task_call_args[0]
+    expected_task = {
+        'app_engine_http_request':
+            {
+                'relative_uri': urls.OUTBOUND_EMAIL_TASK + '.do',
+                'body': json.dumps({
+                    'to': 'user'
+                }).encode(),
+                'headers': {
+                    'Content-type': 'application/json'
+                }
+            }
+    }
+    self.assertEqual(task, expected_task)
+    ((_parent, task), _kwargs) = task_call_args[1]
+    expected_task = {
+        'app_engine_http_request':
+            {
+                'relative_uri': urls.OUTBOUND_EMAIL_TASK + '.do',
+                'body': json.dumps({
+                    'to': 'user2'
+                }).encode(),
+                'headers': {
+                    'Content-type': 'application/json'
+                }
+            }
+    }
+    self.assertEqual(task, expected_task)
+
+
+class MergeLinkedAccountReasonsTest(unittest.TestCase):
+
+  def setUp(self):
+    parent = user_pb2.User(
+        user_id=111, email='parent@example.org',
+        linked_child_ids=[222])
+    child = user_pb2.User(
+        user_id=222, email='child@example.org',
+        linked_parent_id=111)
+    user_3 = user_pb2.User(
+        user_id=333, email='user4@example.org')
+    user_4 = user_pb2.User(
+        user_id=444, email='user4@example.org')
+    self.addr_perm_parent = notify_reasons.AddrPerm(
+        False, parent.email, parent, notify_reasons.REPLY_NOT_ALLOWED,
+        user_pb2.UserPrefs())
+    self.addr_perm_child = notify_reasons.AddrPerm(
+        False, child.email, child, notify_reasons.REPLY_NOT_ALLOWED,
+        user_pb2.UserPrefs())
+    self.addr_perm_3 = notify_reasons.AddrPerm(
+        False, user_3.email, user_3, notify_reasons.REPLY_NOT_ALLOWED,
+        user_pb2.UserPrefs())
+    self.addr_perm_4 = notify_reasons.AddrPerm(
+        False, user_4.email, user_4, notify_reasons.REPLY_NOT_ALLOWED,
+        user_pb2.UserPrefs())
+    self.addr_perm_5 = notify_reasons.AddrPerm(
+        False, 'alias@example.com', None, notify_reasons.REPLY_NOT_ALLOWED,
+        user_pb2.UserPrefs())
+
+  def testEmptyDict(self):
+    """Zero users to notify."""
+    self.assertEqual(
+        {},
+        notify_helpers._MergeLinkedAccountReasons({}, {}))
+
+  def testNormal(self):
+    """No users are related."""
+    addr_to_addrperm = {
+       self.addr_perm_parent.address: self.addr_perm_parent,
+       self.addr_perm_3.address: self.addr_perm_3,
+       self.addr_perm_4.address: self.addr_perm_4,
+       self.addr_perm_5.address: self.addr_perm_5,
+       }
+    addr_to_reasons = {
+       self.addr_perm_parent.address: [notify_reasons.REASON_CCD],
+       self.addr_perm_3.address: [notify_reasons.REASON_OWNER],
+       self.addr_perm_4.address: [notify_reasons.REASON_CCD],
+       self.addr_perm_5.address: [notify_reasons.REASON_CCD],
+       }
+    self.assertEqual(
+        {self.addr_perm_parent.address: [notify_reasons.REASON_CCD],
+         self.addr_perm_3.address: [notify_reasons.REASON_OWNER],
+         self.addr_perm_4.address: [notify_reasons.REASON_CCD],
+         self.addr_perm_5.address: [notify_reasons.REASON_CCD]
+         },
+        notify_helpers._MergeLinkedAccountReasons(
+            addr_to_addrperm, addr_to_reasons))
+
+  def testMerged(self):
+    """A child is merged into parent notification."""
+    addr_to_addrperm = {
+       self.addr_perm_parent.address: self.addr_perm_parent,
+       self.addr_perm_child.address: self.addr_perm_child,
+       }
+    addr_to_reasons = {
+       self.addr_perm_parent.address: [notify_reasons.REASON_OWNER],
+       self.addr_perm_child.address: [notify_reasons.REASON_CCD],
+       }
+    self.assertEqual(
+        {self.addr_perm_parent.address:
+         [notify_reasons.REASON_OWNER,
+          notify_reasons.REASON_LINKED_ACCOUNT]
+         },
+        notify_helpers._MergeLinkedAccountReasons(
+            addr_to_addrperm, addr_to_reasons))
+
+
+class MakeBulletedEmailWorkItemsTest(unittest.TestCase):
+
+  def setUp(self):
+    self.project = fake.Project(project_name='proj1')
+    self.commenter_view = framework_views.StuffUserView(
+        111, 'test@example.com', True)
+    self.issue = fake.MakeTestIssue(
+        self.project.project_id, 1234, 'summary', 'New', 111)
+    self.detail_url = 'http://test-detail-url.com/id=1234'
+
+  def testEmptyAddrs(self):
+    """Test the case where we found zero users to notify."""
+    email_tasks = notify_helpers.MakeBulletedEmailWorkItems(
+        [], self.issue, 'link only body', 'non-member body', 'member body',
+        self.project, 'example.com',
+        self.commenter_view, self.detail_url)
+    self.assertEqual([], email_tasks)
+    email_tasks = notify_helpers.MakeBulletedEmailWorkItems(
+        [([], 'reason')], self.issue, 'link only body', 'non-member body',
+        'member body', self.project,
+        'example.com', self.commenter_view, self.detail_url)
+    self.assertEqual([], email_tasks)
+
+
+class LinkOnlyLogicTest(unittest.TestCase):
+
+  def setUp(self):
+    self.user_prefs = user_pb2.UserPrefs()
+    self.user = user_pb2.User()
+    self.issue = fake.MakeTestIssue(
+        789, 1, 'summary one', 'New', 111)
+    self.rvg_issue = fake.MakeTestIssue(
+        789, 2, 'summary two', 'New', 111, labels=['Restrict-View-Google'])
+    self.more_restricted_issue = fake.MakeTestIssue(
+        789, 3, 'summary three', 'New', 111, labels=['Restrict-View-Core'])
+    self.both_restricted_issue = fake.MakeTestIssue(
+        789, 4, 'summary four', 'New', 111,
+        labels=['Restrict-View-Google', 'Restrict-View-Core'])
+    self.addr_perm = notify_reasons.AddrPerm(
+        False, 'user@example.com', self.user, notify_reasons.REPLY_MAY_COMMENT,
+        self.user_prefs)
+
+  def testGetNotifyRestrictedIssues_NoPrefsPassed(self):
+    """AlsoNotify and all-issues addresses have no UserPrefs.  None is used."""
+    actual = notify_helpers._GetNotifyRestrictedIssues(
+        None, 'user@example.com', self.user)
+    self.assertEqual('notify with link only', actual)
+
+    self.user.last_visit_timestamp = 123456789
+    actual = notify_helpers._GetNotifyRestrictedIssues(
+        None, 'user@example.com', self.user)
+    self.assertEqual('notify with details', actual)
+
+  def testGetNotifyRestrictedIssues_PrefIsSet(self):
+    """When the notify_restricted_issues pref is set, we use it."""
+    self.user_prefs.prefs.extend([
+        user_pb2.UserPrefValue(name='x', value='y'),
+        user_pb2.UserPrefValue(name='notify_restricted_issues', value='z'),
+        ])
+    actual = notify_helpers._GetNotifyRestrictedIssues(
+        self.user_prefs, 'user@example.com', self.user)
+    self.assertEqual('z', actual)
+
+  def testGetNotifyRestrictedIssues_UserHasVisited(self):
+    """If user has ever visited, we know that they are not a mailing list."""
+    self.user.last_visit_timestamp = 123456789
+    actual = notify_helpers._GetNotifyRestrictedIssues(
+        self.user_prefs, 'user@example.com', self.user)
+    self.assertEqual('notify with details', actual)
+
+  def testGetNotifyRestrictedIssues_GooglerNeverVisited(self):
+    """It could be a noogler or google mailing list."""
+    actual = notify_helpers._GetNotifyRestrictedIssues(
+        self.user_prefs, 'user@google.com', self.user)
+    self.assertEqual('notify with details: Google', actual)
+
+  def testGetNotifyRestrictedIssues_NonGooglerNeverVisited(self):
+    """It could be a new non-noogler or public mailing list."""
+    actual = notify_helpers._GetNotifyRestrictedIssues(
+        self.user_prefs, 'user@example.com', self.user)
+    self.assertEqual('notify with link only', actual)
+
+    # If email does not match any known user, user object will be None.
+    actual = notify_helpers._GetNotifyRestrictedIssues(
+        self.user_prefs, 'user@example.com', None)
+    self.assertEqual('notify with link only', actual)
+
+  def testShouldUseLinkOnly_UnrestrictedIssue(self):
+    """Issue is not restricted, so go ahead and send comment details."""
+    self.assertFalse(notify_helpers.ShouldUseLinkOnly(
+        self.addr_perm, self.issue))
+
+  def testShouldUseLinkOnly_AlwaysDetailed(self):
+    """Issue is not restricted, so go ahead and send comment details."""
+    self.assertFalse(
+        notify_helpers.ShouldUseLinkOnly(self.addr_perm, self.issue, True))
+
+  @mock.patch('features.notify_helpers._GetNotifyRestrictedIssues')
+  def testShouldUseLinkOnly_NotifyWithDetails(self, fake_gnri):
+    """Issue is restricted, and user is allowed to get full comment details."""
+    fake_gnri.return_value = notify_helpers.NOTIFY_WITH_DETAILS
+    self.assertFalse(notify_helpers.ShouldUseLinkOnly(
+        self.addr_perm, self.rvg_issue))
+    self.assertFalse(notify_helpers.ShouldUseLinkOnly(
+        self.addr_perm, self.more_restricted_issue))
+    self.assertFalse(notify_helpers.ShouldUseLinkOnly(
+        self.addr_perm, self.both_restricted_issue))
+
+
+class MakeEmailWorkItemTest(unittest.TestCase):
+
+  def setUp(self):
+    self.project = fake.Project(project_name='proj1')
+    self.project.process_inbound_email = True
+    self.project2 = fake.Project(project_name='proj2')
+    self.project2.issue_notify_always_detailed = True
+    self.commenter_view = framework_views.StuffUserView(
+        111, 'test@example.com', True)
+    self.expected_html_footer = (
+        'You received this message because:<br/>  1. reason<br/><br/>You may '
+        'adjust your notification preferences at:<br/><a href="https://'
+        'example.com/hosting/settings">https://example.com/hosting/settings'
+        '</a>')
+    self.services = service_manager.Services(
+        user=fake.UserService())
+    self.member = self.services.user.TestAddUser('member@example.com', 222)
+    self.issue = fake.MakeTestIssue(
+        self.project.project_id, 1234, 'summary', 'New', 111,
+        project_name='proj1')
+    self.detail_url = 'http://test-detail-url.com/id=1234'
+
+  @mock.patch('features.notify_helpers.ShouldUseLinkOnly')
+  def testBodySelection_LinkOnly(self, mock_sulo):
+    """We send a link-only body when ShouldUseLinkOnly() is true."""
+    mock_sulo.return_value = True
+    email_task = notify_helpers._MakeEmailWorkItem(
+        notify_reasons.AddrPerm(
+            True, 'a@a.com', self.member, REPLY_NOT_ALLOWED,
+            user_pb2.UserPrefs()),
+        ['reason'], self.issue,
+        'body link-only', 'body mem', 'body mem', self.project,
+        'example.com', self.commenter_view, self.detail_url)
+    self.assertIn('body link-only', email_task['body'])
+
+  def testBodySelection_Member(self):
+    """We send members the email body that is indented for members."""
+    email_task = notify_helpers._MakeEmailWorkItem(
+        notify_reasons.AddrPerm(
+            True, 'a@a.com', self.member, REPLY_NOT_ALLOWED,
+            user_pb2.UserPrefs()),
+        ['reason'], self.issue,
+        'body link-only', 'body mem', 'body mem', self.project,
+        'example.com', self.commenter_view, self.detail_url)
+    self.assertIn('body mem', email_task['body'])
+
+  def testBodySelection_AlwaysDetailed(self):
+    """Always send full email when project configuration requires it."""
+    email_task = notify_helpers._MakeEmailWorkItem(
+        notify_reasons.AddrPerm(
+            True, 'a@a.com', self.member, REPLY_NOT_ALLOWED,
+            user_pb2.UserPrefs()), ['reason'], self.issue, 'body link-only',
+        'body mem', 'body mem', self.project2, 'example.com',
+        self.commenter_view, self.detail_url)
+    self.assertIn('body mem', email_task['body'])
+
+  def testBodySelection_NonMember(self):
+    """We send non-members the email body that is indented for non-members."""
+    email_task = notify_helpers._MakeEmailWorkItem(
+        notify_reasons.AddrPerm(
+            False, 'a@a.com', self.member, REPLY_NOT_ALLOWED,
+            user_pb2.UserPrefs()),
+        ['reason'], self.issue,
+        'body link-only', 'body non', 'body mem', self.project,
+        'example.com', self.commenter_view, self.detail_url)
+
+    self.assertEqual('a@a.com', email_task['to'])
+    self.assertEqual('Issue 1234 in proj1: summary', email_task['subject'])
+    self.assertIn('body non', email_task['body'])
+    self.assertEqual(
+      emailfmt.FormatFromAddr(self.project, commenter_view=self.commenter_view,
+                              can_reply_to=False),
+      email_task['from_addr'])
+    self.assertEqual(emailfmt.NoReplyAddress(), email_task['reply_to'])
+
+  def testHtmlBody(self):
+    """"An html body is sent if a detail_url is specified."""
+    email_task = notify_helpers._MakeEmailWorkItem(
+        notify_reasons.AddrPerm(
+            False, 'a@a.com', self.member, REPLY_NOT_ALLOWED,
+            user_pb2.UserPrefs()),
+        ['reason'], self.issue,
+        'body link-only', 'body non', 'body mem', self.project,
+        'example.com', self.commenter_view, self.detail_url)
+
+    expected_html_body = (
+        notify_helpers.HTML_BODY_WITH_GMAIL_ACTION_TEMPLATE % {
+            'url': self.detail_url,
+            'body': 'body non-- <br/>%s' % self.expected_html_footer})
+    self.assertEqual(expected_html_body, email_task['html_body'])
+
+  def testHtmlBody_WithUnicodeChars(self):
+    """"An html body is sent if a detail_url is specified."""
+    unicode_content = '\xe2\x9d\xa4     â    â'
+    email_task = notify_helpers._MakeEmailWorkItem(
+        notify_reasons.AddrPerm(
+            False, 'a@a.com', self.member, REPLY_NOT_ALLOWED,
+            user_pb2.UserPrefs()),
+        ['reason'], self.issue,
+        'body link-only', unicode_content, 'unused body mem',
+        self.project, 'example.com', self.commenter_view, self.detail_url)
+
+    expected_html_body = (
+        notify_helpers.HTML_BODY_WITH_GMAIL_ACTION_TEMPLATE % {
+            'url': self.detail_url,
+            'body': '%s-- <br/>%s' % (unicode_content.decode('utf-8'),
+                                      self.expected_html_footer)})
+    self.assertEqual(expected_html_body, email_task['html_body'])
+
+  def testHtmlBody_WithLinks(self):
+    """"An html body is sent if a detail_url is specified."""
+    email_task = notify_helpers._MakeEmailWorkItem(
+        notify_reasons.AddrPerm(
+            False, 'a@a.com', self.member, REPLY_NOT_ALLOWED,
+            user_pb2.UserPrefs()),
+        ['reason'], self.issue,
+        'body link-only', 'test google.com test', 'unused body mem',
+        self.project, 'example.com', self.commenter_view, self.detail_url)
+
+    expected_html_body = (
+        notify_helpers.HTML_BODY_WITH_GMAIL_ACTION_TEMPLATE % {
+            'url': self.detail_url,
+            'body': (
+            'test <a href="http://google.com">google.com</a> test-- <br/>%s' % (
+                self.expected_html_footer))})
+    self.assertEqual(expected_html_body, email_task['html_body'])
+
+  def testHtmlBody_LinkWithinTags(self):
+    """"An html body is sent with correct <a href>s."""
+    email_task = notify_helpers._MakeEmailWorkItem(
+        notify_reasons.AddrPerm(
+            False, 'a@a.com', self.member, REPLY_NOT_ALLOWED,
+            user_pb2.UserPrefs()),
+        ['reason'], self.issue,
+        'body link-only', 'a <http://google.com> z', 'unused body',
+        self.project, 'example.com', self.commenter_view,
+        self.detail_url)
+
+    expected_html_body = (
+        notify_helpers.HTML_BODY_WITH_GMAIL_ACTION_TEMPLATE % {
+            'url': self.detail_url,
+            'body': (
+                'a &lt;<a href="http://google.com">http://google.com</a>&gt; '
+                'z-- <br/>%s' % self.expected_html_footer)})
+    self.assertEqual(expected_html_body, email_task['html_body'])
+
+  def testHtmlBody_EmailWithinTags(self):
+    """"An html body is sent with correct <a href>s."""
+    email_task = notify_helpers._MakeEmailWorkItem(
+        notify_reasons.AddrPerm(
+            False, 'a@a.com', self.member, REPLY_NOT_ALLOWED,
+            user_pb2.UserPrefs()),
+        ['reason'], self.issue,
+        'body link-only', 'a <tt@chromium.org> <aa@chromium.org> z',
+        'unused body mem', self.project, 'example.com', self.commenter_view,
+        self.detail_url)
+
+    expected_html_body = (
+        notify_helpers.HTML_BODY_WITH_GMAIL_ACTION_TEMPLATE % {
+            'url': self.detail_url,
+            'body': (
+                'a &lt;<a href="mailto:tt@chromium.org">tt@chromium.org</a>&gt;'
+                ' &lt;<a href="mailto:aa@chromium.org">aa@chromium.org</a>&gt; '
+                'z-- <br/>%s' % self.expected_html_footer)})
+    self.assertEqual(expected_html_body, email_task['html_body'])
+
+  def testHtmlBody_WithEscapedHtml(self):
+    """"An html body is sent with html content escaped."""
+    body_with_html_content = (
+        '<a href="http://www.google.com">test</a> \'something\'')
+    email_task = notify_helpers._MakeEmailWorkItem(
+        notify_reasons.AddrPerm(
+            False, 'a@a.com', self.member, REPLY_NOT_ALLOWED,
+            user_pb2.UserPrefs()),
+        ['reason'], self.issue,
+        'body link-only', body_with_html_content, 'unused body mem',
+        self.project, 'example.com', self.commenter_view, self.detail_url)
+
+    escaped_body_with_html_content = (
+        '&lt;a href=&quot;http://www.google.com&quot;&gt;test&lt;/a&gt; '
+        '&#39;something&#39;')
+    notify_helpers._MakeNotificationFooter(
+        ['reason'], REPLY_NOT_ALLOWED, 'example.com')
+    expected_html_body = (
+        notify_helpers.HTML_BODY_WITH_GMAIL_ACTION_TEMPLATE % {
+            'url': self.detail_url,
+            'body': '%s-- <br/>%s' % (escaped_body_with_html_content,
+                                      self.expected_html_footer)})
+    self.assertEqual(expected_html_body, email_task['html_body'])
+
+  def doTestAddHTMLTags(self, body, expected):
+    actual = notify_helpers._AddHTMLTags(body)
+    self.assertEqual(expected, actual)
+
+  def testAddHTMLTags_Email(self):
+    """An email address produces <a href="mailto:...">...</a>."""
+    self.doTestAddHTMLTags(
+      'test test@example.com.',
+      ('test <a href="mailto:test@example.com">'
+       'test@example.com</a>.'))
+
+  def testAddHTMLTags_EmailInQuotes(self):
+    """Quoted "test@example.com" produces "<a href="...">...</a>"."""
+    self.doTestAddHTMLTags(
+      'test "test@example.com".',
+      ('test &quot;<a href="mailto:test@example.com">'
+       'test@example.com</a>&quot;.'))
+
+  def testAddHTMLTags_EmailInAngles(self):
+    """Bracketed <test@example.com> produces &lt;<a href="...">...</a>&gt;."""
+    self.doTestAddHTMLTags(
+      'test <test@example.com>.',
+      ('test &lt;<a href="mailto:test@example.com">'
+       'test@example.com</a>&gt;.'))
+
+  def testAddHTMLTags_Website(self):
+    """A website URL produces <a href="http:...">...</a>."""
+    self.doTestAddHTMLTags(
+      'test http://www.example.com.',
+      ('test <a href="http://www.example.com">'
+       'http://www.example.com</a>.'))
+
+  def testAddHTMLTags_WebsiteInQuotes(self):
+    """A link in quotes gets the quotes escaped."""
+    self.doTestAddHTMLTags(
+      'test "http://www.example.com".',
+      ('test &quot;<a href="http://www.example.com">'
+       'http://www.example.com</a>&quot;.'))
+
+  def testAddHTMLTags_WebsiteInAngles(self):
+    """Bracketed <www.example.com> produces &lt;<a href="...">...</a>&gt;."""
+    self.doTestAddHTMLTags(
+      'test <http://www.example.com>.',
+      ('test &lt;<a href="http://www.example.com">'
+       'http://www.example.com</a>&gt;.'))
+
+  def testReplyInvitation(self):
+    """We include a footer about replying that is appropriate for that user."""
+    email_task = notify_helpers._MakeEmailWorkItem(
+        notify_reasons.AddrPerm(
+            True, 'a@a.com', self.member, REPLY_NOT_ALLOWED,
+            user_pb2.UserPrefs()),
+        ['reason'], self.issue,
+        'body link-only', 'body non', 'body mem', self.project,
+        'example.com', self.commenter_view, self.detail_url)
+    self.assertEqual(emailfmt.NoReplyAddress(), email_task['reply_to'])
+    self.assertNotIn('Reply to this email', email_task['body'])
+
+    email_task = notify_helpers._MakeEmailWorkItem(
+        notify_reasons.AddrPerm(
+            True, 'a@a.com', self.member, REPLY_MAY_COMMENT,
+            user_pb2.UserPrefs()),
+        ['reason'], self.issue,
+        'body link-only', 'body non', 'body mem', self.project,
+        'example.com', self.commenter_view, self.detail_url)
+    self.assertEqual(
+      '%s@%s' % (self.project.project_name, emailfmt.MailDomain()),
+      email_task['reply_to'])
+    self.assertIn('Reply to this email to add a comment', email_task['body'])
+    self.assertNotIn('make changes', email_task['body'])
+
+    email_task = notify_helpers._MakeEmailWorkItem(
+        notify_reasons.AddrPerm(
+            True, 'a@a.com', self.member, REPLY_MAY_UPDATE,
+            user_pb2.UserPrefs()),
+        ['reason'], self.issue,
+        'body link-only', 'body non', 'body mem', self.project,
+        'example.com', self.commenter_view, self.detail_url)
+    self.assertEqual(
+      '%s@%s' % (self.project.project_name, emailfmt.MailDomain()),
+      email_task['reply_to'])
+    self.assertIn('Reply to this email to add a comment', email_task['body'])
+    self.assertIn('make updates', email_task['body'])
+
+  def testInboundEmailDisabled(self):
+    """We don't invite replies if they are disabled for this project."""
+    self.project.process_inbound_email = False
+    email_task = notify_helpers._MakeEmailWorkItem(
+        notify_reasons.AddrPerm(
+            True, 'a@a.com', self.member, REPLY_MAY_UPDATE,
+            user_pb2.UserPrefs()),
+        ['reason'], self.issue,
+        'body link-only', 'body non', 'body mem',
+        self.project, 'example.com', self.commenter_view, self.detail_url)
+    self.assertEqual(emailfmt.NoReplyAddress(), email_task['reply_to'])
+
+  def testReasons(self):
+    """The footer lists reasons why that email was sent to that user."""
+    email_task = notify_helpers._MakeEmailWorkItem(
+        notify_reasons.AddrPerm(
+            True, 'a@a.com', self.member, REPLY_MAY_UPDATE,
+            user_pb2.UserPrefs()),
+        ['Funny', 'Caring', 'Near'], self.issue,
+        'body link-only', 'body non', 'body mem',
+        self.project, 'example.com', self.commenter_view, self.detail_url)
+    self.assertIn('because:', email_task['body'])
+    self.assertIn('1. Funny', email_task['body'])
+    self.assertIn('2. Caring', email_task['body'])
+    self.assertIn('3. Near', email_task['body'])
+
+    email_task = notify_helpers._MakeEmailWorkItem(
+        notify_reasons.AddrPerm(
+            True, 'a@a.com', self.member, REPLY_MAY_UPDATE,
+            user_pb2.UserPrefs()),
+        [], self.issue,
+        'body link-only', 'body non', 'body mem',
+        self.project, 'example.com', self.commenter_view, self.detail_url)
+    self.assertNotIn('because', email_task['body'])
+
+
+class MakeNotificationFooterTest(unittest.TestCase):
+
+  def testMakeNotificationFooter_NoReason(self):
+    footer = notify_helpers._MakeNotificationFooter(
+        [], REPLY_NOT_ALLOWED, 'example.com')
+    self.assertEqual('', footer)
+
+  def testMakeNotificationFooter_WithReason(self):
+    footer = notify_helpers._MakeNotificationFooter(
+        ['REASON'], REPLY_NOT_ALLOWED, 'example.com')
+    self.assertIn('REASON', footer)
+    self.assertIn('https://example.com/hosting/settings', footer)
+
+    footer = notify_helpers._MakeNotificationFooter(
+        ['REASON'], REPLY_NOT_ALLOWED, 'example.com')
+    self.assertIn('REASON', footer)
+    self.assertIn('https://example.com/hosting/settings', footer)
+
+  def testMakeNotificationFooter_ManyReasons(self):
+    footer = notify_helpers._MakeNotificationFooter(
+        ['Funny', 'Caring', 'Warmblooded'], REPLY_NOT_ALLOWED,
+        'example.com')
+    self.assertIn('Funny', footer)
+    self.assertIn('Caring', footer)
+    self.assertIn('Warmblooded', footer)
+
+  def testMakeNotificationFooter_WithReplyInstructions(self):
+    footer = notify_helpers._MakeNotificationFooter(
+        ['REASON'], REPLY_NOT_ALLOWED, 'example.com')
+    self.assertNotIn('Reply', footer)
+    self.assertIn('https://example.com/hosting/settings', footer)
+
+    footer = notify_helpers._MakeNotificationFooter(
+        ['REASON'], REPLY_MAY_COMMENT, 'example.com')
+    self.assertIn('add a comment', footer)
+    self.assertNotIn('make updates', footer)
+    self.assertIn('https://example.com/hosting/settings', footer)
+
+    footer = notify_helpers._MakeNotificationFooter(
+        ['REASON'], REPLY_MAY_UPDATE, 'example.com')
+    self.assertIn('add a comment', footer)
+    self.assertIn('make updates', footer)
+    self.assertIn('https://example.com/hosting/settings', footer)
diff --git a/features/test/notify_reasons_test.py b/features/test/notify_reasons_test.py
new file mode 100644
index 0000000..559e322
--- /dev/null
+++ b/features/test/notify_reasons_test.py
@@ -0,0 +1,407 @@
+# Copyright 2017 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 notify_reasons.py."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+import os
+
+from features import notify_reasons
+from framework import emailfmt
+from framework import framework_views
+from framework import urls
+from proto import user_pb2
+from proto import usergroup_pb2
+from services import service_manager
+from testing import fake
+from tracker import tracker_bizobj
+
+
+REPLY_NOT_ALLOWED = notify_reasons.REPLY_NOT_ALLOWED
+REPLY_MAY_COMMENT = notify_reasons.REPLY_MAY_COMMENT
+REPLY_MAY_UPDATE = notify_reasons.REPLY_MAY_UPDATE
+
+
+class ComputeIssueChangeAddressPermListTest(unittest.TestCase):
+
+  def setUp(self):
+    self.users_by_id = {
+        111: framework_views.StuffUserView(111, 'owner@example.com', True),
+        222: framework_views.StuffUserView(222, 'member@example.com', True),
+        999: framework_views.StuffUserView(999, 'visitor@example.com', True),
+        }
+    self.services = service_manager.Services(
+        project=fake.ProjectService(),
+        config=fake.ConfigService(),
+        issue=fake.IssueService(),
+        user=fake.UserService(),
+        usergroup=fake.UserGroupService())
+    self.owner = self.services.user.TestAddUser('owner@example.com', 111)
+    self.member = self.services.user.TestAddUser('member@example.com', 222)
+    self.visitor = self.services.user.TestAddUser('visitor@example.com', 999)
+    self.project = self.services.project.TestAddProject(
+        'proj', owner_ids=[111], committer_ids=[222])
+    self.project.process_inbound_email = True
+    self.issue = fake.MakeTestIssue(
+        self.project.project_id, 1, 'summary', 'New', 111)
+
+  def testEmptyIDs(self):
+    cnxn = 'fake cnxn'
+    addr_perm_list = notify_reasons.ComputeIssueChangeAddressPermList(
+        cnxn, [], self.project, self.issue, self.services, [], {})
+    self.assertEqual([], addr_perm_list)
+
+  def testRecipientIsMember(self):
+    cnxn = 'fake cnxn'
+    ids_to_consider = [111, 222, 999]
+    addr_perm_list = notify_reasons.ComputeIssueChangeAddressPermList(
+        cnxn, ids_to_consider, self.project, self.issue, self.services, set(),
+        self.users_by_id, pref_check_function=lambda *args: True)
+    self.assertEqual(
+        [notify_reasons.AddrPerm(
+            True, 'owner@example.com', self.owner, REPLY_MAY_UPDATE,
+            user_pb2.UserPrefs(user_id=111)),
+         notify_reasons.AddrPerm(
+            True, 'member@example.com', self.member, REPLY_MAY_UPDATE,
+            user_pb2.UserPrefs(user_id=222)),
+         notify_reasons.AddrPerm(
+            False, 'visitor@example.com', self.visitor, REPLY_MAY_COMMENT,
+            user_pb2.UserPrefs(user_id=999))],
+        addr_perm_list)
+
+
+class ComputeProjectAndIssueNotificationAddrListTest(unittest.TestCase):
+
+  def setUp(self):
+    self.cnxn = 'fake cnxn'
+    self.services = service_manager.Services(
+        project=fake.ProjectService(),
+        user=fake.UserService())
+    self.project = self.services.project.TestAddProject('project')
+    self.services.user.TestAddUser('alice@gmail.com', 111)
+    self.services.user.TestAddUser('bob@gmail.com', 222)
+    self.services.user.TestAddUser('fred@gmail.com', 555)
+
+  def testNotifyAddress(self):
+    # No mailing list or filter rules are defined
+    addr_perm_list = notify_reasons.ComputeProjectNotificationAddrList(
+        self.cnxn, self.services, self.project, True, set())
+    self.assertListEqual([], addr_perm_list)
+
+    # Only mailing list is notified.
+    self.project.issue_notify_address = 'mailing-list@domain.com'
+    addr_perm_list = notify_reasons.ComputeProjectNotificationAddrList(
+        self.cnxn, self.services, self.project, True, set())
+    self.assertListEqual(
+        [notify_reasons.AddrPerm(
+            False, 'mailing-list@domain.com', None, REPLY_NOT_ALLOWED,
+            user_pb2.UserPrefs())],
+        addr_perm_list)
+
+    # No one is notified because mailing list was already notified.
+    omit_addrs = {'mailing-list@domain.com'}
+    addr_perm_list = notify_reasons.ComputeProjectNotificationAddrList(
+        self.cnxn, self.services, self.project, False, omit_addrs)
+    self.assertListEqual([], addr_perm_list)
+
+    # No one is notified because anon users cannot view.
+    addr_perm_list = notify_reasons.ComputeProjectNotificationAddrList(
+        self.cnxn, self.services, self.project, False, set())
+    self.assertListEqual([], addr_perm_list)
+
+  def testFilterRuleNotifyAddresses(self):
+    issue = fake.MakeTestIssue(
+        self.project.project_id, 1, 'summary', 'New', 555)
+    issue.derived_notify_addrs.extend(['notify@domain.com'])
+
+    addr_perm_list = notify_reasons.ComputeIssueNotificationAddrList(
+        self.cnxn, self.services, issue, set())
+    self.assertListEqual(
+        [notify_reasons.AddrPerm(
+            False, 'notify@domain.com', None, REPLY_NOT_ALLOWED,
+            user_pb2.UserPrefs())],
+        addr_perm_list)
+
+    # Also-notify addresses can be omitted (e.g., if it is the same as
+    # the email address of the user who made the change).
+    addr_perm_list = notify_reasons.ComputeIssueNotificationAddrList(
+        self.cnxn, self.services, issue, {'notify@domain.com'})
+    self.assertListEqual([], addr_perm_list)
+
+
+class ComputeGroupReasonListTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        project=fake.ProjectService(),
+        config=fake.ConfigService(),
+        issue=fake.IssueService(),
+        features=fake.FeaturesService(),
+        user=fake.UserService(),
+        usergroup=fake.UserGroupService())
+    self.project = self.services.project.TestAddProject(
+      'project', project_id=789)
+    self.config = self.services.config.GetProjectConfig('cnxn', 789)
+    self.alice = self.services.user.TestAddUser('alice@example.com', 111)
+    self.bob = self.services.user.TestAddUser('bob@example.com', 222)
+    self.fred = self.services.user.TestAddUser('fred@example.com', 555)
+    self.users_by_id = framework_views.MakeAllUserViews(
+        'cnxn', self.services.user, [111, 222, 555])
+    self.issue = fake.MakeTestIssue(
+        self.project.project_id, 1, 'summary', 'New', 555)
+
+  def CheckGroupReasonList(
+      self,
+      actual,
+      reporter_apl=None,
+      owner_apl=None,
+      old_owner_apl=None,
+      default_owner_apl=None,
+      ccd_apl=None,
+      group_ccd_apl=None,
+      default_ccd_apl=None,
+      starrer_apl=None,
+      subscriber_apl=None,
+      also_notified_apl=None,
+      all_notifications_apl=None):
+    (
+        you_report, you_own, you_old_owner, you_default_owner, you_ccd,
+        you_group_ccd, you_default_ccd, you_star, you_subscribe,
+        you_also_notify, all_notifications) = actual
+    self.assertEqual(
+        (reporter_apl or [], notify_reasons.REASON_REPORTER),
+        you_report)
+    self.assertEqual(
+        (owner_apl or [], notify_reasons.REASON_OWNER),
+        you_own)
+    self.assertEqual(
+        (old_owner_apl or [], notify_reasons.REASON_OLD_OWNER),
+        you_old_owner)
+    self.assertEqual(
+        (default_owner_apl or [], notify_reasons.REASON_DEFAULT_OWNER),
+        you_default_owner)
+    self.assertEqual(
+        (ccd_apl or [], notify_reasons.REASON_CCD),
+        you_ccd)
+    self.assertEqual(
+        (group_ccd_apl or [], notify_reasons.REASON_GROUP_CCD), you_group_ccd)
+    self.assertEqual(
+        (default_ccd_apl or [], notify_reasons.REASON_DEFAULT_CCD),
+        you_default_ccd)
+    self.assertEqual(
+        (starrer_apl or [], notify_reasons.REASON_STARRER),
+        you_star)
+    self.assertEqual(
+        (subscriber_apl or [], notify_reasons.REASON_SUBSCRIBER),
+        you_subscribe)
+    self.assertEqual(
+        (also_notified_apl or [], notify_reasons.REASON_ALSO_NOTIFY),
+        you_also_notify)
+    self.assertEqual(
+        (all_notifications_apl or [], notify_reasons.REASON_ALL_NOTIFICATIONS),
+        all_notifications)
+
+  def testComputeGroupReasonList_OwnerAndCC(self):
+    """Fred owns the issue, Alice is CC'd."""
+    self.issue.cc_ids = [self.alice.user_id]
+    actual = notify_reasons.ComputeGroupReasonList(
+        'cnxn', self.services, self.project, self.issue, self.config,
+        self.users_by_id, [], True)
+    self.CheckGroupReasonList(
+        actual,
+        owner_apl=[notify_reasons.AddrPerm(
+            False, self.fred.email, self.fred, REPLY_NOT_ALLOWED,
+            user_pb2.UserPrefs(user_id=self.fred.user_id))],
+        ccd_apl=[notify_reasons.AddrPerm(
+            False, self.alice.email, self.alice, REPLY_NOT_ALLOWED,
+            user_pb2.UserPrefs(user_id=self.alice.user_id))])
+
+  def testComputeGroupReasonList_DerivedCcs(self):
+    """Check we correctly compute reasons for component and rule ccs."""
+    member_1 = self.services.user.TestAddUser('member_1@example.com', 991)
+    member_2 = self.services.user.TestAddUser('member_2@example.com', 992)
+    member_3 = self.services.user.TestAddUser('member_3@example.com', 993)
+
+    expanded_group = self.services.user.TestAddUser('group@example.com', 999)
+    self.services.usergroup.CreateGroup(
+        'cnxn', self.services, expanded_group.email, 'owners')
+    self.services.usergroup.TestAddGroupSettings(
+        expanded_group.user_id,
+        expanded_group.email,
+        notify_members=True,
+        notify_group=False)
+    self.services.usergroup.TestAddMembers(
+        expanded_group.user_id, [member_1.user_id, member_2.user_id])
+
+    group = self.services.user.TestAddUser('group_1@example.com', 888)
+    self.services.usergroup.CreateGroup(
+        'cnxn', self.services, group.email, 'owners')
+    self.services.usergroup.TestAddGroupSettings(
+        group.user_id, group.email, notify_members=False, notify_group=True)
+    self.services.usergroup.TestAddMembers(
+        group.user_id, [member_2.user_id, member_3.user_id])
+
+    users_by_id = framework_views.MakeAllUserViews(
+        'cnxn', self.services.user, [
+            self.alice.user_id, self.fred.user_id, member_1.user_id,
+            member_2.user_id, member_3.user_id, group.user_id,
+            expanded_group.user_id
+        ])
+
+    comp_id = 123
+    self.config.component_defs = [
+        fake.MakeTestComponentDef(
+            self.project.project_id,
+            comp_id,
+            path='Chicken',
+            cc_ids=[group.user_id, expanded_group.user_id, self.alice.user_id])
+    ]
+    derived_cc_ids = [
+        self.fred.user_id,  # cc'd directly due to a rule
+        self.alice.user_id,  # cc'd due to the component
+        expanded_group
+        .user_id,  # cc'd due to the component, members notified directly
+        group.user_id,  # cc'd due to the component
+        # cc'd directly due to a rule,
+        # not removed from rule cc notifications due to transitive cc of
+        # expanded_group.
+        member_1.user_id,
+        member_3.user_id,
+    ]
+    issue = fake.MakeTestIssue(
+        self.project.project_id,
+        2,
+        'summary',
+        'New',
+        0,
+        derived_cc_ids=derived_cc_ids,
+        component_ids=[comp_id])
+    actual = notify_reasons.ComputeGroupReasonList(
+        'cnxn', self.services, self.project, issue, self.config, users_by_id,
+        [], True)
+
+    # Asserts list/reason of derived ccs from rules (not components).
+    # The derived ccs list/reason is the 7th tuple returned by
+    # ComputeGroupReasonList()
+    actual_ccd_apl, actual_ccd_reason = actual[6]
+    self.assertEqual(
+        actual_ccd_apl, [
+            notify_reasons.AddrPerm(
+                False, member_3.email, member_3, REPLY_NOT_ALLOWED,
+                user_pb2.UserPrefs(user_id=member_3.user_id)),
+            notify_reasons.AddrPerm(
+                False, self.fred.email, self.fred, REPLY_NOT_ALLOWED,
+                user_pb2.UserPrefs(user_id=self.fred.user_id)),
+            notify_reasons.AddrPerm(
+                False, member_1.email, member_1, REPLY_NOT_ALLOWED,
+                user_pb2.UserPrefs(user_id=member_1.user_id)),
+        ])
+    self.assertEqual(actual_ccd_reason, notify_reasons.REASON_DEFAULT_CCD)
+
+    # Asserts list/reason of derived ccs from components.
+    # The component derived ccs list/reason is hte 8th tuple returned by
+    # ComputeGroupReasonList() when there are component derived ccs.
+    actual_component_apl, actual_comp_reason = actual[7]
+    self.assertEqual(
+        actual_component_apl, [
+            notify_reasons.AddrPerm(
+                False, group.email, group, REPLY_NOT_ALLOWED,
+                user_pb2.UserPrefs(user_id=group.user_id)),
+            notify_reasons.AddrPerm(
+                False, self.alice.email, self.alice, REPLY_NOT_ALLOWED,
+                user_pb2.UserPrefs(user_id=self.alice.user_id)),
+            notify_reasons.AddrPerm(
+                False, member_2.email, member_2, REPLY_NOT_ALLOWED,
+                user_pb2.UserPrefs(user_id=member_2.user_id)),
+            notify_reasons.AddrPerm(
+                False, member_1.email, member_1, REPLY_NOT_ALLOWED,
+                user_pb2.UserPrefs(user_id=member_1.user_id)),
+        ])
+    self.assertEqual(
+        actual_comp_reason,
+        "You are auto-CC'd on all issues in component Chicken")
+
+  def testComputeGroupReasonList_Starrers(self):
+    """Bob and Alice starred it, but Alice opts out of notifications."""
+    self.alice.notify_starred_issue_change = False
+    actual = notify_reasons.ComputeGroupReasonList(
+        'cnxn', self.services, self.project, self.issue, self.config,
+        self.users_by_id, [], True,
+        starrer_ids=[self.alice.user_id, self.bob.user_id])
+    self.CheckGroupReasonList(
+        actual,
+        owner_apl=[notify_reasons.AddrPerm(
+            False, self.fred.email, self.fred, REPLY_NOT_ALLOWED,
+            user_pb2.UserPrefs(user_id=self.fred.user_id))],
+        starrer_apl=[notify_reasons.AddrPerm(
+            False, self.bob.email, self.bob, REPLY_NOT_ALLOWED,
+            user_pb2.UserPrefs(user_id=self.bob.user_id))])
+
+  def testComputeGroupReasonList_Subscribers(self):
+    """Bob subscribed."""
+    sq = tracker_bizobj.MakeSavedQuery(
+          1, 'freds issues', 1, 'owner:fred@example.com',
+          subscription_mode='immediate', executes_in_project_ids=[789])
+    self.services.features.UpdateUserSavedQueries(
+        'cnxn', self.bob.user_id, [sq])
+    actual = notify_reasons.ComputeGroupReasonList(
+        'cnxn', self.services, self.project, self.issue, self.config,
+        self.users_by_id, [], True)
+    self.CheckGroupReasonList(
+        actual,
+        owner_apl=[notify_reasons.AddrPerm(
+            False, self.fred.email, self.fred, REPLY_NOT_ALLOWED,
+            user_pb2.UserPrefs(user_id=self.fred.user_id))],
+        subscriber_apl=[notify_reasons.AddrPerm(
+            False, self.bob.email, self.bob, REPLY_NOT_ALLOWED,
+            user_pb2.UserPrefs(user_id=self.bob.user_id))])
+
+    # Now with subscriber notifications disabled.
+    actual = notify_reasons.ComputeGroupReasonList(
+        'cnxn', self.services, self.project, self.issue, self.config,
+        self.users_by_id, [], True, include_subscribers=False)
+    self.CheckGroupReasonList(
+        actual,
+        owner_apl=[notify_reasons.AddrPerm(
+            False, self.fred.email, self.fred, REPLY_NOT_ALLOWED,
+            user_pb2.UserPrefs(user_id=self.fred.user_id))])
+
+  def testComputeGroupReasonList_NotifyAll(self):
+    """Project is configured to always notify issues@example.com."""
+    self.project.issue_notify_address = 'issues@example.com'
+    actual = notify_reasons.ComputeGroupReasonList(
+        'cnxn', self.services, self.project, self.issue, self.config,
+        self.users_by_id, [], True)
+    self.CheckGroupReasonList(
+        actual,
+        owner_apl=[notify_reasons.AddrPerm(
+            False, self.fred.email, self.fred, REPLY_NOT_ALLOWED,
+            user_pb2.UserPrefs(user_id=self.fred.user_id))],
+        all_notifications_apl=[notify_reasons.AddrPerm(
+            False, 'issues@example.com', None, REPLY_NOT_ALLOWED,
+            user_pb2.UserPrefs())])
+
+    # We don't use the notify-all address when the issue is not public.
+    actual = notify_reasons.ComputeGroupReasonList(
+        'cnxn', self.services, self.project, self.issue, self.config,
+        self.users_by_id, [], False)
+    self.CheckGroupReasonList(
+        actual,
+        owner_apl=[notify_reasons.AddrPerm(
+            False, self.fred.email, self.fred, REPLY_NOT_ALLOWED,
+            user_pb2.UserPrefs(user_id=self.fred.user_id))])
+
+    # Now with the notify-all address disabled.
+    actual = notify_reasons.ComputeGroupReasonList(
+        'cnxn', self.services, self.project, self.issue, self.config,
+        self.users_by_id, [], True, include_notify_all=False)
+    self.CheckGroupReasonList(
+        actual,
+        owner_apl=[notify_reasons.AddrPerm(
+            False, self.fred.email, self.fred, REPLY_NOT_ALLOWED,
+            user_pb2.UserPrefs(user_id=self.fred.user_id))])
diff --git a/features/test/notify_test.py b/features/test/notify_test.py
new file mode 100644
index 0000000..00de106
--- /dev/null
+++ b/features/test/notify_test.py
@@ -0,0 +1,708 @@
+# 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 notify.py."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import json
+import mock
+import unittest
+import webapp2
+
+from google.appengine.ext import testbed
+
+from features import notify
+from features import notify_reasons
+from framework import emailfmt
+from framework import urls
+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 third_party import cloudstorage
+
+
+def MakeTestIssue(project_id, local_id, owner_id, reporter_id, is_spam=False):
+  issue = tracker_pb2.Issue()
+  issue.project_id = project_id
+  issue.local_id = local_id
+  issue.issue_id = 1000 * project_id + local_id
+  issue.owner_id = owner_id
+  issue.reporter_id = reporter_id
+  issue.is_spam = is_spam
+  return issue
+
+
+class NotifyTaskHandleRequestTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        user=fake.UserService(),
+        usergroup=fake.UserGroupService(),
+        project=fake.ProjectService(),
+        config=fake.ConfigService(),
+        issue=fake.IssueService(),
+        issue_star=fake.IssueStarService(),
+        features=fake.FeaturesService())
+    self.requester = self.services.user.TestAddUser('requester@example.com', 1)
+    self.nonmember = self.services.user.TestAddUser('user@example.com', 2)
+    self.member = self.services.user.TestAddUser('member@example.com', 3)
+    self.project = self.services.project.TestAddProject(
+        'test-project', owner_ids=[1, 3], project_id=12345)
+    self.issue1 = MakeTestIssue(
+        project_id=12345, local_id=1, owner_id=2, reporter_id=1)
+    self.issue2 = MakeTestIssue(
+        project_id=12345, local_id=2, owner_id=2, reporter_id=1)
+    self.services.issue.TestAddIssue(self.issue1)
+
+    self._old_gcs_open = cloudstorage.open
+    cloudstorage.open = fake.gcs_open
+    self.orig_sign_attachment_id = attachment_helpers.SignAttachmentID
+    attachment_helpers.SignAttachmentID = (
+        lambda aid: 'signed_%d' % aid)
+
+    self.testbed = testbed.Testbed()
+    self.testbed.activate()
+    self.testbed.init_memcache_stub()
+    self.testbed.init_datastore_v3_stub()
+
+  def tearDown(self):
+    cloudstorage.open = self._old_gcs_open
+    attachment_helpers.SignAttachmentID = self.orig_sign_attachment_id
+
+  def get_filtered_task_call_args(self, create_task_mock, relative_uri):
+    return [
+        (args, kwargs)
+        for (args, kwargs) in create_task_mock.call_args_list
+        if args[0]['app_engine_http_request']['relative_uri'] == relative_uri
+    ]
+
+  def VerifyParams(self, result, params):
+    self.assertEqual(
+        bool(params['send_email']), result['params']['send_email'])
+    if 'issue_id' in params:
+      self.assertEqual(params['issue_id'], result['params']['issue_id'])
+    if 'issue_ids' in params:
+      self.assertEqual([int(p) for p in params['issue_ids'].split(',')],
+                       result['params']['issue_ids'])
+
+  def testNotifyIssueChangeTask_Normal(self):
+    task = notify.NotifyIssueChangeTask(
+        request=None, response=None, services=self.services)
+    params = {'send_email': 1, 'issue_id': 12345001, 'seq': 0,
+              'commenter_id': 2}
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 1},
+        params=params,
+        method='POST',
+        services=self.services)
+    result = task.HandleRequest(mr)
+    self.VerifyParams(result, params)
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testNotifyIssueChangeTask_Spam(self, _create_task_mock):
+    issue = MakeTestIssue(
+        project_id=12345, local_id=1, owner_id=1, reporter_id=1,
+        is_spam=True)
+    self.services.issue.TestAddIssue(issue)
+    task = notify.NotifyIssueChangeTask(
+        request=None, response=None, services=self.services)
+    params = {'send_email': 0, 'issue_id': issue.issue_id, 'seq': 0,
+              'commenter_id': 2}
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 1},
+        params=params,
+        method='POST',
+        services=self.services)
+    result = task.HandleRequest(mr)
+    self.assertEqual(0, len(result['notified']))
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testNotifyBlockingChangeTask_Normal(self, _create_task_mock):
+    issue2 = MakeTestIssue(
+        project_id=12345, local_id=2, owner_id=2, reporter_id=1)
+    self.services.issue.TestAddIssue(issue2)
+    task = notify.NotifyBlockingChangeTask(
+        request=None, response=None, services=self.services)
+    params = {
+        'send_email': 1, 'issue_id': issue2.issue_id, 'seq': 0,
+        'delta_blocker_iids': self.issue1.issue_id, 'commenter_id': 1,
+        'hostport': 'bugs.chromium.org'}
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 1},
+        params=params,
+        method='POST',
+        services=self.services)
+    result = task.HandleRequest(mr)
+    self.VerifyParams(result, params)
+
+  def testNotifyBlockingChangeTask_Spam(self):
+    issue2 = MakeTestIssue(
+        project_id=12345, local_id=2, owner_id=2, reporter_id=1,
+        is_spam=True)
+    self.services.issue.TestAddIssue(issue2)
+    task = notify.NotifyBlockingChangeTask(
+        request=None, response=None, services=self.services)
+    params = {
+        'send_email': 1, 'issue_id': issue2.issue_id, 'seq': 0,
+        'delta_blocker_iids': self.issue1.issue_id, 'commenter_id': 1}
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 1},
+        params=params,
+        method='POST',
+        services=self.services)
+    result = task.HandleRequest(mr)
+    self.assertEqual(0, len(result['notified']))
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testNotifyBulkChangeTask_Normal(self, create_task_mock):
+    """We generate email tasks for each user involved in the issues."""
+    issue2 = MakeTestIssue(
+        project_id=12345, local_id=2, owner_id=2, reporter_id=1)
+    issue2.cc_ids = [3]
+    self.services.issue.TestAddIssue(issue2)
+    task = notify.NotifyBulkChangeTask(
+        request=None, response=None, services=self.services)
+    params = {
+        'send_email': 1, 'seq': 0,
+        'issue_ids': '%d,%d' % (self.issue1.issue_id, issue2.issue_id),
+        'old_owner_ids': '1,1', 'commenter_id': 1}
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 1},
+        params=params,
+        method='POST',
+        services=self.services)
+    result = task.HandleRequest(mr)
+    self.VerifyParams(result, params)
+
+    call_args_list = self.get_filtered_task_call_args(
+        create_task_mock, urls.OUTBOUND_EMAIL_TASK + '.do')
+    self.assertEqual(2, len(call_args_list))
+
+    for (args, _kwargs) in call_args_list:
+      task = args[0]
+      body = json.loads(task['app_engine_http_request']['body'].decode())
+      if 'user' in body['to']:
+        self.assertIn(u'\u2026', body['from_addr'])
+      # Full email for members
+      if 'member' in body['to']:
+        self.assertNotIn(u'\u2026', body['from_addr'])
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testNotifyBulkChangeTask_AlsoNotify(self, create_task_mock):
+    """We generate email tasks for also-notify addresses."""
+    self.issue1.derived_notify_addrs = [
+        'mailing-list@example.com', 'member@example.com']
+    task = notify.NotifyBulkChangeTask(
+        request=None, response=None, services=self.services)
+    params = {
+        'send_email': 1, 'seq': 0,
+        'issue_ids': '%d' % (self.issue1.issue_id),
+        'old_owner_ids': '1', 'commenter_id': 1}
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 1},
+        params=params,
+        method='POST',
+        services=self.services)
+    result = task.HandleRequest(mr)
+    self.VerifyParams(result, params)
+
+    call_args_list = self.get_filtered_task_call_args(
+        create_task_mock, urls.OUTBOUND_EMAIL_TASK + '.do')
+    self.assertEqual(3, len(call_args_list))
+
+    self.assertItemsEqual(
+        ['user@example.com', 'mailing-list@example.com', 'member@example.com'],
+        result['notified'])
+    for (args, _kwargs) in call_args_list:
+      task = args[0]
+      body = json.loads(task['app_engine_http_request']['body'].decode())
+      # obfuscated email for non-members
+      if 'user' in body['to']:
+        self.assertIn(u'\u2026', body['from_addr'])
+      # Full email for members
+      if 'member' in body['to']:
+        self.assertNotIn(u'\u2026', body['from_addr'])
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testNotifyBulkChangeTask_ProjectNotify(self, create_task_mock):
+    """We generate email tasks for project.issue_notify_address."""
+    self.project.issue_notify_address = 'mailing-list@example.com'
+    task = notify.NotifyBulkChangeTask(
+        request=None, response=None, services=self.services)
+    params = {
+        'send_email': 1, 'seq': 0,
+        'issue_ids': '%d' % (self.issue1.issue_id),
+        'old_owner_ids': '1', 'commenter_id': 1}
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 1},
+        params=params,
+        method='POST',
+        services=self.services)
+    result = task.HandleRequest(mr)
+    self.VerifyParams(result, params)
+
+    call_args_list = self.get_filtered_task_call_args(
+        create_task_mock, urls.OUTBOUND_EMAIL_TASK + '.do')
+    self.assertEqual(2, len(call_args_list))
+
+    self.assertItemsEqual(
+        ['user@example.com', 'mailing-list@example.com'],
+        result['notified'])
+
+    for (args, _kwargs) in call_args_list:
+      task = args[0]
+      body = json.loads(task['app_engine_http_request']['body'].decode())
+      # obfuscated email for non-members
+      if 'user' in body['to']:
+        self.assertIn(u'\u2026', body['from_addr'])
+      # Full email for members
+      if 'member' in body['to']:
+        self.assertNotIn(u'\u2026', body['from_addr'])
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testNotifyBulkChangeTask_SubscriberGetsEmail(self, create_task_mock):
+    """If a user subscription matches the issue, notify that user."""
+    task = notify.NotifyBulkChangeTask(
+        request=None, response=None, services=self.services)
+    params = {
+        'send_email': 1,
+        'issue_ids': '%d' % (self.issue1.issue_id),
+        'seq': 0,
+        'old_owner_ids': '1', 'commenter_id': 1}
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 1},
+        params=params,
+        method='POST',
+        services=self.services)
+    self.services.user.TestAddUser('subscriber@example.com', 4)
+    sq = tracker_bizobj.MakeSavedQuery(
+        1, 'all open issues', 2, '', subscription_mode='immediate',
+        executes_in_project_ids=[self.issue1.project_id])
+    self.services.features.UpdateUserSavedQueries('cnxn', 4, [sq])
+    result = task.HandleRequest(mr)
+    self.VerifyParams(result, params)
+
+    call_args_list = self.get_filtered_task_call_args(
+        create_task_mock, urls.OUTBOUND_EMAIL_TASK + '.do')
+    self.assertEqual(2, len(call_args_list))
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testNotifyBulkChangeTask_CCAndSubscriberListsIssueOnce(
+      self, create_task_mock):
+    """If a user both CCs and subscribes, include issue only once."""
+    task = notify.NotifyBulkChangeTask(
+        request=None, response=None, services=self.services)
+    params = {
+        'send_email': 1,
+        'issue_ids': '%d' % (self.issue1.issue_id),
+        'seq': 0,
+        'old_owner_ids': '1', 'commenter_id': 1}
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 1},
+        params=params,
+        method='POST',
+        services=self.services)
+    self.services.user.TestAddUser('subscriber@example.com', 4)
+    self.issue1.cc_ids = [4]
+    sq = tracker_bizobj.MakeSavedQuery(
+        1, 'all open issues', 2, '', subscription_mode='immediate',
+        executes_in_project_ids=[self.issue1.project_id])
+    self.services.features.UpdateUserSavedQueries('cnxn', 4, [sq])
+    result = task.HandleRequest(mr)
+    self.VerifyParams(result, params)
+
+    call_args_list = self.get_filtered_task_call_args(
+        create_task_mock, urls.OUTBOUND_EMAIL_TASK + '.do')
+    self.assertEqual(2, len(call_args_list))
+
+    found = False
+    for (args, _kwargs) in call_args_list:
+      task = args[0]
+      body = json.loads(task['app_engine_http_request']['body'].decode())
+      if body['to'] == 'subscriber@example.com':
+        found = True
+        task_body = body['body']
+        self.assertEqual(1, task_body.count('Issue %d' % self.issue1.local_id))
+    self.assertTrue(found)
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testNotifyBulkChangeTask_Spam(self, _create_task_mock):
+    """A spam issue is excluded from notification emails."""
+    issue2 = MakeTestIssue(
+        project_id=12345, local_id=2, owner_id=2, reporter_id=1,
+        is_spam=True)
+    self.services.issue.TestAddIssue(issue2)
+    task = notify.NotifyBulkChangeTask(
+        request=None, response=None, services=self.services)
+    params = {
+        'send_email': 1,
+        'issue_ids': '%d,%d' % (self.issue1.issue_id, issue2.issue_id),
+        'seq': 0,
+        'old_owner_ids': '1,1', 'commenter_id': 1}
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 1},
+        params=params,
+        method='POST',
+        services=self.services)
+    result = task.HandleRequest(mr)
+    self.assertEqual(1, len(result['notified']))
+
+  def testFormatBulkIssues_Normal_Single(self):
+    """A user may see full notification details for all changed issues."""
+    self.issue1.summary = 'one summary'
+    task = notify.NotifyBulkChangeTask(
+        request=None, response=None, services=self.services)
+    users_by_id = {}
+    commenter_view = None
+    config = self.services.config.GetProjectConfig('cnxn', 12345)
+    addrperm = notify_reasons.AddrPerm(
+        False, 'nonmember@example.com', self.nonmember,
+        notify_reasons.REPLY_NOT_ALLOWED, None)
+
+    subject, body = task._FormatBulkIssues(
+      [self.issue1], users_by_id, commenter_view, 'localhost:8080',
+      'test comment', [], config, addrperm)
+
+    self.assertIn('one summary', subject)
+    self.assertIn('one summary', body)
+    self.assertIn('test comment', body)
+
+  def testFormatBulkIssues_Normal_Multiple(self):
+    """A user may see full notification details for all changed issues."""
+    self.issue1.summary = 'one summary'
+    self.issue2.summary = 'two summary'
+    task = notify.NotifyBulkChangeTask(
+        request=None, response=None, services=self.services)
+    users_by_id = {}
+    commenter_view = None
+    config = self.services.config.GetProjectConfig('cnxn', 12345)
+    addrperm = notify_reasons.AddrPerm(
+        False, 'nonmember@example.com', self.nonmember,
+        notify_reasons.REPLY_NOT_ALLOWED, None)
+
+    subject, body = task._FormatBulkIssues(
+      [self.issue1, self.issue2], users_by_id, commenter_view, 'localhost:8080',
+      'test comment', [], config, addrperm)
+
+    self.assertIn('2 issues changed', subject)
+    self.assertIn('one summary', body)
+    self.assertIn('two summary', body)
+    self.assertIn('test comment', body)
+
+  def testFormatBulkIssues_LinkOnly_Single(self):
+    """A user may not see full notification details for some changed issue."""
+    self.issue1.summary = 'one summary'
+    self.issue1.labels = ['Restrict-View-Google']
+    task = notify.NotifyBulkChangeTask(
+        request=None, response=None, services=self.services)
+    users_by_id = {}
+    commenter_view = None
+    config = self.services.config.GetProjectConfig('cnxn', 12345)
+    addrperm = notify_reasons.AddrPerm(
+        False, 'nonmember@example.com', self.nonmember,
+        notify_reasons.REPLY_NOT_ALLOWED, None)
+
+    subject, body = task._FormatBulkIssues(
+      [self.issue1], users_by_id, commenter_view, 'localhost:8080',
+      'test comment', [], config, addrperm)
+
+    self.assertIn('issue 1', subject)
+    self.assertNotIn('one summary', subject)
+    self.assertNotIn('one summary', body)
+    self.assertNotIn('test comment', body)
+
+  def testFormatBulkIssues_LinkOnly_Multiple(self):
+    """A user may not see full notification details for some changed issue."""
+    self.issue1.summary = 'one summary'
+    self.issue1.labels = ['Restrict-View-Google']
+    self.issue2.summary = 'two summary'
+    task = notify.NotifyBulkChangeTask(
+        request=None, response=None, services=self.services)
+    users_by_id = {}
+    commenter_view = None
+    config = self.services.config.GetProjectConfig('cnxn', 12345)
+    addrperm = notify_reasons.AddrPerm(
+        False, 'nonmember@example.com', self.nonmember,
+        notify_reasons.REPLY_NOT_ALLOWED, None)
+
+    subject, body = task._FormatBulkIssues(
+      [self.issue1, self.issue2], users_by_id, commenter_view, 'localhost:8080',
+      'test comment', [], config, addrperm)
+
+    self.assertIn('2 issues', subject)
+    self.assertNotIn('summary', subject)
+    self.assertNotIn('one summary', body)
+    self.assertIn('two summary', body)
+    self.assertNotIn('test comment', body)
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testNotifyApprovalChangeTask_Normal(self, _create_task_mock):
+    config = self.services.config.GetProjectConfig('cnxn', 12345)
+    config.field_defs = [
+        # issue's User field with any_comment is notified.
+        tracker_bizobj.MakeFieldDef(
+            121, 12345, 'TL', tracker_pb2.FieldTypes.USER_TYPE,
+            '', '', False, False, False, None, None, None, False, '',
+            None, tracker_pb2.NotifyTriggers.ANY_COMMENT, 'no_action',
+            'TL, notified on everything', False),
+        # issue's User field with never is not notified.
+        tracker_bizobj.MakeFieldDef(
+            122, 12345, 'silentTL', tracker_pb2.FieldTypes.USER_TYPE,
+            '', '', False, False, False, None, None, None, False, '',
+            None, tracker_pb2.NotifyTriggers.NEVER, 'no_action',
+            'TL, notified on nothing', False),
+        # approval's User field with any_comment is notified.
+        tracker_bizobj.MakeFieldDef(
+            123, 12345, 'otherapprovalTL', tracker_pb2.FieldTypes.USER_TYPE,
+            '', '', False, False, False, None, None, None, False, '',
+            None, tracker_pb2.NotifyTriggers.ANY_COMMENT, 'no_action',
+            'TL on the approvers team', False, approval_id=3),
+        # another approval's User field with any_comment is not notified.
+        tracker_bizobj.MakeFieldDef(
+            124, 12345, 'otherapprovalTL', tracker_pb2.FieldTypes.USER_TYPE,
+            '', '', False, False, False, None, None, None, False, '',
+            None, tracker_pb2.NotifyTriggers.ANY_COMMENT, 'no_action',
+            'TL on another approvers team', False, approval_id=4),
+        tracker_bizobj.MakeFieldDef(
+            3, 12345, 'Goat-Approval', tracker_pb2.FieldTypes.APPROVAL_TYPE,
+            '', '', False, False, False, None, None, None, False, '',
+            None, tracker_pb2.NotifyTriggers.NEVER, 'no_action',
+            'Get Approval from Goats', False)
+    ]
+    self.services.config.StoreConfig('cnxn', config)
+
+    # Custom user_type field TLs
+    self.services.user.TestAddUser('TL@example.com', 111)
+    self.services.user.TestAddUser('silentTL@example.com', 222)
+    self.services.user.TestAddUser('approvalTL@example.com', 333)
+    self.services.user.TestAddUser('otherapprovalTL@example.com', 444)
+
+    # Approvers
+    self.services.user.TestAddUser('approver_old@example.com', 777)
+    self.services.user.TestAddUser('approver_new@example.com', 888)
+    self.services.user.TestAddUser('approver_still@example.com', 999)
+    self.services.user.TestAddUser('approver_group@example.com', 666)
+    self.services.user.TestAddUser('group_mem1@example.com', 661)
+    self.services.user.TestAddUser('group_mem2@example.com', 662)
+    self.services.user.TestAddUser('group_mem3@example.com', 663)
+    self.services.usergroup.TestAddGroupSettings(
+        666, 'approver_group@example.com')
+    self.services.usergroup.TestAddMembers(666, [661, 662, 663])
+    canary_phase = tracker_pb2.Phase(
+        name='Canary', phase_id=1, rank=1)
+    approval_values = [
+        tracker_pb2.ApprovalValue(approval_id=3,
+                                  approver_ids=[888, 999, 666, 661])]
+    approval_issue = MakeTestIssue(
+        project_id=12345, local_id=2, owner_id=2, reporter_id=1,
+        is_spam=True)
+    approval_issue.phases = [canary_phase]
+    approval_issue.approval_values = approval_values
+    approval_issue.field_values = [
+        tracker_bizobj.MakeFieldValue(121, None, None, 111, None, None, False),
+        tracker_bizobj.MakeFieldValue(122, None, None, 222, None, None, False),
+        tracker_bizobj.MakeFieldValue(123, None, None, 333, None, None, False),
+        tracker_bizobj.MakeFieldValue(124, None, None, 444, None, None, False),
+    ]
+    self.services.issue.TestAddIssue(approval_issue)
+
+    amend = tracker_bizobj.MakeApprovalApproversAmendment([888], [777])
+
+    comment = tracker_pb2.IssueComment(
+        project_id=12345, user_id=999, issue_id=approval_issue.issue_id,
+        amendments=[amend], timestamp=1234567890, content='just a comment.')
+    attach = tracker_pb2.Attachment(
+        attachment_id=4567, filename='sploot.jpg', mimetype='image/png',
+        gcs_object_id='/pid/attachments/abcd', filesize=(1024 * 1023))
+    comment.attachments.append(attach)
+    self.services.issue.TestAddComment(comment, approval_issue.local_id)
+    self.services.issue.TestAddAttachment(
+        attach, comment.id, approval_issue.issue_id)
+
+    task = notify.NotifyApprovalChangeTask(
+        request=None, response=None, services=self.services)
+    params = {
+        'send_email': 1,
+        'issue_id': approval_issue.issue_id,
+        'approval_id': 3,
+        'comment_id': comment.id,
+    }
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 1},
+        params=params,
+        method='POST',
+        services=self.services)
+    result = task.HandleRequest(mr)
+    self.assertTrue('just a comment' in result['tasks'][0]['body'])
+    self.assertTrue('Approvers: -appro...' in result['tasks'][0]['body'])
+    self.assertTrue('sploot.jpg' in result['tasks'][0]['body'])
+    self.assertTrue(
+        '/issues/attachment?aid=4567' in result['tasks'][0]['body'])
+    self.assertItemsEqual(
+        ['user@example.com', 'approver_old@example.com',
+         'approver_new@example.com', 'TL@example.com',
+         'approvalTL@example.com', 'group_mem1@example.com',
+         'group_mem2@example.com', 'group_mem3@example.com'],
+        result['notified'])
+
+    # Test no approvers/groups notified
+    # Status change to NEED_INFO does not email approvers.
+    amend2 = tracker_bizobj.MakeApprovalStatusAmendment(
+        tracker_pb2.ApprovalStatus.NEED_INFO)
+    comment2 = tracker_pb2.IssueComment(
+        project_id=12345, user_id=999, issue_id=approval_issue.issue_id,
+        amendments=[amend2], timestamp=1234567891, content='')
+    self.services.issue.TestAddComment(comment2, approval_issue.local_id)
+    task = notify.NotifyApprovalChangeTask(
+        request=None, response=None, services=self.services)
+    params = {
+        'send_email': 1,
+        'issue_id': approval_issue.issue_id,
+        'approval_id': 3,
+        'comment_id': comment2.id,
+    }
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 1},
+        params=params,
+        method='POST',
+        services=self.services)
+    result = task.HandleRequest(mr)
+
+    self.assertIsNotNone(result['tasks'][0].get('references'))
+    self.assertEqual(result['tasks'][0]['reply_to'], emailfmt.NoReplyAddress())
+    self.assertTrue('Status: need_info' in result['tasks'][0]['body'])
+    self.assertItemsEqual(
+        ['user@example.com', 'TL@example.com', 'approvalTL@example.com'],
+        result['notified'])
+
+  def testNotifyApprovalChangeTask_GetApprovalEmailRecipients(self):
+    task = notify.NotifyApprovalChangeTask(
+        request=None, response=None, services=self.services)
+    issue = fake.MakeTestIssue(789, 1, 'summary', 'New', 111)
+    approval_value = tracker_pb2.ApprovalValue(
+        approver_ids=[222, 333],
+        status=tracker_pb2.ApprovalStatus.APPROVED)
+    comment = tracker_pb2.IssueComment(
+        project_id=789, user_id=1, issue_id=78901)
+
+    # Comment with not amendments notifies everyone.
+    rids = task._GetApprovalEmailRecipients(
+        approval_value, comment, issue, [777, 888])
+    self.assertItemsEqual(rids, [111, 222, 333, 777, 888])
+
+    # New APPROVED status notifies owners and any_comment users.
+    amendment = tracker_bizobj.MakeApprovalStatusAmendment(
+        tracker_pb2.ApprovalStatus.APPROVED)
+    comment.amendments = [amendment]
+    rids = task._GetApprovalEmailRecipients(
+        approval_value, comment, issue, [777, 888])
+    self.assertItemsEqual(rids, [111, 777, 888])
+
+    # New REVIEW_REQUESTED status notifies approvers.
+    approval_value.status = tracker_pb2.ApprovalStatus.REVIEW_REQUESTED
+    amendment = tracker_bizobj.MakeApprovalStatusAmendment(
+        tracker_pb2.ApprovalStatus.REVIEW_REQUESTED)
+    comment.amendments = [amendment]
+    rids = task._GetApprovalEmailRecipients(
+        approval_value, comment, issue, [777, 888])
+    self.assertItemsEqual(rids, [222, 333])
+
+    # Approvers change notifies everyone.
+    amendment = tracker_bizobj.MakeApprovalApproversAmendment(
+        [222], [555])
+    comment.amendments = [amendment]
+    approval_value.approver_ids = [222]
+    rids = task._GetApprovalEmailRecipients(
+        approval_value, comment, issue, [777], omit_ids=[444, 333])
+    self.assertItemsEqual(rids, [111, 222, 555, 777])
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testNotifyRulesDeletedTask(self, _create_task_mock):
+    self.services.project.TestAddProject(
+        'proj', owner_ids=[777, 888], project_id=789)
+    self.services.user.TestAddUser('owner1@test.com', 777)
+    self.services.user.TestAddUser('cow@test.com', 888)
+    task = notify.NotifyRulesDeletedTask(
+        request=None, response=None, services=self.services)
+    params = {'project_id': 789,
+              'filter_rules': 'if green make yellow,if orange make blue'}
+    mr = testing_helpers.MakeMonorailRequest(
+        params=params,
+        method='POST',
+        services=self.services)
+    result = task.HandleRequest(mr)
+    self.assertEqual(len(result['tasks']), 2)
+    body = result['tasks'][0]['body']
+    self.assertTrue('if green make yellow' in body)
+    self.assertTrue('if green make yellow' in body)
+    self.assertTrue('/p/proj/adminRules' in body)
+    self.assertItemsEqual(
+        ['cow@test.com', 'owner1@test.com'], result['notified'])
+
+  def testOutboundEmailTask_Normal(self):
+    """We can send an email."""
+    params = {
+        'from_addr': 'requester@example.com',
+        'reply_to': 'user@example.com',
+        'to': 'user@example.com',
+        'subject': 'Test subject'}
+    body = json.dumps(params)
+    request = webapp2.Request.blank('/', body=body)
+    task = notify.OutboundEmailTask(
+        request=request, response=None, services=self.services)
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 1},
+        payload=body,
+        method='POST',
+        services=self.services)
+    result = task.HandleRequest(mr)
+    self.assertEqual(params['from_addr'], result['sender'])
+    self.assertEqual(params['subject'], result['subject'])
+
+  def testOutboundEmailTask_MissingTo(self):
+    """We skip emails that don't specify the To-line."""
+    params = {
+        'from_addr': 'requester@example.com',
+        'reply_to': 'user@example.com',
+        'subject': 'Test subject'}
+    body = json.dumps(params)
+    request = webapp2.Request.blank('/', body=body)
+    task = notify.OutboundEmailTask(
+        request=request, response=None, services=self.services)
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 1},
+        payload=body,
+        method='POST',
+        services=self.services)
+    result = task.HandleRequest(mr)
+    self.assertEqual('Skipping because no "to" address found.', result['note'])
+    self.assertNotIn('from_addr', result)
+
+  def testOutboundEmailTask_BannedUser(self):
+    """We don't send emails to banned users.."""
+    params = {
+        'from_addr': 'requester@example.com',
+        'reply_to': 'user@example.com',
+        'to': 'banned@example.com',
+        'subject': 'Test subject'}
+    body = json.dumps(params)
+    request = webapp2.Request.blank('/', body=body)
+    task = notify.OutboundEmailTask(
+        request=request, response=None, services=self.services)
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 1},
+        payload=body,
+        method='POST',
+        services=self.services)
+    self.services.user.TestAddUser('banned@example.com', 404, banned=True)
+    result = task.HandleRequest(mr)
+    self.assertEqual('Skipping because user is banned.', result['note'])
+    self.assertNotIn('from_addr', result)
diff --git a/features/test/prettify_test.py b/features/test/prettify_test.py
new file mode 100644
index 0000000..07fce43
--- /dev/null
+++ b/features/test/prettify_test.py
@@ -0,0 +1,92 @@
+# 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 prettify module."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+import ezt
+
+from features import prettify
+
+
+class SourceBrowseTest(unittest.TestCase):
+
+  def testPrepareSourceLinesForHighlighting(self):
+    # String representing an empty source file
+    src = ''
+
+    file_lines = prettify.PrepareSourceLinesForHighlighting(src)
+    self.assertEqual(len(file_lines), 0)
+
+  def testPrepareSourceLinesForHighlightingNoBreaks(self):
+    # seven lines of text with no blank lines
+    src = ' 1\n 2\n 3\n 4\n 5\n 6\n 7'
+
+    file_lines = prettify.PrepareSourceLinesForHighlighting(src)
+    self.assertEqual(len(file_lines), 7)
+    out_lines = [fl.line for fl in file_lines]
+    self.assertEqual('\n'.join(out_lines), src)
+
+    file_lines = prettify.PrepareSourceLinesForHighlighting(src)
+    self.assertEqual(len(file_lines), 7)
+
+  def testPrepareSourceLinesForHighlightingWithBreaks(self):
+    # seven lines of text with line 5 being blank
+    src = ' 1\n 2\n 3\n 4\n\n 6\n 7'
+
+    file_lines = prettify.PrepareSourceLinesForHighlighting(src)
+    self.assertEqual(len(file_lines), 7)
+
+
+class BuildPrettifyDataTest(unittest.TestCase):
+
+  def testNonSourceFile(self):
+    prettify_data = prettify.BuildPrettifyData(0, '/dev/null')
+    self.assertDictEqual(
+        dict(should_prettify=ezt.boolean(False),
+             prettify_class=None),
+        prettify_data)
+
+    prettify_data = prettify.BuildPrettifyData(10, 'readme.txt')
+    self.assertDictEqual(
+        dict(should_prettify=ezt.boolean(False),
+             prettify_class=None),
+        prettify_data)
+
+  def testGenericLanguage(self):
+    prettify_data = prettify.BuildPrettifyData(123, 'trunk/src/hello.php')
+    self.assertDictEqual(
+        dict(should_prettify=ezt.boolean(True),
+             prettify_class=''),
+        prettify_data)
+
+  def testSpecificLanguage(self):
+    prettify_data = prettify.BuildPrettifyData(123, 'trunk/src/hello.java')
+    self.assertDictEqual(
+        dict(should_prettify=ezt.boolean(True),
+             prettify_class='lang-java'),
+        prettify_data)
+
+  def testThirdPartyExtensionLanguages(self):
+    for ext in ['apollo', 'agc', 'aea', 'el', 'scm', 'cl', 'lisp',
+                'go', 'hs', 'lua', 'fs', 'ml', 'proto', 'scala',
+                'sql', 'vb', 'vbs', 'vhdl', 'vhd', 'wiki', 'yaml',
+                'yml', 'clj']:
+      prettify_data = prettify.BuildPrettifyData(123, '/trunk/src/hello.' + ext)
+      self.assertDictEqual(
+          dict(should_prettify=ezt.boolean(True),
+               prettify_class='lang-' + ext),
+          prettify_data)
+
+  def testExactFilename(self):
+    prettify_data = prettify.BuildPrettifyData(123, 'trunk/src/Makefile')
+    self.assertDictEqual(
+        dict(should_prettify=ezt.boolean(True),
+             prettify_class='lang-sh'),
+        prettify_data)
diff --git a/features/test/pubsub_test.py b/features/test/pubsub_test.py
new file mode 100644
index 0000000..2044cf7
--- /dev/null
+++ b/features/test/pubsub_test.py
@@ -0,0 +1,110 @@
+# Copyright 2019 The Chromium Authors. All rights reserved.
+# Use of this source code is govered 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 features.pubsub."""
+
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from mock import Mock
+
+from features import pubsub
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+
+
+class PublishPubsubIssueChangeTaskTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        user=fake.UserService(),
+        project=fake.ProjectService(),
+        config=fake.ConfigService(),
+        issue=fake.IssueService(),
+        features=fake.FeaturesService())
+    self.services.project.TestAddProject(
+        'test-project', owner_ids=[1, 3],
+        project_id=12345)
+
+    # Stub the pubsub API (there is no pubsub testbed stub).
+    self.pubsub_client_mock = Mock()
+    pubsub.set_up_pubsub_api = Mock(return_value=self.pubsub_client_mock)
+
+  def testPublishPubsubIssueChangeTask_NoIssueIdParam(self):
+    """Test case when issue_id param is not passed."""
+    task = pubsub.PublishPubsubIssueChangeTask(
+        request=None, response=None, services=self.services)
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 1},
+        params={},
+        method='POST',
+        services=self.services)
+    result = task.HandleRequest(mr)
+    expected_body = {
+      'error': 'Cannot proceed without a valid issue ID.',
+    }
+    self.assertEqual(result, expected_body)
+
+  def testPublishPubsubIssueChangeTask_PubSubAPIInitFailure(self):
+    """Test case when pub/sub API fails to init."""
+    pubsub.set_up_pubsub_api = Mock(return_value=None)
+    task = pubsub.PublishPubsubIssueChangeTask(
+        request=None, response=None, services=self.services)
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 1},
+        params={},
+        method='POST',
+        services=self.services)
+    result = task.HandleRequest(mr)
+    expected_body = {
+      'error': 'Pub/Sub API init failure.',
+    }
+    self.assertEqual(result, expected_body)
+
+  def testPublishPubsubIssueChangeTask_IssueNotFound(self):
+    """Test case when issue is not found."""
+    task = pubsub.PublishPubsubIssueChangeTask(
+        request=None, response=None, services=self.services)
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 1},
+        params={'issue_id': 314159},
+        method='POST',
+        services=self.services)
+    result = task.HandleRequest(mr)
+    expected_body = {
+      'error': 'Could not find issue with ID 314159',
+    }
+    self.assertEqual(result, expected_body)
+
+  def testPublishPubsubIssueChangeTask_Normal(self):
+    """Test normal happy-path case."""
+    issue = fake.MakeTestIssue(789, 543, 'sum', 'New', 111, issue_id=78901,
+        project_name='rutabaga')
+    self.services.issue.TestAddIssue(issue)
+    task = pubsub.PublishPubsubIssueChangeTask(
+        request=None, response=None, services=self.services)
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 1},
+        params={'issue_id': 78901},
+        method='POST',
+        services=self.services)
+    result = task.HandleRequest(mr)
+
+    self.pubsub_client_mock.projects().topics().publish.assert_called_once_with(
+      topic='projects/testing-app/topics/issue-updates',
+      body={
+        'messages': [{
+          'attributes': {
+            'local_id': '543',
+            'project_name': 'rutabaga',
+          },
+        }],
+      }
+    )
+    self.assertEqual(result, {})
diff --git a/features/test/savedqueries_helpers_test.py b/features/test/savedqueries_helpers_test.py
new file mode 100644
index 0000000..d635fe1
--- /dev/null
+++ b/features/test/savedqueries_helpers_test.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
+
+"""Unit tests for savedqueries_helpers feature."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+import mox
+
+from features import savedqueries_helpers
+from testing import fake
+from tracker import tracker_bizobj
+
+
+class SavedQueriesHelperTest(unittest.TestCase):
+
+  def setUp(self):
+    self.features = fake.FeaturesService()
+    self.project = fake.ProjectService()
+    self.cnxn = 'fake cnxn'
+    self.mox = mox.Mox()
+
+  def tearDown(self):
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def testParseSavedQueries(self):
+    post_data = {
+        'xyz_savedquery_name_1': '',
+        'xyz_savedquery_name_2': 'name2',
+        'xyz_savedquery_name_3': 'name3',
+        'xyz_savedquery_id_1': 1,
+        'xyz_savedquery_id_2': 2,
+        'xyz_savedquery_id_3': 3,
+        'xyz_savedquery_projects_1': '123',
+        'xyz_savedquery_projects_2': 'abc',
+        'xyz_savedquery_projects_3': 'def',
+        'xyz_savedquery_base_1': 4,
+        'xyz_savedquery_base_2': 5,
+        'xyz_savedquery_base_3': 6,
+        'xyz_savedquery_query_1': 'query1',
+        'xyz_savedquery_query_2': 'query2',
+        'xyz_savedquery_query_3': 'query3',
+        'xyz_savedquery_sub_mode_1': 'sub_mode1',
+        'xyz_savedquery_sub_mode_2': 'sub_mode2',
+        'xyz_savedquery_sub_mode_3': 'sub_mode3',
+    }
+    self.project.TestAddProject(name='abc', project_id=1001)
+    self.project.TestAddProject(name='def', project_id=1002)
+
+    saved_queries = savedqueries_helpers.ParseSavedQueries(
+        self.cnxn, post_data, self.project, prefix='xyz_')
+    self.assertEqual(2, len(saved_queries))
+
+    # pylint: disable=unbalanced-tuple-unpacking
+    saved_query1, saved_query2 = saved_queries
+    # Assert contents of saved_query1.
+    self.assertEqual(2, saved_query1.query_id)
+    self.assertEqual('name2', saved_query1.name)
+    self.assertEqual(5, saved_query1.base_query_id)
+    self.assertEqual('query2', saved_query1.query)
+    self.assertEqual([1001], saved_query1.executes_in_project_ids)
+    self.assertEqual('sub_mode2', saved_query1.subscription_mode)
+    # Assert contents of saved_query2.
+    self.assertEqual(3, saved_query2.query_id)
+    self.assertEqual('name3', saved_query2.name)
+    self.assertEqual(6, saved_query2.base_query_id)
+    self.assertEqual('query3', saved_query2.query)
+    self.assertEqual([1002], saved_query2.executes_in_project_ids)
+    self.assertEqual('sub_mode3', saved_query2.subscription_mode)
+
+  def testSavedQueryToCond(self):
+    class MockSavedQuery:
+      def __init__(self):
+        self.base_query_id = 1
+        self.query = 'query'
+    saved_query = MockSavedQuery()
+
+    cond_for_missing_query = savedqueries_helpers.SavedQueryToCond(None)
+    self.assertEqual('', cond_for_missing_query)
+
+    cond_with_no_base = savedqueries_helpers.SavedQueryToCond(saved_query)
+    self.assertEqual('query', cond_with_no_base)
+
+    self.mox.StubOutWithMock(tracker_bizobj, 'GetBuiltInQuery')
+    tracker_bizobj.GetBuiltInQuery(1).AndReturn('base')
+    self.mox.ReplayAll()
+    cond_with_base = savedqueries_helpers.SavedQueryToCond(saved_query)
+    self.assertEqual('base query', cond_with_base)
+    self.mox.VerifyAll()
+
+  def testSavedQueryIDToCond(self):
+    self.mox.StubOutWithMock(savedqueries_helpers, 'SavedQueryToCond')
+    savedqueries_helpers.SavedQueryToCond(mox.IgnoreArg()).AndReturn('ret')
+    self.mox.ReplayAll()
+    query_cond = savedqueries_helpers.SavedQueryIDToCond(
+        self.cnxn, self.features, 1)
+    self.assertEqual('ret', query_cond)
+    self.mox.VerifyAll()
+
+    self.mox.StubOutWithMock(tracker_bizobj, 'GetBuiltInQuery')
+    tracker_bizobj.GetBuiltInQuery(1).AndReturn('built_in_query')
+    self.mox.ReplayAll()
+    query_cond = savedqueries_helpers.SavedQueryIDToCond(
+        self.cnxn, self.features, 1)
+    self.assertEqual('built_in_query', query_cond)
+    self.mox.VerifyAll()
diff --git a/features/test/savedqueries_test.py b/features/test/savedqueries_test.py
new file mode 100644
index 0000000..08624a2
--- /dev/null
+++ b/features/test/savedqueries_test.py
@@ -0,0 +1,43 @@
+# 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 savedqueries feature."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from features import savedqueries
+from framework import monorailrequest
+from framework import permissions
+from services import service_manager
+from testing import fake
+
+
+class SavedQueriesTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        user=fake.UserService())
+    self.servlet = savedqueries.SavedQueries(
+        'req', 'res', services=self.services)
+    self.services.user.TestAddUser('a@example.com', 111)
+
+  def testAssertBasePermission(self):
+    """Only permit site admins and users viewing themselves."""
+    mr = monorailrequest.MonorailRequest(self.services)
+    mr.viewed_user_auth.user_id = 111
+    mr.auth.user_id = 222
+
+    self.assertRaises(permissions.PermissionException,
+                      self.servlet.AssertBasePermission, mr)
+
+    mr.auth.user_id = 111
+    self.servlet.AssertBasePermission(mr)
+
+    mr.auth.user_id = 222
+    mr.auth.user_pb.is_site_admin = True
+    self.servlet.AssertBasePermission(mr)
diff --git a/features/test/send_notifications_test.py b/features/test/send_notifications_test.py
new file mode 100644
index 0000000..435a67d
--- /dev/null
+++ b/features/test/send_notifications_test.py
@@ -0,0 +1,141 @@
+# 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 prepareandsend.py"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import mock
+import unittest
+import urlparse
+
+from features import send_notifications
+from framework import urls
+from tracker import tracker_bizobj
+
+
+class SendNotificationTest(unittest.TestCase):
+
+  def _get_filtered_task_call_args(self, create_task_mock, relative_uri):
+    return [
+        (args, _kwargs)
+        for (args, _kwargs) in create_task_mock.call_args_list
+        if args[0]['app_engine_http_request']['relative_uri'].startswith(
+            relative_uri)
+    ]
+
+  def _get_create_task_path_and_params(self, call):
+    (args, _kwargs) = call
+    path = args[0]['app_engine_http_request']['relative_uri']
+    encoded_params = args[0]['app_engine_http_request']['body']
+    params = {
+        k: v[0] for k, v in urlparse.parse_qs(encoded_params, True).items()
+    }
+    return path, params
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testPrepareAndSendIssueChangeNotification(self, create_task_mock):
+    send_notifications.PrepareAndSendIssueChangeNotification(
+        issue_id=78901,
+        hostport='testbed-test.appspotmail.com',
+        commenter_id=1,
+        old_owner_id=2,
+        send_email=True)
+
+    call_args_list = self._get_filtered_task_call_args(
+        create_task_mock, urls.NOTIFY_ISSUE_CHANGE_TASK + '.do')
+    self.assertEqual(1, len(call_args_list))
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testPrepareAndSendIssueBlockingNotification(self, create_task_mock):
+    send_notifications.PrepareAndSendIssueBlockingNotification(
+        issue_id=78901,
+        hostport='testbed-test.appspotmail.com',
+        delta_blocker_iids=[],
+        commenter_id=1,
+        send_email=True)
+
+    call_args_list = self._get_filtered_task_call_args(
+        create_task_mock, urls.NOTIFY_BLOCKING_CHANGE_TASK + '.do')
+    self.assertEqual(0, len(call_args_list))
+
+    send_notifications.PrepareAndSendIssueBlockingNotification(
+        issue_id=78901,
+        hostport='testbed-test.appspotmail.com',
+        delta_blocker_iids=[2],
+        commenter_id=1,
+        send_email=True)
+
+    call_args_list = self._get_filtered_task_call_args(
+        create_task_mock, urls.NOTIFY_BLOCKING_CHANGE_TASK + '.do')
+    self.assertEqual(1, len(call_args_list))
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testPrepareAndSendApprovalChangeNotification(self, create_task_mock):
+    send_notifications.PrepareAndSendApprovalChangeNotification(
+        78901, 3, 'testbed-test.appspotmail.com', 55)
+
+    call_args_list = self._get_filtered_task_call_args(
+        create_task_mock, urls.NOTIFY_APPROVAL_CHANGE_TASK + '.do')
+    self.assertEqual(1, len(call_args_list))
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testSendIssueBulkChangeNotification_CommentOnly(self, create_task_mock):
+    send_notifications.SendIssueBulkChangeNotification(
+        issue_ids=[78901],
+        hostport='testbed-test.appspotmail.com',
+        old_owner_ids=[2],
+        comment_text='comment',
+        commenter_id=1,
+        amendments=[],
+        send_email=True,
+        users_by_id=2)
+
+    call_args_list = self._get_filtered_task_call_args(
+        create_task_mock, urls.NOTIFY_BULK_CHANGE_TASK + '.do')
+    self.assertEqual(1, len(call_args_list))
+    _path, params = self._get_create_task_path_and_params(call_args_list[0])
+    self.assertEqual(params['comment_text'], 'comment')
+    self.assertEqual(params['amendments'], '')
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testSendIssueBulkChangeNotification_Normal(self, create_task_mock):
+    send_notifications.SendIssueBulkChangeNotification(
+        issue_ids=[78901],
+        hostport='testbed-test.appspotmail.com',
+        old_owner_ids=[2],
+        comment_text='comment',
+        commenter_id=1,
+        amendments=[
+            tracker_bizobj.MakeStatusAmendment('New', 'Old'),
+            tracker_bizobj.MakeLabelsAmendment(['Added'], ['Removed']),
+            tracker_bizobj.MakeStatusAmendment('New', 'Old'),
+            ],
+        send_email=True,
+        users_by_id=2)
+
+    call_args_list = self._get_filtered_task_call_args(
+        create_task_mock, urls.NOTIFY_BULK_CHANGE_TASK + '.do')
+    self.assertEqual(1, len(call_args_list))
+    _path, params = self._get_create_task_path_and_params(call_args_list[0])
+    self.assertEqual(params['comment_text'], 'comment')
+    self.assertEqual(
+        params['amendments'].split('\n'),
+        ['    Status: New', '    Labels: -Removed Added'])
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testPrepareAndSendDeletedFilterRulesNotifications(self, create_task_mock):
+    filter_rule_strs = ['if yellow make orange', 'if orange make blue']
+    send_notifications.PrepareAndSendDeletedFilterRulesNotification(
+        789, 'testbed-test.appspotmail.com', filter_rule_strs)
+
+    call_args_list = self._get_filtered_task_call_args(
+        create_task_mock, urls.NOTIFY_RULES_DELETED_TASK + '.do')
+    self.assertEqual(1, len(call_args_list))
+    _path, params = self._get_create_task_path_and_params(call_args_list[0])
+    self.assertEqual(params['project_id'], '789')
+    self.assertEqual(
+        params['filter_rules'], 'if yellow make orange,if orange make blue')
diff --git a/features/test/spammodel_test.py b/features/test/spammodel_test.py
new file mode 100644
index 0000000..3e99c8f
--- /dev/null
+++ b/features/test/spammodel_test.py
@@ -0,0 +1,39 @@
+# 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.
+"""Tests for the spammodel module."""
+
+from __future__ import absolute_import
+from __future__ import division
+from __future__ import print_function
+
+import mock
+import unittest
+import webapp2
+
+from features import spammodel
+from framework import urls
+
+
+class TrainingDataExportTest(unittest.TestCase):
+
+  def test_handler_definition(self):
+    instance = spammodel.TrainingDataExport()
+    self.assertIsInstance(instance, webapp2.RequestHandler)
+
+  @mock.patch('framework.cloud_tasks_helpers._get_client')
+  def test_enqueues_task(self, get_client_mock):
+    spammodel.TrainingDataExport().get()
+    task = {
+        'app_engine_http_request':
+            {
+                'relative_uri': urls.SPAM_DATA_EXPORT_TASK + '.do',
+                'body': '',
+                'headers': {
+                    'Content-type': 'application/x-www-form-urlencoded'
+                }
+            }
+    }
+    get_client_mock().create_task.assert_called_once()
+    ((_parent, called_task), _kwargs) = get_client_mock().create_task.call_args
+    self.assertEqual(called_task, task)
diff --git a/features/userhotlists.py b/features/userhotlists.py
new file mode 100644
index 0000000..330ab73
--- /dev/null
+++ b/features/userhotlists.py
@@ -0,0 +1,83 @@
+# 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
+
+"""Page for showing a user's hotlists."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import ezt
+
+from features import features_bizobj
+from features import hotlist_views
+from framework import framework_views
+from framework import servlet
+
+
+class UserHotlists(servlet.Servlet):
+  """Servlet to display all of a user's hotlists."""
+
+  _PAGE_TEMPLATE = 'features/user-hotlists.ezt'
+
+  def GatherPageData(self, mr):
+    viewed_users_hotlists = self.services.features.GetHotlistsByUserID(
+        mr.cnxn, mr.viewed_user_auth.user_id)
+
+    viewed_starred_hids = self.services.hotlist_star.LookupStarredItemIDs(
+        mr.cnxn, mr.viewed_user_auth.user_id)
+    viewed_users_starred_hotlists, _ = self.services.features.GetHotlistsByID(
+        mr.cnxn, viewed_starred_hids)
+
+    viewed_users_relevant_hotlists = viewed_users_hotlists + list(
+        set(viewed_users_starred_hotlists.values()) -
+        set(viewed_users_hotlists))
+
+    users_by_id = framework_views.MakeAllUserViews(
+        mr.cnxn, self.services.user,
+        features_bizobj.UsersInvolvedInHotlists(viewed_users_relevant_hotlists))
+
+    views = [hotlist_views.HotlistView(
+        hotlist_pb, mr.perms, mr.auth, mr.viewed_user_auth.user_id,
+        users_by_id, self.services.hotlist_star.IsItemStarredBy(
+            mr.cnxn, hotlist_pb.hotlist_id, mr.auth.user_id))
+        for hotlist_pb in viewed_users_relevant_hotlists]
+
+    # visible to viewer, not viewed_user
+    visible_hotlists = [view for view in views if view.visible]
+
+    owner_of_hotlists = [hotlist_view for hotlist_view in visible_hotlists
+                         if hotlist_view.role_name == 'owner']
+    editor_of_hotlists = [hotlist_view for hotlist_view in visible_hotlists
+                          if hotlist_view.role_name == 'editor']
+    follower_of_hotlists = [hotlist_view for hotlist_view in visible_hotlists
+                         if hotlist_view.role_name == '']
+    starred_hotlists = [hotlist_view for hotlist_view in visible_hotlists
+                        if hotlist_view.hotlist_id in viewed_starred_hids]
+
+    viewed_user_display_name = framework_views.GetViewedUserDisplayName(mr)
+
+    return {
+        'user_tab_mode': 'st6',
+        'viewed_user_display_name': viewed_user_display_name,
+        'owner_of_hotlists': owner_of_hotlists,
+        'editor_of_hotlists': editor_of_hotlists,
+        'follower_of_hotlists': follower_of_hotlists,
+        'starred_hotlists': starred_hotlists,
+        'viewing_user_page': ezt.boolean(True),
+        }
+
+  def GatherHelpData(self, mr, page_data):
+    """Return a dict of values to drive on-page user help.
+
+    Args:
+      mr: common information parsed from the HTTP 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(UserHotlists, self).GatherHelpData(mr, page_data)
+    help_data['cue'] = 'explain_hotlist_starring'
+    return help_data