Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 1 | # 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. |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 4 | |
| 5 | """Business objects for the Monorail issue tracker. |
| 6 | |
| 7 | These are classes and functions that operate on the objects that |
| 8 | users care about in the issue tracker: e.g., issues, and the issue |
| 9 | tracker configuration. |
| 10 | """ |
| 11 | from __future__ import print_function |
| 12 | from __future__ import division |
| 13 | from __future__ import absolute_import |
| 14 | |
| 15 | import collections |
| 16 | import logging |
| 17 | import time |
| 18 | |
| 19 | from six import string_types |
| 20 | |
| 21 | from features import federated |
| 22 | from framework import exceptions |
| 23 | from framework import framework_bizobj |
| 24 | from framework import framework_constants |
| 25 | from framework import framework_helpers |
| 26 | from framework import timestr |
| 27 | from framework import urls |
Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 28 | from mrproto import tracker_pb2 |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 29 | from tracker import tracker_constants |
| 30 | |
| 31 | |
| 32 | def 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 | |
| 38 | def 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 | |
| 43 | def 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 | |
| 48 | def 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 | |
| 57 | def GetLabels(issue): |
| 58 | """Get the labels of an issue, whether explicit or derived.""" |
| 59 | return issue.labels + issue.derived_labels |
| 60 | |
| 61 | |
| 62 | def 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 | |
| 93 | def 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 | |
| 105 | def 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 | |
| 114 | def 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 | |
| 123 | def 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 | |
| 132 | def 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 | |
| 141 | def 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 | |
| 151 | def 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 | |
| 160 | def 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 | |
| 169 | def 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 | |
| 182 | def 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 | |
| 204 | def 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 | |
| 226 | def 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 | |
| 232 | def 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 | |
| 246 | def 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 | |
| 257 | def 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 | |
| 320 | def 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 | |
| 342 | def 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 | |
| 368 | def 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 | |
| 390 | def 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 | |
| 400 | def 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 | |
| 419 | def 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 | |
| 428 | def 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 | |
| 435 | def GetIssueComponentsAndAncestors(issue, config): |
| 436 | """Return a list of all the components that an issue is in.""" |
Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 437 | result = [] |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 438 | 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ínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 444 | result.append(cd) |
| 445 | result.extend(ancestors) |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 446 | |
| 447 | return sorted(result, key=lambda cd: cd.path) |
| 448 | |
| 449 | |
| 450 | def 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 | |
| 457 | def 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 | |
| 470 | def 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 | |
| 485 | def 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 | |
| 495 | def 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 | |
| 505 | def 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 | |
| 513 | def 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 | |
| 535 | def 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 | |
| 581 | def 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 | |
| 592 | def 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 | |
| 651 | def 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 | |
| 674 | def _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 | |
| 701 | def _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 | |
| 741 | def 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 | |
| 746 | def 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 | |
| 756 | def _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 | |
| 773 | def 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 | |
| 786 | def 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 | |
| 803 | def 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 | |
| 819 | def 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 | |
| 833 | def 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 | |
| 841 | def 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 | |
| 855 | def UsersInvolvedInApprovalDefs(approval_defs, matching_fds): |
Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 856 | # type: (Sequence[mrproto.tracker_pb2.ApprovalDef], |
| 857 | # Sequence[mrproto.tracker_pb2.FieldDef]) -> Collection[int] |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 858 | """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 | |
| 867 | def 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 | |
| 878 | def 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 | |
| 886 | def 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 | |
| 907 | def 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 | |
| 944 | def 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 | |
| 964 | def 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ínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 988 | all_fv_phase_ids = set(fd_removed_values_by_phase.keys()) | set( |
| 989 | fd_added_values_by_phase.keys()) |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 990 | 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 | |
| 1022 | def 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 | |
| 1199 | def 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ínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 1228 | 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 | ] |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 1234 | 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 | |
| 1274 | def MakeAmendment( |
Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 1275 | 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): |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 1283 | """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ínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 1308 | 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 | |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 1314 | return amendment |
| 1315 | |
| 1316 | |
| 1317 | def _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 | |
| 1337 | def _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 | |
| 1345 | def _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 | |
| 1356 | def 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 | |
| 1362 | def 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 | |
| 1368 | def 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 | |
| 1374 | def 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 | |
| 1380 | def 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 | |
| 1386 | def 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 | |
| 1404 | def 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 | |
| 1458 | def 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 | |
| 1469 | def 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 | |
| 1488 | def 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 | |
| 1502 | def 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 | |
| 1517 | def 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ínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 1523 | valid_added_comp_ids = [] |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 1524 | 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ínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 1528 | valid_added_comp_ids.append(comp_id) |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 1529 | |
| 1530 | removed_comp_paths = [] |
Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 1531 | valid_removed_comp_ids = [] |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 1532 | 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ínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 1536 | valid_removed_comp_ids.append(comp_id) |
| 1537 | values = _PlusMinusString(added_comp_paths, removed_comp_paths) |
| 1538 | return MakeAmendment( |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 1539 | tracker_pb2.FieldID.COMPONENTS, |
Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 1540 | values, [], [], |
| 1541 | added_component_ids=valid_added_comp_ids, |
| 1542 | removed_component_ids=valid_removed_comp_ids) |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 1543 | |
| 1544 | |
| 1545 | def 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 | |
| 1553 | def 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 | |
| 1560 | def 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 | |
| 1568 | def 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 | |
| 1574 | def 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 | |
| 1612 | def 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 | |
| 1645 | def 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 | |
| 1696 | def 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 | |
| 1705 | def 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 | |
| 1714 | def 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 | |
| 1725 | def 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 | |
| 1741 | def 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 | |
| 1761 | def _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 | |
| 1769 | def _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 | |
| 1825 | def 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, [] |