blob: f3f259426445bd6af585209580fa95c40c05f9a2 [file] [log] [blame]
Copybara854996b2021-09-07 19:36:02 +00001# Copyright 2016 The Chromium Authors. All rights reserved.
2# Use of this source code is governed 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
8These are classes and functions that operate on the objects that
9users care about in the issue tracker: e.g., issues, and the issue
10tracker configuration.
11"""
12from __future__ import print_function
13from __future__ import division
14from __future__ import absolute_import
15
16import collections
17import logging
18import time
19
20from six import string_types
21
22from features import federated
23from framework import exceptions
24from framework import framework_bizobj
25from framework import framework_constants
26from framework import framework_helpers
27from framework import timestr
28from framework import urls
29from proto import tracker_pb2
30from tracker import tracker_constants
31
32
33def GetOwnerId(issue):
34 """Get the owner of an issue, whether it is explicit or derived."""
35 return (issue.owner_id or issue.derived_owner_id or
36 framework_constants.NO_USER_SPECIFIED)
37
38
39def GetStatus(issue):
40 """Get the status of an issue, whether it is explicit or derived."""
41 return issue.status or issue.derived_status or ''
42
43
44def GetCcIds(issue):
45 """Get the Cc's of an issue, whether they are explicit or derived."""
46 return issue.cc_ids + issue.derived_cc_ids
47
48
49def GetApproverIds(issue):
50 """Get the Approvers' ids of an isuses approval_values."""
51 approver_ids = []
52 for av in issue.approval_values:
53 approver_ids.extend(av.approver_ids)
54
55 return list(set(approver_ids))
56
57
58def GetLabels(issue):
59 """Get the labels of an issue, whether explicit or derived."""
60 return issue.labels + issue.derived_labels
61
62
63def MakeProjectIssueConfig(
64 project_id, well_known_statuses, statuses_offer_merge, well_known_labels,
65 excl_label_prefixes, col_spec):
66 """Return a ProjectIssueConfig with the given values."""
67 # pylint: disable=multiple-statements
68 if not well_known_statuses: well_known_statuses = []
69 if not statuses_offer_merge: statuses_offer_merge = []
70 if not well_known_labels: well_known_labels = []
71 if not excl_label_prefixes: excl_label_prefixes = []
72 if not col_spec: col_spec = ' '
73
74 project_config = tracker_pb2.ProjectIssueConfig()
75 if project_id: # There is no ID for harmonized configs.
76 project_config.project_id = project_id
77
78 SetConfigStatuses(project_config, well_known_statuses)
79 project_config.statuses_offer_merge = statuses_offer_merge
80 SetConfigLabels(project_config, well_known_labels)
81 project_config.exclusive_label_prefixes = excl_label_prefixes
82
83 # ID 0 means that nothing has been specified, so use hard-coded defaults.
84 project_config.default_template_for_developers = 0
85 project_config.default_template_for_users = 0
86
87 project_config.default_col_spec = col_spec
88
89 # Note: default project issue config has no filter rules.
90
91 return project_config
92
93
94def FindFieldDef(field_name, config):
95 """Find the specified field, or return None."""
96 if not field_name:
97 return None
98 field_name_lower = field_name.lower()
99 for fd in config.field_defs:
100 if fd.field_name.lower() == field_name_lower:
101 return fd
102
103 return None
104
105
106def FindFieldDefByID(field_id, config):
107 """Find the specified field, or return None."""
108 for fd in config.field_defs:
109 if fd.field_id == field_id:
110 return fd
111
112 return None
113
114
115def FindApprovalDef(approval_name, config):
116 """Find the specified approval, or return None."""
117 fd = FindFieldDef(approval_name, config)
118 if fd:
119 return FindApprovalDefByID(fd.field_id, config)
120
121 return None
122
123
124def FindApprovalDefByID(approval_id, config):
125 """Find the specified approval, or return None."""
126 for approval_def in config.approval_defs:
127 if approval_def.approval_id == approval_id:
128 return approval_def
129
130 return None
131
132
133def FindApprovalValueByID(approval_id, approval_values):
134 """Find the specified approval_value in the given list or return None."""
135 for av in approval_values:
136 if av.approval_id == approval_id:
137 return av
138
139 return None
140
141
142def FindApprovalsSubfields(approval_ids, config):
143 """Return a dict of {approval_ids: approval_subfields}."""
144 approval_subfields_dict = collections.defaultdict(list)
145 for fd in config.field_defs:
146 if fd.approval_id in approval_ids:
147 approval_subfields_dict[fd.approval_id].append(fd)
148
149 return approval_subfields_dict
150
151
152def FindPhaseByID(phase_id, phases):
153 """Find the specified phase, or return None"""
154 for phase in phases:
155 if phase.phase_id == phase_id:
156 return phase
157
158 return None
159
160
161def FindPhase(name, phases):
162 """Find the specified phase, or return None"""
163 for phase in phases:
164 if phase.name.lower() == name.lower():
165 return phase
166
167 return None
168
169
170def GetGrantedPerms(issue, effective_ids, config):
171 """Return a set of permissions granted by user-valued fields in an issue."""
172 granted_perms = set()
173 for field_value in issue.field_values:
174 if field_value.user_id in effective_ids:
175 field_def = FindFieldDefByID(field_value.field_id, config)
176 if field_def and field_def.grants_perm:
177 # TODO(jrobbins): allow comma-separated list in grants_perm
178 granted_perms.add(field_def.grants_perm.lower())
179
180 return granted_perms
181
182
183def LabelsByPrefix(labels, lower_field_names):
184 """Convert a list of key-value labels into {lower_prefix: [value, ...]}.
185
186 It also handles custom fields with dashes in the field name.
187 """
188 label_values_by_prefix = collections.defaultdict(list)
189 for lab in labels:
190 if '-' not in lab:
191 continue
192 lower_lab = lab.lower()
193 for lower_field_name in lower_field_names:
194 if lower_lab.startswith(lower_field_name + '-'):
195 prefix = lower_field_name
196 value = lab[len(lower_field_name)+1:]
197 break
198 else: # No field name matched
199 prefix, value = lab.split('-', 1)
200 prefix = prefix.lower()
201 label_values_by_prefix[prefix].append(value)
202 return label_values_by_prefix
203
204
205def LabelIsMaskedByField(label, field_names):
206 """If the label should be displayed as a field, return the field name.
207
208 Args:
209 label: string label to consider.
210 field_names: a list of field names in lowercase.
211
212 Returns:
213 If masked, return the lowercase name of the field, otherwise None. A label
214 is masked by a custom field if the field name "Foo" matches the key part of
215 a key-value label "Foo-Bar".
216 """
217 if '-' not in label:
218 return None
219
220 for field_name_lower in field_names:
221 if label.lower().startswith(field_name_lower + '-'):
222 return field_name_lower
223
224 return None
225
226
227def NonMaskedLabels(labels, field_names):
228 """Return only those labels that are not masked by custom fields."""
229 return [lab for lab in labels
230 if not LabelIsMaskedByField(lab, field_names)]
231
232
233def ExplicitAndDerivedNonMaskedLabels(labels, derived_labels, config):
234 """Return two lists of labels that are not masked by enum custom fields."""
235 field_names = [fd.field_name.lower() for fd in config.field_defs
236 if fd.field_type is tracker_pb2.FieldTypes.ENUM_TYPE and
237 not fd.is_deleted] # TODO(jrobbins): restricts
238 labels = [
239 lab for lab in labels
240 if not LabelIsMaskedByField(lab, field_names)]
241 derived_labels = [
242 lab for lab in derived_labels
243 if not LabelIsMaskedByField(lab, field_names)]
244 return labels, derived_labels
245
246
247def MakeApprovalValue(approval_id, approver_ids=None, status=None,
248 setter_id=None, set_on=None, phase_id=None):
249 """Return an ApprovalValue PB with the given field values."""
250 av = tracker_pb2.ApprovalValue(
251 approval_id=approval_id, status=status,
252 setter_id=setter_id, set_on=set_on, phase_id=phase_id)
253 if approver_ids is not None:
254 av.approver_ids = approver_ids
255 return av
256
257
258def MakeFieldDef(
259 field_id,
260 project_id,
261 field_name,
262 field_type_int,
263 applic_type,
264 applic_pred,
265 is_required,
266 is_niche,
267 is_multivalued,
268 min_value,
269 max_value,
270 regex,
271 needs_member,
272 needs_perm,
273 grants_perm,
274 notify_on,
275 date_action,
276 docstring,
277 is_deleted,
278 approval_id=None,
279 is_phase_field=False,
280 is_restricted_field=False,
281 admin_ids=None,
282 editor_ids=None):
283 """Make a FieldDef PB for the given FieldDef table row tuple."""
284 if isinstance(date_action, string_types):
285 date_action = date_action.upper()
286 fd = tracker_pb2.FieldDef(
287 field_id=field_id,
288 project_id=project_id,
289 field_name=field_name,
290 field_type=field_type_int,
291 is_required=bool(is_required),
292 is_niche=bool(is_niche),
293 is_multivalued=bool(is_multivalued),
294 docstring=docstring,
295 is_deleted=bool(is_deleted),
296 applicable_type=applic_type or '',
297 applicable_predicate=applic_pred or '',
298 needs_member=bool(needs_member),
299 grants_perm=grants_perm or '',
300 notify_on=tracker_pb2.NotifyTriggers(notify_on or 0),
301 date_action=tracker_pb2.DateAction(date_action or 0),
302 is_phase_field=bool(is_phase_field),
303 is_restricted_field=bool(is_restricted_field))
304 if min_value is not None:
305 fd.min_value = min_value
306 if max_value is not None:
307 fd.max_value = max_value
308 if regex is not None:
309 fd.regex = regex
310 if needs_perm is not None:
311 fd.needs_perm = needs_perm
312 if approval_id is not None:
313 fd.approval_id = approval_id
314 if admin_ids:
315 fd.admin_ids = admin_ids
316 if editor_ids:
317 fd.editor_ids = editor_ids
318 return fd
319
320
321def MakeFieldValue(
322 field_id, int_value, str_value, user_id, date_value, url_value, derived,
323 phase_id=None):
324 """Make a FieldValue based on the given information."""
325 fv = tracker_pb2.FieldValue(field_id=field_id, derived=derived)
326 if phase_id is not None:
327 fv.phase_id = phase_id
328 if int_value is not None:
329 fv.int_value = int_value
330 elif str_value is not None:
331 fv.str_value = str_value
332 elif user_id is not None:
333 fv.user_id = user_id
334 elif date_value is not None:
335 fv.date_value = date_value
336 elif url_value is not None:
337 fv.url_value = url_value
338 else:
339 raise ValueError('Unexpected field value')
340 return fv
341
342
343def GetFieldValueWithRawValue(field_type, field_value, users_by_id, raw_value):
344 """Find and return the field value of the specified field type.
345
346 If the specified field_value is None or is empty then the raw_value is
347 returned. When the field type is USER_TYPE the raw_value is used as a key to
348 lookup users_by_id.
349
350 Args:
351 field_type: tracker_pb2.FieldTypes type.
352 field_value: tracker_pb2.FieldValue type.
353 users_by_id: Dict mapping user_ids to UserViews.
354 raw_value: String to use if field_value is not specified.
355
356 Returns:
357 Value of the specified field type.
358 """
359 ret_value = GetFieldValue(field_value, users_by_id)
360 if ret_value:
361 return ret_value
362 # Special case for user types.
363 if field_type == tracker_pb2.FieldTypes.USER_TYPE:
364 if raw_value in users_by_id:
365 return users_by_id[raw_value].email
366 return raw_value
367
368
369def GetFieldValue(fv, users_by_id):
370 """Return the value of this field. Give emails for users in users_by_id."""
371 if fv is None:
372 return None
373 elif fv.int_value is not None:
374 return fv.int_value
375 elif fv.str_value is not None:
376 return fv.str_value
377 elif fv.user_id is not None:
378 if fv.user_id in users_by_id:
379 return users_by_id[fv.user_id].email
380 else:
381 logging.info('Failed to lookup user %d when getting field', fv.user_id)
382 return fv.user_id
383 elif fv.date_value is not None:
384 return timestr.TimestampToDateWidgetStr(fv.date_value)
385 elif fv.url_value is not None:
386 return fv.url_value
387 else:
388 return None
389
390
391def FindComponentDef(path, config):
392 """Find the specified component, or return None."""
393 path_lower = path.lower()
394 for cd in config.component_defs:
395 if cd.path.lower() == path_lower:
396 return cd
397
398 return None
399
400
401def FindMatchingComponentIDs(path, config, exact=True):
402 """Return a list of components that match the given path."""
403 component_ids = []
404 path_lower = path.lower()
405
406 if exact:
407 for cd in config.component_defs:
408 if cd.path.lower() == path_lower:
409 component_ids.append(cd.component_id)
410 else:
411 path_lower_delim = path.lower() + '>'
412 for cd in config.component_defs:
413 target_delim = cd.path.lower() + '>'
414 if target_delim.startswith(path_lower_delim):
415 component_ids.append(cd.component_id)
416
417 return component_ids
418
419
420def FindComponentDefByID(component_id, config):
421 """Find the specified component, or return None."""
422 for cd in config.component_defs:
423 if cd.component_id == component_id:
424 return cd
425
426 return None
427
428
429def FindAncestorComponents(config, component_def):
430 """Return a list of all components the given component is under."""
431 path_lower = component_def.path.lower()
432 return [cd for cd in config.component_defs
433 if path_lower.startswith(cd.path.lower() + '>')]
434
435
436def GetIssueComponentsAndAncestors(issue, config):
437 """Return a list of all the components that an issue is in."""
438 result = set()
439 for component_id in issue.component_ids:
440 cd = FindComponentDefByID(component_id, config)
441 if cd is None:
442 logging.error('Tried to look up non-existent component %r' % component_id)
443 continue
444 ancestors = FindAncestorComponents(config, cd)
445 result.add(cd)
446 result.update(ancestors)
447
448 return sorted(result, key=lambda cd: cd.path)
449
450
451def FindDescendantComponents(config, component_def):
452 """Return a list of all nested components under the given component."""
453 path_plus_delim = component_def.path.lower() + '>'
454 return [cd for cd in config.component_defs
455 if cd.path.lower().startswith(path_plus_delim)]
456
457
458def MakeComponentDef(
459 component_id, project_id, path, docstring, deprecated, admin_ids, cc_ids,
460 created, creator_id, modified=None, modifier_id=None, label_ids=None):
461 """Make a ComponentDef PB for the given FieldDef table row tuple."""
462 cd = tracker_pb2.ComponentDef(
463 component_id=component_id, project_id=project_id, path=path,
464 docstring=docstring, deprecated=bool(deprecated),
465 admin_ids=admin_ids, cc_ids=cc_ids, created=created,
466 creator_id=creator_id, modified=modified, modifier_id=modifier_id,
467 label_ids=label_ids or [])
468 return cd
469
470
471def MakeSavedQuery(
472 query_id, name, base_query_id, query, subscription_mode=None,
473 executes_in_project_ids=None):
474 """Make SavedQuery PB for the given info."""
475 saved_query = tracker_pb2.SavedQuery(
476 name=name, base_query_id=base_query_id, query=query)
477 if query_id is not None:
478 saved_query.query_id = query_id
479 if subscription_mode is not None:
480 saved_query.subscription_mode = subscription_mode
481 if executes_in_project_ids is not None:
482 saved_query.executes_in_project_ids = executes_in_project_ids
483 return saved_query
484
485
486def SetConfigStatuses(project_config, well_known_statuses):
487 """Internal method to set the well-known statuses of ProjectIssueConfig."""
488 project_config.well_known_statuses = []
489 for status, docstring, means_open, deprecated in well_known_statuses:
490 canonical_status = framework_bizobj.CanonicalizeLabel(status)
491 project_config.well_known_statuses.append(tracker_pb2.StatusDef(
492 status_docstring=docstring, status=canonical_status,
493 means_open=means_open, deprecated=deprecated))
494
495
496def SetConfigLabels(project_config, well_known_labels):
497 """Internal method to set the well-known labels of a ProjectIssueConfig."""
498 project_config.well_known_labels = []
499 for label, docstring, deprecated in well_known_labels:
500 canonical_label = framework_bizobj.CanonicalizeLabel(label)
501 project_config.well_known_labels.append(tracker_pb2.LabelDef(
502 label=canonical_label, label_docstring=docstring,
503 deprecated=deprecated))
504
505
506def SetConfigApprovals(project_config, approval_def_tuples):
507 """Internal method to set up approval defs of a ProjectissueConfig."""
508 project_config.approval_defs = []
509 for approval_id, approver_ids, survey in approval_def_tuples:
510 project_config.approval_defs.append(tracker_pb2.ApprovalDef(
511 approval_id=approval_id, approver_ids=approver_ids, survey=survey))
512
513
514def ConvertDictToTemplate(template_dict):
515 """Construct a Template PB with the values from template_dict.
516
517 Args:
518 template_dict: dictionary with fields corresponding to the Template
519 PB fields.
520
521 Returns:
522 A Template protocol buffer that can be stored in the
523 project's ProjectIssueConfig PB.
524 """
525 return MakeIssueTemplate(
526 template_dict.get('name'), template_dict.get('summary'),
527 template_dict.get('status'), template_dict.get('owner_id'),
528 template_dict.get('content'), template_dict.get('labels'), [], [],
529 template_dict.get('components'),
530 summary_must_be_edited=template_dict.get('summary_must_be_edited'),
531 owner_defaults_to_member=template_dict.get('owner_defaults_to_member'),
532 component_required=template_dict.get('component_required'),
533 members_only=template_dict.get('members_only'))
534
535
536def MakeIssueTemplate(
537 name,
538 summary,
539 status,
540 owner_id,
541 content,
542 labels,
543 field_values,
544 admin_ids,
545 component_ids,
546 summary_must_be_edited=None,
547 owner_defaults_to_member=None,
548 component_required=None,
549 members_only=None,
550 phases=None,
551 approval_values=None):
552 """Make an issue template PB."""
553 template = tracker_pb2.TemplateDef()
554 template.name = name
555 if summary:
556 template.summary = summary
557 if status:
558 template.status = status
559 if owner_id:
560 template.owner_id = owner_id
561 template.content = content
562 template.field_values = field_values
563 template.labels = labels or []
564 template.admin_ids = admin_ids
565 template.component_ids = component_ids or []
566 template.approval_values = approval_values or []
567
568 if summary_must_be_edited is not None:
569 template.summary_must_be_edited = summary_must_be_edited
570 if owner_defaults_to_member is not None:
571 template.owner_defaults_to_member = owner_defaults_to_member
572 if component_required is not None:
573 template.component_required = component_required
574 if members_only is not None:
575 template.members_only = members_only
576 if phases is not None:
577 template.phases = phases
578
579 return template
580
581
582def MakeDefaultProjectIssueConfig(project_id):
583 """Return a ProjectIssueConfig with use by projects that don't have one."""
584 return MakeProjectIssueConfig(
585 project_id,
586 tracker_constants.DEFAULT_WELL_KNOWN_STATUSES,
587 tracker_constants.DEFAULT_STATUSES_OFFER_MERGE,
588 tracker_constants.DEFAULT_WELL_KNOWN_LABELS,
589 tracker_constants.DEFAULT_EXCL_LABEL_PREFIXES,
590 tracker_constants.DEFAULT_COL_SPEC)
591
592
593def HarmonizeConfigs(config_list):
594 """Combine several ProjectIssueConfigs into one for cross-project sorting.
595
596 Args:
597 config_list: a list of ProjectIssueConfig PBs with labels and statuses
598 among other fields.
599
600 Returns:
601 A new ProjectIssueConfig with just the labels and status values filled
602 in to be a logical union of the given configs. Specifically, the order
603 of the combined status and label lists should be maintained.
604 """
605 if not config_list:
606 return MakeDefaultProjectIssueConfig(None)
607
608 harmonized_status_names = _CombineOrderedLists(
609 [[stat.status for stat in config.well_known_statuses]
610 for config in config_list])
611 harmonized_label_names = _CombineOrderedLists(
612 [[lab.label for lab in config.well_known_labels]
613 for config in config_list])
614 harmonized_default_sort_spec = ' '.join(
615 config.default_sort_spec for config in config_list)
616 harmonized_means_open = {
617 status: any([stat.means_open
618 for config in config_list
619 for stat in config.well_known_statuses
620 if stat.status == status])
621 for status in harmonized_status_names}
622
623 # This col_spec is probably not what the user wants to view because it is
624 # too much information. We join all the col_specs here so that we are sure
625 # to lookup all users needed for sorting, even if it is more than needed.
626 # xxx we need to look up users based on colspec rather than sortspec?
627 harmonized_default_col_spec = ' '.join(
628 config.default_col_spec for config in config_list)
629
630 result_config = tracker_pb2.ProjectIssueConfig()
631 # The combined config is only used during sorting, never stored.
632 result_config.default_col_spec = harmonized_default_col_spec
633 result_config.default_sort_spec = harmonized_default_sort_spec
634
635 for status_name in harmonized_status_names:
636 result_config.well_known_statuses.append(tracker_pb2.StatusDef(
637 status=status_name, means_open=harmonized_means_open[status_name]))
638
639 for label_name in harmonized_label_names:
640 result_config.well_known_labels.append(tracker_pb2.LabelDef(
641 label=label_name))
642
643 for config in config_list:
644 result_config.field_defs.extend(
645 list(fd for fd in config.field_defs if not fd.is_deleted))
646 result_config.component_defs.extend(config.component_defs)
647 result_config.approval_defs.extend(config.approval_defs)
648
649 return result_config
650
651
652def HarmonizeLabelOrStatusRows(def_rows):
653 """Put the given label defs into a logical global order."""
654 ranked_defs_by_project = {}
655 oddball_defs = []
656 for row in def_rows:
657 def_id, project_id, rank, label = row[0], row[1], row[2], row[3]
658 if rank is not None:
659 ranked_defs_by_project.setdefault(project_id, []).append(
660 (def_id, rank, label))
661 else:
662 oddball_defs.append((def_id, rank, label))
663
664 oddball_defs.sort(reverse=True, key=lambda def_tuple: def_tuple[2].lower())
665 # Compose the list-of-lists in a consistent order by project_id.
666 list_of_lists = [ranked_defs_by_project[pid]
667 for pid in sorted(ranked_defs_by_project.keys())]
668 harmonized_ranked_defs = _CombineOrderedLists(
669 list_of_lists, include_duplicate_keys=True,
670 key=lambda def_tuple: def_tuple[2])
671
672 return oddball_defs + harmonized_ranked_defs
673
674
675def _CombineOrderedLists(
676 list_of_lists, include_duplicate_keys=False, key=lambda x: x):
677 """Combine lists of items while maintaining their desired order.
678
679 Args:
680 list_of_lists: a list of lists of strings.
681 include_duplicate_keys: Pass True to make the combined list have the
682 same total number of elements as the sum of the input lists.
683 key: optional function to choose which part of the list items hold the
684 string used for comparison. The result will have the whole items.
685
686 Returns:
687 A single list of items containing one copy of each of the items
688 in any of the original list, and in an order that maintains the original
689 list ordering as much as possible.
690 """
691 combined_items = []
692 combined_keys = []
693 seen_keys_set = set()
694 for one_list in list_of_lists:
695 _AccumulateCombinedList(
696 one_list, combined_items, combined_keys, seen_keys_set, key=key,
697 include_duplicate_keys=include_duplicate_keys)
698
699 return combined_items
700
701
702def _AccumulateCombinedList(
703 one_list, combined_items, combined_keys, seen_keys_set,
704 include_duplicate_keys=False, key=lambda x: x):
705 """Accumulate strings into a combined list while its maintaining ordering.
706
707 Args:
708 one_list: list of strings in a desired order.
709 combined_items: accumulated list of items in the desired order.
710 combined_keys: accumulated list of key strings in the desired order.
711 seen_keys_set: set of strings that are already in combined_list.
712 include_duplicate_keys: Pass True to make the combined list have the
713 same total number of elements as the sum of the input lists.
714 key: optional function to choose which part of the list items hold the
715 string used for comparison. The result will have the whole items.
716
717 Returns:
718 Nothing. But, combined_items is modified to mix in all the items of
719 one_list at appropriate points such that nothing in combined_items
720 is reordered, and the ordering of items from one_list is maintained
721 as much as possible. Also, seen_keys_set is modified to add any keys
722 for items that were added to combined_items.
723
724 Also, any strings that begin with "#" are compared regardless of the "#".
725 The purpose of such strings is to guide the final ordering.
726 """
727 insert_idx = 0
728 for item in one_list:
729 s = key(item).lower()
730 if s in seen_keys_set:
731 item_idx = combined_keys.index(s) # Need parallel list of keys
732 insert_idx = max(insert_idx, item_idx + 1)
733
734 if s not in seen_keys_set or include_duplicate_keys:
735 combined_items.insert(insert_idx, item)
736 combined_keys.insert(insert_idx, s)
737 insert_idx += 1
738
739 seen_keys_set.add(s)
740
741
742def GetBuiltInQuery(query_id):
743 """If the given query ID is for a built-in query, return that string."""
744 return tracker_constants.DEFAULT_CANNED_QUERY_CONDS.get(query_id, '')
745
746
747def UsersInvolvedInAmendments(amendments):
748 """Return a set of all user IDs mentioned in the given Amendments."""
749 user_id_set = set()
750 for amendment in amendments:
751 user_id_set.update(amendment.added_user_ids)
752 user_id_set.update(amendment.removed_user_ids)
753
754 return user_id_set
755
756
757def _AccumulateUsersInvolvedInComment(comment, user_id_set):
758 """Build up a set of all users involved in an IssueComment.
759
760 Args:
761 comment: an IssueComment PB.
762 user_id_set: a set of user IDs to build up.
763
764 Returns:
765 The same set, but modified to have the user IDs of user who
766 entered the comment, and all the users mentioned in any amendments.
767 """
768 user_id_set.add(comment.user_id)
769 user_id_set.update(UsersInvolvedInAmendments(comment.amendments))
770
771 return user_id_set
772
773
774def UsersInvolvedInComment(comment):
775 """Return a set of all users involved in an IssueComment.
776
777 Args:
778 comment: an IssueComment PB.
779
780 Returns:
781 A set with the user IDs of user who entered the comment, and all the
782 users mentioned in any amendments.
783 """
784 return _AccumulateUsersInvolvedInComment(comment, set())
785
786
787def UsersInvolvedInCommentList(comments):
788 """Return a set of all users involved in a list of IssueComments.
789
790 Args:
791 comments: a list of IssueComment PBs.
792
793 Returns:
794 A set with the user IDs of user who entered the comment, and all the
795 users mentioned in any amendments.
796 """
797 result = set()
798 for c in comments:
799 _AccumulateUsersInvolvedInComment(c, result)
800
801 return result
802
803
804def UsersInvolvedInIssues(issues):
805 """Return a set of all user IDs referenced in the issues' metadata."""
806 result = set()
807 for issue in issues:
808 result.update([issue.reporter_id, issue.owner_id, issue.derived_owner_id])
809 result.update(issue.cc_ids)
810 result.update(issue.derived_cc_ids)
811 result.update(fv.user_id for fv in issue.field_values if fv.user_id)
812 for av in issue.approval_values:
813 result.update(approver_id for approver_id in av.approver_ids)
814 if av.setter_id:
815 result.update([av.setter_id])
816
817 return result
818
819
820def UsersInvolvedInTemplate(template):
821 """Return a set of all user IDs referenced in the template."""
822 result = set(
823 template.admin_ids +
824 [fv.user_id for fv in template.field_values if fv.user_id])
825 if template.owner_id:
826 result.add(template.owner_id)
827 for av in template.approval_values:
828 result.update(set(av.approver_ids))
829 if av.setter_id:
830 result.add(av.setter_id)
831 return result
832
833
834def UsersInvolvedInTemplates(templates):
835 """Return a set of all user IDs referenced in the given templates."""
836 result = set()
837 for template in templates:
838 result.update(UsersInvolvedInTemplate(template))
839 return result
840
841
842def UsersInvolvedInComponents(component_defs):
843 """Return a set of user IDs referenced in the given components."""
844 result = set()
845 for cd in component_defs:
846 result.update(cd.admin_ids)
847 result.update(cd.cc_ids)
848 if cd.creator_id:
849 result.add(cd.creator_id)
850 if cd.modifier_id:
851 result.add(cd.modifier_id)
852
853 return result
854
855
856def UsersInvolvedInApprovalDefs(approval_defs, matching_fds):
857 # type: (Sequence[proto.tracker_pb2.ApprovalDef],
858 # Sequence[proto.tracker_pb2.FieldDef]) -> Collection[int]
859 """Return a set of user IDs referenced in the approval_defs and field defs"""
860 result = set()
861 for ad in approval_defs:
862 result.update(ad.approver_ids)
863 for fd in matching_fds:
864 result.update(fd.admin_ids)
865 return result
866
867
868def UsersInvolvedInConfig(config):
869 """Return a set of all user IDs referenced in the config."""
870 result = set()
871 for ad in config.approval_defs:
872 result.update(ad.approver_ids)
873 for fd in config.field_defs:
874 result.update(fd.admin_ids)
875 result.update(UsersInvolvedInComponents(config.component_defs))
876 return result
877
878
879def LabelIDsInvolvedInConfig(config):
880 """Return a set of all label IDs referenced in the config."""
881 result = set()
882 for cd in config.component_defs:
883 result.update(cd.label_ids)
884 return result
885
886
887def MakeApprovalDelta(
888 status, setter_id, approver_ids_add, approver_ids_remove,
889 subfield_vals_add, subfield_vals_remove, subfields_clear, labels_add,
890 labels_remove, set_on=None):
891 approval_delta = tracker_pb2.ApprovalDelta(
892 approver_ids_add=approver_ids_add,
893 approver_ids_remove=approver_ids_remove,
894 subfield_vals_add=subfield_vals_add,
895 subfield_vals_remove=subfield_vals_remove,
896 subfields_clear=subfields_clear,
897 labels_add=labels_add,
898 labels_remove=labels_remove
899 )
900 if status is not None:
901 approval_delta.status = status
902 approval_delta.set_on = set_on or int(time.time())
903 approval_delta.setter_id = setter_id
904
905 return approval_delta
906
907
908def MakeIssueDelta(
909 status, owner_id, cc_ids_add, cc_ids_remove, comp_ids_add, comp_ids_remove,
910 labels_add, labels_remove, field_vals_add, field_vals_remove, fields_clear,
911 blocked_on_add, blocked_on_remove, blocking_add, blocking_remove,
912 merged_into, summary, ext_blocked_on_add=None, ext_blocked_on_remove=None,
913 ext_blocking_add=None, ext_blocking_remove=None, merged_into_external=None):
914 """Construct an IssueDelta object with the given fields, iff non-None."""
915 delta = tracker_pb2.IssueDelta(
916 cc_ids_add=cc_ids_add, cc_ids_remove=cc_ids_remove,
917 comp_ids_add=comp_ids_add, comp_ids_remove=comp_ids_remove,
918 labels_add=labels_add, labels_remove=labels_remove,
919 field_vals_add=field_vals_add, field_vals_remove=field_vals_remove,
920 fields_clear=fields_clear,
921 blocked_on_add=blocked_on_add, blocked_on_remove=blocked_on_remove,
922 blocking_add=blocking_add, blocking_remove=blocking_remove)
923 if status is not None:
924 delta.status = status
925 if owner_id is not None:
926 delta.owner_id = owner_id
927 if merged_into is not None:
928 delta.merged_into = merged_into
929 if merged_into_external is not None:
930 delta.merged_into_external = merged_into_external
931 if summary is not None:
932 delta.summary = summary
933 if ext_blocked_on_add is not None:
934 delta.ext_blocked_on_add = ext_blocked_on_add
935 if ext_blocked_on_remove is not None:
936 delta.ext_blocked_on_remove = ext_blocked_on_remove
937 if ext_blocking_add is not None:
938 delta.ext_blocking_add = ext_blocking_add
939 if ext_blocking_remove is not None:
940 delta.ext_blocking_remove = ext_blocking_remove
941
942 return delta
943
944
945def ApplyLabelChanges(issue, config, labels_add, labels_remove):
946 """Updates the PB issue's labels and returns the amendment or None."""
947 canon_labels_add = [framework_bizobj.CanonicalizeLabel(l)
948 for l in labels_add]
949 labels_add = [l for l in canon_labels_add if l]
950 canon_labels_remove = [framework_bizobj.CanonicalizeLabel(l)
951 for l in labels_remove]
952 labels_remove = [l for l in canon_labels_remove if l]
953
954 (labels, update_labels_add,
955 update_labels_remove) = framework_bizobj.MergeLabels(
956 issue.labels, labels_add, labels_remove, config)
957
958 if update_labels_add or update_labels_remove:
959 issue.labels = labels
960 return MakeLabelsAmendment(
961 update_labels_add, update_labels_remove)
962 return None
963
964
965def ApplyFieldValueChanges(issue, config, fvs_add, fvs_remove, fields_clear):
966 """Updates the PB issue's field_values and returns an amendments list."""
967 phase_names_dict = {phase.phase_id: phase.name for phase in issue.phases}
968 phase_ids = list(phase_names_dict.keys())
969 (field_vals, added_fvs_by_id,
970 removed_fvs_by_id) = _MergeFields(
971 issue.field_values,
972 [fv for fv in fvs_add if not fv.phase_id or fv.phase_id in phase_ids],
973 [fv for fv in fvs_remove if not fv.phase_id or fv.phase_id in phase_ids],
974 config.field_defs)
975 amendments = []
976 if added_fvs_by_id or removed_fvs_by_id:
977 issue.field_values = field_vals
978 for fd in config.field_defs:
979 fd_added_values_by_phase = collections.defaultdict(list)
980 fd_removed_values_by_phase = collections.defaultdict(list)
981 # Split fd's added/removed fvs by the phase they belong to.
982 # non-phase fds will result in {None: [added_fvs]}
983 for fv in added_fvs_by_id.get(fd.field_id, []):
984 fd_added_values_by_phase[fv.phase_id].append(fv)
985 for fv in removed_fvs_by_id.get(fd.field_id, []):
986 fd_removed_values_by_phase[fv.phase_id].append(fv)
987 # Use all_fv_phase_ids to create Amendments, so no empty amendments
988 # are created for issue phases that had no field value changes.
989 all_fv_phase_ids = set(
990 fd_removed_values_by_phase.keys() + fd_added_values_by_phase.keys())
991 for phase_id in all_fv_phase_ids:
992 new_values = [GetFieldValue(fv, {}) for fv
993 in fd_added_values_by_phase.get(phase_id, [])]
994 old_values = [GetFieldValue(fv, {}) for fv
995 in fd_removed_values_by_phase.get(phase_id, [])]
996 amendments.append(MakeFieldAmendment(
997 fd.field_id, config, new_values, old_values=old_values,
998 phase_name=phase_names_dict.get(phase_id)))
999
1000 # Note: Clearing fields is used with bulk-editing and phase fields do
1001 # not appear there and cannot be bulk-edited.
1002 if fields_clear:
1003 field_clear_set = set(fields_clear)
1004 revised_fields = []
1005 for fd in config.field_defs:
1006 if fd.field_id not in field_clear_set:
1007 revised_fields.extend(
1008 fv for fv in issue.field_values if fv.field_id == fd.field_id)
1009 else:
1010 amendments.append(
1011 MakeFieldClearedAmendment(fd.field_id, config))
1012 if fd.field_type == tracker_pb2.FieldTypes.ENUM_TYPE:
1013 prefix = fd.field_name.lower() + '-'
1014 filtered_labels = [
1015 lab for lab in issue.labels
1016 if not lab.lower().startswith(prefix)]
1017 issue.labels = filtered_labels
1018
1019 issue.field_values = revised_fields
1020 return amendments
1021
1022
1023def ApplyIssueDelta(cnxn, issue_service, issue, delta, config):
1024 """Apply an issue delta to an issue in RAM.
1025
1026 Args:
1027 cnxn: connection to SQL database.
1028 issue_service: object to access issue-related data in the database.
1029 issue: Issue to be updated.
1030 delta: IssueDelta object with new values for everything being changed.
1031 config: ProjectIssueConfig object for the project containing the issue.
1032
1033 Returns:
1034 A pair (amendments, impacted_iids) where amendments is a list of Amendment
1035 protos to describe what changed, and impacted_iids is a set of other IIDs
1036 for issues that are modified because they are related to the given issue.
1037 """
1038 amendments = []
1039 impacted_iids = set()
1040 if (delta.status is not None and delta.status != issue.status):
1041 status = framework_bizobj.CanonicalizeLabel(delta.status)
1042 amendments.append(MakeStatusAmendment(status, issue.status))
1043 issue.status = status
1044 if (delta.owner_id is not None and delta.owner_id != issue.owner_id):
1045 amendments.append(MakeOwnerAmendment(delta.owner_id, issue.owner_id))
1046 issue.owner_id = delta.owner_id
1047
1048 # compute the set of cc'd users added and removed
1049 cc_add = [cc for cc in delta.cc_ids_add if cc not in issue.cc_ids]
1050 cc_remove = [cc for cc in delta.cc_ids_remove if cc in issue.cc_ids]
1051 if cc_add or cc_remove:
1052 cc_ids = [cc for cc in list(issue.cc_ids) + cc_add
1053 if cc not in cc_remove]
1054 issue.cc_ids = cc_ids
1055 amendments.append(MakeCcAmendment(cc_add, cc_remove))
1056
1057 # compute the set of components added and removed
1058 comp_ids_add = [
1059 c for c in delta.comp_ids_add if c not in issue.component_ids]
1060 comp_ids_remove = [
1061 c for c in delta.comp_ids_remove if c in issue.component_ids]
1062 if comp_ids_add or comp_ids_remove:
1063 comp_ids = [cid for cid in list(issue.component_ids) + comp_ids_add
1064 if cid not in comp_ids_remove]
1065 issue.component_ids = comp_ids
1066 amendments.append(MakeComponentsAmendment(
1067 comp_ids_add, comp_ids_remove, config))
1068
1069 # compute the set of labels added and removed
1070 label_amendment = ApplyLabelChanges(
1071 issue, config, delta.labels_add, delta.labels_remove)
1072 if label_amendment:
1073 amendments.append(label_amendment)
1074
1075 # compute the set of custom fields added and removed
1076 fv_amendments = ApplyFieldValueChanges(
1077 issue, config, delta.field_vals_add, delta.field_vals_remove,
1078 delta.fields_clear)
1079 amendments.extend(fv_amendments)
1080
1081 # Update blocking and blocked on issues.
1082 (block_changes_amendments,
1083 block_changes_impacted_iids) = ApplyIssueBlockRelationChanges(
1084 cnxn, issue, delta.blocked_on_add, delta.blocked_on_remove,
1085 delta.blocking_add, delta.blocking_remove, issue_service)
1086 amendments.extend(block_changes_amendments)
1087 impacted_iids.update(block_changes_impacted_iids)
1088
1089 # Update external issue references.
1090 if delta.ext_blocked_on_add or delta.ext_blocked_on_remove:
1091 add_refs = []
1092 for ext_id in delta.ext_blocked_on_add:
1093 ref = tracker_pb2.DanglingIssueRef(ext_issue_identifier=ext_id)
1094 if (federated.IsShortlinkValid(ext_id) and
1095 ref not in issue.dangling_blocked_on_refs and
1096 ext_id not in delta.ext_blocked_on_remove):
1097 add_refs.append(ref)
1098 remove_refs = []
1099 for ext_id in delta.ext_blocked_on_remove:
1100 ref = tracker_pb2.DanglingIssueRef(ext_issue_identifier=ext_id)
1101 if (federated.IsShortlinkValid(ext_id) and
1102 ref in issue.dangling_blocked_on_refs):
1103 remove_refs.append(ref)
1104 if add_refs or remove_refs:
1105 amendments.append(MakeBlockedOnAmendment(add_refs, remove_refs))
1106 issue.dangling_blocked_on_refs = [
1107 ref for ref in issue.dangling_blocked_on_refs + add_refs
1108 if ref.ext_issue_identifier not in delta.ext_blocked_on_remove]
1109
1110 # Update external issue references.
1111 if delta.ext_blocking_add or delta.ext_blocking_remove:
1112 add_refs = []
1113 for ext_id in delta.ext_blocking_add:
1114 ref = tracker_pb2.DanglingIssueRef(ext_issue_identifier=ext_id)
1115 if (federated.IsShortlinkValid(ext_id) and
1116 ref not in issue.dangling_blocking_refs and
1117 ext_id not in delta.ext_blocking_remove):
1118 add_refs.append(ref)
1119 remove_refs = []
1120 for ext_id in delta.ext_blocking_remove:
1121 ref = tracker_pb2.DanglingIssueRef(ext_issue_identifier=ext_id)
1122 if (federated.IsShortlinkValid(ext_id) and
1123 ref in issue.dangling_blocking_refs):
1124 remove_refs.append(ref)
1125 if add_refs or remove_refs:
1126 amendments.append(MakeBlockingAmendment(add_refs, remove_refs))
1127 issue.dangling_blocking_refs = [
1128 ref for ref in issue.dangling_blocking_refs + add_refs
1129 if ref.ext_issue_identifier not in delta.ext_blocking_remove]
1130
1131 if delta.merged_into is not None and delta.merged_into_external is not None:
1132 raise ValueError(('Cannot update merged_into and merged_into_external'
1133 ' fields at the same time.'))
1134
1135 if (delta.merged_into is not None and
1136 delta.merged_into != issue.merged_into and
1137 ((delta.merged_into == 0 and issue.merged_into is not None) or
1138 delta.merged_into != 0)):
1139
1140 # Handle removing the existing internal merged_into.
1141 try:
1142 merged_remove = issue.merged_into
1143 remove_issue = issue_service.GetIssue(cnxn, merged_remove)
1144 remove_ref = remove_issue.project_name, remove_issue.local_id
1145 impacted_iids.add(merged_remove)
1146 except exceptions.NoSuchIssueException:
1147 remove_ref = None
1148
1149 # Handle going from external->internal mergedinto.
1150 if issue.merged_into_external:
1151 remove_ref = tracker_pb2.DanglingIssueRef(
1152 ext_issue_identifier=issue.merged_into_external)
1153 issue.merged_into_external = None
1154
1155 # Handle adding the new merged_into.
1156 try:
1157 merged_add = delta.merged_into
1158 issue.merged_into = delta.merged_into
1159 add_issue = issue_service.GetIssue(cnxn, merged_add)
1160 add_ref = add_issue.project_name, add_issue.local_id
1161 impacted_iids.add(merged_add)
1162 except exceptions.NoSuchIssueException:
1163 add_ref = None
1164
1165 amendments.append(MakeMergedIntoAmendment(
1166 [add_ref], [remove_ref], default_project_name=issue.project_name))
1167
1168 if (delta.merged_into_external is not None and
1169 delta.merged_into_external != issue.merged_into_external and
1170 (federated.IsShortlinkValid(delta.merged_into_external) or
1171 (delta.merged_into_external == '' and issue.merged_into_external))):
1172
1173 remove_ref = None
1174 if issue.merged_into_external:
1175 remove_ref = tracker_pb2.DanglingIssueRef(
1176 ext_issue_identifier=issue.merged_into_external)
1177 elif issue.merged_into:
1178 # Handle moving from internal->external mergedinto.
1179 try:
1180 remove_issue = issue_service.GetIssue(cnxn, issue.merged_into)
1181 remove_ref = remove_issue.project_name, remove_issue.local_id
1182 impacted_iids.add(issue.merged_into)
1183 except exceptions.NoSuchIssueException:
1184 pass
1185
1186 add_ref = tracker_pb2.DanglingIssueRef(
1187 ext_issue_identifier=delta.merged_into_external)
1188 issue.merged_into = 0
1189 issue.merged_into_external = delta.merged_into_external
1190 amendments.append(MakeMergedIntoAmendment([add_ref], [remove_ref],
1191 default_project_name=issue.project_name))
1192
1193 if delta.summary and delta.summary != issue.summary:
1194 amendments.append(MakeSummaryAmendment(delta.summary, issue.summary))
1195 issue.summary = delta.summary
1196
1197 return amendments, impacted_iids
1198
1199
1200def ApplyIssueBlockRelationChanges(
1201 cnxn, issue, blocked_on_add, blocked_on_remove, blocking_add,
1202 blocking_remove, issue_service):
1203 # type: (MonorailConnection, Issue, Collection[int], Collection[int],
1204 # Collection[int], Collection[int], IssueService) ->
1205 # Sequence[Amendment], Collection[int]
1206 """Apply issue blocking/blocked_on relation changes to an issue in RAM.
1207
1208 Args:
1209 cnxn: connection to SQL database.
1210 issue: Issue PB that we are applying the changes to.
1211 blocked_on_add: list of issue IDs that we want to add as blocked_on.
1212 blocked_on_remove: list of issue IDs that we want to remove from blocked_on.
1213 blocking_add: list of issue IDs that we want to add as blocking.
1214 blocking_remove: list of issue IDs that we want to remove from blocking.
1215 issue_service: IssueService used to fetch info from DB or cache.
1216
1217 Returns:
1218 A tuple that holds the list of Amendments that represent the applied changes
1219 and a set of issue IDs that are impacted by the changes.
1220
1221
1222 Side-effect:
1223 The given issue's blocked_on and blocking fields will be modified.
1224 """
1225 amendments = []
1226 impacted_iids = set()
1227
1228 def addAmendment(add_iids, remove_iids, amendment_func):
1229 add_refs = issue_service.LookupIssueRefs(cnxn, add_iids).values()
1230 remove_refs = issue_service.LookupIssueRefs(cnxn, remove_iids).values()
1231 new_am = amendment_func(
1232 add_refs, remove_refs, default_project_name=issue.project_name)
1233 amendments.append(new_am)
1234
1235 # Apply blocked_on changes.
1236 old_blocked_on = issue.blocked_on_iids
1237 blocked_on_add = [iid for iid in blocked_on_add if iid not in old_blocked_on]
1238 blocked_on_remove = [
1239 iid for iid in blocked_on_remove if iid in old_blocked_on
1240 ]
1241 # blocked_on_add and blocked_on_remove are filtered above such that they
1242 # could not contain matching items.
1243 if blocked_on_add or blocked_on_remove:
1244 addAmendment(blocked_on_add, blocked_on_remove, MakeBlockedOnAmendment)
1245
1246 new_blocked_on_iids = [
1247 iid for iid in old_blocked_on + blocked_on_add
1248 if iid not in blocked_on_remove
1249 ]
1250 (issue.blocked_on_iids,
1251 issue.blocked_on_ranks) = issue_service.SortBlockedOn(
1252 cnxn, issue, new_blocked_on_iids)
1253 impacted_iids.update(blocked_on_add + blocked_on_remove)
1254
1255 # Apply blocking changes.
1256 old_blocking = issue.blocking_iids
1257 blocking_add = [iid for iid in blocking_add if iid not in old_blocking]
1258 blocking_remove = [iid for iid in blocking_remove if iid in old_blocking]
1259 # blocking_add and blocking_remove are filtered above such that they
1260 # could not contain matching items.
1261 if blocking_add or blocking_remove:
1262 addAmendment(blocking_add, blocking_remove, MakeBlockingAmendment)
1263 issue.blocking_iids = [
1264 iid for iid in old_blocking + blocking_add if iid not in blocking_remove
1265 ]
1266 impacted_iids.update(blocking_add + blocking_remove)
1267
1268 return amendments, impacted_iids
1269
1270
1271def MakeAmendment(
1272 field, new_value, added_ids, removed_ids, custom_field_name=None,
1273 old_value=None):
1274 """Utility function to populate an Amendment PB.
1275
1276 Args:
1277 field: enum for the field being updated.
1278 new_value: new string value of that field.
1279 added_ids: list of user IDs being added.
1280 removed_ids: list of user IDs being removed.
1281 custom_field_name: optional name of a custom field.
1282 old_value: old string value of that field.
1283
1284 Returns:
1285 An instance of Amendment.
1286 """
1287 amendment = tracker_pb2.Amendment()
1288 amendment.field = field
1289 amendment.newvalue = new_value
1290 amendment.added_user_ids.extend(added_ids)
1291 amendment.removed_user_ids.extend(removed_ids)
1292
1293 if old_value is not None:
1294 amendment.oldvalue = old_value
1295
1296 if custom_field_name is not None:
1297 amendment.custom_field_name = custom_field_name
1298
1299 return amendment
1300
1301
1302def _PlusMinusString(added_items, removed_items):
1303 """Return a concatenation of the items, with a minus on removed items.
1304
1305 Args:
1306 added_items: list of string items added.
1307 removed_items: list of string items removed.
1308
1309 Returns:
1310 A unicode string with all the removed items first (preceeded by minus
1311 signs) and then the added items.
1312 """
1313 assert all(isinstance(item, string_types)
1314 for item in added_items + removed_items)
1315 # TODO(jrobbins): this is not good when values can be negative ints.
1316 return ' '.join(
1317 ['-%s' % item.strip()
1318 for item in removed_items if item] +
1319 ['%s' % item for item in added_items if item])
1320
1321
1322def _PlusMinusAmendment(
1323 field, added_items, removed_items, custom_field_name=None):
1324 """Make an Amendment PB with the given added/removed items."""
1325 return MakeAmendment(
1326 field, _PlusMinusString(added_items, removed_items), [], [],
1327 custom_field_name=custom_field_name)
1328
1329
1330def _PlusMinusRefsAmendment(
1331 field, added_refs, removed_refs, default_project_name=None):
1332 """Make an Amendment PB with the given added/removed refs."""
1333 return _PlusMinusAmendment(
1334 field,
1335 [FormatIssueRef(r, default_project_name=default_project_name)
1336 for r in added_refs if r],
1337 [FormatIssueRef(r, default_project_name=default_project_name)
1338 for r in removed_refs if r])
1339
1340
1341def MakeSummaryAmendment(new_summary, old_summary):
1342 """Make an Amendment PB for a change to the summary."""
1343 return MakeAmendment(
1344 tracker_pb2.FieldID.SUMMARY, new_summary, [], [], old_value=old_summary)
1345
1346
1347def MakeStatusAmendment(new_status, old_status):
1348 """Make an Amendment PB for a change to the status."""
1349 return MakeAmendment(
1350 tracker_pb2.FieldID.STATUS, new_status, [], [], old_value=old_status)
1351
1352
1353def MakeOwnerAmendment(new_owner_id, old_owner_id):
1354 """Make an Amendment PB for a change to the owner."""
1355 return MakeAmendment(
1356 tracker_pb2.FieldID.OWNER, '', [new_owner_id], [old_owner_id])
1357
1358
1359def MakeCcAmendment(added_cc_ids, removed_cc_ids):
1360 """Make an Amendment PB for a change to the Cc list."""
1361 return MakeAmendment(
1362 tracker_pb2.FieldID.CC, '', added_cc_ids, removed_cc_ids)
1363
1364
1365def MakeLabelsAmendment(added_labels, removed_labels):
1366 """Make an Amendment PB for a change to the labels."""
1367 return _PlusMinusAmendment(
1368 tracker_pb2.FieldID.LABELS, added_labels, removed_labels)
1369
1370
1371def DiffValueLists(new_list, old_list):
1372 """Give an old list and a new list, return the added and removed items."""
1373 if not old_list:
1374 return new_list, []
1375 if not new_list:
1376 return [], old_list
1377
1378 added = []
1379 removed = old_list[:] # Assume everything was removed, then narrow that down
1380 for val in new_list:
1381 if val in removed:
1382 removed.remove(val)
1383 else:
1384 added.append(val)
1385
1386 return added, removed
1387
1388
1389def MakeFieldAmendment(
1390 field_id, config, new_values, old_values=None, phase_name=None):
1391 """Return an amendment showing how an issue's field changed.
1392
1393 Args:
1394 field_id: int field ID of a built-in or custom issue field.
1395 config: config info for the current project, including field_defs.
1396 new_values: list of strings representing new values of field.
1397 old_values: list of strings representing old values of field.
1398 phase_name: name of the phase that owned the field that was changed.
1399
1400 Returns:
1401 A new Amemdnent object.
1402
1403 Raises:
1404 ValueError: if the specified field was not found.
1405 """
1406 fd = FindFieldDefByID(field_id, config)
1407
1408 if fd is None:
1409 raise ValueError('field %r vanished mid-request', field_id)
1410
1411 field_name = fd.field_name if not phase_name else '%s-%s' % (
1412 phase_name, fd.field_name)
1413 if fd.is_multivalued:
1414 old_values = old_values or []
1415 added, removed = DiffValueLists(new_values, old_values)
1416 if fd.field_type == tracker_pb2.FieldTypes.USER_TYPE:
1417 return MakeAmendment(
1418 tracker_pb2.FieldID.CUSTOM, '', added, removed,
1419 custom_field_name=field_name)
1420 else:
1421 return _PlusMinusAmendment(
1422 tracker_pb2.FieldID.CUSTOM,
1423 ['%s' % item for item in added],
1424 ['%s' % item for item in removed],
1425 custom_field_name=field_name)
1426
1427 else:
1428 if fd.field_type == tracker_pb2.FieldTypes.USER_TYPE:
1429 return MakeAmendment(
1430 tracker_pb2.FieldID.CUSTOM, '', new_values, [],
1431 custom_field_name=field_name)
1432
1433 if new_values:
1434 new_str = ', '.join('%s' % item for item in new_values)
1435 else:
1436 new_str = '----'
1437
1438 return MakeAmendment(
1439 tracker_pb2.FieldID.CUSTOM, new_str, [], [],
1440 custom_field_name=field_name)
1441
1442
1443def MakeFieldClearedAmendment(field_id, config):
1444 fd = FindFieldDefByID(field_id, config)
1445
1446 if fd is None:
1447 raise ValueError('field %r vanished mid-request', field_id)
1448
1449 return MakeAmendment(
1450 tracker_pb2.FieldID.CUSTOM, '----', [], [],
1451 custom_field_name=fd.field_name)
1452
1453
1454def MakeApprovalStructureAmendment(new_approvals, old_approvals):
1455 """Return an Amendment showing an issue's approval structure changed.
1456
1457 Args:
1458 new_approvals: the new list of approvals.
1459 old_approvals: the old list of approvals.
1460
1461 Returns:
1462 A new Amendment object.
1463 """
1464
1465 approvals_added, approvals_removed = DiffValueLists(
1466 new_approvals, old_approvals)
1467 return MakeAmendment(
1468 tracker_pb2.FieldID.CUSTOM, _PlusMinusString(
1469 approvals_added, approvals_removed),
1470 [], [], custom_field_name='Approvals')
1471
1472
1473def MakeApprovalStatusAmendment(new_status):
1474 """Return an Amendment showing an issue approval's status changed.
1475
1476 Args:
1477 new_status: ApprovalStatus representing the new approval status.
1478
1479 Returns:
1480 A new Amemdnent object.
1481 """
1482 return MakeAmendment(
1483 tracker_pb2.FieldID.CUSTOM, new_status.name.lower(), [], [],
1484 custom_field_name='Status')
1485
1486
1487def MakeApprovalApproversAmendment(approvers_add, approvers_remove):
1488 """Return an Amendment showing an issue approval's approvers changed.
1489
1490 Args:
1491 approvers_add: list of approver user_ids being added.
1492 approvers_remove: list of approver user_ids being removed.
1493
1494 Returns:
1495 A new Amendment object.
1496 """
1497 return MakeAmendment(
1498 tracker_pb2.FieldID.CUSTOM, '', approvers_add, approvers_remove,
1499 custom_field_name='Approvers')
1500
1501
1502def MakeComponentsAmendment(added_comp_ids, removed_comp_ids, config):
1503 """Make an Amendment PB for a change to the components."""
1504 # TODO(jrobbins): record component IDs as ints and display them with
1505 # lookups (and maybe permission checks in the future). But, what
1506 # about history that references deleleted components?
1507 added_comp_paths = []
1508 for comp_id in added_comp_ids:
1509 cd = FindComponentDefByID(comp_id, config)
1510 if cd:
1511 added_comp_paths.append(cd.path)
1512
1513 removed_comp_paths = []
1514 for comp_id in removed_comp_ids:
1515 cd = FindComponentDefByID(comp_id, config)
1516 if cd:
1517 removed_comp_paths.append(cd.path)
1518
1519 return _PlusMinusAmendment(
1520 tracker_pb2.FieldID.COMPONENTS,
1521 added_comp_paths, removed_comp_paths)
1522
1523
1524def MakeBlockedOnAmendment(
1525 added_refs, removed_refs, default_project_name=None):
1526 """Make an Amendment PB for a change to the blocked on issues."""
1527 return _PlusMinusRefsAmendment(
1528 tracker_pb2.FieldID.BLOCKEDON, added_refs, removed_refs,
1529 default_project_name=default_project_name)
1530
1531
1532def MakeBlockingAmendment(added_refs, removed_refs, default_project_name=None):
1533 """Make an Amendment PB for a change to the blocking issues."""
1534 return _PlusMinusRefsAmendment(
1535 tracker_pb2.FieldID.BLOCKING, added_refs, removed_refs,
1536 default_project_name=default_project_name)
1537
1538
1539def MakeMergedIntoAmendment(
1540 added_refs, removed_refs, default_project_name=None):
1541 """Make an Amendment PB for a change to the merged-into issue."""
1542 return _PlusMinusRefsAmendment(
1543 tracker_pb2.FieldID.MERGEDINTO, added_refs, removed_refs,
1544 default_project_name=default_project_name)
1545
1546
1547def MakeProjectAmendment(new_project_name):
1548 """Make an Amendment PB for a change to an issue's project."""
1549 return MakeAmendment(
1550 tracker_pb2.FieldID.PROJECT, new_project_name, [], [])
1551
1552
1553def AmendmentString_New(amendment, user_display_names):
1554 # type: (tracker_pb2.Amendment, Mapping[int, str]) -> str
1555 """Produce a displayable string for an Amendment PB.
1556
1557 Args:
1558 amendment: Amendment PB to display.
1559 user_display_names: dict {user_id: display_name, ...} including all users
1560 mentioned in amendment.
1561
1562 Returns:
1563 A string that could be displayed on a web page or sent in email.
1564 """
1565 if amendment.newvalue:
1566 return amendment.newvalue
1567
1568 # Display new owner only
1569 if amendment.field == tracker_pb2.FieldID.OWNER:
1570 if amendment.added_user_ids and amendment.added_user_ids[0]:
1571 uid = amendment.added_user_ids[0]
1572 result = user_display_names[uid]
1573 else:
1574 result = framework_constants.NO_USER_NAME
1575 else:
1576 added = [
1577 user_display_names[uid]
1578 for uid in amendment.added_user_ids
1579 if uid in user_display_names
1580 ]
1581 removed = [
1582 user_display_names[uid]
1583 for uid in amendment.removed_user_ids
1584 if uid in user_display_names
1585 ]
1586 result = _PlusMinusString(added, removed)
1587
1588 return result
1589
1590
1591def AmendmentString(amendment, user_views_by_id):
1592 """Produce a displayable string for an Amendment PB.
1593
1594 TODO(crbug.com/monorail/7571): Delete this function in favor of _New.
1595
1596 Args:
1597 amendment: Amendment PB to display.
1598 user_views_by_id: dict {user_id: user_view, ...} including all users
1599 mentioned in amendment.
1600
1601 Returns:
1602 A string that could be displayed on a web page or sent in email.
1603 """
1604 if amendment.newvalue:
1605 return amendment.newvalue
1606
1607 # Display new owner only
1608 if amendment.field == tracker_pb2.FieldID.OWNER:
1609 if amendment.added_user_ids and amendment.added_user_ids[0]:
1610 uid = amendment.added_user_ids[0]
1611 result = user_views_by_id[uid].display_name
1612 else:
1613 result = framework_constants.NO_USER_NAME
1614 else:
1615 result = _PlusMinusString(
1616 [user_views_by_id[uid].display_name for uid in amendment.added_user_ids
1617 if uid in user_views_by_id],
1618 [user_views_by_id[uid].display_name
1619 for uid in amendment.removed_user_ids if uid in user_views_by_id])
1620
1621 return result
1622
1623
1624def AmendmentLinks(amendment, users_by_id, project_name):
1625 """Produce a list of value/url pairs for an Amendment PB.
1626
1627 Args:
1628 amendment: Amendment PB to display.
1629 users_by_id: dict {user_id: user_view, ...} including all users
1630 mentioned in amendment.
1631 project_nme: Name of project the issue/comment/amendment is in.
1632
1633 Returns:
1634 A list of dicts with 'value' and 'url' keys. 'url' may be None.
1635 """
1636 # Display both old and new summary, status
1637 if (amendment.field == tracker_pb2.FieldID.SUMMARY or
1638 amendment.field == tracker_pb2.FieldID.STATUS):
1639 result = amendment.newvalue
1640 oldValue = amendment.oldvalue;
1641 # Old issues have a 'NULL' string as the old value of the summary
1642 # or status fields. See crbug.com/monorail/3805
1643 if oldValue and oldValue != 'NULL':
1644 result += ' (was: %s)' % amendment.oldvalue
1645 return [{'value': result, 'url': None}]
1646 # Display new owner only
1647 elif amendment.field == tracker_pb2.FieldID.OWNER:
1648 if amendment.added_user_ids and amendment.added_user_ids[0]:
1649 uid = amendment.added_user_ids[0]
1650 return [{'value': users_by_id[uid].display_name, 'url': None}]
1651 return [{'value': framework_constants.NO_USER_NAME, 'url': None}]
1652 elif amendment.field in (tracker_pb2.FieldID.BLOCKEDON,
1653 tracker_pb2.FieldID.BLOCKING,
1654 tracker_pb2.FieldID.MERGEDINTO):
1655 values = amendment.newvalue.split()
1656 bug_refs = [_SafeParseIssueRef(v.strip()) for v in values]
1657 issue_urls = [FormatIssueURL(ref, default_project_name=project_name)
1658 for ref in bug_refs]
1659 # TODO(jrobbins): Permission checks on referenced issues to allow
1660 # showing summary on hover.
1661 return [{'value': v, 'url': u} for (v, u) in zip(values, issue_urls)]
1662 elif amendment.newvalue:
1663 # Catchall for everything except user-valued fields.
1664 return [{'value': v, 'url': None} for v in amendment.newvalue.split()]
1665 else:
1666 # Applies to field==CC or CUSTOM with user type.
1667 values = _PlusMinusString(
1668 [users_by_id[uid].display_name for uid in amendment.added_user_ids
1669 if uid in users_by_id],
1670 [users_by_id[uid].display_name for uid in amendment.removed_user_ids
1671 if uid in users_by_id])
1672 return [{'value': v.strip(), 'url': None} for v in values.split()]
1673
1674
1675def GetAmendmentFieldName(amendment):
1676 """Get user-visible name for an amendment to a built-in or custom field."""
1677 if amendment.custom_field_name:
1678 return amendment.custom_field_name
1679 else:
1680 field_name = str(amendment.field)
1681 return field_name.capitalize()
1682
1683
1684def MakeDanglingIssueRef(project_name, issue_id, ext_id=''):
1685 """Create a DanglingIssueRef pb."""
1686 ret = tracker_pb2.DanglingIssueRef()
1687 ret.project = project_name
1688 ret.issue_id = issue_id
1689 ret.ext_issue_identifier = ext_id
1690 return ret
1691
1692
1693def FormatIssueURL(issue_ref_tuple, default_project_name=None):
1694 """Format an issue url from an issue ref."""
1695 if issue_ref_tuple is None:
1696 return ''
1697 project_name, local_id = issue_ref_tuple
1698 project_name = project_name or default_project_name
1699 url = framework_helpers.FormatURL(
1700 None, '/p/%s%s' % (project_name, urls.ISSUE_DETAIL), id=local_id)
1701 return url
1702
1703
1704def FormatIssueRef(issue_ref_tuple, default_project_name=None):
1705 """Format an issue reference for users: e.g., 123, or projectname:123."""
1706 if issue_ref_tuple is None:
1707 return ''
1708
1709 # TODO(jeffcarp): Improve method signature to not require isinstance.
1710 if isinstance(issue_ref_tuple, tracker_pb2.DanglingIssueRef):
1711 return issue_ref_tuple.ext_issue_identifier or ''
1712
1713 project_name, local_id = issue_ref_tuple
1714 if project_name and project_name != default_project_name:
1715 return '%s:%d' % (project_name, local_id)
1716 else:
1717 return str(local_id)
1718
1719
1720def ParseIssueRef(ref_str):
1721 """Parse an issue ref string: e.g., 123, or projectname:123 into a tuple.
1722
1723 Raises ValueError if the ref string exists but can't be parsed.
1724 """
1725 if not ref_str.strip():
1726 return None
1727
1728 if ':' in ref_str:
1729 project_name, id_str = ref_str.split(':', 1)
1730 project_name = project_name.strip().lstrip('-')
1731 else:
1732 project_name = None
1733 id_str = ref_str
1734
1735 id_str = id_str.lstrip('-')
1736
1737 return project_name, int(id_str)
1738
1739
1740def _SafeParseIssueRef(ref_str):
1741 """Same as ParseIssueRef, but catches ValueError and returns None instead."""
1742 try:
1743 return ParseIssueRef(ref_str)
1744 except ValueError:
1745 return None
1746
1747
1748def _MergeFields(field_values, fields_add, fields_remove, field_defs):
1749 """Merge the fields to add/remove into the current field values.
1750
1751 Args:
1752 field_values: list of current FieldValue PBs.
1753 fields_add: list of FieldValue PBs to add to field_values. If any of these
1754 is for a single-valued field, it replaces all previous values for the
1755 same field_id in field_values.
1756 fields_remove: list of FieldValues to remove from field_values, if found.
1757 field_defs: list of FieldDef PBs from the issue's project's config.
1758
1759 Returns:
1760 A 3-tuple with the merged list of field values and {field_id: field_values}
1761 dict for the specific values that are added or removed. The actual added
1762 or removed might be fewer than the requested ones if the issue already had
1763 one of the values-to-add or lacked one of the values-to-remove.
1764 """
1765 is_multi = {fd.field_id: fd.is_multivalued for fd in field_defs}
1766 merged_fvs = list(field_values)
1767 added_fvs_by_id = collections.defaultdict(list)
1768 for fv_consider in fields_add:
1769 consider_value = GetFieldValue(fv_consider, {})
1770 for old_fv in field_values:
1771 # Don't add fv_consider if field_values already contains consider_value
1772 if (fv_consider.field_id == old_fv.field_id and
1773 GetFieldValue(old_fv, {}) == consider_value and
1774 fv_consider.phase_id == old_fv.phase_id):
1775 break
1776 else:
1777 # Drop any existing values for non-multi fields.
1778 if not is_multi.get(fv_consider.field_id):
1779 if fv_consider.phase_id:
1780 # Drop existing phase fvs that belong to the same phase
1781 merged_fvs = [fv for fv in merged_fvs if
1782 not (fv.field_id == fv_consider.field_id
1783 and fv.phase_id == fv_consider.phase_id)]
1784 else:
1785 # Drop existing non-phase fvs
1786 merged_fvs = [fv for fv in merged_fvs if
1787 not fv.field_id == fv_consider.field_id]
1788 added_fvs_by_id[fv_consider.field_id].append(fv_consider)
1789 merged_fvs.append(fv_consider)
1790
1791 removed_fvs_by_id = collections.defaultdict(list)
1792 for fv_consider in fields_remove:
1793 consider_value = GetFieldValue(fv_consider, {})
1794 for old_fv in field_values:
1795 # Only remove fv_consider if field_values contains consider_value
1796 if (fv_consider.field_id == old_fv.field_id and
1797 GetFieldValue(old_fv, {}) == consider_value and
1798 fv_consider.phase_id == old_fv.phase_id):
1799 removed_fvs_by_id[fv_consider.field_id].append(fv_consider)
1800 merged_fvs.remove(old_fv)
1801 return merged_fvs, added_fvs_by_id, removed_fvs_by_id
1802
1803
1804def SplitBlockedOnRanks(issue, target_iid, split_above, open_iids):
1805 """Splits issue relation rankings by some target issue's rank
1806
1807 Args:
1808 issue: Issue PB for the issue considered.
1809 target_iid: the global ID of the issue to split rankings about.
1810 split_above: False to split below the target issue, True to split above.
1811 open_iids: a list of global IDs of open and visible issues blocking
1812 the considered issue.
1813
1814 Returns:
1815 A tuple (lower, higher) where both are lists of
1816 [(blocker_iid, rank),...] of issues in rank order. If split_above is False
1817 the target issue is included in higher, otherwise it is included in lower
1818 """
1819 issue_rank_pairs = [(dst_iid, rank)
1820 for (dst_iid, rank) in zip(issue.blocked_on_iids, issue.blocked_on_ranks)
1821 if dst_iid in open_iids]
1822 # blocked_on_iids is sorted high-to-low, we need low-to-high
1823 issue_rank_pairs.reverse()
1824 offset = int(split_above)
1825 for i, (dst_iid, _) in enumerate(issue_rank_pairs):
1826 if dst_iid == target_iid:
1827 return issue_rank_pairs[:i + offset], issue_rank_pairs[i + offset:]
1828
1829 logging.error('Target issue %r was not found in blocked_on_iids of %r',
1830 target_iid, issue)
1831 return issue_rank_pairs, []