blob: 2ad4c1c2c10bc90c429cbb47c2086abb2ca1813e [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"""Helper functions for sorting lists of project artifacts.
6
7This module exports the SortArtifacts function that does sorting of
8Monorail business objects (e.g., an issue). The sorting is done by
9extracting relevant values from the PB using a dictionary of
10accessor functions.
11
12The desired sorting directives are specified in part of the user's
13HTTP request. This sort spec consists of the names of the columns
14with optional minus signs to indicate descending sort order.
15
16The tool configuration object also affects sorting. When sorting by
17key-value labels, the well-known labels are considered to come
18before any non-well-known labels, and those well-known labels sort in
19the order in which they are defined in the tool config PB.
20"""
21from __future__ import print_function
22from __future__ import division
23from __future__ import absolute_import
24
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +010025import functools
Copybara854996b2021-09-07 19:36:02 +000026
27import settings
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +010028from mrproto import tracker_pb2
Copybara854996b2021-09-07 19:36:02 +000029from services import caches
30from tracker import tracker_bizobj
31from tracker import tracker_constants
32
33
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +010034@functools.total_ordering
Copybara854996b2021-09-07 19:36:02 +000035class DescendingValue(object):
36 """A wrapper which reverses the sort order of values."""
37
38 @classmethod
39 def MakeDescendingValue(cls, obj):
40 """Make a value that sorts in the reverse order as obj."""
41 if isinstance(obj, int):
42 return -obj
43 if obj == MAX_STRING:
44 return MIN_STRING
45 if obj == MIN_STRING:
46 return MAX_STRING
47 if isinstance(obj, list):
48 return [cls.MakeDescendingValue(item) for item in reversed(obj)]
49 return DescendingValue(obj)
50
51 def __init__(self, val):
52 self.val = val
53
54 def __eq__(self, other):
55 if isinstance(other, DescendingValue):
56 return self.val == other.val
57 return self.val == other
58
59 def __ne__(self, other):
60 if isinstance(other, DescendingValue):
61 return self.val != other.val
62 return self.val != other
63
64 def __lt__(self, other):
65 if isinstance(other, DescendingValue):
66 return other.val < self.val
67 return other < self.val
68
69 def __repr__(self):
70 return 'DescendingValue(%r)' % self.val
71
72
73# A string that sorts after every other string, and one that sorts before them.
74MAX_STRING = '~~~'
75MIN_STRING = DescendingValue(MAX_STRING)
76
77
78# RAMCache {issue_id: {column_name: sort_key, ...}, ...}
79art_values_cache = None
80
81
82def InitializeArtValues(services):
83 global art_values_cache
84 art_values_cache = caches.RamCache(
85 services.cache_manager, 'issue', max_size=settings.issue_cache_max_size)
86
87
88def InvalidateArtValuesKeys(cnxn, keys):
89 art_values_cache.InvalidateKeys(cnxn, keys)
90
91
92def SortArtifacts(
93 artifacts, config, accessors, postprocessors, group_by_spec, sort_spec,
94 users_by_id=None, tie_breakers=None):
95 """Return a list of artifacts sorted by the user's sort specification.
96
97 In the following, an "accessor" is a function(art) -> [field_value, ...].
98
99 Args:
100 artifacts: an unsorted list of project artifact PBs.
101 config: Project config PB instance that defines the sort order for
102 labels and statuses in this project.
103 accessors: dict {column_name: accessor} to get values from the artifacts.
104 postprocessors: dict {column_name: postprocessor} to get user emails
105 and timestamps.
106 group_by_spec: string that lists the grouping order
107 sort_spec: string that lists the sort order
108 users_by_id: optional dictionary {user_id: user_view,...} for all users
109 who participate in the list of artifacts.
110 tie_breakers: list of column names to add to the end of the sort
111 spec if they are not already somewhere in the sort spec.
112
113 Returns:
114 A sorted list of artifacts.
115
116 Note: if username_cols is supplied, then users_by_id should be too.
117
118 The approach to sorting is to construct a comprehensive sort key for
119 each artifact. To create the sort key, we (a) build lists with a
120 variable number of fields to sort on, and (b) allow individual
121 fields to be sorted in descending order. Even with the time taken
122 to build the sort keys, calling sorted() with the key seems to be
123 faster overall than doing multiple stable-sorts or doing one sort
124 using a multi-field comparison function.
125 """
126 sort_directives = ComputeSortDirectives(
127 config, group_by_spec, sort_spec, tie_breakers=tie_breakers)
128
129 # Build a list of accessors that will extract sort keys from the issues.
130 accessor_pairs = [
131 (sd, _MakeCombinedSortKeyAccessor(
132 sd, config, accessors, postprocessors, users_by_id))
133 for sd in sort_directives]
134
135 def SortKey(art):
136 """Make a sort_key for the given artifact, used by sorted() below."""
137 if art_values_cache.HasItem(art.issue_id):
138 art_values = art_values_cache.GetItem(art.issue_id)
139 else:
140 art_values = {}
141
142 sort_key = []
143 for sd, accessor in accessor_pairs:
144 if sd not in art_values:
145 art_values[sd] = accessor(art)
146 sort_key.append(art_values[sd])
147
148 art_values_cache.CacheItem(art.issue_id, art_values)
149 return sort_key
150
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100151 return sorted(artifacts, key=lambda x: Python2Key(SortKey(x)))
Copybara854996b2021-09-07 19:36:02 +0000152
153
154def ComputeSortDirectives(config, group_by_spec, sort_spec, tie_breakers=None):
155 """Return a list with sort directives to be used in sorting.
156
157 Args:
158 config: Project config PB instance that defines the sort order for
159 labels and statuses in this project.
160 group_by_spec: string that lists the grouping order
161 sort_spec: string that lists the sort order
162 tie_breakers: list of column names to add to the end of the sort
163 spec if they are not already somewhere in the sort spec.
164
165 Returns:
166 A list of lower-case column names, each one may have a leading
167 minus-sign.
168 """
169 # Prepend the end-user's sort spec to any project default sort spec.
170 if tie_breakers is None:
171 tie_breakers = ['id']
172 sort_spec = '%s %s %s' % (
173 group_by_spec, sort_spec, config.default_sort_spec)
174 # Sort specs can have interfering sort orders, so remove any duplicates.
175 field_names = set()
176 sort_directives = []
177 for sort_directive in sort_spec.lower().split():
178 field_name = sort_directive.lstrip('-')
179 if field_name not in field_names:
180 sort_directives.append(sort_directive)
181 field_names.add(field_name)
182
183 # Add in the project name so that the overall ordering is completely
184 # defined in cross-project search. Otherwise, issues jump up and
185 # down on each reload of the same query, and prev/next links get
186 # messed up. It's a no-op in single projects.
187 if 'project' not in sort_directives:
188 sort_directives.append('project')
189
190 for tie_breaker in tie_breakers:
191 if tie_breaker not in sort_directives:
192 sort_directives.append(tie_breaker)
193
194 return sort_directives
195
196
197def _MakeCombinedSortKeyAccessor(
198 sort_directive, config, accessors, postprocessors, users_by_id):
199 """Return an accessor that extracts a sort key for a UI table column.
200
201 Args:
202 sort_directive: string with column name and optional leading minus sign,
203 for combined columns, it may have slashes, e.g., "-priority/pri".
204 config: ProjectIssueConfig instance that defines the sort order for
205 labels and statuses in this project.
206 accessors: dictionary of (column_name -> accessor) to get values
207 from the artifacts.
208 postprocessors: dict {column_name: postprocessor} to get user emails
209 and timestamps.
210 users_by_id: dictionary {user_id: user_view,...} for all users
211 who participate in the list of artifacts (e.g., owners, reporters, cc).
212
213 Returns:
214 A list of accessor functions that can be applied to an issue to extract
215 the relevant sort key value.
216
217 The strings for status and labels are converted to lower case in
218 this method so that they sort like case-insensitive enumerations.
219 Any component-specific field of the artifact is sorted according to the
220 value returned by the accessors defined in that component. Those
221 accessor functions should lower case string values for fields where
222 case-insensitive sorting is desired.
223 """
224 if sort_directive.startswith('-'):
225 combined_col_name = sort_directive[1:]
226 descending = True
227 else:
228 combined_col_name = sort_directive
229 descending = False
230
231 wk_labels = [wkl.label for wkl in config.well_known_labels]
232 accessors = [
233 _MakeSingleSortKeyAccessor(
234 col_name, config, accessors, postprocessors, users_by_id, wk_labels)
235 for col_name in combined_col_name.split('/')]
236
237 # The most common case is that we sort on a single column, like "priority".
238 if len(accessors) == 1:
239 return _MaybeMakeDescending(accessors[0], descending)
240
241 # Less commonly, we are sorting on a combined column like "priority/pri".
242 def CombinedAccessor(art):
243 """Flatten and sort the values for each column in a combined column."""
244 key_part = []
245 for single_accessor in accessors:
246 value = single_accessor(art)
247 if isinstance(value, list):
248 key_part.extend(value)
249 else:
250 key_part.append(value)
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100251 return sorted(key_part, key=Python2Key)
Copybara854996b2021-09-07 19:36:02 +0000252
253 return _MaybeMakeDescending(CombinedAccessor, descending)
254
255
256def _MaybeMakeDescending(accessor, descending):
257 """If descending is True, return a new function that reverses accessor."""
258 if not descending:
259 return accessor
260
261 def DescendingAccessor(art):
262 asc_value = accessor(art)
263 return DescendingValue.MakeDescendingValue(asc_value)
264
265 return DescendingAccessor
266
267
268def _MakeSingleSortKeyAccessor(
269 col_name, config, accessors, postprocessors, users_by_id, wk_labels):
270 """Return an accessor function for a single simple UI column."""
271 # Case 1. Handle built-in fields: status, component.
272 if col_name == 'status':
273 wk_statuses = [wks.status for wks in config.well_known_statuses]
274 return _IndexOrLexical(wk_statuses, accessors[col_name])
275
276 if col_name == 'component':
277 comp_defs = sorted(config.component_defs, key=lambda cd: cd.path.lower())
278 comp_ids = [cd.component_id for cd in comp_defs]
279 return _IndexListAccessor(comp_ids, accessors[col_name])
280
281 # Case 2. Any other defined accessor functions.
282 if col_name in accessors:
283 if postprocessors and col_name in postprocessors:
284 # sort users by email address or timestamp rather than user ids.
285 return _MakeAccessorWithPostProcessor(
286 users_by_id, accessors[col_name], postprocessors[col_name])
287 else:
288 return accessors[col_name]
289
290 # Case 3. Anything else is assumed to be a label prefix or custom field.
291 return _IndexOrLexicalList(
292 wk_labels, config.field_defs, col_name, users_by_id)
293
294
295IGNORABLE_INDICATOR = -1
296
297
298def _PrecomputeSortIndexes(values, col_name):
299 """Precompute indexes of strings in the values list for fast lookup later."""
300 # Make a dictionary that immediately gives us the index of any value
301 # in the list, and also add the same values in all-lower letters. In
302 # the case where two values differ only by case, the later value wins,
303 # which is fine.
304 indexes = {}
305 if col_name:
306 prefix = col_name + '-'
307 else:
308 prefix = ''
309 for idx, val in enumerate(values):
310 if val.lower().startswith(prefix):
311 indexes[val] = idx
312 indexes[val.lower()] = idx
313 else:
314 indexes[val] = IGNORABLE_INDICATOR
315 indexes[val.lower()] = IGNORABLE_INDICATOR
316
317 return indexes
318
319
320def _MakeAccessorWithPostProcessor(users_by_id, base_accessor, postprocessor):
321 """Make an accessor that returns a list of user_view properties for sorting.
322
323 Args:
324 users_by_id: dictionary {user_id: user_view, ...} for all participants
325 in the entire list of artifacts.
326 base_accessor: an accessor function f(artifact) -> user_id.
327 postprocessor: function f(user_view) -> single sortable value.
328
329 Returns:
330 An accessor f(artifact) -> value that can be used in sorting
331 the decorated list.
332 """
333
334 def Accessor(art):
335 """Return a user edit name for the given artifact's base_accessor."""
336 id_or_id_list = base_accessor(art)
337 if isinstance(id_or_id_list, list):
338 values = [postprocessor(users_by_id[user_id])
339 for user_id in id_or_id_list]
340 else:
341 values = [postprocessor(users_by_id[id_or_id_list])]
342
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100343 return sorted(values) or [MAX_STRING]
Copybara854996b2021-09-07 19:36:02 +0000344
345 return Accessor
346
347
348def _MakeColumnAccessor(col_name):
349 """Make an accessor for an issue's labels that have col_name as a prefix.
350
351 Args:
352 col_name: string column name.
353
354 Returns:
355 An accessor that can be applied to an artifact to return a list of
356 labels that have col_name as a prefix.
357
358 For example, _MakeColumnAccessor('priority')(issue) could result in
359 [], or ['priority-high'], or a longer list for multi-valued labels.
360 """
361 prefix = col_name + '-'
362
363 def Accessor(art):
364 """Return a list of label values on the given artifact."""
365 result = [label.lower() for label in tracker_bizobj.GetLabels(art)
366 if label.lower().startswith(prefix)]
367 return result
368
369 return Accessor
370
371
372def _IndexOrLexical(wk_values, base_accessor):
373 """Return an accessor to score an artifact based on a user-defined ordering.
374
375 Args:
376 wk_values: a list of well-known status values from the config.
377 base_accessor: function that gets a field from a given issue.
378
379 Returns:
380 An accessor that can be applied to an issue to return a suitable
381 sort key.
382
383 For example, when used to sort issue statuses, these accessors return an
384 integer for well-known statuses, a string for odd-ball statuses, and an
385 extreme value key for issues with no status. That causes issues to appear
386 in the expected order with odd-ball issues sorted lexicographically after
387 the ones with well-known status values, and issues with no defined status at
388 the very end.
389 """
390 well_known_value_indexes = _PrecomputeSortIndexes(wk_values, '')
391
392 def Accessor(art):
393 """Custom-made function to return a specific value of any issue."""
394 value = base_accessor(art)
395 if not value:
396 # Undefined values sort last.
397 return MAX_STRING
398
399 try:
400 # Well-known values sort by index. Ascending sorting has positive ints
401 # in well_known_value_indexes.
402 return well_known_value_indexes[value]
403 except KeyError:
404 # Odd-ball values after well-known and lexicographically.
405 return value.lower()
406
407 return Accessor
408
409
410def _IndexListAccessor(wk_values, base_accessor):
411 """Return an accessor to score an artifact based on a user-defined ordering.
412
413 Args:
414 wk_values: a list of well-known values from the config.
415 base_accessor: function that gets a field from a given issue.
416
417 Returns:
418 An accessor that can be applied to an issue to return a suitable
419 sort key.
420 """
421 well_known_value_indexes = {
422 val: idx for idx, val in enumerate(wk_values)}
423
424 def Accessor(art):
425 """Custom-made function to return a specific value of any issue."""
426 values = base_accessor(art)
427 if not values:
428 # Undefined values sort last.
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100429 return [MAX_STRING]
Copybara854996b2021-09-07 19:36:02 +0000430
431 indexes = [well_known_value_indexes.get(val, MAX_STRING) for val in values]
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100432 return sorted(indexes, key=Python2Key)
Copybara854996b2021-09-07 19:36:02 +0000433
434 return Accessor
435
436
437def _IndexOrLexicalList(wk_values, full_fd_list, col_name, users_by_id):
438 """Return an accessor to score an artifact based on a user-defined ordering.
439
440 Args:
441 wk_values: A list of well-known labels from the config.
442 full_fd_list: list of FieldDef PBs that belong to the config.
443 col_name: lowercase string name of the column that will be sorted on.
444 users_by_id: A dictionary {user_id: user_view}.
445
446 Returns:
447 An accessor that can be applied to an issue to return a suitable
448 sort key.
449 """
450 well_known_value_indexes = _PrecomputeSortIndexes(wk_values, col_name)
451
452 if col_name.endswith(tracker_constants.APPROVER_COL_SUFFIX):
453 # Custom field names cannot end with the APPROVER_COL_SUFFIX. So the only
454 # possible relevant values are approvers for an APPROVAL_TYPE named
455 # field_name and any values from labels with the key 'field_name-approvers'.
456 field_name = col_name[:-len(tracker_constants.APPROVER_COL_SUFFIX)]
457 approval_fds = [fd for fd in full_fd_list
458 if (fd.field_name.lower() == field_name and
459 fd.field_type == tracker_pb2.FieldTypes.APPROVAL_TYPE)]
460
461 def ApproverAccessor(art):
462 """Custom-made function to return a sort value or an issue's approvers."""
463 idx_or_lex_list = (
464 _SortableApprovalApproverValues(art, approval_fds, users_by_id) +
465 _SortableLabelValues(art, col_name, well_known_value_indexes))
466 if not idx_or_lex_list:
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100467 return [MAX_STRING] # issues with no value sort to the end of the list.
468 return sorted(idx_or_lex_list, key=Python2Key)
Copybara854996b2021-09-07 19:36:02 +0000469
470 return ApproverAccessor
471
472 # Column name does not end with APPROVER_COL_SUFFIX, so relevant values
473 # are Approval statuses or Field Values for fields named col_name and
474 # values from labels with the key equal to col_name.
475 field_name = col_name
476 phase_name = None
477 if '.' in col_name:
478 phase_name, field_name = col_name.split('.', 1)
479
480 fd_list = [fd for fd in full_fd_list
481 if (fd.field_name.lower() == field_name and
482 fd.field_type != tracker_pb2.FieldTypes.ENUM_TYPE and
483 bool(phase_name) == fd.is_phase_field)]
484 approval_fds = []
485 if not phase_name:
486 approval_fds = [fd for fd in fd_list if
487 fd.field_type == tracker_pb2.FieldTypes.APPROVAL_TYPE]
488
489 def Accessor(art):
490 """Custom-made function to return a sort value for any issue."""
491 idx_or_lex_list = (
492 _SortableApprovalStatusValues(art, approval_fds) +
493 _SortableFieldValues(art, fd_list, users_by_id, phase_name) +
494 _SortableLabelValues(art, col_name, well_known_value_indexes))
495 if not idx_or_lex_list:
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100496 return [MAX_STRING] # issues with no value sort to the end of the list.
497 return sorted(idx_or_lex_list, key=Python2Key)
Copybara854996b2021-09-07 19:36:02 +0000498
499 return Accessor
500
501
502def _SortableApprovalStatusValues(art, fd_list):
503 """Return a list of approval statuses relevant to one UI table column."""
504 sortable_value_list = []
505 for fd in fd_list:
506 for av in art.approval_values:
507 if av.approval_id == fd.field_id:
508 # Order approval statuses by life cycle.
509 # NOT_SET == 8 but should be before all other statuses.
510 sortable_value_list.append(
511 0 if av.status.number == 8 else av.status.number)
512
513 return sortable_value_list
514
515
516def _SortableApprovalApproverValues(art, fd_list, users_by_id):
517 """Return a list of approval approvers relevant to one UI table column."""
518 sortable_value_list = []
519 for fd in fd_list:
520 for av in art.approval_values:
521 if av.approval_id == fd.field_id:
522 sortable_value_list.extend(
523 [users_by_id.get(approver_id).email
524 for approver_id in av.approver_ids
525 if users_by_id.get(approver_id)])
526
527 return sortable_value_list
528
529
530def _SortableFieldValues(art, fd_list, users_by_id, phase_name):
531 """Return a list of field values relevant to one UI table column."""
532 phase_id = None
533 if phase_name:
534 phase_id = next((
535 phase.phase_id for phase in art.phases
536 if phase.name.lower() == phase_name), None)
537 sortable_value_list = []
538 for fd in fd_list:
539 for fv in art.field_values:
540 if fv.field_id == fd.field_id and fv.phase_id == phase_id:
541 sortable_value_list.append(
542 tracker_bizobj.GetFieldValue(fv, users_by_id))
543
544 return sortable_value_list
545
546
547def _SortableLabelValues(art, col_name, well_known_value_indexes):
548 """Return a list of ints and strings for labels relevant to one UI column."""
549 col_name_dash = col_name + '-'
550 sortable_value_list = []
551 for label in tracker_bizobj.GetLabels(art):
552 idx_or_lex = well_known_value_indexes.get(label)
553 if idx_or_lex == IGNORABLE_INDICATOR:
554 continue # Label is known to not have the desired prefix.
555 if idx_or_lex is None:
556 if '-' not in label:
557 # Skip an irrelevant OneWord label and remember to ignore it later.
558 well_known_value_indexes[label] = IGNORABLE_INDICATOR
559 continue
560 label_lower = label.lower()
561 if label_lower.startswith(col_name_dash):
562 # Label is a key-value label with an odd-ball value, remember it
563 value = label_lower[len(col_name_dash):]
564 idx_or_lex = value
565 well_known_value_indexes[label] = value
566 else:
567 # Label was a key-value label that is not relevant to this column.
568 # Remember to ignore it later.
569 well_known_value_indexes[label] = IGNORABLE_INDICATOR
570 continue
571
572 sortable_value_list.append(idx_or_lex)
573
574 return sortable_value_list
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100575
576
577def _Python2Cmp(a, b):
578 """Compares two objects in the Python 2 way.
579
580 In Python 3, comparing two objects of different types raises a TypeError.
581 In Python 2, when you compare two objects of different types, they are
582 generally ordered by their type names, with a few special cases carved
583 out for int/float and str/unicode.
584
585 This comparison function also looks through lists and compares them pairwise.
586 It doesn't do the same for other iterables.
587 """
588 try:
589 # First try comparing the objects directly.
590 # https://docs.python.org/3.0/whatsnew/3.0.html#ordering-comparisons
591 return (a > b) - (a < b)
592 except TypeError:
593 s1, s2 = type(a).__name__, type(b).__name__
594 if not (s1 == 'list' and s2 == 'list'):
595 # If they are different types, compare their type names.
596 return (s1 > s2) - (s1 < s2)
597
598 # If they are both lists, compare their elements pairwise.
599 for x, y in zip(a, b):
600 element_cmp = _Python2Cmp(x, y)
601 if element_cmp != 0:
602 return element_cmp
603
604 # If the lists start with the same elements, compare their lengths.
605 return (len(a) > len(b)) - (len(a) < len(b))
606
607
608Python2Key = functools.cmp_to_key(_Python2Cmp)