Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/tracker/tracker_helpers.py b/tracker/tracker_helpers.py
new file mode 100644
index 0000000..c9f9e5a
--- /dev/null
+++ b/tracker/tracker_helpers.py
@@ -0,0 +1,1826 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Helper functions and classes used by the Monorail Issue Tracker pages.
+
+This module has functions that are reused in multiple servlets or
+other modules.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import collections
+import itertools
+import logging
+import re
+import time
+import urllib
+
+from google.appengine.api import app_identity
+
+from six import string_types
+
+import settings
+
+from features import federated
+from framework import authdata
+from framework import exceptions
+from framework import filecontent
+from framework import framework_bizobj
+from framework import framework_constants
+from framework import framework_helpers
+from framework import framework_views
+from framework import permissions
+from framework import sorting
+from framework import template_helpers
+from framework import urls
+from project import project_helpers
+from proto import tracker_pb2
+from services import client_config_svc
+from tracker import field_helpers
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+
+
+# HTML input field names for blocked on and blocking issue refs.
+BLOCKED_ON = 'blocked_on'
+BLOCKING = 'blocking'
+
+# This string is used in HTML form element names to identify custom fields.
+# E.g., a value for a custom field with field_id 12 would be specified in
+# an HTML form element with name="custom_12".
+_CUSTOM_FIELD_NAME_PREFIX = 'custom_'
+
+# When the attachment quota gets within 1MB of the limit, stop offering
+# users the option to attach files.
+_SOFT_QUOTA_LEEWAY = 1024 * 1024
+
+# Accessors for sorting built-in fields.
+SORTABLE_FIELDS = {
+    'project': lambda issue: issue.project_name,
+    'id': lambda issue: issue.local_id,
+    'owner': tracker_bizobj.GetOwnerId,  # And postprocessor
+    'reporter': lambda issue: issue.reporter_id,  # And postprocessor
+    'component': lambda issue: issue.component_ids,
+    'cc': tracker_bizobj.GetCcIds,  # And postprocessor
+    'summary': lambda issue: issue.summary.lower(),
+    'stars': lambda issue: issue.star_count,
+    'attachments': lambda issue: issue.attachment_count,
+    'opened': lambda issue: issue.opened_timestamp,
+    'closed': lambda issue: issue.closed_timestamp,
+    'modified': lambda issue: issue.modified_timestamp,
+    'status': tracker_bizobj.GetStatus,
+    'blocked': lambda issue: bool(issue.blocked_on_iids),
+    'blockedon': lambda issue: issue.blocked_on_iids or sorting.MAX_STRING,
+    'blocking': lambda issue: issue.blocking_iids or sorting.MAX_STRING,
+    'mergedinto': lambda issue: issue.merged_into or sorting.MAX_STRING,
+    'ownermodified': lambda issue: issue.owner_modified_timestamp,
+    'statusmodified': lambda issue: issue.status_modified_timestamp,
+    'componentmodified': lambda issue: issue.component_modified_timestamp,
+    'ownerlastvisit': tracker_bizobj.GetOwnerId,  # And postprocessor
+    }
+
+# Some fields take a user ID from the issue and then use that to index
+# into a dictionary of user views, and then get a field of the user view
+# as the value to sort key.
+SORTABLE_FIELDS_POSTPROCESSORS = {
+    'owner': lambda user_view: user_view.email,
+    'reporter': lambda user_view: user_view.email,
+    'cc': lambda user_view: user_view.email,
+    'ownerlastvisit': lambda user_view: -user_view.user.last_visit_timestamp,
+    }
+
+# Here are some restriction labels to help people do the most common things
+# that they might want to do with restrictions.
+_FREQUENT_ISSUE_RESTRICTIONS = [
+    (permissions.VIEW, permissions.EDIT_ISSUE,
+     'Only users who can edit the issue may access it'),
+    (permissions.ADD_ISSUE_COMMENT, permissions.EDIT_ISSUE,
+     'Only users who can edit the issue may add comments'),
+    ]
+
+# These issue restrictions should be offered as examples whenever the project
+# does not have any custom permissions in use already.
+_EXAMPLE_ISSUE_RESTRICTIONS = [
+    (permissions.VIEW, 'CoreTeam',
+     'Custom permission CoreTeam is needed to access'),
+    ]
+
+# Namedtuples that hold data parsed from post_data.
+ParsedComponents = collections.namedtuple(
+    'ParsedComponents', 'entered_str, paths, paths_remove')
+ParsedFields = collections.namedtuple(
+    'ParsedFields',
+    'vals, vals_remove, fields_clear, '
+    'phase_vals, phase_vals_remove')
+ParsedUsers = collections.namedtuple(
+    'ParsedUsers', 'owner_username, owner_id, cc_usernames, '
+    'cc_usernames_remove, cc_ids, cc_ids_remove')
+ParsedBlockers = collections.namedtuple(
+    'ParsedBlockers', 'entered_str, iids, dangling_refs, '
+    'federated_ref_strings')
+ParsedHotlistRef = collections.namedtuple(
+    'ParsedHotlistRef', 'user_email, hotlist_name')
+ParsedHotlists = collections.namedtuple(
+    'ParsedHotlists', 'entered_str, hotlist_refs')
+ParsedIssue = collections.namedtuple(
+    'ParsedIssue', 'summary, comment, is_description, status, users, labels, '
+    'labels_remove, components, fields, template_name, attachments, '
+    'kept_attachments, blocked_on, blocking, hotlists')
+
+
+def ParseIssueRequest(cnxn, post_data, services, errors, default_project_name):
+  """Parse all the possible arguments out of the request.
+
+  Args:
+    cnxn: connection to SQL database.
+    post_data: HTML form information.
+    services: Connections to persistence layer.
+    errors: object to accumulate validation error info.
+    default_project_name: name of the project that contains the issue.
+
+  Returns:
+    A namedtuple with all parsed information.  User IDs are looked up, but
+    also the strings are returned to allow bouncing the user back to correct
+    any errors.
+  """
+  summary = post_data.get('summary', '')
+  comment = post_data.get('comment', '')
+  is_description = bool(post_data.get('description', ''))
+  status = post_data.get('status', '')
+  template_name = urllib.unquote_plus(post_data.get('template_name', ''))
+  component_str = post_data.get('components', '')
+  label_strs = post_data.getall('label')
+
+  if is_description:
+    tmpl_txt = post_data.get('tmpl_txt', '')
+    comment = MarkupDescriptionOnInput(comment, tmpl_txt)
+
+  comp_paths, comp_paths_remove = _ClassifyPlusMinusItems(
+      re.split('[,;\s]+', component_str))
+  parsed_components = ParsedComponents(
+      component_str, comp_paths, comp_paths_remove)
+  labels, labels_remove = _ClassifyPlusMinusItems(label_strs)
+  parsed_fields = _ParseIssueRequestFields(post_data)
+  # TODO(jrobbins): change from numbered fields to a multi-valued field.
+  attachments = _ParseIssueRequestAttachments(post_data)
+  kept_attachments = _ParseIssueRequestKeptAttachments(post_data)
+  parsed_users = _ParseIssueRequestUsers(cnxn, post_data, services)
+  parsed_blocked_on = _ParseBlockers(
+      cnxn, post_data, services, errors, default_project_name, BLOCKED_ON)
+  parsed_blocking = _ParseBlockers(
+      cnxn, post_data, services, errors, default_project_name, BLOCKING)
+  parsed_hotlists = _ParseHotlists(post_data)
+
+  parsed_issue = ParsedIssue(
+      summary, comment, is_description, status, parsed_users, labels,
+      labels_remove, parsed_components, parsed_fields, template_name,
+      attachments, kept_attachments, parsed_blocked_on, parsed_blocking,
+      parsed_hotlists)
+  return parsed_issue
+
+
+def MarkupDescriptionOnInput(content, tmpl_text):
+  """Return HTML for the content of an issue description or comment.
+
+  Args:
+    content: the text sumbitted by the user, any user-entered markup
+             has already been escaped.
+    tmpl_text: the initial text that was put into the textarea.
+
+  Returns:
+    The description content text with template lines highlighted.
+  """
+  tmpl_lines = tmpl_text.split('\n')
+  tmpl_lines = [pl.strip() for pl in tmpl_lines if pl.strip()]
+
+  entered_lines = content.split('\n')
+  marked_lines = [_MarkupDescriptionLineOnInput(line, tmpl_lines)
+                  for line in entered_lines]
+  return '\n'.join(marked_lines)
+
+
+def _MarkupDescriptionLineOnInput(line, tmpl_lines):
+  """Markup one line of an issue description that was just entered.
+
+  Args:
+    line: string containing one line of the user-entered comment.
+    tmpl_lines: list of strings for the text of the template lines.
+
+  Returns:
+    The same user-entered line, or that line highlighted to
+    indicate that it came from the issue template.
+  """
+  for tmpl_line in tmpl_lines:
+    if line.startswith(tmpl_line):
+      return '<b>' + tmpl_line + '</b>' + line[len(tmpl_line):]
+
+  return line
+
+
+def _ClassifyPlusMinusItems(add_remove_list):
+  """Classify the given plus-or-minus items into add and remove lists."""
+  add_remove_set = {s.strip() for s in add_remove_list}
+  add_strs = [s for s in add_remove_set if s and not s.startswith('-')]
+  remove_strs = [s[1:] for s in add_remove_set if s[1:] and s.startswith('-')]
+  return add_strs, remove_strs
+
+
+def _ParseHotlists(post_data):
+  entered_str = post_data.get('hotlists', '').strip()
+  hotlist_refs = []
+  for ref_str in re.split('[,;\s]+', entered_str):
+    if not ref_str:
+      continue
+    if ':' in ref_str:
+      if ref_str.split(':')[0]:
+        # E-mail isn't empty; full reference.
+        hotlist_refs.append(ParsedHotlistRef(*ref_str.split(':', 1)))
+      else:
+        # Short reference.
+        hotlist_refs.append(ParsedHotlistRef(None, ref_str.split(':', 1)[1]))
+    else:
+      # Short reference
+      hotlist_refs.append(ParsedHotlistRef(None, ref_str))
+  parsed_hotlists = ParsedHotlists(entered_str, hotlist_refs)
+  return parsed_hotlists
+
+
+def _ParseIssueRequestFields(post_data):
+  """Iterate over post_data and return custom field values found in it."""
+  field_val_strs = {}
+  field_val_strs_remove = {}
+  phase_field_val_strs = collections.defaultdict(dict)
+  phase_field_val_strs_remove = collections.defaultdict(dict)
+  for key in post_data.keys():
+    if key.startswith(_CUSTOM_FIELD_NAME_PREFIX):
+      val_strs = [v for v in post_data.getall(key) if v]
+      if val_strs:
+        try:
+          field_id = int(key[len(_CUSTOM_FIELD_NAME_PREFIX):])
+          phase_name = None
+        except ValueError:  # key must be in format <field_id>_<phase_name>
+          field_id, phase_name = key[len(_CUSTOM_FIELD_NAME_PREFIX):].split(
+              '_', 1)
+          field_id = int(field_id)
+        if post_data.get('op_' + key) == 'remove':
+          if phase_name:
+            phase_field_val_strs_remove[field_id][phase_name] = val_strs
+          else:
+            field_val_strs_remove[field_id] = val_strs
+        else:
+          if phase_name:
+            phase_field_val_strs[field_id][phase_name] = val_strs
+          else:
+            field_val_strs[field_id] = val_strs
+
+  # TODO(jojwang): monorail:5154, no support for clearing phase field values.
+  fields_clear = []
+  op_prefix = 'op_' + _CUSTOM_FIELD_NAME_PREFIX
+  for op_key in post_data.keys():
+    if op_key.startswith(op_prefix):
+      if post_data.get(op_key) == 'clear':
+        field_id = int(op_key[len(op_prefix):])
+        fields_clear.append(field_id)
+
+  return ParsedFields(
+      field_val_strs, field_val_strs_remove, fields_clear,
+      phase_field_val_strs, phase_field_val_strs_remove)
+
+
+def _ParseIssueRequestAttachments(post_data):
+  """Extract and clean-up any attached files from the post data.
+
+  Args:
+    post_data: dict w/ values from the user's HTTP POST form data.
+
+  Returns:
+    [(filename, filecontents, mimetype), ...] with items for each attachment.
+  """
+  # TODO(jrobbins): change from numbered fields to a multi-valued field.
+  attachments = []
+  for i in range(1, 16):
+    if 'file%s' % i in post_data:
+      item = post_data['file%s' % i]
+      if isinstance(item, string_types):
+        continue
+      if '\\' in item.filename:  # IE insists on giving us the whole path.
+        item.filename = item.filename[item.filename.rindex('\\') + 1:]
+      if not item.filename:
+        continue  # Skip any FILE fields that were not filled in.
+      attachments.append((
+          item.filename, item.value,
+          filecontent.GuessContentTypeFromFilename(item.filename)))
+
+  return attachments
+
+
+def _ParseIssueRequestKeptAttachments(post_data):
+  """Extract attachment ids for attachments kept when updating description
+
+  Args:
+    post_data: dict w/ values from the user's HTTP POST form data.
+
+  Returns:
+    a list of attachment ids for kept attachments
+  """
+  kept_attachments = post_data.getall('keep-attachment')
+  return [int(aid) for aid in kept_attachments]
+
+
+def _ParseIssueRequestUsers(cnxn, post_data, services):
+  """Extract usernames from the POST data, categorize them, and look up IDs.
+
+  Args:
+    cnxn: connection to SQL database.
+    post_data: dict w/ data from the HTTP POST.
+    services: Services.
+
+  Returns:
+    A namedtuple (owner_username, owner_id, cc_usernames, cc_usernames_remove,
+    cc_ids, cc_ids_remove), containing:
+      - issue owner's name and user ID, if any
+      - the list of all cc'd usernames
+      - the user IDs to add or remove from the issue CC list.
+    Any of these user IDs may be  None if the corresponding username
+    or email address is invalid.
+  """
+  # Get the user-entered values from post_data.
+  cc_username_str = post_data.get('cc', '').lower()
+  owner_email = post_data.get('owner', '').strip().lower()
+
+  cc_usernames, cc_usernames_remove = _ClassifyPlusMinusItems(
+      re.split('[,;\s]+', cc_username_str))
+
+  # Figure out the email addresses to lookup and do the lookup.
+  emails_to_lookup = cc_usernames + cc_usernames_remove
+  if owner_email:
+    emails_to_lookup.append(owner_email)
+  all_user_ids = services.user.LookupUserIDs(
+      cnxn, emails_to_lookup, autocreate=True)
+  if owner_email:
+    owner_id = all_user_ids.get(owner_email)
+  else:
+    owner_id = framework_constants.NO_USER_SPECIFIED
+
+  # Lookup the user IDs of the Cc addresses to add or remove.
+  cc_ids = [all_user_ids.get(cc) for cc in cc_usernames if cc]
+  cc_ids_remove = [all_user_ids.get(cc) for cc in cc_usernames_remove if cc]
+
+  return ParsedUsers(owner_email, owner_id, cc_usernames, cc_usernames_remove,
+                     cc_ids, cc_ids_remove)
+
+
+def _ParseBlockers(cnxn, post_data, services, errors, default_project_name,
+                   field_name):
+  """Parse input for issues that the current issue is blocking/blocked on.
+
+  Args:
+    cnxn: connection to SQL database.
+    post_data: dict w/ values from the user's HTTP POST.
+    services: connections to backend services.
+    errors: object to accumulate validation error info.
+    default_project_name: name of the project that contains the issue.
+    field_name: string HTML input field name, e.g., BLOCKED_ON or BLOCKING.
+
+  Returns:
+    A namedtuple with the user input string, and a list of issue IDs.
+  """
+  entered_str = post_data.get(field_name, '').strip()
+  blocker_iids = []
+  dangling_ref_tuples = []
+  federated_ref_strings = []
+
+  issue_ref = None
+  for ref_str in re.split('[,;\s]+', entered_str):
+    # Handle federated references.
+    if federated.IsShortlinkValid(ref_str):
+      federated_ref_strings.append(ref_str)
+      continue
+
+    try:
+      issue_ref = tracker_bizobj.ParseIssueRef(ref_str)
+    except ValueError:
+      setattr(errors, field_name, 'Invalid issue ID %s' % ref_str.strip())
+      break
+
+    if not issue_ref:
+      continue
+
+    blocker_project_name, blocker_issue_id = issue_ref
+    if not blocker_project_name:
+      blocker_project_name = default_project_name
+
+    # Detect and report if the same issue was specified.
+    current_issue_id = int(post_data.get('id')) if post_data.get('id') else -1
+    if (blocker_issue_id == current_issue_id and
+        blocker_project_name == default_project_name):
+      setattr(errors, field_name, 'Cannot be %s the same issue' % field_name)
+      break
+
+    ref_projects = services.project.GetProjectsByName(
+        cnxn, set([blocker_project_name]))
+    blocker_iid, _misses = services.issue.ResolveIssueRefs(
+        cnxn, ref_projects, default_project_name, [issue_ref])
+    if not blocker_iid:
+      if blocker_project_name in settings.recognized_codesite_projects:
+        # We didn't find the issue, but it had a explicitly-specified project
+        # which we know is on Codesite. Allow it as a dangling reference.
+        dangling_ref_tuples.append(issue_ref)
+        continue
+      else:
+        # Otherwise, it doesn't exist, so report it.
+        setattr(errors, field_name, 'Invalid issue ID %s' % ref_str.strip())
+        break
+    if blocker_iid[0] not in blocker_iids:
+      blocker_iids.extend(blocker_iid)
+
+  blocker_iids.sort()
+  dangling_ref_tuples.sort()
+  return ParsedBlockers(entered_str, blocker_iids, dangling_ref_tuples,
+      federated_ref_strings)
+
+
+def PairDerivedValuesWithRuleExplanations(
+    proposed_issue, traces, derived_users_by_id):
+  """Pair up values and explanations into JSON objects."""
+  derived_labels_and_why = [
+      {'value': lab,
+       'why': traces.get((tracker_pb2.FieldID.LABELS, lab))}
+      for lab in proposed_issue.derived_labels]
+
+  derived_users_by_id = {
+      user_id: user_view.display_name
+      for user_id, user_view in derived_users_by_id.items()
+      if user_view.display_name}
+
+  derived_owner_and_why = []
+  if proposed_issue.derived_owner_id:
+    derived_owner_and_why = [{
+        'value': derived_users_by_id[proposed_issue.derived_owner_id],
+        'why': traces.get(
+            (tracker_pb2.FieldID.OWNER, proposed_issue.derived_owner_id))}]
+  derived_cc_and_why = [
+      {'value': derived_users_by_id[cc_id],
+       'why': traces.get((tracker_pb2.FieldID.CC, cc_id))}
+      for cc_id in proposed_issue.derived_cc_ids
+      if cc_id in derived_users_by_id]
+
+  warnings_and_why = [
+      {'value': warning,
+       'why': traces.get((tracker_pb2.FieldID.WARNING, warning))}
+      for warning in proposed_issue.derived_warnings]
+
+  errors_and_why = [
+      {'value': error,
+       'why': traces.get((tracker_pb2.FieldID.ERROR, error))}
+      for error in proposed_issue.derived_errors]
+
+  return (derived_labels_and_why, derived_owner_and_why, derived_cc_and_why,
+          warnings_and_why, errors_and_why)
+
+
+def IsValidIssueOwner(cnxn, project, owner_id, services):
+  """Return True if the given user ID can be an issue owner.
+
+  Args:
+    cnxn: connection to SQL database.
+    project: the current Project PB.
+    owner_id: the user ID of the proposed issue owner.
+    services: connections to backends.
+
+  It is OK to have 0 for the owner_id, that simply means that the issue is
+  unassigned.
+
+  Returns:
+    A pair (valid, err_msg).  valid is True if the given user ID can be an
+    issue owner. err_msg is an error message string to display to the user
+    if valid == False, and is None if valid == True.
+  """
+  # An issue is always allowed to have no owner specified.
+  if owner_id == framework_constants.NO_USER_SPECIFIED:
+    return True, None
+
+  try:
+    auth = authdata.AuthData.FromUserID(cnxn, owner_id, services)
+    if not framework_bizobj.UserIsInProject(project, auth.effective_ids):
+      return False, 'Issue owner must be a project member.'
+  except exceptions.NoSuchUserException:
+    return False, 'Issue owner user ID not found.'
+
+  group_ids = services.usergroup.DetermineWhichUserIDsAreGroups(
+      cnxn, [owner_id])
+  if owner_id in group_ids:
+    return False, 'Issue owner cannot be a user group.'
+
+  return True, None
+
+
+def GetAllowedOpenedAndClosedIssues(mr, issue_ids, services):
+  """Get filtered lists of open and closed issues identified by issue_ids.
+
+  The function then filters the results to only the issues that the user
+  is allowed to view.  E.g., we only auto-link to issues that the user
+  would be able to view if they clicked the link.
+
+  Args:
+    mr: commonly used info parsed from the request.
+    issue_ids: list of int issue IDs for the target issues.
+    services: connection to issue, config, and project persistence layers.
+
+  Returns:
+    Two lists of issues that the user is allowed to view: one for open
+    issues and one for closed issues.
+  """
+  open_issues, closed_issues = services.issue.GetOpenAndClosedIssues(
+      mr.cnxn, issue_ids)
+  return GetAllowedIssues(mr, [open_issues, closed_issues], services)
+
+
+def GetAllowedIssues(mr, issue_groups, services):
+  """Filter lists of issues identified by issue_groups.
+
+  Args:
+    mr: commonly used info parsed from the request.
+    issue_groups: list of list of issues to filter.
+    services: connection to issue, config, and project persistence layers.
+
+  Returns:
+    List of filtered list of issues.
+  """
+
+  project_dict = GetAllIssueProjects(
+      mr.cnxn, itertools.chain.from_iterable(issue_groups), services.project)
+  config_dict = services.config.GetProjectConfigs(mr.cnxn,
+      list(project_dict.keys()))
+  return [FilterOutNonViewableIssues(
+      mr.auth.effective_ids, mr.auth.user_pb, project_dict, config_dict,
+      issues)
+          for issues in issue_groups]
+
+
+def MakeViewsForUsersInIssues(cnxn, issue_list, user_service, omit_ids=None):
+  """Lookup all the users involved in any of the given issues.
+
+  Args:
+    cnxn: connection to SQL database.
+    issue_list: list of Issue PBs from a result query.
+    user_service: Connection to User backend storage.
+    omit_ids: a list of user_ids to omit, e.g., because we already have them.
+
+  Returns:
+    A dictionary {user_id: user_view,...} for all the users involved
+    in the given issues.
+  """
+  issue_participant_id_set = tracker_bizobj.UsersInvolvedInIssues(issue_list)
+  if omit_ids:
+    issue_participant_id_set.difference_update(omit_ids)
+
+  # TODO(jrobbins): consider caching View objects as well.
+  users_by_id = framework_views.MakeAllUserViews(
+      cnxn, user_service, issue_participant_id_set)
+
+  return users_by_id
+
+
+def FormatIssueListURL(
+    mr, config, absolute=True, project_names=None, **kwargs):
+  """Format a link back to list view as configured by user."""
+  if project_names is None:
+    project_names = [mr.project_name]
+  if tracker_constants.JUMP_RE.match(mr.query):
+    kwargs['q'] = 'id=%s' % mr.query
+    kwargs['can'] = 1  # The specified issue might be closed.
+  else:
+    kwargs['q'] = mr.query
+    if mr.can and mr.can != 2:
+      kwargs['can'] = mr.can
+  def_col_spec = config.default_col_spec
+  if mr.col_spec and mr.col_spec != def_col_spec:
+    kwargs['colspec'] = mr.col_spec
+  if mr.sort_spec:
+    kwargs['sort'] = mr.sort_spec
+  if mr.group_by_spec:
+    kwargs['groupby'] = mr.group_by_spec
+  if mr.start:
+    kwargs['start'] = mr.start
+  if mr.num != tracker_constants.DEFAULT_RESULTS_PER_PAGE:
+    kwargs['num'] = mr.num
+
+  if len(project_names) == 1:
+    url = '/p/%s%s' % (project_names[0], urls.ISSUE_LIST)
+  else:
+    url = urls.ISSUE_LIST
+    kwargs['projects'] = ','.join(sorted(project_names))
+
+  param_strings = ['%s=%s' % (k, urllib.quote((u'%s' % v).encode('utf-8')))
+                   for k, v in kwargs.items()]
+  if param_strings:
+    url += '?' + '&'.join(sorted(param_strings))
+  if absolute:
+    url = '%s://%s%s' % (mr.request.scheme, mr.request.host, url)
+
+  return url
+
+
+def FormatRelativeIssueURL(project_name, path, **kwargs):
+  """Format a URL to get to an issue in the named project.
+
+  Args:
+    project_name: string name of the project containing the issue.
+    path: string servlet path, e.g., from framework/urls.py.
+    **kwargs: additional query-string parameters to include in the URL.
+
+  Returns:
+    A URL string.
+  """
+  return framework_helpers.FormatURL(
+      None, '/p/%s%s' % (project_name, path), **kwargs)
+
+
+def FormatCrBugURL(project_name, local_id):
+  """Format a short URL to get to an issue in the named project.
+
+  Args:
+    project_name: string name of the project containing the issue.
+    local_id: int local ID of the issue.
+
+  Returns:
+    A URL string.
+  """
+  if app_identity.get_application_id() != 'monorail-prod':
+    return FormatRelativeIssueURL(
+      project_name, urls.ISSUE_DETAIL, id=local_id)
+
+  if project_name == 'chromium':
+    return 'https://crbug.com/%d' % local_id
+
+  return 'https://crbug.com/%s/%d' % (project_name, local_id)
+
+
+def ComputeNewQuotaBytesUsed(project, attachments):
+  """Add the given attachments to the project's attachment quota usage.
+
+  Args:
+    project: Project PB  for the project being updated.
+    attachments: a list of attachments being added to an issue.
+
+  Returns:
+    The new number of bytes used.
+
+  Raises:
+    OverAttachmentQuota: If project would go over quota.
+  """
+  total_attach_size = 0
+  for _filename, content, _mimetype in attachments:
+    total_attach_size += len(content)
+
+  new_bytes_used = project.attachment_bytes_used + total_attach_size
+  quota = (project.attachment_quota or
+           tracker_constants.ISSUE_ATTACHMENTS_QUOTA_HARD)
+  if new_bytes_used > quota:
+    raise exceptions.OverAttachmentQuota(new_bytes_used - quota)
+  return new_bytes_used
+
+
+def IsUnderSoftAttachmentQuota(project):
+  """Check the project's attachment quota against the soft quota limit.
+
+  If there is a custom quota on the project, this will check against
+  that instead of the system-wide default quota.
+
+  Args:
+    project: Project PB for the project to examine
+
+  Returns:
+    True if the project is under quota, false otherwise.
+  """
+  quota = tracker_constants.ISSUE_ATTACHMENTS_QUOTA_SOFT
+  if project.attachment_quota:
+    quota = project.attachment_quota - _SOFT_QUOTA_LEEWAY
+
+  return project.attachment_bytes_used < quota
+
+
+def GetAllIssueProjects(cnxn, issues, project_service):
+  """Get all the projects that the given issues belong to.
+
+  Args:
+    cnxn: connection to SQL database.
+    issues: list of issues, which may come from different projects.
+    project_service: connection to project persistence layer.
+
+  Returns:
+    A dictionary {project_id: project} of all the projects that
+    any of the given issues belongs to.
+  """
+  needed_project_ids = {issue.project_id for issue in issues}
+  project_dict = project_service.GetProjects(cnxn, needed_project_ids)
+  return project_dict
+
+
+def GetPermissionsInAllProjects(user, effective_ids, projects):
+  """Look up the permissions for the given user in each project."""
+  return {
+      project.project_id:
+      permissions.GetPermissions(user, effective_ids, project)
+      for project in projects}
+
+
+def FilterOutNonViewableIssues(
+    effective_ids, user, project_dict, config_dict, issues):
+  """Return a filtered list of issues that the user can view."""
+  perms_dict = GetPermissionsInAllProjects(
+      user, effective_ids, list(project_dict.values()))
+
+  denied_project_ids = {
+      pid for pid, p in project_dict.items()
+      if not permissions.CanView(effective_ids, perms_dict[pid], p, [])}
+
+  results = []
+  for issue in issues:
+    if issue.deleted or issue.project_id in denied_project_ids:
+      continue
+
+    if not permissions.HasRestrictions(issue):
+      may_view = True
+    else:
+      perms = perms_dict[issue.project_id]
+      project = project_dict[issue.project_id]
+      config = config_dict.get(issue.project_id, config_dict.get('harmonized'))
+      granted_perms = tracker_bizobj.GetGrantedPerms(
+          issue, effective_ids, config)
+      may_view = permissions.CanViewIssue(
+          effective_ids, perms, project, issue, granted_perms=granted_perms)
+
+    if may_view:
+      results.append(issue)
+
+  return results
+
+
+def MeansOpenInProject(status, config):
+  """Return true if this status means that the issue is still open.
+
+  This defaults to true if we could not find a matching status.
+
+  Args:
+    status: issue status string. E.g., 'New'.
+    config: the config of the current project.
+
+  Returns:
+    Boolean True if the status means that the issue is open.
+  """
+  status_lower = status.lower()
+
+  # iterate over the list of known statuses for this project
+  # return true if we find a match that declares itself to be open
+  for wks in config.well_known_statuses:
+    if wks.status.lower() == status_lower:
+      return wks.means_open
+
+  return True
+
+
+def IsNoisy(num_comments, num_starrers):
+  """Return True if this is a "noisy" issue that would send a ton of emails.
+
+  The rule is that a very active issue with a large number of comments
+  and starrers will only send notification when a comment (or change)
+  is made by a project member.
+
+  Args:
+    num_comments: int number of comments on issue so far.
+    num_starrers: int number of users who starred the issue.
+
+  Returns:
+    True if we will not bother starrers with an email notification for
+    changes made by non-members.
+  """
+  return (num_comments >= tracker_constants.NOISY_ISSUE_COMMENT_COUNT and
+          num_starrers >= tracker_constants.NOISY_ISSUE_STARRER_COUNT)
+
+
+def MergeCCsAndAddComment(services, mr, issue, merge_into_issue):
+  """Modify the CC field of the target issue and add a comment to it."""
+  return MergeCCsAndAddCommentMultipleIssues(
+      services, mr, [issue], merge_into_issue)
+
+
+def MergeCCsAndAddCommentMultipleIssues(
+    services, mr, issues, merge_into_issue):
+  """Modify the CC field of the target issue and add a comment to it."""
+  merge_comment = ''
+  for issue in issues:
+    if issue.project_name == merge_into_issue.project_name:
+      issue_ref_str = '%d' % issue.local_id
+    else:
+      issue_ref_str = '%s:%d' % (issue.project_name, issue.local_id)
+    if merge_comment:
+      merge_comment += '\n'
+    merge_comment += 'Issue %s has been merged into this issue.' % issue_ref_str
+
+  add_cc = _ComputeNewCcsFromIssueMerge(merge_into_issue, issues)
+
+  config = services.config.GetProjectConfig(
+      mr.cnxn, merge_into_issue.project_id)
+  delta = tracker_pb2.IssueDelta(cc_ids_add=add_cc)
+  _, merge_comment_pb = services.issue.DeltaUpdateIssue(
+    mr.cnxn, services, mr.auth.user_id, merge_into_issue.project_id,
+    config, merge_into_issue, delta, index_now=False, comment=merge_comment)
+
+  return merge_comment_pb
+
+
+def GetAttachmentIfAllowed(mr, services):
+  """Retrieve the requested attachment, or raise an appropriate exception.
+
+  Args:
+    mr: commonly used info parsed from the request.
+    services: connections to backend services.
+
+  Returns:
+    The requested Attachment PB, and the Issue that it belongs to.
+
+  Raises:
+    NoSuchAttachmentException: attachment was not found or was marked deleted.
+    NoSuchIssueException: issue that contains attachment was not found.
+    PermissionException: the user is not allowed to view the attachment.
+  """
+  attachment = None
+
+  attachment, cid, issue_id = services.issue.GetAttachmentAndContext(
+      mr.cnxn, mr.aid)
+
+  issue = services.issue.GetIssue(mr.cnxn, issue_id)
+  config = services.config.GetProjectConfig(mr.cnxn, issue.project_id)
+  granted_perms = tracker_bizobj.GetGrantedPerms(
+      issue, mr.auth.effective_ids, config)
+  permit_view = permissions.CanViewIssue(
+      mr.auth.effective_ids, mr.perms, mr.project, issue,
+      granted_perms=granted_perms)
+  if not permit_view:
+    raise permissions.PermissionException('Cannot view attachment\'s issue')
+
+  comment = services.issue.GetComment(mr.cnxn, cid)
+  commenter = services.user.GetUser(mr.cnxn, comment.user_id)
+  issue_perms = permissions.UpdateIssuePermissions(
+      mr.perms, mr.project, issue, mr.auth.effective_ids,
+      granted_perms=granted_perms)
+  can_view_comment = permissions.CanViewComment(
+      comment, commenter, mr.auth.user_id, issue_perms)
+  if not can_view_comment:
+    raise permissions.PermissionException('Cannot view attachment\'s comment')
+
+  return attachment, issue
+
+
+def LabelsMaskedByFields(config, field_names, trim_prefix=False):
+  """Return a list of EZTItems for labels that would be masked by fields."""
+  return _LabelsMaskedOrNot(config, field_names, trim_prefix=trim_prefix)
+
+
+def LabelsNotMaskedByFields(config, field_names, trim_prefix=False):
+  """Return a list of EZTItems for labels that would not be masked."""
+  return _LabelsMaskedOrNot(
+      config, field_names, invert=True, trim_prefix=trim_prefix)
+
+
+def _LabelsMaskedOrNot(config, field_names, invert=False, trim_prefix=False):
+  """Return EZTItems for labels that'd be masked. Or not, when invert=True."""
+  field_names = [fn.lower() for fn in field_names]
+  result = []
+  for wkl in config.well_known_labels:
+    masked_by = tracker_bizobj.LabelIsMaskedByField(wkl.label, field_names)
+    if (masked_by and not invert) or (not masked_by and invert):
+      display_name = wkl.label
+      if trim_prefix:
+        display_name = display_name[len(masked_by) + 1:]
+      result.append(template_helpers.EZTItem(
+          name=display_name,
+          name_padded=display_name.ljust(20),
+          commented='#' if wkl.deprecated else '',
+          docstring=wkl.label_docstring,
+          docstring_short=template_helpers.FitUnsafeText(
+              wkl.label_docstring, 40),
+          idx=len(result)))
+
+  return result
+
+
+def LookupComponentIDs(component_paths, config, errors=None):
+  """Look up the IDs of the specified components in the given config."""
+  component_ids = []
+  for path in component_paths:
+    if not path:
+      continue
+    cd = tracker_bizobj.FindComponentDef(path, config)
+    if cd:
+      component_ids.append(cd.component_id)
+    else:
+      error_text = 'Unknown component %s' % path
+      if errors:
+        errors.components = error_text
+      else:
+        logging.info(error_text)
+
+  return component_ids
+
+
+def ParsePostDataUsers(cnxn, pd_users_str, user_service):
+  """Parse all the usernames from a users string found in a post data."""
+  emails, _remove = _ClassifyPlusMinusItems(re.split('[,;\s]+', pd_users_str))
+  users_ids_by_email = user_service.LookupUserIDs(cnxn, emails, autocreate=True)
+  user_ids = [users_ids_by_email[username] for username in emails if username]
+  return user_ids, pd_users_str
+
+
+def FilterIssueTypes(config):
+  """Return a list of well-known issue types."""
+  well_known_issue_types = []
+  for wk_label in config.well_known_labels:
+    if wk_label.label.lower().startswith('type-'):
+      _, type_name = wk_label.label.split('-', 1)
+      well_known_issue_types.append(type_name)
+
+  return well_known_issue_types
+
+
+def ParseMergeFields(
+    cnxn, services, project_name, post_data, status, config, issue, errors):
+  """Parse info that identifies the issue to merge into, if any."""
+  merge_into_text = ''
+  merge_into_ref = None
+  merge_into_issue = None
+
+  if status not in config.statuses_offer_merge:
+    return '', None
+
+  merge_into_text = post_data.get('merge_into', '')
+  if merge_into_text:
+    try:
+      merge_into_ref = tracker_bizobj.ParseIssueRef(merge_into_text)
+    except ValueError:
+      logging.info('merge_into not an int: %r', merge_into_text)
+      errors.merge_into_id = 'Please enter a valid issue ID'
+
+  if not merge_into_ref:
+    errors.merge_into_id = 'Please enter an issue ID'
+    return merge_into_text, None
+
+  merge_into_project_name, merge_into_id = merge_into_ref
+  if (merge_into_id == issue.local_id and
+      (merge_into_project_name == project_name or
+       not merge_into_project_name)):
+    logging.info('user tried to merge issue into itself: %r', merge_into_ref)
+    errors.merge_into_id = 'Cannot merge issue into itself'
+    return merge_into_text, None
+
+  project = services.project.GetProjectByName(
+      cnxn, merge_into_project_name or project_name)
+  try:
+    # Because we will modify this issue, load from DB rather than cache.
+    merge_into_issue = services.issue.GetIssueByLocalID(
+        cnxn, project.project_id, merge_into_id, use_cache=False)
+  except Exception:
+    logging.info('merge_into issue not found: %r', merge_into_ref)
+    errors.merge_into_id = 'No such issue'
+    return merge_into_text, None
+
+  return merge_into_text, merge_into_issue
+
+
+def GetNewIssueStarrers(cnxn, services, issue_ids, merge_into_iid):
+  # type: (MonorailConnection, Services, Sequence[int], int) ->
+  #     Collection[int]
+  """Get starrers of current issue who have not starred the target issue."""
+  source_starrers_dict = services.issue_star.LookupItemsStarrers(
+      cnxn, issue_ids)
+  source_starrers = list(
+      itertools.chain.from_iterable(source_starrers_dict.values()))
+  target_starrers = services.issue_star.LookupItemStarrers(
+      cnxn, merge_into_iid)
+  return set(source_starrers) - set(target_starrers)
+
+
+def AddIssueStarrers(
+    cnxn, services, mr, merge_into_iid, merge_into_project, new_starrers):
+  """Merge all the starrers for the current issue into the target issue."""
+  project = merge_into_project or mr.project
+  config = services.config.GetProjectConfig(mr.cnxn, project.project_id)
+  services.issue_star.SetStarsBatch(
+      cnxn, services, config, merge_into_iid, new_starrers, True)
+
+
+def IsMergeAllowed(merge_into_issue, mr, services):
+  """Check to see if user has permission to merge with specified issue."""
+  merge_into_project = services.project.GetProjectByName(
+      mr.cnxn, merge_into_issue.project_name)
+  merge_into_config = services.config.GetProjectConfig(
+      mr.cnxn, merge_into_project.project_id)
+  merge_granted_perms = tracker_bizobj.GetGrantedPerms(
+      merge_into_issue, mr.auth.effective_ids, merge_into_config)
+
+  merge_view_allowed = mr.perms.CanUsePerm(
+      permissions.VIEW, mr.auth.effective_ids,
+      merge_into_project, permissions.GetRestrictions(merge_into_issue),
+      granted_perms=merge_granted_perms)
+  merge_edit_allowed = mr.perms.CanUsePerm(
+      permissions.EDIT_ISSUE, mr.auth.effective_ids,
+      merge_into_project, permissions.GetRestrictions(merge_into_issue),
+      granted_perms=merge_granted_perms)
+
+  return merge_view_allowed and merge_edit_allowed
+
+
+def GetVisibleMembers(mr, project, services):
+  all_member_ids = project_helpers.AllProjectMembers(project)
+
+  all_group_ids = services.usergroup.DetermineWhichUserIDsAreGroups(
+      mr.cnxn, all_member_ids)
+
+  (ac_exclusion_ids, no_expand_ids
+   ) = services.project.GetProjectAutocompleteExclusion(
+      mr.cnxn, project.project_id)
+
+  group_ids_to_expand = [
+    gid for gid in all_group_ids if gid not in no_expand_ids]
+
+  # TODO(jrobbins): Normally, users will be allowed view the members
+  # of any user group if the project From: email address is listed
+  # as a group member, as well as any group that they are personally
+  # members of.
+  member_ids, owner_ids = services.usergroup.LookupVisibleMembers(
+      mr.cnxn, group_ids_to_expand, mr.perms, mr.auth.effective_ids, services)
+  indirect_user_ids = set()
+  for gids in member_ids.values():
+    indirect_user_ids.update(gids)
+  for gids in owner_ids.values():
+    indirect_user_ids.update(gids)
+
+  visible_member_ids = _FilterMemberData(
+      mr, project.owner_ids, project.committer_ids, project.contributor_ids,
+      indirect_user_ids, project)
+
+  visible_member_ids = _MergeLinkedMembers(
+      mr.cnxn, services.user, visible_member_ids)
+
+  visible_member_views = framework_views.MakeAllUserViews(
+      mr.cnxn, services.user, visible_member_ids, group_ids=all_group_ids)
+  framework_views.RevealAllEmailsToMembers(
+      mr.cnxn, services, mr.auth, visible_member_views, project)
+
+  # Filter out service accounts
+  service_acct_emails = set(
+      client_config_svc.GetClientConfigSvc().GetClientIDEmails()[1])
+  visible_member_views = {
+      m.user_id: m
+      for m in visible_member_views.values()
+      # Hide service accounts from autocomplete.
+      if not framework_helpers.IsServiceAccount(
+          m.email, client_emails=service_acct_emails)
+      # Hide users who opted out of autocomplete.
+      and not m.user_id in ac_exclusion_ids
+      # Hide users who have obscured email addresses.
+      and not m.obscure_email
+  }
+
+  return visible_member_views
+
+
+def _MergeLinkedMembers(cnxn, user_service, user_ids):
+  """Remove any linked child accounts if the parent would also be shown."""
+  all_ids = set(user_ids)
+  users_by_id = user_service.GetUsersByIDs(cnxn, user_ids)
+  result = [uid for uid in user_ids
+            if users_by_id[uid].linked_parent_id not in all_ids]
+  return result
+
+
+def _FilterMemberData(
+    mr, owner_ids, committer_ids, contributor_ids, indirect_member_ids,
+    project):
+  """Return a filtered list of members that the user can view.
+
+  In most projects, everyone can view the entire member list.  But,
+  some projects are configured to only allow project owners to see
+  all members. In those projects, committers and contributors do not
+  see any contributors.  Regardless of how the project is configured
+  or the role that the user plays in the current project, we include
+  any indirect members through user groups that the user has access
+  to view.
+
+  Args:
+    mr: Commonly used info parsed from the HTTP request.
+    owner_views: list of user IDs for project owners.
+    committer_views: list of user IDs for project committers.
+    contributor_views: list of user IDs for project contributors.
+    indirect_member_views: list of user IDs for users who have
+        an indirect role in the project via a user group, and that the
+        logged in user is allowed to see.
+    project: the Project we're interested in.
+
+  Returns:
+    A list of owners, committer and visible indirect members if the user is not
+    signed in.  If the project is set to display contributors to non-owners or
+    the signed in user has necessary permissions then additionally a list of
+    contributors.
+  """
+  visible_members_ids = set()
+
+  # Everyone can view owners and committers
+  visible_members_ids.update(owner_ids)
+  visible_members_ids.update(committer_ids)
+
+  # The list of indirect members is already limited to ones that the user
+  # is allowed to see according to user group settings.
+  visible_members_ids.update(indirect_member_ids)
+
+  # If the user is allowed to view the list of contributors, add those too.
+  if permissions.CanViewContributorList(mr, project):
+    visible_members_ids.update(contributor_ids)
+
+  return sorted(visible_members_ids)
+
+
+def GetLabelOptions(config, custom_permissions):
+  """Prepares label options for autocomplete."""
+  labels = []
+  field_names = [
+    fd.field_name
+    for fd in config.field_defs
+    if not fd.is_deleted
+    and fd.field_type is tracker_pb2.FieldTypes.ENUM_TYPE
+  ]
+  non_masked_labels = LabelsNotMaskedByFields(config, field_names)
+  for wkl in non_masked_labels:
+    if not wkl.commented:
+      item = {'name': wkl.name, 'doc': wkl.docstring}
+      labels.append(item)
+
+  frequent_restrictions = _FREQUENT_ISSUE_RESTRICTIONS[:]
+  if not custom_permissions:
+    frequent_restrictions.extend(_EXAMPLE_ISSUE_RESTRICTIONS)
+
+  labels.extend(_BuildRestrictionChoices(
+      frequent_restrictions, permissions.STANDARD_ISSUE_PERMISSIONS,
+      custom_permissions))
+
+  return labels
+
+
+def _BuildRestrictionChoices(freq_restrictions, actions, custom_permissions):
+  """Return a list of autocompletion choices for restriction labels.
+
+  Args:
+    freq_restrictions: list of (action, perm, doc) tuples for restrictions
+        that are frequently used.
+    actions: list of strings for actions that are relevant to the current
+      artifact.
+    custom_permissions: list of strings with custom permissions for the project.
+
+  Returns:
+    A list of dictionaries [{'name': 'perm name', 'doc': 'docstring'}, ...]
+    suitable for use in a JSON feed to our JS autocompletion functions.
+  """
+  choices = []
+
+  for action, perm, doc in freq_restrictions:
+    choices.append({
+        'name': 'Restrict-%s-%s' % (action, perm),
+        'doc': doc,
+        })
+
+  for action in actions:
+    for perm in custom_permissions:
+      choices.append({
+          'name': 'Restrict-%s-%s' % (action, perm),
+          'doc': 'Permission %s needed to use %s' % (perm, action),
+          })
+
+  return choices
+
+
+def FilterKeptAttachments(
+    is_description, kept_attachments, comments, approval_id):
+  """Filter kept attachments to be a subset of last description's attachments.
+
+  Args:
+    is_description: bool, if the comment is a change to the issue description.
+    kept_attachments: list of ints with the attachment ids for attachments
+        kept from previous descriptions, if the comment is a change to the
+        issue description.
+    comments: list of IssueComment PBs for the issue we want to edit.
+    approval_id: int id of the APPROVAL_TYPE fielddef, if we're editing an
+        approval description, or None otherwise.
+
+  Returns:
+    A list of kept_attachment ids that are a subset of the last description.
+  """
+  if not is_description:
+    return None
+
+  attachment_ids = set()
+  for comment in reversed(comments):
+    if comment.is_description and comment.approval_id == approval_id:
+      attachment_ids = set([a.attachment_id for a in comment.attachments])
+      break
+
+  kept_attachments = [
+      aid for aid in kept_attachments if aid in attachment_ids]
+  return kept_attachments
+
+
+def _GetEnumFieldValuesAndDocstrings(field_def, config):
+  # type: (proto.tracker_pb2.LabelDef, proto.tracker_pb2.ProjectIssueConfig) ->
+  #     Sequence[tuple(string, string)]
+  """Get sequence of value, docstring tuples for an enum field"""
+  label_defs = config.well_known_labels
+  lower_field_name = field_def.field_name.lower()
+  tuples = []
+  for ld in label_defs:
+    if (ld.label.lower().startswith(lower_field_name + '-') and
+        not ld.deprecated):
+      label_value = ld.label[len(lower_field_name) + 1:]
+      tuples.append((label_value, ld.label_docstring))
+    else:
+      continue
+  return tuples
+
+
+# _IssueChangesTuple is returned by ApplyAllIssueChanges() and is used to bundle
+# the updated issues. resulting amendments, and other information needed by the
+# called to process the changes in the DB and send notifications.
+_IssueChangesTuple = collections.namedtuple(
+    '_IssueChangesTuple', [
+        'issues_to_update_dict', 'merged_from_add_by_iid', 'amendments_by_iid',
+        'imp_amendments_by_iid', 'old_owners_by_iid', 'old_statuses_by_iid',
+        'old_components_by_iid', 'new_starrers_by_iid'
+    ])
+# type: (Mapping[int, Issue], DefaultDict[int, Sequence[int]],
+#     Mapping[int, Amendment], Mapping[int, Amendment], Mapping[int, int],
+#     Mapping[int, str], Mapping[int, Sequence[int]],
+#     Mapping[int, Sequence[int]])-> None
+
+
+def ApplyAllIssueChanges(cnxn, issue_delta_pairs, services):
+  # type: (MonorailConnection, Sequence[Tuple[Issue, IssueDelta]], Services) ->
+  #     IssueChangesTuple
+  """Modify the given issues with the given deltas and impacted issues in RAM.
+
+    Filter rules are not applied in this method.
+    This method implements phases 3 and 4 of the process for modifying issues.
+    See WorkEnv.ModifyIssues() for other phases and overall process.
+
+    Args:
+      cnxn: MonorailConnection object.
+      issue_delta_pairs: List of tuples that couple Issues with the IssueDeltas
+          that represent the updates we want to make to each Issue.
+      services: Services object for connection to backend services.
+
+    Returns:
+      An _IssueChangesTuple named tuple.
+  """
+  impacted_tracker = _IssueChangeImpactedIssues()
+  project_ids = {issue.project_id for issue, _delta in issue_delta_pairs}
+  configs_by_pid = services.config.GetProjectConfigs(cnxn, list(project_ids))
+
+  # Track issues which have been modified in RAM and will need to
+  # be updated in the DB.
+  issues_to_update_dict = {}
+
+  amendments_by_iid = {}
+  old_owners_by_iid = {}
+  old_statuses_by_iid = {}
+  old_components_by_iid = {}
+  # PHASE 3: Update the main issues in RAM (not indirectly, impacted issues).
+  for issue, delta in issue_delta_pairs:
+    # Cache old data that will be used by future computations.
+    old_owner = tracker_bizobj.GetOwnerId(issue)
+    old_status = tracker_bizobj.GetStatus(issue)
+    if delta.owner_id is not None and delta.owner_id != old_owner:
+      old_owners_by_iid[issue.issue_id] = old_owner
+    if delta.status is not None and delta.status != old_status:
+      old_statuses_by_iid[issue.issue_id] = old_status
+    new_components = set(issue.component_ids)
+    new_components.update(delta.comp_ids_add or [])
+    new_components.difference_update(delta.comp_ids_remove or [])
+    if set(issue.component_ids) != new_components:
+      old_components_by_iid[issue.issue_id] = issue.component_ids
+
+    impacted_tracker.TrackImpactedIssues(issue, delta)
+    config = configs_by_pid.get(issue.project_id)
+    amendments, _impacted_iids = tracker_bizobj.ApplyIssueDelta(
+        cnxn, services.issue, issue, delta, config)
+    if amendments:
+      issues_to_update_dict[issue.issue_id] = issue
+      amendments_by_iid[issue.issue_id] = amendments
+
+  # PHASE 4: Update impacted issues in RAM.
+  logging.info('Applying impacted issue changes: %r', impacted_tracker.__dict__)
+  imp_amendments_by_iid = {}
+  impacted_iids = impacted_tracker.ComputeAllImpactedIIDs()
+  new_starrers_by_iid = {}
+  for issue_id in impacted_iids:
+    # Changes made to an impacted issue should be on top of changes
+    # made to it in PHASE 3 where it might have been a 'main' issue.
+    issue = issues_to_update_dict.get(
+        issue_id, services.issue.GetIssue(cnxn, issue_id, use_cache=False))
+
+    # Apply impacted changes.
+    amendments, new_starrers = impacted_tracker.ApplyImpactedIssueChanges(
+        cnxn, issue, services)
+    if amendments:
+      imp_amendments_by_iid[issue.issue_id] = amendments
+      issues_to_update_dict[issue.issue_id] = issue
+      if new_starrers:
+        new_starrers_by_iid[issue.issue_id] = new_starrers
+
+  return _IssueChangesTuple(
+      issues_to_update_dict, impacted_tracker.merged_from_add,
+      amendments_by_iid, imp_amendments_by_iid, old_owners_by_iid,
+      old_statuses_by_iid, old_components_by_iid, new_starrers_by_iid)
+
+
+def UpdateClosedTimestamp(config, issue, old_effective_status):
+  # type: (proto.tracker_pb2.ProjectIssueConfig, proto.tracker_pb2.Issue, str)
+  #     -> None
+  """Sets or unsets the closed_timestamp based based on status changes.
+
+  If the status is changing from open to closed, the closed_timestamp is set to
+  the current time.
+
+  If the status is changing form closed to open, the close_timestamp is unset.
+
+  If the status is changing from one closed to another closed, or from one
+  open to another open, no operations are performed.
+
+  Args:
+    config: the project configuration
+    issue: the issue being updated (a protocol buffer)
+    old_effective_status: the old issue status string. E.g., 'New'
+
+  SIDE EFFECTS:
+    Updated issue in place with new closed timestamp.
+  """
+  old_effective_status = old_effective_status or ''
+  # open -> closed
+  if (MeansOpenInProject(old_effective_status, config) and
+      not MeansOpenInProject(tracker_bizobj.GetStatus(issue), config)):
+
+    issue.closed_timestamp = int(time.time())
+    return
+
+  # closed -> open
+  if (not MeansOpenInProject(old_effective_status, config) and
+      MeansOpenInProject(tracker_bizobj.GetStatus(issue), config)):
+
+    issue.reset('closed_timestamp')
+    return
+
+
+def GroupUniqueDeltaIssues(issue_delta_pairs):
+  # type: (Tuple[Issue, IssueDelta]) -> (
+  #     Sequence[IssueDelta], Sequence[Sequence[Issue]])
+  """Identifies unique IssueDeltas and groups Issues with identical IssueDeltas.
+
+    Args:
+      issue_delta_pairs: List of tuples that couple Issues with the IssueDeltas
+          that represent the updates we want to make to each Issue.
+
+    Returns:
+      (unique_deltas, issues_for_unique_deltas):
+      unique_deltas: List of unique IssueDeltas found in issue_delta_pairs.
+      issues_for_unique_deltas: List of Issue lists. Each Issue list
+              contains all the Issues that had identical IssueDeltas.
+              Each issues_for_unique_deltas[i] is the list of Issues
+              that had unique_deltas[i] as their IssueDeltas.
+  """
+  unique_deltas = []
+  issues_for_unique_deltas = []
+  for issue, delta in issue_delta_pairs:
+    try:
+      delta_index = unique_deltas.index(delta)
+      issues_for_unique_deltas[delta_index].append(issue)
+    except ValueError:
+      # delta is not in unique_deltas yet.
+      # Add delta to unique_deltas and add a new list of issues
+      # to issues_for_unique_deltas at the same index.
+      unique_deltas.append(delta)
+      issues_for_unique_deltas.append([issue])
+
+  return unique_deltas, issues_for_unique_deltas
+
+
+def _AssertNoConflictingDeltas(issue_delta_pairs, refs_dict, err_agg):
+  # type: (Sequence[Tuple[Issue, IssueDelta]], Mapping[int, str],
+  #     exceptions.ErrorAggregator) -> None
+  """Checks if any issue deltas conflict with each other or themselves.
+
+  Note: refs_dict should contain issue ref strings for all issues found
+      in issue_delta_pairs, including all issues found in
+      {blocked_on|blocking}_{add|remove}.
+  """
+  err_message = 'Changes for {} conflict for {}'
+
+  # Track all delta blocked_on_add and blocking_add in terms of
+  # 'blocking_add' so we can track when a {blocked_on|blocking}_remove
+  # is in conflict with some {blocked_on|blocking}_add.
+  blocking_add = collections.defaultdict(list)
+  for issue, delta in issue_delta_pairs:
+    blocking_add[issue.issue_id].extend(delta.blocking_add)
+
+    for imp_iid in delta.blocked_on_add:
+      blocking_add[imp_iid].append(issue.issue_id)
+
+  # Check *_remove for conflicts with tracking blocking_add.
+  for issue, delta in issue_delta_pairs:
+    added_iids = blocking_add[issue.issue_id]
+    # Get intersection of iids that are in `blocking_remove` and
+    # the tracked `blocking_add`.
+    conflict_iids = set(delta.blocking_remove) & set(added_iids)
+
+    # Get iids of `blocked_on_remove` that conflict with the
+    # tracked `blocking_add`.
+    for possible_conflict_iid in delta.blocked_on_remove:
+      if issue.issue_id in blocking_add[possible_conflict_iid]:
+        conflict_iids.add(possible_conflict_iid)
+
+    if conflict_iids:
+      refs_str = ', '.join([refs_dict[iid] for iid in conflict_iids])
+      err_agg.AddErrorMessage(err_message, refs_dict[issue.issue_id], refs_str)
+
+
+def PrepareIssueChanges(
+    cnxn,
+    issue_delta_pairs,
+    services,
+    attachment_uploads=None,
+    comment_content=None):
+  # type: (MonorailConnection, Sequence[Tuple[Issue, IssueDelta]], Services,
+  #     Optional[Sequence[framework_helpers.AttachmentUpload]], Optional[str])
+  #     -> Mapping[int, int]
+  """Clean the deltas and assert they are valid for each paired issue."""
+  _EnforceNonMergeStatusDeltas(cnxn, issue_delta_pairs, services)
+  _AssertIssueChangesValid(
+      cnxn, issue_delta_pairs, services, comment_content=comment_content)
+
+  if attachment_uploads:
+    return _EnforceAttachmentQuotaLimits(
+        cnxn, issue_delta_pairs, services, attachment_uploads)
+  return {}
+
+
+def _EnforceAttachmentQuotaLimits(
+    cnxn, issue_delta_pairs, services, attachment_uploads):
+  # type: (MonorailConnection, Sequence[Tuple[Issue, IssueDelta]], Services
+  #     Optional[Sequence[framework_helpers.AttachmentUpload]]
+  #     -> Mapping[int, int]
+  """Assert that the attachments don't exceed project quotas."""
+  issue_count_by_pid = collections.defaultdict(int)
+  for issue, _delta in issue_delta_pairs:
+    issue_count_by_pid[issue.project_id] += 1
+
+  projects_by_id = services.project.GetProjects(cnxn, issue_count_by_pid.keys())
+
+  new_bytes_by_pid = {}
+  with exceptions.ErrorAggregator(exceptions.OverAttachmentQuota) as err_agg:
+    for pid, count in issue_count_by_pid.items():
+      project = projects_by_id[pid]
+      try:
+        new_bytes_used = ComputeNewQuotaBytesUsed(
+            project, attachment_uploads * count)
+        new_bytes_by_pid[pid] = new_bytes_used
+      except exceptions.OverAttachmentQuota:
+        err_agg.AddErrorMessage(
+            'Attachment quota exceeded for project {}', project.project_name)
+  return new_bytes_by_pid
+
+
+def _AssertIssueChangesValid(
+    cnxn, issue_delta_pairs, services, comment_content=None):
+  # type: (MonorailConnection, Sequence[Tuple[Issue, IssueDelta]], Services,
+  #     Optional[str]) -> None
+  """Assert that the delta changes are valid for each paired issue.
+
+    Note: this method does not check if the changes trigger any FilterRule
+      `warnings` or `errors`.
+  """
+  project_ids = list(
+      {issue.project_id for (issue, _delta) in issue_delta_pairs})
+  projects_by_id = services.project.GetProjects(cnxn, project_ids)
+  configs_by_id = services.config.GetProjectConfigs(cnxn, project_ids)
+  refs_dict = {
+      iss.issue_id: '%s:%d' % (iss.project_name, iss.local_id)
+      for iss, _delta in issue_delta_pairs
+  }
+  # Add refs of deltas' blocking/blocked_on issues needed by
+  # _AssertNoConflictingDeltas.
+  relation_iids = set()
+  for _iss, delta in issue_delta_pairs:
+    relation_iids.update(
+        delta.blocked_on_remove + delta.blocking_remove + delta.blocked_on_add +
+        delta.blocking_add)
+  relation_issues_dict, misses = services.issue.GetIssuesDict(
+      cnxn, relation_iids)
+  if misses:
+    raise exceptions.NoSuchIssueException(
+        'Could not find issues with ids: %r' % misses)
+  for iid, iss in relation_issues_dict.items():
+    if iid not in refs_dict:
+      refs_dict[iid] = '%s:%d' % (iss.project_name, iss.local_id)
+
+  with exceptions.ErrorAggregator(exceptions.InputException) as err_agg:
+    if (comment_content and
+        len(comment_content.strip()) > tracker_constants.MAX_COMMENT_CHARS):
+      err_agg.AddErrorMessage('Comment is too long.')
+
+    _AssertNoConflictingDeltas(issue_delta_pairs, refs_dict, err_agg)
+
+    for issue, delta in issue_delta_pairs:
+      project = projects_by_id.get(issue.project_id)
+      config = configs_by_id.get(issue.project_id)
+      issue_ref = refs_dict[issue.issue_id]
+
+      if (delta.merged_into is not None or
+          delta.merged_into_external is not None or delta.status is not None):
+        end_status = delta.status or issue.status
+        merged_options = [
+            delta.merged_into, delta.merged_into_external, issue.merged_into,
+            issue.merged_into_external
+        ]
+        end_merged_into = next(
+            (merge for merge in merged_options if merge is not None), None)
+
+        is_merge_status = end_status.lower() in [
+            status.lower() for status in config.statuses_offer_merge
+        ]
+
+        if ((is_merge_status and not end_merged_into) or
+            (not is_merge_status and end_merged_into)):
+          err_agg.AddErrorMessage(
+              '{}: MERGED type statuses must accompany mergedInto values.',
+              issue_ref)
+
+      if delta.merged_into and issue.issue_id == delta.merged_into:
+        err_agg.AddErrorMessage(
+            '{}: Cannot merge an issue into itself.', issue_ref)
+      if (issue.issue_id in set(
+          delta.blocked_on_add)) or (issue.issue_id in set(delta.blocking_add)):
+        err_agg.AddErrorMessage(
+            '{}: Cannot block an issue on itself.', issue_ref)
+      if (delta.owner_id is not None) and (delta.owner_id != issue.owner_id):
+        parsed_owner_valid, msg = IsValidIssueOwner(
+            cnxn, project, delta.owner_id, services)
+        if not parsed_owner_valid:
+          err_agg.AddErrorMessage('{}: {}', issue_ref, msg)
+      # Owner already check by IsValidIssueOwner
+      all_users = [uid for uid in delta.cc_ids_add]
+      field_users = [fv.user_id for fv in delta.field_vals_add if fv.user_id]
+      all_users.extend(field_users)
+      AssertUsersExist(cnxn, services, all_users, err_agg)
+      if (delta.summary and
+          len(delta.summary.strip()) > tracker_constants.MAX_SUMMARY_CHARS):
+        err_agg.AddErrorMessage('{}: Summary is too long.', issue_ref)
+      if delta.summary == '':
+        err_agg.AddErrorMessage('{}: Summary required.', issue_ref)
+      if delta.status == '':
+        err_agg.AddErrorMessage('{}: Status is required.', issue_ref)
+      # Do not pass in issue for validation, as issue is pre-update, and would
+      # result in being unable to edit issues in invalid states.
+      fvs_err_msgs = field_helpers.ValidateCustomFields(
+          cnxn, services, delta.field_vals_add, config, project)
+      if fvs_err_msgs:
+        err_agg.AddErrorMessage('{}: {}', issue_ref, '\n'.join(fvs_err_msgs))
+      # TODO(crbug.com/monorail/9156): Validate that we do not remove fields
+      # such that a required field becomes unset.
+
+
+def AssertUsersExist(cnxn, services, user_ids, err_agg):
+  # type: (MonorailConnection, Services, Sequence[int], ErrorAggregator) -> None
+  """Assert that all users exist.
+
+    Has the side-effect of adding error messages to the input ErrorAggregator.
+  """
+  users_dict = services.user.GetUsersByIDs(cnxn, user_ids, skip_missed=True)
+  found_ids = set(users_dict.keys())
+  missing = [user_id for user_id in user_ids if user_id not in found_ids]
+  for missing_user_id in missing:
+    err_agg.AddErrorMessage(
+        'users/{}: User does not exist.'.format(missing_user_id))
+
+
+def AssertValidIssueForCreate(cnxn, services, issue, description):
+  # type: (MonorailConnection, Services, Issue, str) -> None
+  """Assert that issue proto is valid for issue creation.
+
+  Args:
+    cnxn: A connection object to use services with.
+    services: An object containing services to use to look up relevant data.
+    issues: A PB containing the issue to validate.
+    description: The description for the issue.
+
+  Raises:
+    InputException if the issue is not valid.
+  """
+  project = services.project.GetProject(cnxn, issue.project_id)
+  config = services.config.GetProjectConfig(cnxn, issue.project_id)
+
+  with exceptions.ErrorAggregator(exceptions.InputException) as err_agg:
+    owner_is_valid, owner_err_msg = IsValidIssueOwner(
+        cnxn, project, issue.owner_id, services)
+    if not owner_is_valid:
+      err_agg.AddErrorMessage(owner_err_msg)
+    if not issue.summary.strip():
+      err_agg.AddErrorMessage('Summary is required')
+    if not description.strip():
+      err_agg.AddErrorMessage('Description is required')
+    if len(issue.summary) > tracker_constants.MAX_SUMMARY_CHARS:
+      err_agg.AddErrorMessage('Summary is too long')
+    if len(description) > tracker_constants.MAX_COMMENT_CHARS:
+      err_agg.AddErrorMessage('Description is too long')
+
+    # Check all users exist. Owner already check by IsValidIssueOwner.
+    all_users = [uid for uid in issue.cc_ids]
+    for av in issue.approval_values:
+      all_users.extend(av.approver_ids)
+    field_users = [fv.user_id for fv in issue.field_values if fv.user_id]
+    all_users.extend(field_users)
+    AssertUsersExist(cnxn, services, all_users, err_agg)
+
+    field_validity_errors = field_helpers.ValidateCustomFields(
+        cnxn, services, issue.field_values, config, project, issue=issue)
+    if field_validity_errors:
+      err_agg.AddErrorMessage("\n".join(field_validity_errors))
+    if not services.config.LookupStatusID(cnxn, issue.project_id, issue.status,
+                                          autocreate=False):
+      err_agg.AddErrorMessage('Undefined status: %s' % issue.status)
+    all_comp_ids = {
+        cd.component_id for cd in config.component_defs if not cd.deprecated
+    }
+    for comp_id in issue.component_ids:
+      if comp_id not in all_comp_ids:
+        err_agg.AddErrorMessage(
+            'Undefined or deprecated component with id: %d' % comp_id)
+
+
+def _ComputeNewCcsFromIssueMerge(merge_into_issue, source_issues):
+  # type: (Issue, Collection[Issue]) -> Collection[int]
+  """Compute ccs that should be added from source_issues to merge_into_issue."""
+
+  merge_into_restrictions = permissions.GetRestrictions(merge_into_issue)
+  new_cc_ids = set()
+  for issue in source_issues:
+    # We don't want to leak metadata like ccs of restricted issues.
+    # So we don't merge ccs from restricted source issues, unless their
+    # restrictions match the restrictions of the target.
+    if permissions.HasRestrictions(issue, perm='View'):
+      source_restrictions = permissions.GetRestrictions(issue)
+      if (issue.project_id != merge_into_issue.project_id or
+          set(source_restrictions) != set(merge_into_restrictions)):
+        continue
+
+    new_cc_ids.update(issue.cc_ids)
+    if issue.owner_id:
+      new_cc_ids.add(issue.owner_id)
+
+  return [cc_id for cc_id in new_cc_ids if cc_id not in merge_into_issue.cc_ids]
+
+
+def _EnforceNonMergeStatusDeltas(cnxn, issue_delta_pairs, services):
+  # type: (MonorailConnection, Sequence[Tuple[Issue, IssueDelta]], Services)
+  """Update deltas in RAM to remove merged if a MERGED status is removed."""
+  project_ids = list(
+      {issue.project_id for (issue, _delta) in issue_delta_pairs})
+  configs_by_id = services.config.GetProjectConfigs(cnxn, project_ids)
+  statuses_offer_merge_by_pid = {
+      pid:
+      [status.lower() for status in configs_by_id[pid].statuses_offer_merge]
+      for pid in project_ids
+  }
+
+  for issue, delta in issue_delta_pairs:
+    statuses_offer_merge = statuses_offer_merge_by_pid[issue.project_id]
+    # Remove merged_into and merged_into_external when a status is moved
+    # to a non-MERGED status ONLY if the delta does not have merged_into values
+    # If delta does change merged_into values, the request will fail from
+    # AssertIssueChangesValue().
+    if (delta.status and delta.status.lower() not in statuses_offer_merge and
+        delta.merged_into is None and delta.merged_into_external is None):
+      if issue.merged_into:
+        delta.merged_into = 0
+      elif issue.merged_into_external:
+        delta.merged_into_external = ''
+
+
+class _IssueChangeImpactedIssues():
+  """Class to track changes of issues impacted by updates to other issues."""
+
+  def __init__(self):
+
+    # Each of the dicts below should be used to track
+    # {impacted_issue_id: [issues being modified that impact the keyed issue]}.
+
+    # e.g. `blocking_remove` with {iid_1: [iid_2, iid_3]} means that
+    # `TrackImpactedIssues` has been called with a delta of
+    # IssueDelta(blocked_on_remove=[iid_1]) for both issue 2 and issue 3.
+    self.blocking_add = collections.defaultdict(list)
+    self.blocking_remove = collections.defaultdict(list)
+    self.blocked_on_add = collections.defaultdict(list)
+    self.blocked_on_remove = collections.defaultdict(list)
+    self.merged_from_add = collections.defaultdict(list)
+    self.merged_from_remove = collections.defaultdict(list)
+
+  def ComputeAllImpactedIIDs(self):
+    # type: () -> Collection[int]
+    """Computes the unique set of all impacted issue ids."""
+    return set(self.blocking_add.keys() + self.blocking_remove.keys() +
+               self.blocked_on_add.keys() + self.blocked_on_remove.keys() +
+               self.merged_from_add.keys() + self.merged_from_remove.keys())
+
+  def TrackImpactedIssues(self, issue, delta):
+    # type: (Issue, IssueDelta) -> None
+    """Track impacted issues from when `delta` is applied to `issue`.
+
+    Args:
+      issue: Issue that the delta will be applied to, but has not yet.
+      delta: IssueDelta representing the changes that will be made to
+        the issue.
+    """
+    for impacted_iid in delta.blocked_on_add:
+      self.blocking_add[impacted_iid].append(issue.issue_id)
+    for impacted_iid in delta.blocked_on_remove:
+      self.blocking_remove[impacted_iid].append(issue.issue_id)
+
+    for impacted_iid in delta.blocking_add:
+      self.blocked_on_add[impacted_iid].append(issue.issue_id)
+    for impacted_iid in delta.blocking_remove:
+      self.blocked_on_remove[impacted_iid].append(issue.issue_id)
+
+    if (delta.merged_into == framework_constants.NO_ISSUE_SPECIFIED and
+        issue.merged_into):
+      self.merged_from_remove[issue.merged_into].append(issue.issue_id)
+    elif delta.merged_into and issue.merged_into != delta.merged_into:
+      self.merged_from_add[delta.merged_into].append(issue.issue_id)
+      if issue.merged_into:
+        self.merged_from_remove[issue.merged_into].append(issue.issue_id)
+
+  def ApplyImpactedIssueChanges(self, cnxn, impacted_issue, services):
+    # type: (MonorailConnection, Issue, Services) ->
+    #     Tuple[Collection[Amendment], Sequence[int]]
+    """Apply the tracked changes in RAM for the given impacted issue.
+
+    Args:
+      cnxn: connection to SQL database.
+      impacted_issue: Issue PB that we are applying the changes to.
+      services: Services used to fetch info from DB or cache.
+
+    Returns:
+      All the amendments that represent the changes applied to the issue
+      and a list of the new issue starrers.
+
+    Side-effect:
+      The given impacted_issue will be updated in RAM.
+    """
+    issue_id = impacted_issue.issue_id
+
+    # Process changes for blocking/blocked_on issue changes.
+    amendments, _impacted_iids = tracker_bizobj.ApplyIssueBlockRelationChanges(
+        cnxn, impacted_issue, self.blocked_on_add[issue_id],
+        self.blocked_on_remove[issue_id], self.blocking_add[issue_id],
+        self.blocking_remove[issue_id], services.issue)
+
+    # Process changes in merged issues.
+    merged_from_add = self.merged_from_add.get(issue_id, [])
+    merged_from_remove = self.merged_from_remove.get(issue_id, [])
+
+    # Merge ccs into impacted_issue from all merged issues,
+    # compute new starrers, and set star_count.
+    new_starrers = []
+    if merged_from_add:
+      issues_dict, _misses = services.issue.GetIssuesDict(cnxn, merged_from_add)
+      merged_from_add_issues = issues_dict.values()
+      new_cc_ids = _ComputeNewCcsFromIssueMerge(
+          impacted_issue, merged_from_add_issues)
+      if new_cc_ids:
+        impacted_issue.cc_ids.extend(new_cc_ids)
+        amendments.append(
+            tracker_bizobj.MakeCcAmendment(new_cc_ids, []))
+      new_starrers = list(
+          GetNewIssueStarrers(cnxn, services, merged_from_add, issue_id))
+      if new_starrers:
+        impacted_issue.star_count += len(new_starrers)
+
+    if merged_from_add or merged_from_remove:
+      merged_from_add_refs = services.issue.LookupIssueRefs(
+          cnxn, merged_from_add).values()
+      merged_from_remove_refs = services.issue.LookupIssueRefs(
+          cnxn, merged_from_remove).values()
+      amendments.append(
+          tracker_bizobj.MakeMergedIntoAmendment(
+              merged_from_add_refs, merged_from_remove_refs,
+              default_project_name=impacted_issue.project_name))
+    return amendments, new_starrers