| # Copyright 2016 The Chromium Authors |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| """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 mrproto 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 = [] |
| 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.append(cd) |
| result.extend(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[mrproto.tracker_pb2.ApprovalDef], |
| # Sequence[mrproto.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()) | set( |
| 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_dict = issue_service.LookupIssueRefs(cnxn, add_iids) |
| add_refs = [add_refs_dict[iid] for iid in add_iids if iid in add_refs_dict] |
| remove_refs_dict = issue_service.LookupIssueRefs(cnxn, remove_iids) |
| remove_refs = [ |
| remove_refs_dict[iid] for iid in remove_iids if iid in remove_refs_dict |
| ] |
| 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, |
| added_component_ids=None, |
| removed_component_ids=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 |
| |
| if added_component_ids is not None: |
| amendment.added_component_ids.extend(added_component_ids) |
| |
| if removed_component_ids is not None: |
| amendment.removed_component_ids.extend(removed_component_ids) |
| |
| 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 = [] |
| valid_added_comp_ids = [] |
| for comp_id in added_comp_ids: |
| cd = FindComponentDefByID(comp_id, config) |
| if cd: |
| added_comp_paths.append(cd.path) |
| valid_added_comp_ids.append(comp_id) |
| |
| removed_comp_paths = [] |
| valid_removed_comp_ids = [] |
| for comp_id in removed_comp_ids: |
| cd = FindComponentDefByID(comp_id, config) |
| if cd: |
| removed_comp_paths.append(cd.path) |
| valid_removed_comp_ids.append(comp_id) |
| values = _PlusMinusString(added_comp_paths, removed_comp_paths) |
| return MakeAmendment( |
| tracker_pb2.FieldID.COMPONENTS, |
| values, [], [], |
| added_component_ids=valid_added_comp_ids, |
| removed_component_ids=valid_removed_comp_ids) |
| |
| |
| 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, [] |