OLD | NEW |
(Empty) | |
| 1 # Copyright 2016 The Chromium Authors. All rights reserved. |
| 2 # Use of this source code is govered by a BSD-style |
| 3 # license that can be found in the LICENSE file or at |
| 4 # https://developers.google.com/open-source/licenses/bsd |
| 5 |
| 6 """Business objects for the Monorail issue tracker. |
| 7 |
| 8 These are classes and functions that operate on the objects that |
| 9 users care about in the issue tracker: e.g., issues, and the issue |
| 10 tracker configuration. |
| 11 """ |
| 12 |
| 13 import logging |
| 14 |
| 15 from framework import framework_bizobj |
| 16 from framework import framework_constants |
| 17 from framework import framework_helpers |
| 18 from framework import urls |
| 19 from proto import tracker_pb2 |
| 20 from tracker import tracker_constants |
| 21 |
| 22 |
| 23 def GetOwnerId(issue): |
| 24 """Get the owner of an issue, whether it is explicit or derived.""" |
| 25 return (issue.owner_id or issue.derived_owner_id or |
| 26 framework_constants.NO_USER_SPECIFIED) |
| 27 |
| 28 |
| 29 def GetStatus(issue): |
| 30 """Get the status of an issue, whether it is explicit or derived.""" |
| 31 return issue.status or issue.derived_status or '' |
| 32 |
| 33 |
| 34 def GetCcIds(issue): |
| 35 """Get the Cc's of an issue, whether they are explicit or derived.""" |
| 36 return issue.cc_ids + issue.derived_cc_ids |
| 37 |
| 38 |
| 39 def GetLabels(issue): |
| 40 """Get the labels of an issue, whether explicit or derived.""" |
| 41 return issue.labels + issue.derived_labels |
| 42 |
| 43 |
| 44 def MakeProjectIssueConfig( |
| 45 project_id, well_known_statuses, statuses_offer_merge, well_known_labels, |
| 46 excl_label_prefixes, templates, col_spec): |
| 47 """Return a ProjectIssueConfig with the given values.""" |
| 48 # pylint: disable=multiple-statements |
| 49 if not well_known_statuses: well_known_statuses = [] |
| 50 if not statuses_offer_merge: statuses_offer_merge = [] |
| 51 if not well_known_labels: well_known_labels = [] |
| 52 if not excl_label_prefixes: excl_label_prefixes = [] |
| 53 if not templates: templates = [] |
| 54 if not col_spec: col_spec = ' ' |
| 55 |
| 56 project_config = tracker_pb2.ProjectIssueConfig() |
| 57 if project_id: # There is no ID for harmonized configs. |
| 58 project_config.project_id = project_id |
| 59 |
| 60 SetConfigStatuses(project_config, well_known_statuses) |
| 61 project_config.statuses_offer_merge = statuses_offer_merge |
| 62 SetConfigLabels(project_config, well_known_labels) |
| 63 SetConfigTemplates(project_config, templates) |
| 64 project_config.exclusive_label_prefixes = excl_label_prefixes |
| 65 |
| 66 # ID 0 means that nothing has been specified, so use hard-coded defaults. |
| 67 project_config.default_template_for_developers = 0 |
| 68 project_config.default_template_for_users = 0 |
| 69 |
| 70 project_config.default_col_spec = col_spec |
| 71 |
| 72 # Note: default project issue config has no filter rules. |
| 73 |
| 74 return project_config |
| 75 |
| 76 |
| 77 def UsersInvolvedInConfig(config): |
| 78 """Return a set of all user IDs referenced in the ProjectIssueConfig.""" |
| 79 result = set() |
| 80 for template in config.templates: |
| 81 result.add(template.owner_id) |
| 82 result.update(template.admin_ids) |
| 83 for field in config.field_defs: |
| 84 result.update(field.admin_ids) |
| 85 return result |
| 86 |
| 87 |
| 88 def FindFieldDef(field_name, config): |
| 89 """Find the specified field, or return None.""" |
| 90 field_name_lower = field_name.lower() |
| 91 for fd in config.field_defs: |
| 92 if fd.field_name.lower() == field_name_lower: |
| 93 return fd |
| 94 |
| 95 return None |
| 96 |
| 97 |
| 98 def FindFieldDefByID(field_id, config): |
| 99 """Find the specified field, or return None.""" |
| 100 for fd in config.field_defs: |
| 101 if fd.field_id == field_id: |
| 102 return fd |
| 103 |
| 104 return None |
| 105 |
| 106 |
| 107 def GetGrantedPerms(issue, effective_ids, config): |
| 108 """Return a set of permissions granted by user-valued fields in an issue.""" |
| 109 granted_perms = set() |
| 110 for field_value in issue.field_values: |
| 111 if field_value.user_id in effective_ids: |
| 112 field_def = FindFieldDefByID(field_value.field_id, config) |
| 113 if field_def and field_def.grants_perm: |
| 114 # TODO(jrobbins): allow comma-separated list in grants_perm |
| 115 granted_perms.add(field_def.grants_perm.lower()) |
| 116 |
| 117 return granted_perms |
| 118 |
| 119 |
| 120 def LabelIsMaskedByField(label, field_names): |
| 121 """If the label should be displayed as a field, return the field name. |
| 122 |
| 123 Args: |
| 124 label: string label to consider. |
| 125 field_names: a list of field names in lowercase. |
| 126 |
| 127 Returns: |
| 128 If masked, return the lowercase name of the field, otherwise None. A label |
| 129 is masked by a custom field if the field name "Foo" matches the key part of |
| 130 a key-value label "Foo-Bar". |
| 131 """ |
| 132 if '-' not in label: |
| 133 return None |
| 134 |
| 135 for field_name_lower in field_names: |
| 136 if label.lower().startswith(field_name_lower + '-'): |
| 137 return field_name_lower |
| 138 |
| 139 return None |
| 140 |
| 141 |
| 142 def NonMaskedLabels(labels, field_names): |
| 143 """Return only those labels that are not masked by custom fields.""" |
| 144 return [lab for lab in labels |
| 145 if not LabelIsMaskedByField(lab, field_names)] |
| 146 |
| 147 |
| 148 def MakeFieldDef( |
| 149 field_id, project_id, field_name, field_type_int, applic_type, applic_pred, |
| 150 is_required, is_multivalued, min_value, max_value, regex, needs_member, |
| 151 needs_perm, grants_perm, notify_on, docstring, is_deleted): |
| 152 """Make a FieldDef PB for the given FieldDef table row tuple.""" |
| 153 fd = tracker_pb2.FieldDef( |
| 154 field_id=field_id, project_id=project_id, field_name=field_name, |
| 155 field_type=field_type_int, is_required=bool(is_required), |
| 156 is_multivalued=bool(is_multivalued), docstring=docstring, |
| 157 is_deleted=bool(is_deleted), applicable_type=applic_type or '', |
| 158 applicable_predicate=applic_pred or '', |
| 159 needs_member=bool(needs_member), grants_perm=grants_perm or '', |
| 160 notify_on=tracker_pb2.NotifyTriggers(notify_on or 0)) |
| 161 if min_value is not None: |
| 162 fd.min_value = min_value |
| 163 if max_value is not None: |
| 164 fd.max_value = max_value |
| 165 if regex is not None: |
| 166 fd.regex = regex |
| 167 if needs_perm is not None: |
| 168 fd.needs_perm = needs_perm |
| 169 return fd |
| 170 |
| 171 |
| 172 def MakeFieldValue(field_id, int_value, str_value, user_id, derived): |
| 173 """Make a FieldValue based on the given information.""" |
| 174 fv = tracker_pb2.FieldValue(field_id=field_id, derived=derived) |
| 175 if int_value is not None: |
| 176 fv.int_value = int_value |
| 177 elif str_value is not None: |
| 178 fv.str_value = str_value |
| 179 elif user_id is not None: |
| 180 fv.user_id = user_id |
| 181 |
| 182 return fv |
| 183 |
| 184 |
| 185 def GetFieldValueWithRawValue(field_type, field_value, users_by_id, raw_value): |
| 186 """Find and return the field value of the specified field type. |
| 187 |
| 188 If the specified field_value is None or is empty then the raw_value is |
| 189 returned. When the field type is USER_TYPE the raw_value is used as a key to |
| 190 lookup users_by_id. |
| 191 |
| 192 Args: |
| 193 field_type: tracker_pb2.FieldTypes type. |
| 194 field_value: tracker_pb2.FieldValue type. |
| 195 users_by_id: Dict mapping user_ids to UserViews. |
| 196 raw_value: String to use if field_value is not specified. |
| 197 |
| 198 Returns: |
| 199 Value of the specified field type. |
| 200 """ |
| 201 ret_value = GetFieldValue(field_value, users_by_id) |
| 202 if ret_value: |
| 203 return ret_value |
| 204 # Special case for user types. |
| 205 if field_type == tracker_pb2.FieldTypes.USER_TYPE: |
| 206 if raw_value in users_by_id: |
| 207 return users_by_id[raw_value].email |
| 208 return raw_value |
| 209 |
| 210 |
| 211 def GetFieldValue(fv, users_by_id): |
| 212 """Return the value of this field. Give emails for users in users_by_id.""" |
| 213 if fv is None: |
| 214 return None |
| 215 elif fv.int_value is not None: |
| 216 return fv.int_value |
| 217 elif fv.str_value is not None: |
| 218 return fv.str_value |
| 219 elif fv.user_id is not None: |
| 220 if fv.user_id in users_by_id: |
| 221 return users_by_id[fv.user_id].email |
| 222 else: |
| 223 logging.info('Failed to lookup user %d when getting field', fv.user_id) |
| 224 return fv.user_id |
| 225 else: |
| 226 return None |
| 227 |
| 228 |
| 229 def FindComponentDef(path, config): |
| 230 """Find the specified component, or return None.""" |
| 231 path_lower = path.lower() |
| 232 for cd in config.component_defs: |
| 233 if cd.path.lower() == path_lower: |
| 234 return cd |
| 235 |
| 236 return None |
| 237 |
| 238 |
| 239 def FindMatchingComponentIDs(path, config, exact=True): |
| 240 """Return a list of components that match the given path.""" |
| 241 component_ids = [] |
| 242 path_lower = path.lower() |
| 243 |
| 244 if exact: |
| 245 for cd in config.component_defs: |
| 246 if cd.path.lower() == path_lower: |
| 247 component_ids.append(cd.component_id) |
| 248 else: |
| 249 path_lower_delim = path.lower() + '>' |
| 250 for cd in config.component_defs: |
| 251 target_delim = cd.path.lower() + '>' |
| 252 if target_delim.startswith(path_lower_delim): |
| 253 component_ids.append(cd.component_id) |
| 254 |
| 255 return component_ids |
| 256 |
| 257 |
| 258 def FindComponentDefByID(component_id, config): |
| 259 """Find the specified component, or return None.""" |
| 260 for cd in config.component_defs: |
| 261 if cd.component_id == component_id: |
| 262 return cd |
| 263 |
| 264 return None |
| 265 |
| 266 |
| 267 def FindAncestorComponents(config, component_def): |
| 268 """Return a list of all components the given component is under.""" |
| 269 path_lower = component_def.path.lower() |
| 270 return [cd for cd in config.component_defs |
| 271 if path_lower.startswith(cd.path.lower() + '>')] |
| 272 |
| 273 |
| 274 def FindDescendantComponents(config, component_def): |
| 275 """Return a list of all nested components under the given component.""" |
| 276 path_plus_delim = component_def.path.lower() + '>' |
| 277 return [cd for cd in config.component_defs |
| 278 if cd.path.lower().startswith(path_plus_delim)] |
| 279 |
| 280 |
| 281 def MakeComponentDef( |
| 282 component_id, project_id, path, docstring, deprecated, admin_ids, cc_ids, |
| 283 created, creator_id, modified=None, modifier_id=None): |
| 284 """Make a ComponentDef PB for the given FieldDef table row tuple.""" |
| 285 cd = tracker_pb2.ComponentDef( |
| 286 component_id=component_id, project_id=project_id, path=path, |
| 287 docstring=docstring, deprecated=bool(deprecated), |
| 288 admin_ids=admin_ids, cc_ids=cc_ids, created=created, |
| 289 creator_id=creator_id, modified=modified, modifier_id=modifier_id) |
| 290 return cd |
| 291 |
| 292 |
| 293 def MakeSavedQuery( |
| 294 query_id, name, base_query_id, query, subscription_mode=None, |
| 295 executes_in_project_ids=None): |
| 296 """Make SavedQuery PB for the given info.""" |
| 297 saved_query = tracker_pb2.SavedQuery( |
| 298 name=name, base_query_id=base_query_id, query=query) |
| 299 if query_id is not None: |
| 300 saved_query.query_id = query_id |
| 301 if subscription_mode is not None: |
| 302 saved_query.subscription_mode = subscription_mode |
| 303 if executes_in_project_ids is not None: |
| 304 saved_query.executes_in_project_ids = executes_in_project_ids |
| 305 return saved_query |
| 306 |
| 307 |
| 308 def SetConfigStatuses(project_config, well_known_statuses): |
| 309 """Internal method to set the well-known statuses of ProjectIssueConfig.""" |
| 310 project_config.well_known_statuses = [] |
| 311 for status, docstring, means_open, deprecated in well_known_statuses: |
| 312 canonical_status = framework_bizobj.CanonicalizeLabel(status) |
| 313 project_config.well_known_statuses.append(tracker_pb2.StatusDef( |
| 314 status_docstring=docstring, status=canonical_status, |
| 315 means_open=means_open, deprecated=deprecated)) |
| 316 |
| 317 |
| 318 def SetConfigLabels(project_config, well_known_labels): |
| 319 """Internal method to set the well-known labels of a ProjectIssueConfig.""" |
| 320 project_config.well_known_labels = [] |
| 321 for label, docstring, deprecated in well_known_labels: |
| 322 canonical_label = framework_bizobj.CanonicalizeLabel(label) |
| 323 project_config.well_known_labels.append(tracker_pb2.LabelDef( |
| 324 label=canonical_label, label_docstring=docstring, |
| 325 deprecated=deprecated)) |
| 326 |
| 327 |
| 328 def SetConfigTemplates(project_config, template_dict_list): |
| 329 """Internal method to set the templates of a ProjectIssueConfig.""" |
| 330 templates = [ConvertDictToTemplate(template_dict) |
| 331 for template_dict in template_dict_list] |
| 332 project_config.templates = templates |
| 333 |
| 334 |
| 335 def ConvertDictToTemplate(template_dict): |
| 336 """Construct a Template PB with the values from template_dict. |
| 337 |
| 338 Args: |
| 339 template_dict: dictionary with fields corresponding to the Template |
| 340 PB fields. |
| 341 |
| 342 Returns: |
| 343 A Template protocol buffer thatn can be stored in the |
| 344 project's ProjectIssueConfig PB. |
| 345 """ |
| 346 return MakeIssueTemplate( |
| 347 template_dict.get('name'), template_dict.get('summary'), |
| 348 template_dict.get('status'), template_dict.get('owner_id'), |
| 349 template_dict.get('content'), template_dict.get('labels'), [], [], |
| 350 template_dict.get('components'), |
| 351 summary_must_be_edited=template_dict.get('summary_must_be_edited'), |
| 352 owner_defaults_to_member=template_dict.get('owner_defaults_to_member'), |
| 353 component_required=template_dict.get('component_required'), |
| 354 members_only=template_dict.get('members_only')) |
| 355 |
| 356 |
| 357 def MakeIssueTemplate( |
| 358 name, summary, status, owner_id, content, labels, field_values, admin_ids, |
| 359 component_ids, summary_must_be_edited=None, owner_defaults_to_member=None, |
| 360 component_required=None, members_only=None): |
| 361 """Make an issue template PB.""" |
| 362 template = tracker_pb2.TemplateDef() |
| 363 template.name = name |
| 364 if summary: |
| 365 template.summary = summary |
| 366 if status: |
| 367 template.status = status |
| 368 if owner_id: |
| 369 template.owner_id = owner_id |
| 370 template.content = content |
| 371 template.field_values = field_values |
| 372 template.labels = labels or [] |
| 373 template.admin_ids = admin_ids |
| 374 template.component_ids = component_ids or [] |
| 375 |
| 376 if summary_must_be_edited is not None: |
| 377 template.summary_must_be_edited = summary_must_be_edited |
| 378 if owner_defaults_to_member is not None: |
| 379 template.owner_defaults_to_member = owner_defaults_to_member |
| 380 if component_required is not None: |
| 381 template.component_required = component_required |
| 382 if members_only is not None: |
| 383 template.members_only = members_only |
| 384 |
| 385 return template |
| 386 |
| 387 |
| 388 def MakeDefaultProjectIssueConfig(project_id): |
| 389 """Return a ProjectIssueConfig with use by projects that don't have one.""" |
| 390 return MakeProjectIssueConfig( |
| 391 project_id, |
| 392 tracker_constants.DEFAULT_WELL_KNOWN_STATUSES, |
| 393 tracker_constants.DEFAULT_STATUSES_OFFER_MERGE, |
| 394 tracker_constants.DEFAULT_WELL_KNOWN_LABELS, |
| 395 tracker_constants.DEFAULT_EXCL_LABEL_PREFIXES, |
| 396 tracker_constants.DEFAULT_TEMPLATES, |
| 397 tracker_constants.DEFAULT_COL_SPEC) |
| 398 |
| 399 |
| 400 def HarmonizeConfigs(config_list): |
| 401 """Combine several ProjectIssueConfigs into one for cross-project sorting. |
| 402 |
| 403 Args: |
| 404 config_list: a list of ProjectIssueConfig PBs with labels and statuses |
| 405 among other fields. |
| 406 |
| 407 Returns: |
| 408 A new ProjectIssueConfig with just the labels and status values filled |
| 409 in to be a logical union of the given configs. Specifically, the order |
| 410 of the combined status and label lists should be maintained. |
| 411 """ |
| 412 if not config_list: |
| 413 return MakeDefaultProjectIssueConfig(None) |
| 414 |
| 415 harmonized_status_names = _CombineOrderedLists( |
| 416 [[stat.status for stat in config.well_known_statuses] |
| 417 for config in config_list]) |
| 418 harmonized_label_names = _CombineOrderedLists( |
| 419 [[lab.label for lab in config.well_known_labels] |
| 420 for config in config_list]) |
| 421 harmonized_default_sort_spec = ' '.join( |
| 422 config.default_sort_spec for config in config_list) |
| 423 # This col_spec is probably not what the user wants to view because it is |
| 424 # too much information. We join all the col_specs here so that we are sure |
| 425 # to lookup all users needed for sorting, even if it is more than needed. |
| 426 # xxx we need to look up users based on colspec rather than sortspec? |
| 427 harmonized_default_col_spec = ' '.join( |
| 428 config.default_col_spec for config in config_list) |
| 429 |
| 430 result_config = tracker_pb2.ProjectIssueConfig() |
| 431 # The combined config is only used during sorting, never stored. |
| 432 result_config.default_col_spec = harmonized_default_col_spec |
| 433 result_config.default_sort_spec = harmonized_default_sort_spec |
| 434 |
| 435 for status_name in harmonized_status_names: |
| 436 result_config.well_known_statuses.append(tracker_pb2.StatusDef( |
| 437 status=status_name, means_open=True)) |
| 438 |
| 439 for label_name in harmonized_label_names: |
| 440 result_config.well_known_labels.append(tracker_pb2.LabelDef( |
| 441 label=label_name)) |
| 442 |
| 443 for config in config_list: |
| 444 result_config.field_defs.extend(config.field_defs) |
| 445 result_config.component_defs.extend(config.component_defs) |
| 446 |
| 447 return result_config |
| 448 |
| 449 |
| 450 def HarmonizeLabelOrStatusRows(def_rows): |
| 451 """Put the given label defs into a logical global order.""" |
| 452 ranked_defs_by_project = {} |
| 453 oddball_defs = [] |
| 454 for row in def_rows: |
| 455 def_id, project_id, rank, label = row[0], row[1], row[2], row[3] |
| 456 if rank is not None: |
| 457 ranked_defs_by_project.setdefault(project_id, []).append( |
| 458 (def_id, rank, label)) |
| 459 else: |
| 460 oddball_defs.append((def_id, rank, label)) |
| 461 |
| 462 oddball_defs.sort(reverse=True, key=lambda def_tuple: def_tuple[2].lower()) |
| 463 # Compose the list-of-lists in a consistent order by project_id. |
| 464 list_of_lists = [ranked_defs_by_project[pid] |
| 465 for pid in sorted(ranked_defs_by_project.keys())] |
| 466 harmonized_ranked_defs = _CombineOrderedLists( |
| 467 list_of_lists, include_duplicate_keys=True, |
| 468 key=lambda def_tuple: def_tuple[2]) |
| 469 |
| 470 return oddball_defs + harmonized_ranked_defs |
| 471 |
| 472 |
| 473 def _CombineOrderedLists( |
| 474 list_of_lists, include_duplicate_keys=False, key=lambda x: x): |
| 475 """Combine lists of items while maintaining their desired order. |
| 476 |
| 477 Args: |
| 478 list_of_lists: a list of lists of strings. |
| 479 include_duplicate_keys: Pass True to make the combined list have the |
| 480 same total number of elements as the sum of the input lists. |
| 481 key: optional function to choose which part of the list items hold the |
| 482 string used for comparison. The result will have the whole items. |
| 483 |
| 484 Returns: |
| 485 A single list of items containing one copy of each of the items |
| 486 in any of the original list, and in an order that maintains the original |
| 487 list ordering as much as possible. |
| 488 """ |
| 489 combined_items = [] |
| 490 combined_keys = [] |
| 491 seen_keys_set = set() |
| 492 for one_list in list_of_lists: |
| 493 _AccumulateCombinedList( |
| 494 one_list, combined_items, combined_keys, seen_keys_set, key=key, |
| 495 include_duplicate_keys=include_duplicate_keys) |
| 496 |
| 497 return combined_items |
| 498 |
| 499 |
| 500 def _AccumulateCombinedList( |
| 501 one_list, combined_items, combined_keys, seen_keys_set, |
| 502 include_duplicate_keys=False, key=lambda x: x): |
| 503 """Accumulate strings into a combined list while its maintaining ordering. |
| 504 |
| 505 Args: |
| 506 one_list: list of strings in a desired order. |
| 507 combined_items: accumulated list of items in the desired order. |
| 508 combined_keys: accumulated list of key strings in the desired order. |
| 509 seen_keys_set: set of strings that are already in combined_list. |
| 510 include_duplicate_keys: Pass True to make the combined list have the |
| 511 same total number of elements as the sum of the input lists. |
| 512 key: optional function to choose which part of the list items hold the |
| 513 string used for comparison. The result will have the whole items. |
| 514 |
| 515 Returns: |
| 516 Nothing. But, combined_items is modified to mix in all the items of |
| 517 one_list at appropriate points such that nothing in combined_items |
| 518 is reordered, and the ordering of items from one_list is maintained |
| 519 as much as possible. Also, seen_keys_set is modified to add any keys |
| 520 for items that were added to combined_items. |
| 521 |
| 522 Also, any strings that begin with "#" are compared regardless of the "#". |
| 523 The purpose of such strings is to guide the final ordering. |
| 524 """ |
| 525 insert_idx = 0 |
| 526 for item in one_list: |
| 527 s = key(item).lower() |
| 528 if s in seen_keys_set: |
| 529 item_idx = combined_keys.index(s) # Need parallel list of keys |
| 530 insert_idx = max(insert_idx, item_idx + 1) |
| 531 |
| 532 if s not in seen_keys_set or include_duplicate_keys: |
| 533 combined_items.insert(insert_idx, item) |
| 534 combined_keys.insert(insert_idx, s) |
| 535 insert_idx += 1 |
| 536 |
| 537 seen_keys_set.add(s) |
| 538 |
| 539 |
| 540 def GetBuiltInQuery(query_id): |
| 541 """If the given query ID is for a built-in query, return that string.""" |
| 542 return tracker_constants.DEFAULT_CANNED_QUERY_CONDS.get(query_id, '') |
| 543 |
| 544 |
| 545 def UsersInvolvedInAmendments(amendments): |
| 546 """Return a set of all user IDs mentioned in the given Amendments.""" |
| 547 user_id_set = set() |
| 548 for amendment in amendments: |
| 549 user_id_set.update(amendment.added_user_ids) |
| 550 user_id_set.update(amendment.removed_user_ids) |
| 551 |
| 552 return user_id_set |
| 553 |
| 554 |
| 555 def _AccumulateUsersInvolvedInComment(comment, user_id_set): |
| 556 """Build up a set of all users involved in an IssueComment. |
| 557 |
| 558 Args: |
| 559 comment: an IssueComment PB. |
| 560 user_id_set: a set of user IDs to build up. |
| 561 |
| 562 Returns: |
| 563 The same set, but modified to have the user IDs of user who |
| 564 entered the comment, and all the users mentioned in any amendments. |
| 565 """ |
| 566 user_id_set.add(comment.user_id) |
| 567 user_id_set.update(UsersInvolvedInAmendments(comment.amendments)) |
| 568 |
| 569 return user_id_set |
| 570 |
| 571 |
| 572 def UsersInvolvedInComment(comment): |
| 573 """Return a set of all users involved in an IssueComment. |
| 574 |
| 575 Args: |
| 576 comment: an IssueComment PB. |
| 577 |
| 578 Returns: |
| 579 A set with the user IDs of user who entered the comment, and all the |
| 580 users mentioned in any amendments. |
| 581 """ |
| 582 return _AccumulateUsersInvolvedInComment(comment, set()) |
| 583 |
| 584 |
| 585 def UsersInvolvedInCommentList(comments): |
| 586 """Return a set of all users involved in a list of IssueComments. |
| 587 |
| 588 Args: |
| 589 comments: a list of IssueComment PBs. |
| 590 |
| 591 Returns: |
| 592 A set with the user IDs of user who entered the comment, and all the |
| 593 users mentioned in any amendments. |
| 594 """ |
| 595 result = set() |
| 596 for c in comments: |
| 597 _AccumulateUsersInvolvedInComment(c, result) |
| 598 |
| 599 return result |
| 600 |
| 601 |
| 602 def UsersInvolvedInIssues(issues): |
| 603 """Return a set of all user IDs referenced in the issues' metadata.""" |
| 604 result = set() |
| 605 for issue in issues: |
| 606 result.update([issue.reporter_id, issue.owner_id, issue.derived_owner_id]) |
| 607 result.update(issue.cc_ids) |
| 608 result.update(issue.derived_cc_ids) |
| 609 result.update(fv.user_id for fv in issue.field_values if fv.user_id) |
| 610 |
| 611 return result |
| 612 |
| 613 |
| 614 def MakeAmendment( |
| 615 field, new_value, added_ids, removed_ids, custom_field_name=None, |
| 616 old_value=None): |
| 617 """Utility function to populate an Amendment PB. |
| 618 |
| 619 Args: |
| 620 field: enum for the field being updated. |
| 621 new_value: new string value of that field. |
| 622 added_ids: list of user IDs being added. |
| 623 removed_ids: list of user IDs being removed. |
| 624 custom_field_name: optional name of a custom field. |
| 625 old_value: old string value of that field. |
| 626 |
| 627 Returns: |
| 628 An instance of Amendment. |
| 629 """ |
| 630 amendment = tracker_pb2.Amendment() |
| 631 amendment.field = field |
| 632 amendment.newvalue = new_value |
| 633 amendment.added_user_ids.extend(added_ids) |
| 634 amendment.removed_user_ids.extend(removed_ids) |
| 635 |
| 636 if old_value is not None: |
| 637 amendment.oldvalue = old_value |
| 638 |
| 639 if custom_field_name is not None: |
| 640 amendment.custom_field_name = custom_field_name |
| 641 |
| 642 return amendment |
| 643 |
| 644 |
| 645 def _PlusMinusString(added_items, removed_items): |
| 646 """Return a concatenation of the items, with a minus on removed items. |
| 647 |
| 648 Args: |
| 649 added_items: list of string items added. |
| 650 removed_items: list of string items removed. |
| 651 |
| 652 Returns: |
| 653 A unicode string with all the removed items first (preceeded by minus |
| 654 signs) and then the added items. |
| 655 """ |
| 656 assert all(isinstance(item, basestring) |
| 657 for item in added_items + removed_items) |
| 658 # TODO(jrobbins): this is not good when values can be negative ints. |
| 659 return ' '.join( |
| 660 ['-%s' % item.strip() |
| 661 for item in removed_items if item] + |
| 662 ['%s' % item for item in added_items if item]) |
| 663 |
| 664 |
| 665 def _PlusMinusAmendment( |
| 666 field, added_items, removed_items, custom_field_name=None): |
| 667 """Make an Amendment PB with the given added/removed items.""" |
| 668 return MakeAmendment( |
| 669 field, _PlusMinusString(added_items, removed_items), [], [], |
| 670 custom_field_name=custom_field_name) |
| 671 |
| 672 |
| 673 def _PlusMinusRefsAmendment( |
| 674 field, added_refs, removed_refs, default_project_name=None): |
| 675 """Make an Amendment PB with the given added/removed refs.""" |
| 676 return _PlusMinusAmendment( |
| 677 field, |
| 678 [FormatIssueRef(r, default_project_name=default_project_name) |
| 679 for r in added_refs if r], |
| 680 [FormatIssueRef(r, default_project_name=default_project_name) |
| 681 for r in removed_refs if r]) |
| 682 |
| 683 |
| 684 def MakeSummaryAmendment(new_summary, old_summary): |
| 685 """Make an Amendment PB for a change to the summary.""" |
| 686 return MakeAmendment( |
| 687 tracker_pb2.FieldID.SUMMARY, new_summary, [], [], old_value=old_summary) |
| 688 |
| 689 |
| 690 def MakeStatusAmendment(new_status, old_status): |
| 691 """Make an Amendment PB for a change to the status.""" |
| 692 return MakeAmendment( |
| 693 tracker_pb2.FieldID.STATUS, new_status, [], [], old_value=old_status) |
| 694 |
| 695 |
| 696 def MakeOwnerAmendment(new_owner_id, old_owner_id): |
| 697 """Make an Amendment PB for a change to the owner.""" |
| 698 return MakeAmendment( |
| 699 tracker_pb2.FieldID.OWNER, '', [new_owner_id], [old_owner_id]) |
| 700 |
| 701 |
| 702 def MakeCcAmendment(added_cc_ids, removed_cc_ids): |
| 703 """Make an Amendment PB for a change to the Cc list.""" |
| 704 return MakeAmendment( |
| 705 tracker_pb2.FieldID.CC, '', added_cc_ids, removed_cc_ids) |
| 706 |
| 707 |
| 708 def MakeLabelsAmendment(added_labels, removed_labels): |
| 709 """Make an Amendment PB for a change to the labels.""" |
| 710 return _PlusMinusAmendment( |
| 711 tracker_pb2.FieldID.LABELS, added_labels, removed_labels) |
| 712 |
| 713 |
| 714 def DiffValueLists(new_list, old_list): |
| 715 """Give an old list and a new list, return the added and removed items.""" |
| 716 if not old_list: |
| 717 return new_list, [] |
| 718 if not new_list: |
| 719 return [], old_list |
| 720 |
| 721 added = [] |
| 722 removed = old_list[:] # Assume everything was removed, then narrow that down |
| 723 for val in new_list: |
| 724 if val in removed: |
| 725 removed.remove(val) |
| 726 else: |
| 727 added.append(val) |
| 728 |
| 729 return added, removed |
| 730 |
| 731 |
| 732 def MakeFieldAmendment(field_id, config, new_values, old_values=None): |
| 733 """Return an amendment showing how an issue's field changed. |
| 734 |
| 735 Args: |
| 736 field_id: int field ID of a built-in or custom issue field. |
| 737 config: config info for the current project, including field_defs. |
| 738 new_values: list of strings representing new values of field. |
| 739 old_values: list of strings representing old values of field. |
| 740 |
| 741 Returns: |
| 742 A new Amemdnent object. |
| 743 |
| 744 Raises: |
| 745 ValueError: if the specified field was not found. |
| 746 """ |
| 747 fd = FindFieldDefByID(field_id, config) |
| 748 |
| 749 if fd is None: |
| 750 raise ValueError('field %r vanished mid-request', field_id) |
| 751 |
| 752 if fd.is_multivalued: |
| 753 old_values = old_values or [] |
| 754 added, removed = DiffValueLists(new_values, old_values) |
| 755 if fd.field_type == tracker_pb2.FieldTypes.USER_TYPE: |
| 756 return MakeAmendment( |
| 757 tracker_pb2.FieldID.CUSTOM, '', added, removed, |
| 758 custom_field_name=fd.field_name) |
| 759 else: |
| 760 return _PlusMinusAmendment( |
| 761 tracker_pb2.FieldID.CUSTOM, |
| 762 ['%s' % item for item in added], |
| 763 ['%s' % item for item in removed], |
| 764 custom_field_name=fd.field_name) |
| 765 |
| 766 else: |
| 767 if fd.field_type == tracker_pb2.FieldTypes.USER_TYPE: |
| 768 return MakeAmendment( |
| 769 tracker_pb2.FieldID.CUSTOM, '', new_values, [], |
| 770 custom_field_name=fd.field_name) |
| 771 |
| 772 if new_values: |
| 773 new_str = ', '.join('%s' % item for item in new_values) |
| 774 else: |
| 775 new_str = '----' |
| 776 |
| 777 return MakeAmendment( |
| 778 tracker_pb2.FieldID.CUSTOM, new_str, [], [], |
| 779 custom_field_name=fd.field_name) |
| 780 |
| 781 |
| 782 def MakeFieldClearedAmendment(field_id, config): |
| 783 fd = FindFieldDefByID(field_id, config) |
| 784 |
| 785 if fd is None: |
| 786 raise ValueError('field %r vanished mid-request', field_id) |
| 787 |
| 788 return MakeAmendment( |
| 789 tracker_pb2.FieldID.CUSTOM, '----', [], [], |
| 790 custom_field_name=fd.field_name) |
| 791 |
| 792 |
| 793 def MakeComponentsAmendment(added_comp_ids, removed_comp_ids, config): |
| 794 """Make an Amendment PB for a change to the components.""" |
| 795 # TODO(jrobbins): record component IDs as ints and display them with |
| 796 # lookups (and maybe permission checks in the future). But, what |
| 797 # about history that references deleleted components? |
| 798 added_comp_paths = [] |
| 799 for comp_id in added_comp_ids: |
| 800 cd = FindComponentDefByID(comp_id, config) |
| 801 if cd: |
| 802 added_comp_paths.append(cd.path) |
| 803 |
| 804 removed_comp_paths = [] |
| 805 for comp_id in removed_comp_ids: |
| 806 cd = FindComponentDefByID(comp_id, config) |
| 807 if cd: |
| 808 removed_comp_paths.append(cd.path) |
| 809 |
| 810 return _PlusMinusAmendment( |
| 811 tracker_pb2.FieldID.COMPONENTS, |
| 812 added_comp_paths, removed_comp_paths) |
| 813 |
| 814 |
| 815 def MakeBlockedOnAmendment( |
| 816 added_refs, removed_refs, default_project_name=None): |
| 817 """Make an Amendment PB for a change to the blocked on issues.""" |
| 818 return _PlusMinusRefsAmendment( |
| 819 tracker_pb2.FieldID.BLOCKEDON, added_refs, removed_refs, |
| 820 default_project_name=default_project_name) |
| 821 |
| 822 |
| 823 def MakeBlockingAmendment(added_refs, removed_refs, default_project_name=None): |
| 824 """Make an Amendment PB for a change to the blocking issues.""" |
| 825 return _PlusMinusRefsAmendment( |
| 826 tracker_pb2.FieldID.BLOCKING, added_refs, removed_refs, |
| 827 default_project_name=default_project_name) |
| 828 |
| 829 |
| 830 def MakeMergedIntoAmendment(added_ref, removed_ref, default_project_name=None): |
| 831 """Make an Amendment PB for a change to the merged-into issue.""" |
| 832 return _PlusMinusRefsAmendment( |
| 833 tracker_pb2.FieldID.MERGEDINTO, [added_ref], [removed_ref], |
| 834 default_project_name=default_project_name) |
| 835 |
| 836 |
| 837 def MakeProjectAmendment(new_project_name): |
| 838 """Make an Amendment PB for a change to an issue's project.""" |
| 839 return MakeAmendment( |
| 840 tracker_pb2.FieldID.PROJECT, new_project_name, [], []) |
| 841 |
| 842 |
| 843 def AmendmentString(amendment, users_by_id): |
| 844 """Produce a displayable string for an Amendment PB. |
| 845 |
| 846 Args: |
| 847 amendment: Amendment PB to display. |
| 848 users_by_id: dict {user_id: user_view, ...} including all users |
| 849 mentioned in amendment. |
| 850 |
| 851 Returns: |
| 852 A string that could be displayed on a web page or sent in email. |
| 853 """ |
| 854 if amendment.newvalue: |
| 855 return amendment.newvalue |
| 856 |
| 857 # Display new owner only |
| 858 if amendment.field == tracker_pb2.FieldID.OWNER: |
| 859 if amendment.added_user_ids and amendment.added_user_ids[0] > 0: |
| 860 uid = amendment.added_user_ids[0] |
| 861 result = users_by_id[uid].display_name |
| 862 else: |
| 863 result = framework_constants.NO_USER_NAME |
| 864 else: |
| 865 result = _PlusMinusString( |
| 866 [users_by_id[uid].display_name for uid in amendment.added_user_ids |
| 867 if uid in users_by_id], |
| 868 [users_by_id[uid].display_name for uid in amendment.removed_user_ids |
| 869 if uid in users_by_id]) |
| 870 |
| 871 return result |
| 872 |
| 873 |
| 874 def AmendmentLinks(amendment, users_by_id, project_name): |
| 875 """Produce a list of value/url pairs for an Amendment PB. |
| 876 |
| 877 Args: |
| 878 amendment: Amendment PB to display. |
| 879 users_by_id: dict {user_id: user_view, ...} including all users |
| 880 mentioned in amendment. |
| 881 project_nme: Name of project the issue/comment/amendment is in. |
| 882 |
| 883 Returns: |
| 884 A list of dicts with 'value' and 'url' keys. 'url' may be None. |
| 885 """ |
| 886 # Display both old and new summary |
| 887 if amendment.field == tracker_pb2.FieldID.SUMMARY: |
| 888 result = amendment.newvalue |
| 889 if amendment.oldvalue: |
| 890 result += ' (was: %s)' % amendment.oldvalue |
| 891 return [{'value': result, 'url': None}] |
| 892 # Display new owner only |
| 893 elif amendment.field == tracker_pb2.FieldID.OWNER: |
| 894 if amendment.added_user_ids and amendment.added_user_ids[0] > 0: |
| 895 uid = amendment.added_user_ids[0] |
| 896 return [{'value': users_by_id[uid].display_name, 'url': None}] |
| 897 else: |
| 898 return [{'value': framework_constants.NO_USER_NAME, 'url': None}] |
| 899 elif amendment.field in (tracker_pb2.FieldID.BLOCKEDON, |
| 900 tracker_pb2.FieldID.BLOCKING, |
| 901 tracker_pb2.FieldID.MERGEDINTO): |
| 902 values = amendment.newvalue.split() |
| 903 bug_refs = [_SafeParseIssueRef(v.strip()) for v in values] |
| 904 issue_urls = [FormatIssueUrl(ref, default_project_name=project_name) |
| 905 for ref in bug_refs] |
| 906 # TODO(jrobbins): Permission checks on referenced issues to allow |
| 907 # showing summary on hover. |
| 908 return [{'value': v, 'url': u} for (v, u) in zip(values, issue_urls)] |
| 909 elif amendment.newvalue: |
| 910 # Catchall for everything except user-valued fields. |
| 911 return [{'value': v, 'url': None} for v in amendment.newvalue.split()] |
| 912 else: |
| 913 # Applies to field==CC or CUSTOM with user type. |
| 914 values = _PlusMinusString( |
| 915 [users_by_id[uid].display_name for uid in amendment.added_user_ids |
| 916 if uid in users_by_id], |
| 917 [users_by_id[uid].display_name for uid in amendment.removed_user_ids |
| 918 if uid in users_by_id]) |
| 919 return [{'value': v.strip(), 'url': None} for v in values.split()] |
| 920 |
| 921 |
| 922 def GetAmendmentFieldName(amendment): |
| 923 """Get user-visible name for an amendment to a built-in or custom field.""" |
| 924 if amendment.custom_field_name: |
| 925 return amendment.custom_field_name |
| 926 else: |
| 927 field_name = str(amendment.field) |
| 928 return field_name.capitalize() |
| 929 |
| 930 |
| 931 def MakeDanglingIssueRef(project_name, issue_id): |
| 932 """Create a DanglingIssueRef pb.""" |
| 933 ret = tracker_pb2.DanglingIssueRef() |
| 934 ret.project = project_name |
| 935 ret.issue_id = issue_id |
| 936 return ret |
| 937 |
| 938 |
| 939 def FormatIssueUrl(issue_ref_tuple, default_project_name=None): |
| 940 """Format an issue url from an issue ref.""" |
| 941 if issue_ref_tuple is None: |
| 942 return '' |
| 943 project_name, local_id = issue_ref_tuple |
| 944 project_name = project_name or default_project_name |
| 945 url = framework_helpers.FormatURL( |
| 946 None, '/p/%s%s' % (project_name, urls.ISSUE_DETAIL), id=local_id) |
| 947 return url |
| 948 |
| 949 |
| 950 def FormatIssueRef(issue_ref_tuple, default_project_name=None): |
| 951 """Format an issue reference for users: e.g., 123, or projectname:123.""" |
| 952 if issue_ref_tuple is None: |
| 953 return '' |
| 954 project_name, local_id = issue_ref_tuple |
| 955 if project_name and project_name != default_project_name: |
| 956 return '%s:%d' % (project_name, local_id) |
| 957 else: |
| 958 return str(local_id) |
| 959 |
| 960 |
| 961 def ParseIssueRef(ref_str): |
| 962 """Parse an issue ref string: e.g., 123, or projectname:123 into a tuple. |
| 963 |
| 964 Raises ValueError if the ref string exists but can't be parsed. |
| 965 """ |
| 966 if not ref_str.strip(): |
| 967 return None |
| 968 |
| 969 if ':' in ref_str: |
| 970 project_name, id_str = ref_str.split(':', 1) |
| 971 project_name = project_name.strip().lstrip('-') |
| 972 else: |
| 973 project_name = None |
| 974 id_str = ref_str |
| 975 |
| 976 id_str = id_str.lstrip('-') |
| 977 |
| 978 return project_name, int(id_str) |
| 979 |
| 980 |
| 981 def _SafeParseIssueRef(ref_str): |
| 982 """Same as ParseIssueRef, but catches ValueError and returns None instead.""" |
| 983 try: |
| 984 return ParseIssueRef(ref_str) |
| 985 except ValueError: |
| 986 return None |
| 987 |
| 988 |
| 989 def MergeFields(field_values, fields_add, fields_remove, field_defs): |
| 990 """Merge the fields to add/remove into the current field values. |
| 991 |
| 992 Args: |
| 993 field_values: list of current FieldValue PBs. |
| 994 fields_add: list of FieldValue PBs to add to field_values. If any of these |
| 995 is for a single-valued field, it replaces all previous values for the |
| 996 same field_id in field_values. |
| 997 fields_remove: list of FieldValues to remove from field_values, if found. |
| 998 field_defs: list of FieldDef PBs from the issue's project's config. |
| 999 |
| 1000 Returns: |
| 1001 A 3-tuple with the merged field values, the specific values that added |
| 1002 or removed. The actual added or removed might be fewer than the requested |
| 1003 ones if the issue already had one of the values-to-add or lacked one of the |
| 1004 values-to-remove. |
| 1005 """ |
| 1006 is_multi = {fd.field_id: fd.is_multivalued for fd in field_defs} |
| 1007 merged_fvs = list(field_values) |
| 1008 fvs_added = [] |
| 1009 for fv_consider in fields_add: |
| 1010 consider_value = GetFieldValue(fv_consider, {}) |
| 1011 for old_fv in field_values: |
| 1012 if (fv_consider.field_id == old_fv.field_id and |
| 1013 GetFieldValue(old_fv, {}) == consider_value): |
| 1014 break |
| 1015 else: |
| 1016 # Drop any existing values for non-multi fields. |
| 1017 if not is_multi.get(fv_consider.field_id): |
| 1018 merged_fvs = [fv for fv in merged_fvs |
| 1019 if fv.field_id != fv_consider.field_id] |
| 1020 fvs_added.append(fv_consider) |
| 1021 merged_fvs.append(fv_consider) |
| 1022 |
| 1023 fvs_removed = [] |
| 1024 for fv_consider in fields_remove: |
| 1025 consider_value = GetFieldValue(fv_consider, {}) |
| 1026 for old_fv in field_values: |
| 1027 if (fv_consider.field_id == old_fv.field_id and |
| 1028 GetFieldValue(old_fv, {}) == consider_value): |
| 1029 fvs_removed.append(fv_consider) |
| 1030 merged_fvs.remove(old_fv) |
| 1031 |
| 1032 return merged_fvs, fvs_added, fvs_removed |
OLD | NEW |