Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/tracker/tracker_bizobj.py b/tracker/tracker_bizobj.py
new file mode 100644
index 0000000..f3f2594
--- /dev/null
+++ b/tracker/tracker_bizobj.py
@@ -0,0 +1,1831 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Business objects for the Monorail issue tracker.
+
+These are classes and functions that operate on the objects that
+users care about in the issue tracker: e.g., issues, and the issue
+tracker configuration.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import collections
+import logging
+import time
+
+from six import string_types
+
+from features import federated
+from framework import exceptions
+from framework import framework_bizobj
+from framework import framework_constants
+from framework import framework_helpers
+from framework import timestr
+from framework import urls
+from proto import tracker_pb2
+from tracker import tracker_constants
+
+
+def GetOwnerId(issue):
+  """Get the owner of an issue, whether it is explicit or derived."""
+  return (issue.owner_id or issue.derived_owner_id or
+          framework_constants.NO_USER_SPECIFIED)
+
+
+def GetStatus(issue):
+  """Get the status of an issue, whether it is explicit or derived."""
+  return issue.status or issue.derived_status or  ''
+
+
+def GetCcIds(issue):
+  """Get the Cc's of an issue, whether they are explicit or derived."""
+  return issue.cc_ids + issue.derived_cc_ids
+
+
+def GetApproverIds(issue):
+  """Get the Approvers' ids of an isuses approval_values."""
+  approver_ids = []
+  for av in issue.approval_values:
+    approver_ids.extend(av.approver_ids)
+
+  return list(set(approver_ids))
+
+
+def GetLabels(issue):
+  """Get the labels of an issue, whether explicit or derived."""
+  return issue.labels + issue.derived_labels
+
+
+def MakeProjectIssueConfig(
+    project_id, well_known_statuses, statuses_offer_merge, well_known_labels,
+    excl_label_prefixes, col_spec):
+  """Return a ProjectIssueConfig with the given values."""
+  # pylint: disable=multiple-statements
+  if not well_known_statuses: well_known_statuses = []
+  if not statuses_offer_merge: statuses_offer_merge = []
+  if not well_known_labels: well_known_labels = []
+  if not excl_label_prefixes: excl_label_prefixes = []
+  if not col_spec: col_spec = ' '
+
+  project_config = tracker_pb2.ProjectIssueConfig()
+  if project_id:  # There is no ID for harmonized configs.
+    project_config.project_id = project_id
+
+  SetConfigStatuses(project_config, well_known_statuses)
+  project_config.statuses_offer_merge = statuses_offer_merge
+  SetConfigLabels(project_config, well_known_labels)
+  project_config.exclusive_label_prefixes = excl_label_prefixes
+
+  # ID 0 means that nothing has been specified, so use hard-coded defaults.
+  project_config.default_template_for_developers = 0
+  project_config.default_template_for_users = 0
+
+  project_config.default_col_spec = col_spec
+
+  # Note: default project issue config has no filter rules.
+
+  return project_config
+
+
+def FindFieldDef(field_name, config):
+  """Find the specified field, or return None."""
+  if not field_name:
+    return None
+  field_name_lower = field_name.lower()
+  for fd in config.field_defs:
+    if fd.field_name.lower() == field_name_lower:
+      return fd
+
+  return None
+
+
+def FindFieldDefByID(field_id, config):
+  """Find the specified field, or return None."""
+  for fd in config.field_defs:
+    if fd.field_id == field_id:
+      return fd
+
+  return None
+
+
+def FindApprovalDef(approval_name, config):
+  """Find the specified approval, or return None."""
+  fd = FindFieldDef(approval_name, config)
+  if fd:
+    return FindApprovalDefByID(fd.field_id, config)
+
+  return None
+
+
+def FindApprovalDefByID(approval_id, config):
+  """Find the specified approval, or return None."""
+  for approval_def in config.approval_defs:
+    if approval_def.approval_id == approval_id:
+      return approval_def
+
+  return None
+
+
+def FindApprovalValueByID(approval_id, approval_values):
+  """Find the specified approval_value in the given list or return None."""
+  for av in approval_values:
+    if av.approval_id == approval_id:
+      return av
+
+  return None
+
+
+def FindApprovalsSubfields(approval_ids, config):
+  """Return a dict of {approval_ids: approval_subfields}."""
+  approval_subfields_dict = collections.defaultdict(list)
+  for fd in config.field_defs:
+    if fd.approval_id in approval_ids:
+      approval_subfields_dict[fd.approval_id].append(fd)
+
+  return approval_subfields_dict
+
+
+def FindPhaseByID(phase_id, phases):
+  """Find the specified phase, or return None"""
+  for phase in phases:
+    if phase.phase_id == phase_id:
+      return phase
+
+  return None
+
+
+def FindPhase(name, phases):
+  """Find the specified phase, or return None"""
+  for phase in phases:
+    if phase.name.lower() == name.lower():
+      return phase
+
+  return None
+
+
+def GetGrantedPerms(issue, effective_ids, config):
+  """Return a set of permissions granted by user-valued fields in an issue."""
+  granted_perms = set()
+  for field_value in issue.field_values:
+    if field_value.user_id in effective_ids:
+      field_def = FindFieldDefByID(field_value.field_id, config)
+      if field_def and field_def.grants_perm:
+        # TODO(jrobbins): allow comma-separated list in grants_perm
+        granted_perms.add(field_def.grants_perm.lower())
+
+  return granted_perms
+
+
+def LabelsByPrefix(labels, lower_field_names):
+  """Convert a list of key-value labels into {lower_prefix: [value, ...]}.
+
+  It also handles custom fields with dashes in the field name.
+  """
+  label_values_by_prefix = collections.defaultdict(list)
+  for lab in labels:
+    if '-' not in lab:
+      continue
+    lower_lab = lab.lower()
+    for lower_field_name in lower_field_names:
+      if lower_lab.startswith(lower_field_name + '-'):
+        prefix = lower_field_name
+        value = lab[len(lower_field_name)+1:]
+        break
+    else:  # No field name matched
+      prefix, value = lab.split('-', 1)
+      prefix = prefix.lower()
+    label_values_by_prefix[prefix].append(value)
+  return label_values_by_prefix
+
+
+def LabelIsMaskedByField(label, field_names):
+  """If the label should be displayed as a field, return the field name.
+
+  Args:
+    label: string label to consider.
+    field_names: a list of field names in lowercase.
+
+  Returns:
+    If masked, return the lowercase name of the field, otherwise None.  A label
+    is masked by a custom field if the field name "Foo" matches the key part of
+    a key-value label "Foo-Bar".
+  """
+  if '-' not in label:
+    return None
+
+  for field_name_lower in field_names:
+    if label.lower().startswith(field_name_lower + '-'):
+      return field_name_lower
+
+  return None
+
+
+def NonMaskedLabels(labels, field_names):
+  """Return only those labels that are not masked by custom fields."""
+  return [lab for lab in labels
+          if not LabelIsMaskedByField(lab, field_names)]
+
+
+def ExplicitAndDerivedNonMaskedLabels(labels, derived_labels, config):
+  """Return two lists of labels that are not masked by enum custom fields."""
+  field_names = [fd.field_name.lower() for fd in config.field_defs
+                 if fd.field_type is tracker_pb2.FieldTypes.ENUM_TYPE and
+                 not fd.is_deleted]  # TODO(jrobbins): restricts
+  labels = [
+      lab for lab in labels
+      if not LabelIsMaskedByField(lab, field_names)]
+  derived_labels = [
+    lab for lab in derived_labels
+    if not LabelIsMaskedByField(lab, field_names)]
+  return labels, derived_labels
+
+
+def MakeApprovalValue(approval_id, approver_ids=None, status=None,
+                      setter_id=None, set_on=None, phase_id=None):
+  """Return an ApprovalValue PB with the given field values."""
+  av = tracker_pb2.ApprovalValue(
+      approval_id=approval_id, status=status,
+      setter_id=setter_id, set_on=set_on, phase_id=phase_id)
+  if approver_ids is not None:
+    av.approver_ids = approver_ids
+  return av
+
+
+def MakeFieldDef(
+    field_id,
+    project_id,
+    field_name,
+    field_type_int,
+    applic_type,
+    applic_pred,
+    is_required,
+    is_niche,
+    is_multivalued,
+    min_value,
+    max_value,
+    regex,
+    needs_member,
+    needs_perm,
+    grants_perm,
+    notify_on,
+    date_action,
+    docstring,
+    is_deleted,
+    approval_id=None,
+    is_phase_field=False,
+    is_restricted_field=False,
+    admin_ids=None,
+    editor_ids=None):
+  """Make a FieldDef PB for the given FieldDef table row tuple."""
+  if isinstance(date_action, string_types):
+    date_action = date_action.upper()
+  fd = tracker_pb2.FieldDef(
+      field_id=field_id,
+      project_id=project_id,
+      field_name=field_name,
+      field_type=field_type_int,
+      is_required=bool(is_required),
+      is_niche=bool(is_niche),
+      is_multivalued=bool(is_multivalued),
+      docstring=docstring,
+      is_deleted=bool(is_deleted),
+      applicable_type=applic_type or '',
+      applicable_predicate=applic_pred or '',
+      needs_member=bool(needs_member),
+      grants_perm=grants_perm or '',
+      notify_on=tracker_pb2.NotifyTriggers(notify_on or 0),
+      date_action=tracker_pb2.DateAction(date_action or 0),
+      is_phase_field=bool(is_phase_field),
+      is_restricted_field=bool(is_restricted_field))
+  if min_value is not None:
+    fd.min_value = min_value
+  if max_value is not None:
+    fd.max_value = max_value
+  if regex is not None:
+    fd.regex = regex
+  if needs_perm is not None:
+    fd.needs_perm = needs_perm
+  if approval_id is not None:
+    fd.approval_id = approval_id
+  if admin_ids:
+    fd.admin_ids = admin_ids
+  if editor_ids:
+    fd.editor_ids = editor_ids
+  return fd
+
+
+def MakeFieldValue(
+    field_id, int_value, str_value, user_id, date_value, url_value, derived,
+    phase_id=None):
+  """Make a FieldValue based on the given information."""
+  fv = tracker_pb2.FieldValue(field_id=field_id, derived=derived)
+  if phase_id is not None:
+    fv.phase_id = phase_id
+  if int_value is not None:
+    fv.int_value = int_value
+  elif str_value is not None:
+    fv.str_value = str_value
+  elif user_id is not None:
+    fv.user_id = user_id
+  elif date_value is not None:
+    fv.date_value = date_value
+  elif url_value is not None:
+    fv.url_value = url_value
+  else:
+    raise ValueError('Unexpected field value')
+  return fv
+
+
+def GetFieldValueWithRawValue(field_type, field_value, users_by_id, raw_value):
+  """Find and return the field value of the specified field type.
+
+  If the specified field_value is None or is empty then the raw_value is
+  returned. When the field type is USER_TYPE the raw_value is used as a key to
+  lookup users_by_id.
+
+  Args:
+    field_type: tracker_pb2.FieldTypes type.
+    field_value: tracker_pb2.FieldValue type.
+    users_by_id: Dict mapping user_ids to UserViews.
+    raw_value: String to use if field_value is not specified.
+
+  Returns:
+    Value of the specified field type.
+  """
+  ret_value = GetFieldValue(field_value, users_by_id)
+  if ret_value:
+    return ret_value
+  # Special case for user types.
+  if field_type == tracker_pb2.FieldTypes.USER_TYPE:
+    if raw_value in users_by_id:
+      return users_by_id[raw_value].email
+  return raw_value
+
+
+def GetFieldValue(fv, users_by_id):
+  """Return the value of this field.  Give emails for users in users_by_id."""
+  if fv is None:
+    return None
+  elif fv.int_value is not None:
+    return fv.int_value
+  elif fv.str_value is not None:
+    return fv.str_value
+  elif fv.user_id is not None:
+    if fv.user_id in users_by_id:
+      return users_by_id[fv.user_id].email
+    else:
+      logging.info('Failed to lookup user %d when getting field', fv.user_id)
+      return fv.user_id
+  elif fv.date_value is not None:
+    return timestr.TimestampToDateWidgetStr(fv.date_value)
+  elif fv.url_value is not None:
+    return fv.url_value
+  else:
+    return None
+
+
+def FindComponentDef(path, config):
+  """Find the specified component, or return None."""
+  path_lower = path.lower()
+  for cd in config.component_defs:
+    if cd.path.lower() == path_lower:
+      return cd
+
+  return None
+
+
+def FindMatchingComponentIDs(path, config, exact=True):
+  """Return a list of components that match the given path."""
+  component_ids = []
+  path_lower = path.lower()
+
+  if exact:
+    for cd in config.component_defs:
+      if cd.path.lower() == path_lower:
+        component_ids.append(cd.component_id)
+  else:
+    path_lower_delim = path.lower() + '>'
+    for cd in config.component_defs:
+      target_delim = cd.path.lower() + '>'
+      if target_delim.startswith(path_lower_delim):
+        component_ids.append(cd.component_id)
+
+  return component_ids
+
+
+def FindComponentDefByID(component_id, config):
+  """Find the specified component, or return None."""
+  for cd in config.component_defs:
+    if cd.component_id == component_id:
+      return cd
+
+  return None
+
+
+def FindAncestorComponents(config, component_def):
+  """Return a list of all components the given component is under."""
+  path_lower = component_def.path.lower()
+  return [cd for cd in config.component_defs
+          if path_lower.startswith(cd.path.lower() + '>')]
+
+
+def GetIssueComponentsAndAncestors(issue, config):
+  """Return a list of all the components that an issue is in."""
+  result = set()
+  for component_id in issue.component_ids:
+    cd = FindComponentDefByID(component_id, config)
+    if cd is None:
+      logging.error('Tried to look up non-existent component %r' % component_id)
+      continue
+    ancestors = FindAncestorComponents(config, cd)
+    result.add(cd)
+    result.update(ancestors)
+
+  return sorted(result, key=lambda cd: cd.path)
+
+
+def FindDescendantComponents(config, component_def):
+  """Return a list of all nested components under the given component."""
+  path_plus_delim = component_def.path.lower() + '>'
+  return [cd for cd in config.component_defs
+          if cd.path.lower().startswith(path_plus_delim)]
+
+
+def MakeComponentDef(
+    component_id, project_id, path, docstring, deprecated, admin_ids, cc_ids,
+    created, creator_id, modified=None, modifier_id=None, label_ids=None):
+  """Make a ComponentDef PB for the given FieldDef table row tuple."""
+  cd = tracker_pb2.ComponentDef(
+      component_id=component_id, project_id=project_id, path=path,
+      docstring=docstring, deprecated=bool(deprecated),
+      admin_ids=admin_ids, cc_ids=cc_ids, created=created,
+      creator_id=creator_id, modified=modified, modifier_id=modifier_id,
+      label_ids=label_ids or [])
+  return cd
+
+
+def MakeSavedQuery(
+    query_id, name, base_query_id, query, subscription_mode=None,
+    executes_in_project_ids=None):
+  """Make SavedQuery PB for the given info."""
+  saved_query = tracker_pb2.SavedQuery(
+      name=name, base_query_id=base_query_id, query=query)
+  if query_id is not None:
+    saved_query.query_id = query_id
+  if subscription_mode is not None:
+    saved_query.subscription_mode = subscription_mode
+  if executes_in_project_ids is not None:
+    saved_query.executes_in_project_ids = executes_in_project_ids
+  return saved_query
+
+
+def SetConfigStatuses(project_config, well_known_statuses):
+  """Internal method to set the well-known statuses of ProjectIssueConfig."""
+  project_config.well_known_statuses = []
+  for status, docstring, means_open, deprecated in well_known_statuses:
+    canonical_status = framework_bizobj.CanonicalizeLabel(status)
+    project_config.well_known_statuses.append(tracker_pb2.StatusDef(
+        status_docstring=docstring, status=canonical_status,
+        means_open=means_open, deprecated=deprecated))
+
+
+def SetConfigLabels(project_config, well_known_labels):
+  """Internal method to set the well-known labels of a ProjectIssueConfig."""
+  project_config.well_known_labels = []
+  for label, docstring, deprecated in well_known_labels:
+    canonical_label = framework_bizobj.CanonicalizeLabel(label)
+    project_config.well_known_labels.append(tracker_pb2.LabelDef(
+        label=canonical_label, label_docstring=docstring,
+        deprecated=deprecated))
+
+
+def SetConfigApprovals(project_config, approval_def_tuples):
+  """Internal method to set up approval defs of a ProjectissueConfig."""
+  project_config.approval_defs = []
+  for approval_id, approver_ids, survey in approval_def_tuples:
+    project_config.approval_defs.append(tracker_pb2.ApprovalDef(
+        approval_id=approval_id, approver_ids=approver_ids, survey=survey))
+
+
+def ConvertDictToTemplate(template_dict):
+  """Construct a Template PB with the values from template_dict.
+
+  Args:
+    template_dict: dictionary with fields corresponding to the Template
+        PB fields.
+
+  Returns:
+    A Template protocol buffer that can be stored in the
+    project's ProjectIssueConfig PB.
+  """
+  return MakeIssueTemplate(
+      template_dict.get('name'), template_dict.get('summary'),
+      template_dict.get('status'), template_dict.get('owner_id'),
+      template_dict.get('content'), template_dict.get('labels'), [], [],
+      template_dict.get('components'),
+      summary_must_be_edited=template_dict.get('summary_must_be_edited'),
+      owner_defaults_to_member=template_dict.get('owner_defaults_to_member'),
+      component_required=template_dict.get('component_required'),
+      members_only=template_dict.get('members_only'))
+
+
+def MakeIssueTemplate(
+    name,
+    summary,
+    status,
+    owner_id,
+    content,
+    labels,
+    field_values,
+    admin_ids,
+    component_ids,
+    summary_must_be_edited=None,
+    owner_defaults_to_member=None,
+    component_required=None,
+    members_only=None,
+    phases=None,
+    approval_values=None):
+  """Make an issue template PB."""
+  template = tracker_pb2.TemplateDef()
+  template.name = name
+  if summary:
+    template.summary = summary
+  if status:
+    template.status = status
+  if owner_id:
+    template.owner_id = owner_id
+  template.content = content
+  template.field_values = field_values
+  template.labels = labels or []
+  template.admin_ids = admin_ids
+  template.component_ids = component_ids or []
+  template.approval_values = approval_values or []
+
+  if summary_must_be_edited is not None:
+    template.summary_must_be_edited = summary_must_be_edited
+  if owner_defaults_to_member is not None:
+    template.owner_defaults_to_member = owner_defaults_to_member
+  if component_required is not None:
+    template.component_required = component_required
+  if members_only is not None:
+    template.members_only = members_only
+  if phases is not None:
+    template.phases = phases
+
+  return template
+
+
+def MakeDefaultProjectIssueConfig(project_id):
+  """Return a ProjectIssueConfig with use by projects that don't have one."""
+  return MakeProjectIssueConfig(
+      project_id,
+      tracker_constants.DEFAULT_WELL_KNOWN_STATUSES,
+      tracker_constants.DEFAULT_STATUSES_OFFER_MERGE,
+      tracker_constants.DEFAULT_WELL_KNOWN_LABELS,
+      tracker_constants.DEFAULT_EXCL_LABEL_PREFIXES,
+      tracker_constants.DEFAULT_COL_SPEC)
+
+
+def HarmonizeConfigs(config_list):
+  """Combine several ProjectIssueConfigs into one for cross-project sorting.
+
+  Args:
+    config_list: a list of ProjectIssueConfig PBs with labels and statuses
+        among other fields.
+
+  Returns:
+    A new ProjectIssueConfig with just the labels and status values filled
+    in to be a logical union of the given configs.  Specifically, the order
+    of the combined status and label lists should be maintained.
+  """
+  if not config_list:
+    return MakeDefaultProjectIssueConfig(None)
+
+  harmonized_status_names = _CombineOrderedLists(
+      [[stat.status for stat in config.well_known_statuses]
+       for config in config_list])
+  harmonized_label_names = _CombineOrderedLists(
+      [[lab.label for lab in config.well_known_labels]
+       for config in config_list])
+  harmonized_default_sort_spec = ' '.join(
+      config.default_sort_spec for config in config_list)
+  harmonized_means_open = {
+      status: any([stat.means_open
+                   for config in config_list
+                   for stat in config.well_known_statuses
+                   if stat.status == status])
+      for status in harmonized_status_names}
+
+  # This col_spec is probably not what the user wants to view because it is
+  # too much information.  We join all the col_specs here so that we are sure
+  # to lookup all users needed for sorting, even if it is more than needed.
+  # xxx we need to look up users based on colspec rather than sortspec?
+  harmonized_default_col_spec = ' '.join(
+      config.default_col_spec for config in config_list)
+
+  result_config = tracker_pb2.ProjectIssueConfig()
+  # The combined config is only used during sorting, never stored.
+  result_config.default_col_spec = harmonized_default_col_spec
+  result_config.default_sort_spec = harmonized_default_sort_spec
+
+  for status_name in harmonized_status_names:
+    result_config.well_known_statuses.append(tracker_pb2.StatusDef(
+        status=status_name, means_open=harmonized_means_open[status_name]))
+
+  for label_name in harmonized_label_names:
+    result_config.well_known_labels.append(tracker_pb2.LabelDef(
+        label=label_name))
+
+  for config in config_list:
+    result_config.field_defs.extend(
+      list(fd for fd in config.field_defs if not fd.is_deleted))
+    result_config.component_defs.extend(config.component_defs)
+    result_config.approval_defs.extend(config.approval_defs)
+
+  return result_config
+
+
+def HarmonizeLabelOrStatusRows(def_rows):
+  """Put the given label defs into a logical global order."""
+  ranked_defs_by_project = {}
+  oddball_defs = []
+  for row in def_rows:
+    def_id, project_id, rank, label = row[0], row[1], row[2], row[3]
+    if rank is not None:
+      ranked_defs_by_project.setdefault(project_id, []).append(
+          (def_id, rank, label))
+    else:
+      oddball_defs.append((def_id, rank, label))
+
+  oddball_defs.sort(reverse=True, key=lambda def_tuple: def_tuple[2].lower())
+  # Compose the list-of-lists in a consistent order by project_id.
+  list_of_lists = [ranked_defs_by_project[pid]
+                   for pid in sorted(ranked_defs_by_project.keys())]
+  harmonized_ranked_defs = _CombineOrderedLists(
+      list_of_lists, include_duplicate_keys=True,
+      key=lambda def_tuple: def_tuple[2])
+
+  return oddball_defs + harmonized_ranked_defs
+
+
+def _CombineOrderedLists(
+    list_of_lists, include_duplicate_keys=False, key=lambda x: x):
+  """Combine lists of items while maintaining their desired order.
+
+  Args:
+    list_of_lists: a list of lists of strings.
+    include_duplicate_keys: Pass True to make the combined list have the
+        same total number of elements as the sum of the input lists.
+    key: optional function to choose which part of the list items hold the
+        string used for comparison.  The result will have the whole items.
+
+  Returns:
+    A single list of items containing one copy of each of the items
+    in any of the original list, and in an order that maintains the original
+    list ordering as much as possible.
+  """
+  combined_items = []
+  combined_keys = []
+  seen_keys_set = set()
+  for one_list in list_of_lists:
+    _AccumulateCombinedList(
+        one_list, combined_items, combined_keys, seen_keys_set, key=key,
+        include_duplicate_keys=include_duplicate_keys)
+
+  return combined_items
+
+
+def _AccumulateCombinedList(
+    one_list, combined_items, combined_keys, seen_keys_set,
+    include_duplicate_keys=False, key=lambda x: x):
+  """Accumulate strings into a combined list while its maintaining ordering.
+
+  Args:
+    one_list: list of strings in a desired order.
+    combined_items: accumulated list of items in the desired order.
+    combined_keys: accumulated list of key strings in the desired order.
+    seen_keys_set: set of strings that are already in combined_list.
+    include_duplicate_keys: Pass True to make the combined list have the
+        same total number of elements as the sum of the input lists.
+    key: optional function to choose which part of the list items hold the
+        string used for comparison.  The result will have the whole items.
+
+  Returns:
+    Nothing.  But, combined_items is modified to mix in all the items of
+    one_list at appropriate points such that nothing in combined_items
+    is reordered, and the ordering of items from one_list is maintained
+    as much as possible.  Also, seen_keys_set is modified to add any keys
+    for items that were added to combined_items.
+
+  Also, any strings that begin with "#" are compared regardless of the "#".
+  The purpose of such strings is to guide the final ordering.
+  """
+  insert_idx = 0
+  for item in one_list:
+    s = key(item).lower()
+    if s in seen_keys_set:
+      item_idx = combined_keys.index(s)  # Need parallel list of keys
+      insert_idx = max(insert_idx, item_idx + 1)
+
+    if s not in seen_keys_set or include_duplicate_keys:
+      combined_items.insert(insert_idx, item)
+      combined_keys.insert(insert_idx, s)
+      insert_idx += 1
+
+    seen_keys_set.add(s)
+
+
+def GetBuiltInQuery(query_id):
+  """If the given query ID is for a built-in query, return that string."""
+  return tracker_constants.DEFAULT_CANNED_QUERY_CONDS.get(query_id, '')
+
+
+def UsersInvolvedInAmendments(amendments):
+  """Return a set of all user IDs mentioned in the given Amendments."""
+  user_id_set = set()
+  for amendment in amendments:
+    user_id_set.update(amendment.added_user_ids)
+    user_id_set.update(amendment.removed_user_ids)
+
+  return user_id_set
+
+
+def _AccumulateUsersInvolvedInComment(comment, user_id_set):
+  """Build up a set of all users involved in an IssueComment.
+
+  Args:
+    comment: an IssueComment PB.
+    user_id_set: a set of user IDs to build up.
+
+  Returns:
+    The same set, but modified to have the user IDs of user who
+    entered the comment, and all the users mentioned in any amendments.
+  """
+  user_id_set.add(comment.user_id)
+  user_id_set.update(UsersInvolvedInAmendments(comment.amendments))
+
+  return user_id_set
+
+
+def UsersInvolvedInComment(comment):
+  """Return a set of all users involved in an IssueComment.
+
+  Args:
+    comment: an IssueComment PB.
+
+  Returns:
+    A set with the user IDs of user who entered the comment, and all the
+    users mentioned in any amendments.
+  """
+  return _AccumulateUsersInvolvedInComment(comment, set())
+
+
+def UsersInvolvedInCommentList(comments):
+  """Return a set of all users involved in a list of IssueComments.
+
+  Args:
+    comments: a list of IssueComment PBs.
+
+  Returns:
+    A set with the user IDs of user who entered the comment, and all the
+    users mentioned in any amendments.
+  """
+  result = set()
+  for c in comments:
+    _AccumulateUsersInvolvedInComment(c, result)
+
+  return result
+
+
+def UsersInvolvedInIssues(issues):
+  """Return a set of all user IDs referenced in the issues' metadata."""
+  result = set()
+  for issue in issues:
+    result.update([issue.reporter_id, issue.owner_id, issue.derived_owner_id])
+    result.update(issue.cc_ids)
+    result.update(issue.derived_cc_ids)
+    result.update(fv.user_id for fv in issue.field_values if fv.user_id)
+    for av in issue.approval_values:
+      result.update(approver_id for approver_id in av.approver_ids)
+      if av.setter_id:
+        result.update([av.setter_id])
+
+  return result
+
+
+def UsersInvolvedInTemplate(template):
+  """Return a set of all user IDs referenced in the template."""
+  result = set(
+    template.admin_ids +
+    [fv.user_id for fv in template.field_values if fv.user_id])
+  if template.owner_id:
+    result.add(template.owner_id)
+  for av in template.approval_values:
+    result.update(set(av.approver_ids))
+    if av.setter_id:
+      result.add(av.setter_id)
+  return result
+
+
+def UsersInvolvedInTemplates(templates):
+  """Return a set of all user IDs referenced in the given templates."""
+  result = set()
+  for template in templates:
+    result.update(UsersInvolvedInTemplate(template))
+  return result
+
+
+def UsersInvolvedInComponents(component_defs):
+  """Return a set of user IDs referenced in the given components."""
+  result = set()
+  for cd in component_defs:
+    result.update(cd.admin_ids)
+    result.update(cd.cc_ids)
+    if cd.creator_id:
+      result.add(cd.creator_id)
+    if cd.modifier_id:
+      result.add(cd.modifier_id)
+
+  return result
+
+
+def UsersInvolvedInApprovalDefs(approval_defs, matching_fds):
+  # type: (Sequence[proto.tracker_pb2.ApprovalDef],
+  #     Sequence[proto.tracker_pb2.FieldDef]) -> Collection[int]
+  """Return a set of user IDs referenced in the approval_defs and field defs"""
+  result = set()
+  for ad in approval_defs:
+    result.update(ad.approver_ids)
+  for fd in matching_fds:
+    result.update(fd.admin_ids)
+  return result
+
+
+def UsersInvolvedInConfig(config):
+  """Return a set of all user IDs referenced in the config."""
+  result = set()
+  for ad in config.approval_defs:
+    result.update(ad.approver_ids)
+  for fd in config.field_defs:
+    result.update(fd.admin_ids)
+  result.update(UsersInvolvedInComponents(config.component_defs))
+  return result
+
+
+def LabelIDsInvolvedInConfig(config):
+  """Return a set of all label IDs referenced in the config."""
+  result = set()
+  for cd in config.component_defs:
+    result.update(cd.label_ids)
+  return result
+
+
+def MakeApprovalDelta(
+    status, setter_id, approver_ids_add, approver_ids_remove,
+    subfield_vals_add, subfield_vals_remove, subfields_clear, labels_add,
+    labels_remove, set_on=None):
+  approval_delta = tracker_pb2.ApprovalDelta(
+      approver_ids_add=approver_ids_add,
+      approver_ids_remove=approver_ids_remove,
+      subfield_vals_add=subfield_vals_add,
+      subfield_vals_remove=subfield_vals_remove,
+      subfields_clear=subfields_clear,
+      labels_add=labels_add,
+      labels_remove=labels_remove
+  )
+  if status is not None:
+    approval_delta.status = status
+    approval_delta.set_on = set_on or int(time.time())
+    approval_delta.setter_id = setter_id
+
+  return approval_delta
+
+
+def MakeIssueDelta(
+    status, owner_id, cc_ids_add, cc_ids_remove, comp_ids_add, comp_ids_remove,
+    labels_add, labels_remove, field_vals_add, field_vals_remove, fields_clear,
+    blocked_on_add, blocked_on_remove, blocking_add, blocking_remove,
+    merged_into, summary, ext_blocked_on_add=None, ext_blocked_on_remove=None,
+    ext_blocking_add=None, ext_blocking_remove=None, merged_into_external=None):
+  """Construct an IssueDelta object with the given fields, iff non-None."""
+  delta = tracker_pb2.IssueDelta(
+      cc_ids_add=cc_ids_add, cc_ids_remove=cc_ids_remove,
+      comp_ids_add=comp_ids_add, comp_ids_remove=comp_ids_remove,
+      labels_add=labels_add, labels_remove=labels_remove,
+      field_vals_add=field_vals_add, field_vals_remove=field_vals_remove,
+      fields_clear=fields_clear,
+      blocked_on_add=blocked_on_add, blocked_on_remove=blocked_on_remove,
+      blocking_add=blocking_add, blocking_remove=blocking_remove)
+  if status is not None:
+    delta.status = status
+  if owner_id is not None:
+    delta.owner_id = owner_id
+  if merged_into is not None:
+    delta.merged_into = merged_into
+  if merged_into_external is not None:
+    delta.merged_into_external = merged_into_external
+  if summary is not None:
+    delta.summary = summary
+  if ext_blocked_on_add is not None:
+    delta.ext_blocked_on_add = ext_blocked_on_add
+  if ext_blocked_on_remove is not None:
+    delta.ext_blocked_on_remove = ext_blocked_on_remove
+  if ext_blocking_add is not None:
+    delta.ext_blocking_add = ext_blocking_add
+  if ext_blocking_remove is not None:
+    delta.ext_blocking_remove = ext_blocking_remove
+
+  return delta
+
+
+def ApplyLabelChanges(issue, config, labels_add, labels_remove):
+  """Updates the PB issue's labels and returns the amendment or None."""
+  canon_labels_add = [framework_bizobj.CanonicalizeLabel(l)
+                      for l in labels_add]
+  labels_add = [l for l in canon_labels_add if l]
+  canon_labels_remove = [framework_bizobj.CanonicalizeLabel(l)
+                         for l in labels_remove]
+  labels_remove = [l for l in canon_labels_remove if l]
+
+  (labels, update_labels_add,
+   update_labels_remove) = framework_bizobj.MergeLabels(
+       issue.labels, labels_add, labels_remove, config)
+
+  if update_labels_add or update_labels_remove:
+    issue.labels = labels
+    return MakeLabelsAmendment(
+          update_labels_add, update_labels_remove)
+  return None
+
+
+def ApplyFieldValueChanges(issue, config, fvs_add, fvs_remove, fields_clear):
+  """Updates the PB issue's field_values and returns an amendments list."""
+  phase_names_dict = {phase.phase_id: phase.name for phase in issue.phases}
+  phase_ids = list(phase_names_dict.keys())
+  (field_vals, added_fvs_by_id,
+   removed_fvs_by_id) = _MergeFields(
+       issue.field_values,
+       [fv for fv in fvs_add if not fv.phase_id or fv.phase_id in phase_ids],
+       [fv for fv in fvs_remove if not fv.phase_id or fv.phase_id in phase_ids],
+       config.field_defs)
+  amendments = []
+  if added_fvs_by_id or removed_fvs_by_id:
+    issue.field_values = field_vals
+    for fd in config.field_defs:
+      fd_added_values_by_phase = collections.defaultdict(list)
+      fd_removed_values_by_phase = collections.defaultdict(list)
+      # Split fd's added/removed fvs by the phase they belong to.
+      # non-phase fds will result in {None: [added_fvs]}
+      for fv in added_fvs_by_id.get(fd.field_id, []):
+        fd_added_values_by_phase[fv.phase_id].append(fv)
+      for fv in removed_fvs_by_id.get(fd.field_id, []):
+        fd_removed_values_by_phase[fv.phase_id].append(fv)
+      # Use all_fv_phase_ids to create Amendments, so no empty amendments
+      # are created for issue phases that had no field value changes.
+      all_fv_phase_ids = set(
+          fd_removed_values_by_phase.keys() + fd_added_values_by_phase.keys())
+      for phase_id in all_fv_phase_ids:
+        new_values = [GetFieldValue(fv, {}) for fv
+                      in fd_added_values_by_phase.get(phase_id, [])]
+        old_values = [GetFieldValue(fv, {}) for fv
+                      in fd_removed_values_by_phase.get(phase_id, [])]
+        amendments.append(MakeFieldAmendment(
+              fd.field_id, config, new_values, old_values=old_values,
+              phase_name=phase_names_dict.get(phase_id)))
+
+  # Note: Clearing fields is used with bulk-editing and phase fields do
+  # not appear there and cannot be bulk-edited.
+  if fields_clear:
+    field_clear_set = set(fields_clear)
+    revised_fields = []
+    for fd in config.field_defs:
+      if fd.field_id not in field_clear_set:
+        revised_fields.extend(
+            fv for fv in issue.field_values if fv.field_id == fd.field_id)
+      else:
+        amendments.append(
+            MakeFieldClearedAmendment(fd.field_id, config))
+        if fd.field_type == tracker_pb2.FieldTypes.ENUM_TYPE:
+          prefix = fd.field_name.lower() + '-'
+          filtered_labels = [
+              lab for lab in issue.labels
+              if not lab.lower().startswith(prefix)]
+          issue.labels = filtered_labels
+
+    issue.field_values = revised_fields
+  return amendments
+
+
+def ApplyIssueDelta(cnxn, issue_service, issue, delta, config):
+  """Apply an issue delta to an issue in RAM.
+
+  Args:
+    cnxn: connection to SQL database.
+    issue_service: object to access issue-related data in the database.
+    issue: Issue to be updated.
+    delta: IssueDelta object with new values for everything being changed.
+    config: ProjectIssueConfig object for the project containing the issue.
+
+  Returns:
+    A pair (amendments, impacted_iids) where amendments is a list of Amendment
+    protos to describe what changed, and impacted_iids is a set of other IIDs
+    for issues that are modified because they are related to the given issue.
+  """
+  amendments = []
+  impacted_iids = set()
+  if (delta.status is not None and delta.status != issue.status):
+    status = framework_bizobj.CanonicalizeLabel(delta.status)
+    amendments.append(MakeStatusAmendment(status, issue.status))
+    issue.status = status
+  if (delta.owner_id is not None and delta.owner_id != issue.owner_id):
+    amendments.append(MakeOwnerAmendment(delta.owner_id, issue.owner_id))
+    issue.owner_id = delta.owner_id
+
+  # compute the set of cc'd users added and removed
+  cc_add = [cc for cc in delta.cc_ids_add if cc not in issue.cc_ids]
+  cc_remove = [cc for cc in delta.cc_ids_remove if cc in issue.cc_ids]
+  if cc_add or cc_remove:
+    cc_ids = [cc for cc in list(issue.cc_ids) + cc_add
+              if cc not in cc_remove]
+    issue.cc_ids = cc_ids
+    amendments.append(MakeCcAmendment(cc_add, cc_remove))
+
+  # compute the set of components added and removed
+  comp_ids_add = [
+      c for c in delta.comp_ids_add if c not in issue.component_ids]
+  comp_ids_remove = [
+      c for c in delta.comp_ids_remove if c in issue.component_ids]
+  if comp_ids_add or comp_ids_remove:
+    comp_ids = [cid for cid in list(issue.component_ids) + comp_ids_add
+                if cid not in comp_ids_remove]
+    issue.component_ids = comp_ids
+    amendments.append(MakeComponentsAmendment(
+        comp_ids_add, comp_ids_remove, config))
+
+  # compute the set of labels added and removed
+  label_amendment = ApplyLabelChanges(
+      issue, config, delta.labels_add, delta.labels_remove)
+  if label_amendment:
+    amendments.append(label_amendment)
+
+  # compute the set of custom fields added and removed
+  fv_amendments = ApplyFieldValueChanges(
+      issue, config, delta.field_vals_add, delta.field_vals_remove,
+      delta.fields_clear)
+  amendments.extend(fv_amendments)
+
+  # Update blocking and blocked on issues.
+  (block_changes_amendments,
+   block_changes_impacted_iids) = ApplyIssueBlockRelationChanges(
+       cnxn, issue, delta.blocked_on_add, delta.blocked_on_remove,
+       delta.blocking_add, delta.blocking_remove, issue_service)
+  amendments.extend(block_changes_amendments)
+  impacted_iids.update(block_changes_impacted_iids)
+
+  # Update external issue references.
+  if delta.ext_blocked_on_add or delta.ext_blocked_on_remove:
+    add_refs = []
+    for ext_id in delta.ext_blocked_on_add:
+      ref = tracker_pb2.DanglingIssueRef(ext_issue_identifier=ext_id)
+      if (federated.IsShortlinkValid(ext_id) and
+          ref not in issue.dangling_blocked_on_refs and
+          ext_id not in delta.ext_blocked_on_remove):
+        add_refs.append(ref)
+    remove_refs = []
+    for ext_id in delta.ext_blocked_on_remove:
+      ref = tracker_pb2.DanglingIssueRef(ext_issue_identifier=ext_id)
+      if (federated.IsShortlinkValid(ext_id) and
+          ref in issue.dangling_blocked_on_refs):
+        remove_refs.append(ref)
+    if add_refs or remove_refs:
+      amendments.append(MakeBlockedOnAmendment(add_refs, remove_refs))
+    issue.dangling_blocked_on_refs = [
+        ref for ref in issue.dangling_blocked_on_refs + add_refs
+        if ref.ext_issue_identifier not in delta.ext_blocked_on_remove]
+
+  # Update external issue references.
+  if delta.ext_blocking_add or delta.ext_blocking_remove:
+    add_refs = []
+    for ext_id in delta.ext_blocking_add:
+      ref = tracker_pb2.DanglingIssueRef(ext_issue_identifier=ext_id)
+      if (federated.IsShortlinkValid(ext_id) and
+          ref not in issue.dangling_blocking_refs and
+          ext_id not in delta.ext_blocking_remove):
+        add_refs.append(ref)
+    remove_refs = []
+    for ext_id in delta.ext_blocking_remove:
+      ref = tracker_pb2.DanglingIssueRef(ext_issue_identifier=ext_id)
+      if (federated.IsShortlinkValid(ext_id) and
+          ref in issue.dangling_blocking_refs):
+        remove_refs.append(ref)
+    if add_refs or remove_refs:
+      amendments.append(MakeBlockingAmendment(add_refs, remove_refs))
+    issue.dangling_blocking_refs = [
+        ref for ref in issue.dangling_blocking_refs + add_refs
+        if ref.ext_issue_identifier not in delta.ext_blocking_remove]
+
+  if delta.merged_into is not None and delta.merged_into_external is not None:
+    raise ValueError(('Cannot update merged_into and merged_into_external'
+      ' fields at the same time.'))
+
+  if (delta.merged_into is not None and
+      delta.merged_into != issue.merged_into and
+      ((delta.merged_into == 0 and issue.merged_into is not None) or
+       delta.merged_into != 0)):
+
+    # Handle removing the existing internal merged_into.
+    try:
+      merged_remove = issue.merged_into
+      remove_issue = issue_service.GetIssue(cnxn, merged_remove)
+      remove_ref = remove_issue.project_name, remove_issue.local_id
+      impacted_iids.add(merged_remove)
+    except exceptions.NoSuchIssueException:
+      remove_ref = None
+
+    # Handle going from external->internal mergedinto.
+    if issue.merged_into_external:
+      remove_ref = tracker_pb2.DanglingIssueRef(
+          ext_issue_identifier=issue.merged_into_external)
+      issue.merged_into_external = None
+
+    # Handle adding the new merged_into.
+    try:
+      merged_add = delta.merged_into
+      issue.merged_into = delta.merged_into
+      add_issue = issue_service.GetIssue(cnxn, merged_add)
+      add_ref = add_issue.project_name, add_issue.local_id
+      impacted_iids.add(merged_add)
+    except exceptions.NoSuchIssueException:
+      add_ref = None
+
+    amendments.append(MakeMergedIntoAmendment(
+        [add_ref], [remove_ref], default_project_name=issue.project_name))
+
+  if (delta.merged_into_external is not None and
+      delta.merged_into_external != issue.merged_into_external and
+      (federated.IsShortlinkValid(delta.merged_into_external) or
+       (delta.merged_into_external == '' and issue.merged_into_external))):
+
+    remove_ref = None
+    if issue.merged_into_external:
+      remove_ref = tracker_pb2.DanglingIssueRef(
+          ext_issue_identifier=issue.merged_into_external)
+    elif issue.merged_into:
+      # Handle moving from internal->external mergedinto.
+      try:
+        remove_issue = issue_service.GetIssue(cnxn, issue.merged_into)
+        remove_ref = remove_issue.project_name, remove_issue.local_id
+        impacted_iids.add(issue.merged_into)
+      except exceptions.NoSuchIssueException:
+        pass
+
+    add_ref = tracker_pb2.DanglingIssueRef(
+        ext_issue_identifier=delta.merged_into_external)
+    issue.merged_into = 0
+    issue.merged_into_external = delta.merged_into_external
+    amendments.append(MakeMergedIntoAmendment([add_ref], [remove_ref],
+        default_project_name=issue.project_name))
+
+  if delta.summary and delta.summary != issue.summary:
+    amendments.append(MakeSummaryAmendment(delta.summary, issue.summary))
+    issue.summary = delta.summary
+
+  return amendments, impacted_iids
+
+
+def ApplyIssueBlockRelationChanges(
+    cnxn, issue, blocked_on_add, blocked_on_remove, blocking_add,
+    blocking_remove, issue_service):
+  # type: (MonorailConnection, Issue, Collection[int], Collection[int],
+  #     Collection[int], Collection[int], IssueService) ->
+  #     Sequence[Amendment], Collection[int]
+  """Apply issue blocking/blocked_on relation changes to an issue in RAM.
+
+  Args:
+    cnxn: connection to SQL database.
+    issue: Issue PB that we are applying the changes to.
+    blocked_on_add: list of issue IDs that we want to add as blocked_on.
+    blocked_on_remove: list of issue IDs that we want to remove from blocked_on.
+    blocking_add: list of issue IDs that we want to add as blocking.
+    blocking_remove: list of issue IDs that we want to remove from blocking.
+    issue_service: IssueService used to fetch info from DB or cache.
+
+  Returns:
+    A tuple that holds the list of Amendments that represent the applied changes
+    and a set of issue IDs that are impacted by the changes.
+
+
+  Side-effect:
+    The given issue's blocked_on and blocking fields will be modified.
+  """
+  amendments = []
+  impacted_iids = set()
+
+  def addAmendment(add_iids, remove_iids, amendment_func):
+    add_refs = issue_service.LookupIssueRefs(cnxn, add_iids).values()
+    remove_refs = issue_service.LookupIssueRefs(cnxn, remove_iids).values()
+    new_am = amendment_func(
+        add_refs, remove_refs, default_project_name=issue.project_name)
+    amendments.append(new_am)
+
+  # Apply blocked_on changes.
+  old_blocked_on = issue.blocked_on_iids
+  blocked_on_add = [iid for iid in blocked_on_add if iid not in old_blocked_on]
+  blocked_on_remove = [
+      iid for iid in blocked_on_remove if iid in old_blocked_on
+  ]
+  # blocked_on_add and blocked_on_remove are filtered above such that they
+  # could not contain matching items.
+  if blocked_on_add or blocked_on_remove:
+    addAmendment(blocked_on_add, blocked_on_remove, MakeBlockedOnAmendment)
+
+    new_blocked_on_iids = [
+        iid for iid in old_blocked_on + blocked_on_add
+        if iid not in blocked_on_remove
+    ]
+    (issue.blocked_on_iids,
+     issue.blocked_on_ranks) = issue_service.SortBlockedOn(
+         cnxn, issue, new_blocked_on_iids)
+    impacted_iids.update(blocked_on_add + blocked_on_remove)
+
+  # Apply blocking changes.
+  old_blocking = issue.blocking_iids
+  blocking_add = [iid for iid in blocking_add if iid not in old_blocking]
+  blocking_remove = [iid for iid in blocking_remove if iid in old_blocking]
+  # blocking_add and blocking_remove are filtered above such that they
+  # could not contain matching items.
+  if blocking_add or blocking_remove:
+    addAmendment(blocking_add, blocking_remove, MakeBlockingAmendment)
+    issue.blocking_iids = [
+        iid for iid in old_blocking + blocking_add if iid not in blocking_remove
+    ]
+    impacted_iids.update(blocking_add + blocking_remove)
+
+  return amendments, impacted_iids
+
+
+def MakeAmendment(
+    field, new_value, added_ids, removed_ids, custom_field_name=None,
+    old_value=None):
+  """Utility function to populate an Amendment PB.
+
+  Args:
+    field: enum for the field being updated.
+    new_value: new string value of that field.
+    added_ids: list of user IDs being added.
+    removed_ids: list of user IDs being removed.
+    custom_field_name: optional name of a custom field.
+    old_value: old string value of that field.
+
+  Returns:
+    An instance of Amendment.
+  """
+  amendment = tracker_pb2.Amendment()
+  amendment.field = field
+  amendment.newvalue = new_value
+  amendment.added_user_ids.extend(added_ids)
+  amendment.removed_user_ids.extend(removed_ids)
+
+  if old_value is not None:
+    amendment.oldvalue = old_value
+
+  if custom_field_name is not None:
+    amendment.custom_field_name = custom_field_name
+
+  return amendment
+
+
+def _PlusMinusString(added_items, removed_items):
+  """Return a concatenation of the items, with a minus on removed items.
+
+  Args:
+    added_items: list of string items added.
+    removed_items: list of string items removed.
+
+  Returns:
+    A unicode string with all the removed items first (preceeded by minus
+    signs) and then the added items.
+  """
+  assert all(isinstance(item, string_types)
+             for item in added_items + removed_items)
+  # TODO(jrobbins): this is not good when values can be negative ints.
+  return ' '.join(
+      ['-%s' % item.strip()
+       for item in removed_items if item] +
+      ['%s' % item for item in added_items if item])
+
+
+def _PlusMinusAmendment(
+    field, added_items, removed_items, custom_field_name=None):
+  """Make an Amendment PB with the given added/removed items."""
+  return MakeAmendment(
+      field, _PlusMinusString(added_items, removed_items), [], [],
+      custom_field_name=custom_field_name)
+
+
+def _PlusMinusRefsAmendment(
+    field, added_refs, removed_refs, default_project_name=None):
+  """Make an Amendment PB with the given added/removed refs."""
+  return _PlusMinusAmendment(
+      field,
+      [FormatIssueRef(r, default_project_name=default_project_name)
+       for r in added_refs if r],
+      [FormatIssueRef(r, default_project_name=default_project_name)
+       for r in removed_refs if r])
+
+
+def MakeSummaryAmendment(new_summary, old_summary):
+  """Make an Amendment PB for a change to the summary."""
+  return MakeAmendment(
+      tracker_pb2.FieldID.SUMMARY, new_summary, [], [], old_value=old_summary)
+
+
+def MakeStatusAmendment(new_status, old_status):
+  """Make an Amendment PB for a change to the status."""
+  return MakeAmendment(
+      tracker_pb2.FieldID.STATUS, new_status, [], [], old_value=old_status)
+
+
+def MakeOwnerAmendment(new_owner_id, old_owner_id):
+  """Make an Amendment PB for a change to the owner."""
+  return MakeAmendment(
+      tracker_pb2.FieldID.OWNER, '', [new_owner_id], [old_owner_id])
+
+
+def MakeCcAmendment(added_cc_ids, removed_cc_ids):
+  """Make an Amendment PB for a change to the Cc list."""
+  return MakeAmendment(
+      tracker_pb2.FieldID.CC, '', added_cc_ids, removed_cc_ids)
+
+
+def MakeLabelsAmendment(added_labels, removed_labels):
+  """Make an Amendment PB for a change to the labels."""
+  return _PlusMinusAmendment(
+      tracker_pb2.FieldID.LABELS, added_labels, removed_labels)
+
+
+def DiffValueLists(new_list, old_list):
+  """Give an old list and a new list, return the added and removed items."""
+  if not old_list:
+    return new_list, []
+  if not new_list:
+    return [], old_list
+
+  added = []
+  removed = old_list[:]  # Assume everything was removed, then narrow that down
+  for val in new_list:
+    if val in removed:
+      removed.remove(val)
+    else:
+      added.append(val)
+
+  return added, removed
+
+
+def MakeFieldAmendment(
+    field_id, config, new_values, old_values=None, phase_name=None):
+  """Return an amendment showing how an issue's field changed.
+
+  Args:
+    field_id: int field ID of a built-in or custom issue field.
+    config: config info for the current project, including field_defs.
+    new_values: list of strings representing new values of field.
+    old_values: list of strings representing old values of field.
+    phase_name: name of the phase that owned the field that was changed.
+
+  Returns:
+    A new Amemdnent object.
+
+  Raises:
+    ValueError: if the specified field was not found.
+  """
+  fd = FindFieldDefByID(field_id, config)
+
+  if fd is None:
+    raise ValueError('field %r vanished mid-request', field_id)
+
+  field_name = fd.field_name if not phase_name else '%s-%s' % (
+      phase_name, fd.field_name)
+  if fd.is_multivalued:
+    old_values = old_values or []
+    added, removed = DiffValueLists(new_values, old_values)
+    if fd.field_type == tracker_pb2.FieldTypes.USER_TYPE:
+      return MakeAmendment(
+          tracker_pb2.FieldID.CUSTOM, '', added, removed,
+          custom_field_name=field_name)
+    else:
+      return _PlusMinusAmendment(
+          tracker_pb2.FieldID.CUSTOM,
+          ['%s' % item for item in added],
+          ['%s' % item for item in removed],
+          custom_field_name=field_name)
+
+  else:
+    if fd.field_type == tracker_pb2.FieldTypes.USER_TYPE:
+      return MakeAmendment(
+          tracker_pb2.FieldID.CUSTOM, '', new_values, [],
+          custom_field_name=field_name)
+
+    if new_values:
+      new_str = ', '.join('%s' % item for item in new_values)
+    else:
+      new_str = '----'
+
+    return MakeAmendment(
+        tracker_pb2.FieldID.CUSTOM, new_str, [], [],
+        custom_field_name=field_name)
+
+
+def MakeFieldClearedAmendment(field_id, config):
+  fd = FindFieldDefByID(field_id, config)
+
+  if fd is None:
+    raise ValueError('field %r vanished mid-request', field_id)
+
+  return MakeAmendment(
+      tracker_pb2.FieldID.CUSTOM, '----', [], [],
+      custom_field_name=fd.field_name)
+
+
+def MakeApprovalStructureAmendment(new_approvals, old_approvals):
+  """Return an Amendment showing an issue's approval structure changed.
+
+  Args:
+    new_approvals: the new list of approvals.
+    old_approvals: the old list of approvals.
+
+  Returns:
+    A new Amendment object.
+  """
+
+  approvals_added, approvals_removed = DiffValueLists(
+      new_approvals, old_approvals)
+  return MakeAmendment(
+      tracker_pb2.FieldID.CUSTOM, _PlusMinusString(
+          approvals_added, approvals_removed),
+      [], [], custom_field_name='Approvals')
+
+
+def MakeApprovalStatusAmendment(new_status):
+  """Return an Amendment showing an issue approval's status changed.
+
+  Args:
+    new_status: ApprovalStatus representing the new approval status.
+
+  Returns:
+    A new Amemdnent object.
+  """
+  return MakeAmendment(
+      tracker_pb2.FieldID.CUSTOM, new_status.name.lower(), [], [],
+      custom_field_name='Status')
+
+
+def MakeApprovalApproversAmendment(approvers_add, approvers_remove):
+  """Return an Amendment showing an issue approval's approvers changed.
+
+  Args:
+    approvers_add: list of approver user_ids being added.
+    approvers_remove: list of approver user_ids being removed.
+
+  Returns:
+    A new Amendment object.
+  """
+  return MakeAmendment(
+      tracker_pb2.FieldID.CUSTOM, '', approvers_add, approvers_remove,
+      custom_field_name='Approvers')
+
+
+def MakeComponentsAmendment(added_comp_ids, removed_comp_ids, config):
+  """Make an Amendment PB for a change to the components."""
+  # TODO(jrobbins): record component IDs as ints and display them with
+  # lookups (and maybe permission checks in the future).  But, what
+  # about history that references deleleted components?
+  added_comp_paths = []
+  for comp_id in added_comp_ids:
+    cd = FindComponentDefByID(comp_id, config)
+    if cd:
+      added_comp_paths.append(cd.path)
+
+  removed_comp_paths = []
+  for comp_id in removed_comp_ids:
+    cd = FindComponentDefByID(comp_id, config)
+    if cd:
+      removed_comp_paths.append(cd.path)
+
+  return _PlusMinusAmendment(
+      tracker_pb2.FieldID.COMPONENTS,
+      added_comp_paths, removed_comp_paths)
+
+
+def MakeBlockedOnAmendment(
+    added_refs, removed_refs, default_project_name=None):
+  """Make an Amendment PB for a change to the blocked on issues."""
+  return _PlusMinusRefsAmendment(
+      tracker_pb2.FieldID.BLOCKEDON, added_refs, removed_refs,
+      default_project_name=default_project_name)
+
+
+def MakeBlockingAmendment(added_refs, removed_refs, default_project_name=None):
+  """Make an Amendment PB for a change to the blocking issues."""
+  return _PlusMinusRefsAmendment(
+      tracker_pb2.FieldID.BLOCKING, added_refs, removed_refs,
+      default_project_name=default_project_name)
+
+
+def MakeMergedIntoAmendment(
+    added_refs, removed_refs, default_project_name=None):
+  """Make an Amendment PB for a change to the merged-into issue."""
+  return _PlusMinusRefsAmendment(
+      tracker_pb2.FieldID.MERGEDINTO, added_refs, removed_refs,
+      default_project_name=default_project_name)
+
+
+def MakeProjectAmendment(new_project_name):
+  """Make an Amendment PB for a change to an issue's project."""
+  return MakeAmendment(
+      tracker_pb2.FieldID.PROJECT, new_project_name, [], [])
+
+
+def AmendmentString_New(amendment, user_display_names):
+  # type: (tracker_pb2.Amendment, Mapping[int, str]) -> str
+  """Produce a displayable string for an Amendment PB.
+
+  Args:
+    amendment: Amendment PB to display.
+    user_display_names: dict {user_id: display_name, ...} including all users
+        mentioned in amendment.
+
+  Returns:
+    A string that could be displayed on a web page or sent in email.
+  """
+  if amendment.newvalue:
+    return amendment.newvalue
+
+  # Display new owner only
+  if amendment.field == tracker_pb2.FieldID.OWNER:
+    if amendment.added_user_ids and amendment.added_user_ids[0]:
+      uid = amendment.added_user_ids[0]
+      result = user_display_names[uid]
+    else:
+      result = framework_constants.NO_USER_NAME
+  else:
+    added = [
+        user_display_names[uid]
+        for uid in amendment.added_user_ids
+        if uid in user_display_names
+    ]
+    removed = [
+        user_display_names[uid]
+        for uid in amendment.removed_user_ids
+        if uid in user_display_names
+    ]
+    result = _PlusMinusString(added, removed)
+
+  return result
+
+
+def AmendmentString(amendment, user_views_by_id):
+  """Produce a displayable string for an Amendment PB.
+
+  TODO(crbug.com/monorail/7571): Delete this function in favor of _New.
+
+  Args:
+    amendment: Amendment PB to display.
+    user_views_by_id: dict {user_id: user_view, ...} including all users
+        mentioned in amendment.
+
+  Returns:
+    A string that could be displayed on a web page or sent in email.
+  """
+  if amendment.newvalue:
+    return amendment.newvalue
+
+  # Display new owner only
+  if amendment.field == tracker_pb2.FieldID.OWNER:
+    if amendment.added_user_ids and amendment.added_user_ids[0]:
+      uid = amendment.added_user_ids[0]
+      result = user_views_by_id[uid].display_name
+    else:
+      result = framework_constants.NO_USER_NAME
+  else:
+    result = _PlusMinusString(
+        [user_views_by_id[uid].display_name for uid in amendment.added_user_ids
+         if uid in user_views_by_id],
+        [user_views_by_id[uid].display_name
+         for uid in amendment.removed_user_ids if uid in user_views_by_id])
+
+  return result
+
+
+def AmendmentLinks(amendment, users_by_id, project_name):
+  """Produce a list of value/url pairs for an Amendment PB.
+
+  Args:
+    amendment: Amendment PB to display.
+    users_by_id: dict {user_id: user_view, ...} including all users
+      mentioned in amendment.
+    project_nme: Name of project the issue/comment/amendment is in.
+
+  Returns:
+    A list of dicts with 'value' and 'url' keys. 'url' may be None.
+  """
+  # Display both old and new summary, status
+  if (amendment.field == tracker_pb2.FieldID.SUMMARY or
+      amendment.field == tracker_pb2.FieldID.STATUS):
+    result = amendment.newvalue
+    oldValue = amendment.oldvalue;
+    # Old issues have a 'NULL' string as the old value of the summary
+    # or status fields. See crbug.com/monorail/3805
+    if oldValue and oldValue != 'NULL':
+      result += ' (was: %s)' % amendment.oldvalue
+    return [{'value': result, 'url': None}]
+  # Display new owner only
+  elif amendment.field == tracker_pb2.FieldID.OWNER:
+    if amendment.added_user_ids and amendment.added_user_ids[0]:
+      uid = amendment.added_user_ids[0]
+      return [{'value': users_by_id[uid].display_name, 'url': None}]
+    return [{'value': framework_constants.NO_USER_NAME, 'url': None}]
+  elif amendment.field in (tracker_pb2.FieldID.BLOCKEDON,
+                           tracker_pb2.FieldID.BLOCKING,
+                           tracker_pb2.FieldID.MERGEDINTO):
+    values = amendment.newvalue.split()
+    bug_refs = [_SafeParseIssueRef(v.strip()) for v in values]
+    issue_urls = [FormatIssueURL(ref, default_project_name=project_name)
+                  for ref in bug_refs]
+    # TODO(jrobbins): Permission checks on referenced issues to allow
+    # showing summary on hover.
+    return [{'value': v, 'url': u} for (v, u) in zip(values, issue_urls)]
+  elif amendment.newvalue:
+    # Catchall for everything except user-valued fields.
+    return [{'value': v, 'url': None} for v in amendment.newvalue.split()]
+  else:
+    # Applies to field==CC or CUSTOM with user type.
+    values = _PlusMinusString(
+        [users_by_id[uid].display_name for uid in amendment.added_user_ids
+         if uid in users_by_id],
+        [users_by_id[uid].display_name for uid in amendment.removed_user_ids
+         if uid in users_by_id])
+    return [{'value': v.strip(), 'url': None} for v in values.split()]
+
+
+def GetAmendmentFieldName(amendment):
+  """Get user-visible name for an amendment to a built-in or custom field."""
+  if amendment.custom_field_name:
+    return amendment.custom_field_name
+  else:
+    field_name = str(amendment.field)
+    return field_name.capitalize()
+
+
+def MakeDanglingIssueRef(project_name, issue_id, ext_id=''):
+  """Create a DanglingIssueRef pb."""
+  ret = tracker_pb2.DanglingIssueRef()
+  ret.project = project_name
+  ret.issue_id = issue_id
+  ret.ext_issue_identifier = ext_id
+  return ret
+
+
+def FormatIssueURL(issue_ref_tuple, default_project_name=None):
+  """Format an issue url from an issue ref."""
+  if issue_ref_tuple is None:
+    return ''
+  project_name, local_id = issue_ref_tuple
+  project_name = project_name or default_project_name
+  url = framework_helpers.FormatURL(
+    None, '/p/%s%s' % (project_name, urls.ISSUE_DETAIL), id=local_id)
+  return url
+
+
+def FormatIssueRef(issue_ref_tuple, default_project_name=None):
+  """Format an issue reference for users: e.g., 123, or projectname:123."""
+  if issue_ref_tuple is None:
+    return ''
+
+  # TODO(jeffcarp): Improve method signature to not require isinstance.
+  if isinstance(issue_ref_tuple, tracker_pb2.DanglingIssueRef):
+    return issue_ref_tuple.ext_issue_identifier or ''
+
+  project_name, local_id = issue_ref_tuple
+  if project_name and project_name != default_project_name:
+    return '%s:%d' % (project_name, local_id)
+  else:
+    return str(local_id)
+
+
+def ParseIssueRef(ref_str):
+  """Parse an issue ref string: e.g., 123, or projectname:123 into a tuple.
+
+  Raises ValueError if the ref string exists but can't be parsed.
+  """
+  if not ref_str.strip():
+    return None
+
+  if ':' in ref_str:
+    project_name, id_str = ref_str.split(':', 1)
+    project_name = project_name.strip().lstrip('-')
+  else:
+    project_name = None
+    id_str = ref_str
+
+  id_str = id_str.lstrip('-')
+
+  return project_name, int(id_str)
+
+
+def _SafeParseIssueRef(ref_str):
+  """Same as ParseIssueRef, but catches ValueError and returns None instead."""
+  try:
+    return ParseIssueRef(ref_str)
+  except ValueError:
+    return None
+
+
+def _MergeFields(field_values, fields_add, fields_remove, field_defs):
+  """Merge the fields to add/remove into the current field values.
+
+  Args:
+    field_values: list of current FieldValue PBs.
+    fields_add: list of FieldValue PBs to add to field_values.  If any of these
+        is for a single-valued field, it replaces all previous values for the
+        same field_id in field_values.
+    fields_remove: list of FieldValues to remove from field_values, if found.
+    field_defs: list of FieldDef PBs from the issue's project's config.
+
+  Returns:
+    A 3-tuple with the merged list of field values and {field_id: field_values}
+    dict for the specific values that are added or removed.  The actual added
+    or removed might be fewer than the requested ones if the issue already had
+    one of the values-to-add or lacked one of the values-to-remove.
+  """
+  is_multi = {fd.field_id: fd.is_multivalued for fd in field_defs}
+  merged_fvs = list(field_values)
+  added_fvs_by_id = collections.defaultdict(list)
+  for fv_consider in fields_add:
+    consider_value = GetFieldValue(fv_consider, {})
+    for old_fv in field_values:
+      # Don't add fv_consider if field_values already contains consider_value
+      if (fv_consider.field_id == old_fv.field_id and
+          GetFieldValue(old_fv, {}) == consider_value and
+          fv_consider.phase_id == old_fv.phase_id):
+        break
+    else:
+      # Drop any existing values for non-multi fields.
+      if not is_multi.get(fv_consider.field_id):
+        if fv_consider.phase_id:
+          # Drop existing phase fvs that belong to the same phase
+          merged_fvs = [fv for fv in merged_fvs if
+                        not (fv.field_id == fv_consider.field_id
+                             and fv.phase_id == fv_consider.phase_id)]
+        else:
+          # Drop existing non-phase fvs
+          merged_fvs = [fv for fv in merged_fvs if
+                        not fv.field_id == fv_consider.field_id]
+      added_fvs_by_id[fv_consider.field_id].append(fv_consider)
+      merged_fvs.append(fv_consider)
+
+  removed_fvs_by_id = collections.defaultdict(list)
+  for fv_consider in fields_remove:
+    consider_value = GetFieldValue(fv_consider, {})
+    for old_fv in field_values:
+      # Only remove fv_consider if field_values contains consider_value
+      if (fv_consider.field_id == old_fv.field_id and
+          GetFieldValue(old_fv, {}) == consider_value and
+          fv_consider.phase_id == old_fv.phase_id):
+        removed_fvs_by_id[fv_consider.field_id].append(fv_consider)
+        merged_fvs.remove(old_fv)
+  return merged_fvs, added_fvs_by_id, removed_fvs_by_id
+
+
+def SplitBlockedOnRanks(issue, target_iid, split_above, open_iids):
+  """Splits issue relation rankings by some target issue's rank
+
+  Args:
+    issue: Issue PB for the issue considered.
+    target_iid: the global ID of the issue to split rankings about.
+    split_above: False to split below the target issue, True to split above.
+    open_iids: a list of global IDs of open and visible issues blocking
+      the considered issue.
+
+  Returns:
+    A tuple (lower, higher) where both are lists of
+    [(blocker_iid, rank),...] of issues in rank order. If split_above is False
+    the target issue is included in higher, otherwise it is included in lower
+  """
+  issue_rank_pairs = [(dst_iid, rank)
+      for (dst_iid, rank) in zip(issue.blocked_on_iids, issue.blocked_on_ranks)
+      if dst_iid in open_iids]
+  # blocked_on_iids is sorted high-to-low, we need low-to-high
+  issue_rank_pairs.reverse()
+  offset = int(split_above)
+  for i, (dst_iid, _) in enumerate(issue_rank_pairs):
+    if dst_iid == target_iid:
+      return issue_rank_pairs[:i + offset], issue_rank_pairs[i + offset:]
+
+  logging.error('Target issue %r was not found in blocked_on_iids of %r',
+                target_iid, issue)
+  return issue_rank_pairs, []