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