| Index: appengine/monorail/tracker/tracker_bizobj.py
|
| diff --git a/appengine/monorail/tracker/tracker_bizobj.py b/appengine/monorail/tracker/tracker_bizobj.py
|
| new file mode 100644
|
| index 0000000000000000000000000000000000000000..4c434e851175b14fa66bd011b57358c09334b35c
|
| --- /dev/null
|
| +++ b/appengine/monorail/tracker/tracker_bizobj.py
|
| @@ -0,0 +1,1032 @@
|
| +# Copyright 2016 The Chromium Authors. All rights reserved.
|
| +# Use of this source code is govered by a BSD-style
|
| +# license that can be found in the LICENSE file or at
|
| +# https://developers.google.com/open-source/licenses/bsd
|
| +
|
| +"""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.
|
| +"""
|
| +
|
| +import logging
|
| +
|
| +from framework import framework_bizobj
|
| +from framework import framework_constants
|
| +from framework import framework_helpers
|
| +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 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, templates, 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 templates: templates = []
|
| + 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)
|
| + SetConfigTemplates(project_config, templates)
|
| + 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 UsersInvolvedInConfig(config):
|
| + """Return a set of all user IDs referenced in the ProjectIssueConfig."""
|
| + result = set()
|
| + for template in config.templates:
|
| + result.add(template.owner_id)
|
| + result.update(template.admin_ids)
|
| + for field in config.field_defs:
|
| + result.update(field.admin_ids)
|
| + return result
|
| +
|
| +
|
| +def FindFieldDef(field_name, config):
|
| + """Find the specified field, or 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 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 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 MakeFieldDef(
|
| + field_id, project_id, field_name, field_type_int, applic_type, applic_pred,
|
| + is_required, is_multivalued, min_value, max_value, regex, needs_member,
|
| + needs_perm, grants_perm, notify_on, docstring, is_deleted):
|
| + """Make a FieldDef PB for the given FieldDef table row tuple."""
|
| + 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_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))
|
| + 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
|
| + return fd
|
| +
|
| +
|
| +def MakeFieldValue(field_id, int_value, str_value, user_id, derived):
|
| + """Make a FieldValue based on the given information."""
|
| + fv = tracker_pb2.FieldValue(field_id=field_id, derived=derived)
|
| + 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
|
| +
|
| + 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
|
| + 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 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):
|
| + """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)
|
| + 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 SetConfigTemplates(project_config, template_dict_list):
|
| + """Internal method to set the templates of a ProjectIssueConfig."""
|
| + templates = [ConvertDictToTemplate(template_dict)
|
| + for template_dict in template_dict_list]
|
| + project_config.templates = templates
|
| +
|
| +
|
| +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 thatn 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):
|
| + """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 []
|
| +
|
| + 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
|
| +
|
| + 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_TEMPLATES,
|
| + 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)
|
| + # 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=True))
|
| +
|
| + 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(config.field_defs)
|
| + result_config.component_defs.extend(config.component_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)
|
| +
|
| + return result
|
| +
|
| +
|
| +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, basestring)
|
| + 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):
|
| + """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.
|
| +
|
| + 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)
|
| +
|
| + 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=fd.field_name)
|
| + else:
|
| + return _PlusMinusAmendment(
|
| + tracker_pb2.FieldID.CUSTOM,
|
| + ['%s' % item for item in added],
|
| + ['%s' % item for item in removed],
|
| + custom_field_name=fd.field_name)
|
| +
|
| + else:
|
| + if fd.field_type == tracker_pb2.FieldTypes.USER_TYPE:
|
| + return MakeAmendment(
|
| + tracker_pb2.FieldID.CUSTOM, '', new_values, [],
|
| + custom_field_name=fd.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=fd.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 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_ref, removed_ref, default_project_name=None):
|
| + """Make an Amendment PB for a change to the merged-into issue."""
|
| + return _PlusMinusRefsAmendment(
|
| + tracker_pb2.FieldID.MERGEDINTO, [added_ref], [removed_ref],
|
| + 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(amendment, users_by_id):
|
| + """Produce a displayable string for an Amendment PB.
|
| +
|
| + Args:
|
| + amendment: Amendment PB to display.
|
| + users_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] > 0:
|
| + uid = amendment.added_user_ids[0]
|
| + result = users_by_id[uid].display_name
|
| + else:
|
| + result = framework_constants.NO_USER_NAME
|
| + else:
|
| + result = _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 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
|
| + if amendment.field == tracker_pb2.FieldID.SUMMARY:
|
| + result = amendment.newvalue
|
| + if amendment.oldvalue:
|
| + 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] > 0:
|
| + uid = amendment.added_user_ids[0]
|
| + return [{'value': users_by_id[uid].display_name, 'url': None}]
|
| + else:
|
| + 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):
|
| + """Create a DanglingIssueRef pb."""
|
| + ret = tracker_pb2.DanglingIssueRef()
|
| + ret.project = project_name
|
| + ret.issue_id = issue_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 ''
|
| + 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 field values, the specific values that 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)
|
| + fvs_added = []
|
| + for fv_consider in fields_add:
|
| + consider_value = GetFieldValue(fv_consider, {})
|
| + for old_fv in field_values:
|
| + if (fv_consider.field_id == old_fv.field_id and
|
| + GetFieldValue(old_fv, {}) == consider_value):
|
| + break
|
| + else:
|
| + # Drop any existing values for non-multi fields.
|
| + if not is_multi.get(fv_consider.field_id):
|
| + merged_fvs = [fv for fv in merged_fvs
|
| + if fv.field_id != fv_consider.field_id]
|
| + fvs_added.append(fv_consider)
|
| + merged_fvs.append(fv_consider)
|
| +
|
| + fvs_removed = []
|
| + for fv_consider in fields_remove:
|
| + consider_value = GetFieldValue(fv_consider, {})
|
| + for old_fv in field_values:
|
| + if (fv_consider.field_id == old_fv.field_id and
|
| + GetFieldValue(old_fv, {}) == consider_value):
|
| + fvs_removed.append(fv_consider)
|
| + merged_fvs.remove(old_fv)
|
| +
|
| + return merged_fvs, fvs_added, fvs_removed
|
|
|