blob: 9d38b6751adc310e3368000a72144e26dab4ddc4 [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"""Implementation of the filter rules helper functions."""
6from __future__ import print_function
7from __future__ import division
8from __future__ import absolute_import
9
10import logging
11import re
12import six
13
14from six import string_types
15
16import settings
17from features import features_constants
18from framework import cloud_tasks_helpers
19from framework import exceptions
20from framework import framework_bizobj
21from framework import framework_constants
22from framework import urls
23from framework import validate
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +010024from mrproto import ast_pb2
25from mrproto import tracker_pb2
Copybara854996b2021-09-07 19:36:02 +000026from search import query2ast
27from search import searchpipeline
28from tracker import component_helpers
29from tracker import tracker_bizobj
30from tracker import tracker_constants
31from tracker import tracker_helpers
32
33
34# Maximum number of filer rules that can be specified in a given
35# project. This helps us bound the amount of time needed to
36# (re)compute derived fields.
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +010037MAX_RULES = 250
Copybara854996b2021-09-07 19:36:02 +000038
39BLOCK = tracker_constants.RECOMPUTE_DERIVED_FIELDS_BLOCK_SIZE
40
41
42# TODO(jrobbins): implement a more efficient way to update just those
43# issues affected by a specific component change.
44def RecomputeAllDerivedFields(cnxn, services, project, config):
45 """Create work items to update all issues after filter rule changes.
46
47 Args:
48 cnxn: connection to SQL database.
49 services: connections to backend services.
50 project: Project PB for the project that was edited.
51 config: ProjectIssueConfig PB for the project that was edited,
52 including the edits made.
53 """
54 if not settings.recompute_derived_fields_in_worker:
55 # Background tasks are not enabled, just do everything in the servlet.
56 RecomputeAllDerivedFieldsNow(cnxn, services, project, config)
57 return
58
59 highest_id = services.issue.GetHighestLocalID(cnxn, project.project_id)
60 if highest_id == 0:
61 return # No work to do.
62
63 # Enqueue work items for blocks of issues to recompute.
64 steps = list(range(1, highest_id + 1, BLOCK))
65 steps.reverse() # Update higher numbered issues sooner, old issues last.
66 # Cycle through shard_ids just to load-balance among the replicas. Each
67 # block includes all issues in that local_id range, not just 1/10 of them.
68 shard_id = 0
69 for step in steps:
70 params = {
71 'project_id': project.project_id,
72 'lower_bound': step,
73 'upper_bound': min(step + BLOCK, highest_id + 1),
74 'shard_id': shard_id,
75 }
76 task = cloud_tasks_helpers.generate_simple_task(
77 urls.RECOMPUTE_DERIVED_FIELDS_TASK + '.do', params)
78 cloud_tasks_helpers.create_task(
79 task, queue=features_constants.QUEUE_RECOMPUTE_DERIVED_FIELDS)
80
81 shard_id = (shard_id + 1) % settings.num_logical_shards
82
83
84def RecomputeAllDerivedFieldsNow(
85 cnxn, services, project, config, lower_bound=None, upper_bound=None):
86 """Re-apply all filter rules to all issues in a project.
87
88 Args:
89 cnxn: connection to SQL database.
90 services: connections to persistence layer.
91 project: Project PB for the project that was changed.
92 config: ProjectIssueConfig for that project.
93 lower_bound: optional int lowest issue ID to consider, inclusive.
94 upper_bound: optional int highest issue ID to consider, exclusive.
95
96 SIDE-EFFECT: updates all issues in the project. Stores and re-indexes
97 all those that were changed.
98 """
99 if lower_bound is not None and upper_bound is not None:
100 issues = services.issue.GetIssuesByLocalIDs(
101 cnxn, project.project_id, list(range(lower_bound, upper_bound)),
102 use_cache=False)
103 else:
104 issues = services.issue.GetAllIssuesInProject(
105 cnxn, project.project_id, use_cache=False)
106
107 rules = services.features.GetFilterRules(cnxn, project.project_id)
108 predicate_asts = ParsePredicateASTs(rules, config, [])
109 modified_issues = []
110 for issue in issues:
111 any_change, _traces = ApplyGivenRules(
112 cnxn, services, issue, config, rules, predicate_asts)
113 if any_change:
114 modified_issues.append(issue)
115
116 services.issue.UpdateIssues(cnxn, modified_issues, just_derived=True)
117
118 # Doing the FTS indexing can be too slow, so queue up the issues
119 # that need to be re-indexed by a cron-job later.
120 services.issue.EnqueueIssuesForIndexing(
121 cnxn, [issue.issue_id for issue in modified_issues])
122
123
124def ParsePredicateASTs(rules, config, me_user_ids):
125 """Parse the given rules in QueryAST PBs."""
126 predicates = [rule.predicate for rule in rules]
127 if me_user_ids:
128 predicates = [
129 searchpipeline.ReplaceKeywordsWithUserIDs(me_user_ids, pred)[0]
130 for pred in predicates]
131 predicate_asts = [
132 query2ast.ParseUserQuery(pred, '', query2ast.BUILTIN_ISSUE_FIELDS, config)
133 for pred in predicates]
134 return predicate_asts
135
136
137def ApplyFilterRules(cnxn, services, issue, config):
138 """Apply the filter rules for this project to the given issue.
139
140 Args:
141 cnxn: database connection, used to look up user IDs.
142 services: persistence layer for users, issues, and projects.
143 issue: An Issue PB that has just been updated with new explicit values.
144 config: The project's issue tracker config PB.
145
146 Returns:
147 A pair (any_changes, traces) where any_changes is true if any changes
148 were made to the issue derived fields, and traces is a dictionary
149 {(field_id, new_value): explanation_str} of traces that
150 explain which rule generated each derived value.
151
152 SIDE-EFFECT: update the derived_* fields of the Issue PB.
153 """
154 rules = services.features.GetFilterRules(cnxn, issue.project_id)
155 predicate_asts = ParsePredicateASTs(rules, config, [])
156 return ApplyGivenRules(cnxn, services, issue, config, rules, predicate_asts)
157
158
159def ApplyGivenRules(cnxn, services, issue, config, rules, predicate_asts):
160 """Apply the filter rules for this project to the given issue.
161
162 Args:
163 cnxn: database connection, used to look up user IDs.
164 services: persistence layer for users, issues, and projects.
165 issue: An Issue PB that has just been updated with new explicit values.
166 config: The project's issue tracker config PB.
167 rules: list of FilterRule PBs.
168
169 Returns:
170 A pair (any_changes, traces) where any_changes is true if any changes
171 were made to the issue derived fields, and traces is a dictionary
172 {(field_id, new_value): explanation_str} of traces that
173 explain which rule generated each derived value.
174
175 SIDE-EFFECT: update the derived_* fields of the Issue PB.
176 """
177 (derived_owner_id, derived_status, derived_cc_ids,
178 derived_labels, derived_notify_addrs, traces,
179 new_warnings, new_errors) = _ComputeDerivedFields(
180 cnxn, services, issue, config, rules, predicate_asts)
181
182 any_change = (derived_owner_id != issue.derived_owner_id or
183 derived_status != issue.derived_status or
184 derived_cc_ids != issue.derived_cc_ids or
185 derived_labels != issue.derived_labels or
186 derived_notify_addrs != issue.derived_notify_addrs)
187
188 # Remember any derived values.
189 issue.derived_owner_id = derived_owner_id
190 issue.derived_status = derived_status
191 issue.derived_cc_ids = derived_cc_ids
192 issue.derived_labels = derived_labels
193 issue.derived_notify_addrs = derived_notify_addrs
194 issue.derived_warnings = new_warnings
195 issue.derived_errors = new_errors
196
197 return any_change, traces
198
199
200def _ComputeDerivedFields(cnxn, services, issue, config, rules, predicate_asts):
201 """Compute derived field values for an issue based on filter rules.
202
203 Args:
204 cnxn: database connection, used to look up user IDs.
205 services: persistence layer for users, issues, and projects.
206 issue: the issue to examine.
207 config: ProjectIssueConfig for the project containing the issue.
208 rules: list of FilterRule PBs.
209 predicate_asts: QueryAST PB for each rule.
210
211 Returns:
212 A 8-tuple of derived values for owner_id, status, cc_ids, labels,
213 notify_addrs, traces, warnings, and errors. These values are the result
214 of applying all rules in order. Filter rules only produce derived values
215 that do not conflict with the explicit field values of the issue.
216 """
217 excl_prefixes = [
218 prefix.lower() for prefix in config.exclusive_label_prefixes]
219 # Examine the explicit labels and Cc's on the issue.
220 lower_labels = [lab.lower() for lab in issue.labels]
221 label_set = set(lower_labels)
222 cc_set = set(issue.cc_ids)
223 excl_prefixes_used = set()
224 for lab in lower_labels:
225 prefix = lab.split('-')[0]
226 if prefix in excl_prefixes:
227 excl_prefixes_used.add(prefix)
228 prefix_values_added = {}
229
230 # Start with the assumption that rules don't change anything, then
231 # accumulate changes.
232 derived_owner_id = framework_constants.NO_USER_SPECIFIED
233 derived_status = ''
234 derived_cc_ids = []
235 derived_labels = []
236 derived_notify_addrs = []
237 traces = {} # {(field_id, new_value): explanation_str}
238 new_warnings = []
239 new_errors = []
240
241 def AddLabelConsideringExclusivePrefixes(label):
242 lab_lower = label.lower()
243 if lab_lower in label_set:
244 return False # We already have that label.
245 prefix = lab_lower.split('-')[0]
246 if '-' in lab_lower and prefix in excl_prefixes:
247 if prefix in excl_prefixes_used:
248 return False # Issue already has that prefix.
249 # Replace any earlied-added label that had the same exclusive prefix.
250 if prefix in prefix_values_added:
251 label_set.remove(prefix_values_added[prefix].lower())
252 derived_labels.remove(prefix_values_added[prefix])
253 prefix_values_added[prefix] = label
254
255 derived_labels.append(label)
256 label_set.add(lab_lower)
257 return True
258
259 # Apply component labels and auto-cc's before doing the rules.
260 components = tracker_bizobj.GetIssueComponentsAndAncestors(issue, config)
261 for cd in components:
262 for cc_id in cd.cc_ids:
263 if cc_id not in cc_set:
264 derived_cc_ids.append(cc_id)
265 cc_set.add(cc_id)
266 traces[(tracker_pb2.FieldID.CC, cc_id)] = (
267 'Added by component %s' % cd.path)
268
269 for label_id in cd.label_ids:
270 lab = services.config.LookupLabel(cnxn, config.project_id, label_id)
271 if AddLabelConsideringExclusivePrefixes(lab):
272 traces[(tracker_pb2.FieldID.LABELS, lab)] = (
273 'Added by component %s' % cd.path)
274
275 # Apply each rule in order. Later rules see the results of earlier rules.
276 # Later rules can overwrite or add to results of earlier rules.
277 # TODO(jrobbins): also pass in in-progress values for owner and CCs so
278 # that early rules that set those can affect later rules that check them.
279 for rule, predicate_ast in zip(rules, predicate_asts):
280 (rule_owner_id, rule_status, rule_add_cc_ids,
281 rule_add_labels, rule_add_notify, rule_add_warning,
282 rule_add_error) = _ApplyRule(
283 cnxn, services, rule, predicate_ast, issue, label_set, config)
284
285 # logging.info(
286 # 'rule "%s" gave %r, %r, %r, %r, %r',
287 # rule.predicate, rule_owner_id, rule_status, rule_add_cc_ids,
288 # rule_add_labels, rule_add_notify)
289
290 if rule_owner_id and not issue.owner_id:
291 derived_owner_id = rule_owner_id
292 traces[(tracker_pb2.FieldID.OWNER, rule_owner_id)] = (
293 'Added by rule: IF %s THEN SET DEFAULT OWNER' % rule.predicate)
294
295 if rule_status and not issue.status:
296 derived_status = rule_status
297 traces[(tracker_pb2.FieldID.STATUS, rule_status)] = (
298 'Added by rule: IF %s THEN SET DEFAULT STATUS' % rule.predicate)
299
300 for cc_id in rule_add_cc_ids:
301 if cc_id not in cc_set:
302 derived_cc_ids.append(cc_id)
303 cc_set.add(cc_id)
304 traces[(tracker_pb2.FieldID.CC, cc_id)] = (
305 'Added by rule: IF %s THEN ADD CC' % rule.predicate)
306
307 for lab in rule_add_labels:
308 if AddLabelConsideringExclusivePrefixes(lab):
309 traces[(tracker_pb2.FieldID.LABELS, lab)] = (
310 'Added by rule: IF %s THEN ADD LABEL' % rule.predicate)
311
312 for addr in rule_add_notify:
313 if addr not in derived_notify_addrs:
314 derived_notify_addrs.append(addr)
315 # Note: No trace because also-notify addresses are not shown in the UI.
316
317 if rule_add_warning:
318 new_warnings.append(rule_add_warning)
319 traces[(tracker_pb2.FieldID.WARNING, rule_add_warning)] = (
320 'Added by rule: IF %s THEN ADD WARNING' % rule.predicate)
321
322 if rule_add_error:
323 new_errors.append(rule_add_error)
324 traces[(tracker_pb2.FieldID.ERROR, rule_add_error)] = (
325 'Added by rule: IF %s THEN ADD ERROR' % rule.predicate)
326
327 return (derived_owner_id, derived_status, derived_cc_ids, derived_labels,
328 derived_notify_addrs, traces, new_warnings, new_errors)
329
330
331def EvalPredicate(
332 cnxn, services, predicate_ast, issue, label_set, config, owner_id, cc_ids,
333 status):
334 """Return True if the given issue satisfies the given predicate.
335
336 Args:
337 cnxn: Connection to SQL database.
338 services: persistence layer for users and issues.
339 predicate_ast: QueryAST for rule or saved query string.
340 issue: Issue PB of the issue to evaluate.
341 label_set: set of lower-cased labels on the issue.
342 config: ProjectIssueConfig for the project that contains the issue.
343 owner_id: int user ID of the issue owner.
344 cc_ids: list of int user IDs of the users Cc'd on the issue.
345 status: string status value of the issue.
346
347 Returns:
348 True if the issue satisfies the predicate.
349
350 Note: filter rule evaluation passes in only the explicit owner_id,
351 cc_ids, and status whereas subscription evaluation passes in the
352 combination of explicit values and derived values.
353 """
354 # TODO(jrobbins): Call ast2ast to simplify the predicate and do
355 # most lookups. Refactor to allow that to be done once.
356 project = services.project.GetProject(cnxn, config.project_id)
357 for conj in predicate_ast.conjunctions:
358 if all(_ApplyCond(cnxn, services, project, cond, issue, label_set, config,
359 owner_id, cc_ids, status)
360 for cond in conj.conds):
361 return True
362
363 # All OR-clauses were evaluated, but none of them was matched.
364 return False
365
366
367def _ApplyRule(
368 cnxn, services, rule_pb, predicate_ast, issue, label_set, config):
369 """Test if the given rule should fire and return its result.
370
371 Args:
372 cnxn: database connection, used to look up user IDs.
373 services: persistence layer for users and issues.
374 rule_pb: FilterRule PB instance with a predicate and various actions.
375 predicate_ast: QueryAST for the rule predicate.
376 issue: The Issue PB to be considered.
377 label_set: set of lowercased labels from an issue's explicit
378 label_list plus and labels that have accumlated from previous rules.
379 config: ProjectIssueConfig for the project containing the issue.
380
381 Returns:
382 A 6-tuple of the results from this rule: derived owner id, status,
383 cc_ids to add, labels to add, notify addresses to add, and a warning
384 string. Currently only one will be set and the others will all be
385 None or an empty list.
386 """
387 if EvalPredicate(
388 cnxn, services, predicate_ast, issue, label_set, config,
389 issue.owner_id, issue.cc_ids, issue.status):
390 logging.info('rule adds: %r', rule_pb.add_labels)
391 return (rule_pb.default_owner_id, rule_pb.default_status,
392 rule_pb.add_cc_ids, rule_pb.add_labels,
393 rule_pb.add_notify_addrs, rule_pb.warning, rule_pb.error)
394 else:
395 return None, None, [], [], [], None, None
396
397
398def _ApplyCond(
399 cnxn, services, project, term, issue, label_set, config, owner_id, cc_ids,
400 status):
401 """Return True if the given issue satisfied the given predicate term."""
402 op = term.op
403 vals = term.str_values or term.int_values
404 # Since rules are per-project, there'll be exactly 1 field
405 fd = term.field_defs[0]
406 field = fd.field_name
407
408 if field == 'label':
409 return _Compare(op, vals, label_set)
410 if field == 'component':
411 return _CompareComponents(config, op, vals, issue.component_ids)
412 if field == 'any_field':
413 return _Compare(op, vals, label_set) or _Compare(op, vals, [issue.summary])
414 if field == 'attachments':
415 return _Compare(op, term.int_values, [issue.attachment_count])
416 if field == 'blocked':
417 return _Compare(op, vals, issue.blocked_on_iids)
418 if field == 'blockedon':
419 return _CompareIssueRefs(
420 cnxn, services, project, op, term.str_values, issue.blocked_on_iids)
421 if field == 'blocking':
422 return _CompareIssueRefs(
423 cnxn, services, project, op, term.str_values, issue.blocking_iids)
424 if field == 'cc':
425 return _CompareUsers(cnxn, services.user, op, vals, cc_ids)
426 if field == 'closed':
427 return (issue.closed_timestamp and
428 _Compare(op, vals, [issue.closed_timestamp]))
429 if field == 'id':
430 return _Compare(op, vals, [issue.local_id])
431 if field == 'mergedinto':
432 return _CompareIssueRefs(
433 cnxn, services, project, op, term.str_values, [issue.merged_into or 0])
434 if field == 'modified':
435 return (issue.modified_timestamp and
436 _Compare(op, vals, [issue.modified_timestamp]))
437 if field == 'open':
438 # TODO(jrobbins): this just checks the explicit status, not the result
439 # of any previous rules.
440 return tracker_helpers.MeansOpenInProject(status, config)
441 if field == 'opened':
442 return (issue.opened_timestamp and
443 _Compare(op, vals, [issue.opened_timestamp]))
444 if field == 'owner':
445 return _CompareUsers(cnxn, services.user, op, vals, [owner_id])
446 if field == 'reporter':
447 return _CompareUsers(cnxn, services.user, op, vals, [issue.reporter_id])
448 if field == 'stars':
449 return _Compare(op, term.int_values, [issue.star_count])
450 if field == 'status':
451 return _Compare(op, vals, [status.lower()])
452 if field == 'summary':
453 return _Compare(op, vals, [issue.summary])
454
455 # Since rules are per-project, it makes no sense to support field project.
456 # We would need to load comments to support fields comment, commentby,
457 # description, attachment.
458 # Supporting starredby is probably not worth the complexity.
459
460 logging.info('Rule with unsupported field %r was False', field)
461 return False
462
463
464def _CheckTrivialCases(op, issue_values):
465 """Check has:x and -has:x terms and no values. Otherwise, return None."""
466 # We can do these operators without looking up anything or even knowing
467 # which field is being checked.
468 issue_values_exist = bool(
469 issue_values and issue_values != [''] and issue_values != [0])
470 if op == ast_pb2.QueryOp.IS_DEFINED:
471 return issue_values_exist
472 elif op == ast_pb2.QueryOp.IS_NOT_DEFINED:
473 return not issue_values_exist
474 elif not issue_values_exist:
475 # No other operator can match empty values.
476 return op in (ast_pb2.QueryOp.NE, ast_pb2.QueryOp.NOT_TEXT_HAS)
477
478 return None # Caller should continue processing the term.
479
480def _CompareComponents(config, op, rule_values, issue_values):
481 """Compare the components specified in the rule vs those in the issue."""
482 trivial_result = _CheckTrivialCases(op, issue_values)
483 if trivial_result is not None:
484 return trivial_result
485
486 exact = op in (ast_pb2.QueryOp.EQ, ast_pb2.QueryOp.NE)
487 rule_component_ids = set()
488 for path in rule_values:
489 rule_component_ids.update(tracker_bizobj.FindMatchingComponentIDs(
490 path, config, exact=exact))
491
492 if op == ast_pb2.QueryOp.TEXT_HAS or op == ast_pb2.QueryOp.EQ:
493 return any(rv in issue_values for rv in rule_component_ids)
494 elif op == ast_pb2.QueryOp.NOT_TEXT_HAS or op == ast_pb2.QueryOp.NE:
495 return all(rv not in issue_values for rv in rule_component_ids)
496
497 return False
498
499
500def _CompareIssueRefs(
501 cnxn, services, project, op, rule_str_values, issue_values):
502 """Compare the issues specified in the rule vs referenced in the issue."""
503 trivial_result = _CheckTrivialCases(op, issue_values)
504 if trivial_result is not None:
505 return trivial_result
506
507 rule_refs = []
508 for str_val in rule_str_values:
509 ref = tracker_bizobj.ParseIssueRef(str_val)
510 if ref:
511 rule_refs.append(ref)
512 rule_ref_project_names = set(
513 pn for pn, local_id in rule_refs if pn)
514 rule_ref_projects_dict = services.project.GetProjectsByName(
515 cnxn, rule_ref_project_names)
516 rule_ref_projects_dict[project.project_name] = project
517 rule_iids, _misses = services.issue.ResolveIssueRefs(
518 cnxn, rule_ref_projects_dict, project.project_name, rule_refs)
519
520 if op == ast_pb2.QueryOp.TEXT_HAS:
521 op = ast_pb2.QueryOp.EQ
522 if op == ast_pb2.QueryOp.NOT_TEXT_HAS:
523 op = ast_pb2.QueryOp.NE
524
525 return _Compare(op, rule_iids, issue_values)
526
527
528def _CompareUsers(cnxn, user_service, op, rule_values, issue_values):
529 """Compare the user(s) specified in the rule and the issue."""
530 # Note that all occurances of "me" in rule_values should have already
531 # been resolved to str(user_id) of the subscribing user.
532 # TODO(jrobbins): Project filter rules should not be allowed to have "me".
533
534 trivial_result = _CheckTrivialCases(op, issue_values)
535 if trivial_result is not None:
536 return trivial_result
537
538 try:
539 return _CompareUserIDs(op, rule_values, issue_values)
540 except ValueError:
541 return _CompareEmails(cnxn, user_service, op, rule_values, issue_values)
542
543
544def _CompareUserIDs(op, rule_values, issue_values):
545 """Compare users according to specified user ID integer strings."""
546 rule_user_ids = [int(uid_str) for uid_str in rule_values]
547
548 if op == ast_pb2.QueryOp.TEXT_HAS or op == ast_pb2.QueryOp.EQ:
549 return any(rv in issue_values for rv in rule_user_ids)
550 elif op == ast_pb2.QueryOp.NOT_TEXT_HAS or op == ast_pb2.QueryOp.NE:
551 return all(rv not in issue_values for rv in rule_user_ids)
552
553 logging.info('unexpected numeric user operator %r %r %r',
554 op, rule_values, issue_values)
555 return False
556
557
558def _CompareEmails(cnxn, user_service, op, rule_values, issue_values):
559 """Compare users based on email addresses."""
560 issue_emails = list(
561 user_service.LookupUserEmails(cnxn, issue_values).values())
562
563 if op == ast_pb2.QueryOp.TEXT_HAS:
564 return any(_HasText(rv, issue_emails) for rv in rule_values)
565 elif op == ast_pb2.QueryOp.NOT_TEXT_HAS:
566 return all(not _HasText(rv, issue_emails) for rv in rule_values)
567 elif op == ast_pb2.QueryOp.EQ:
568 return any(rv in issue_emails for rv in rule_values)
569 elif op == ast_pb2.QueryOp.NE:
570 return all(rv not in issue_emails for rv in rule_values)
571
572 logging.info('unexpected user operator %r %r %r',
573 op, rule_values, issue_values)
574 return False
575
576
577def _Compare(op, rule_values, issue_values):
578 """Compare the values specified in the rule and the issue."""
579 trivial_result = _CheckTrivialCases(op, issue_values)
580 if trivial_result is not None:
581 return trivial_result
582
583 if (op in [ast_pb2.QueryOp.TEXT_HAS, ast_pb2.QueryOp.NOT_TEXT_HAS] and
584 issue_values and not isinstance(min(issue_values), string_types)):
585 return False # Empty or numeric fields cannot match substrings
586 elif op == ast_pb2.QueryOp.TEXT_HAS:
587 return any(_HasText(rv, issue_values) for rv in rule_values)
588 elif op == ast_pb2.QueryOp.NOT_TEXT_HAS:
589 return all(not _HasText(rv, issue_values) for rv in rule_values)
590
591 val_type = type(min(issue_values))
592 if val_type in six.integer_types:
593 try:
594 rule_values = [int(rv) for rv in rule_values]
595 except ValueError:
596 logging.info('rule value conversion to int failed: %r', rule_values)
597 return False
598
599 if op == ast_pb2.QueryOp.EQ:
600 return any(rv in issue_values for rv in rule_values)
601 elif op == ast_pb2.QueryOp.NE:
602 return all(rv not in issue_values for rv in rule_values)
603
604 if val_type not in six.integer_types:
605 return False # Inequalities only work on numeric fields
606
607 if op == ast_pb2.QueryOp.GT:
608 return min(issue_values) > min(rule_values)
609 elif op == ast_pb2.QueryOp.GE:
610 return min(issue_values) >= min(rule_values)
611 elif op == ast_pb2.QueryOp.LT:
612 return max(issue_values) < max(rule_values)
613 elif op == ast_pb2.QueryOp.LE:
614 return max(issue_values) <= max(rule_values)
615
616 logging.info('unexpected operator %r %r %r', op, rule_values, issue_values)
617 return False
618
619
620def _HasText(rule_text, issue_values):
621 """Return True if the issue contains the rule text, case insensitive."""
622 rule_lower = rule_text.lower()
623 for iv in issue_values:
624 if iv is not None and rule_lower in iv.lower():
625 return True
626
627 return False
628
629
630def MakeRule(
631 predicate, default_status=None, default_owner_id=None, add_cc_ids=None,
632 add_labels=None, add_notify=None, warning=None, error=None):
633 """Make a FilterRule PB with the supplied information.
634
635 Args:
636 predicate: string query that will trigger the rule if satisfied.
637 default_status: optional default status to set if rule fires.
638 default_owner_id: optional default owner_id to set if rule fires.
639 add_cc_ids: optional cc ids to set if rule fires.
640 add_labels: optional label strings to set if rule fires.
641 add_notify: optional notify email addresses to set if rule fires.
642 warning: optional string for a software development process warning.
643 error: optional string for a software development process error.
644
645 Returns:
646 A new FilterRule PB.
647 """
648 rule_pb = tracker_pb2.FilterRule()
649 rule_pb.predicate = predicate
650
651 if add_labels:
652 rule_pb.add_labels = add_labels
653 if default_status:
654 rule_pb.default_status = default_status
655 if default_owner_id:
656 rule_pb.default_owner_id = default_owner_id
657 if add_cc_ids:
658 rule_pb.add_cc_ids = add_cc_ids
659 if add_notify:
660 rule_pb.add_notify_addrs = add_notify
661 if warning:
662 rule_pb.warning = warning
663 if error:
664 rule_pb.error = error
665
666 return rule_pb
667
668
669def ParseRules(cnxn, post_data, user_service, errors, prefix=''):
670 """Parse rules from the user and return a list of FilterRule PBs.
671
672 Args:
673 cnxn: connection to database.
674 post_data: dictionary of html form data.
675 user_service: connection to user backend services.
676 errors: EZTErrors message used to display field validation errors.
677 prefix: optional string prefix used to differentiate the form fields
678 for existing rules from the form fields for new rules.
679
680 Returns:
681 A list of FilterRule PBs
682 """
683 rules = []
684
685 # The best we can do for now is show all validation errors at the bottom of
686 # the filter rules section, not directly on the rule that had the error :(.
687 error_list = []
688
689 for i in range(1, MAX_RULES + 1):
690 if ('%spredicate%s' % (prefix, i)) not in post_data:
691 continue # skip any entries that are blank or have no predicate.
692 predicate = post_data['%spredicate%s' % (prefix, i)].strip()
693 action_type = post_data.get('%saction_type%s' % (prefix, i),
694 'add_labels').strip()
695 action_value = post_data.get('%saction_value%s' % (prefix, i),
696 '').strip()
697 if predicate:
698 # Note: action_value may be '', meaning no-op.
699 rules.append(_ParseOneRule(
700 cnxn, predicate, action_type, action_value, user_service, i,
701 error_list))
702
703 if error_list:
704 errors.rules = error_list
705
706 return rules
707
708
709def _ParseOneRule(
710 cnxn, predicate, action_type, action_value, user_service,
711 rule_num, error_list):
712 """Parse one FilterRule based on the action type."""
713
714 if action_type == 'default_status':
715 status = framework_bizobj.CanonicalizeLabel(action_value)
716 rule = MakeRule(predicate, default_status=status)
717
718 elif action_type == 'default_owner':
719 if action_value:
720 try:
721 user_id = user_service.LookupUserID(cnxn, action_value)
722 except exceptions.NoSuchUserException:
723 user_id = framework_constants.NO_USER_SPECIFIED
724 error_list.append(
725 'Rule %d: No such user: %s' % (rule_num, action_value))
726 else:
727 user_id = framework_constants.NO_USER_SPECIFIED
728 rule = MakeRule(predicate, default_owner_id=user_id)
729
730 elif action_type == 'add_ccs':
731 cc_ids = []
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100732 for email in re.split(r'[,;\s]+', action_value):
Copybara854996b2021-09-07 19:36:02 +0000733 if not email.strip():
734 continue
735 try:
736 user_id = user_service.LookupUserID(
737 cnxn, email.strip(), autocreate=True)
738 cc_ids.append(user_id)
739 except exceptions.NoSuchUserException:
740 error_list.append(
741 'Rule %d: No such user: %s' % (rule_num, email.strip()))
742
743 rule = MakeRule(predicate, add_cc_ids=cc_ids)
744
745 elif action_type == 'add_labels':
746 add_labels = framework_constants.IDENTIFIER_RE.findall(action_value)
747 rule = MakeRule(predicate, add_labels=add_labels)
748
749 elif action_type == 'also_notify':
750 add_notify = []
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100751 for addr in re.split(r'[,;\s]+', action_value):
Copybara854996b2021-09-07 19:36:02 +0000752 if validate.IsValidEmail(addr.strip()):
753 add_notify.append(addr.strip())
754 else:
755 error_list.append(
756 'Rule %d: Invalid email address: %s' % (rule_num, addr.strip()))
757
758 rule = MakeRule(predicate, add_notify=add_notify)
759
760 elif action_type == 'warning':
761 rule = MakeRule(predicate, warning=action_value)
762
763 elif action_type == 'error':
764 rule = MakeRule(predicate, error=action_value)
765
766 else:
767 logging.info('unexpected action type, probably tampering:%r', action_type)
768 raise exceptions.InputException()
769
770 return rule
771
772
773def OwnerCcsInvolvedInFilterRules(rules):
774 """Finds all user_ids in the given rules and returns them.
775
776 Args:
777 rules: a list of FilterRule PBs.
778
779 Returns:
780 A set of user_ids.
781 """
782 user_ids = set()
783 for rule in rules:
784 if rule.default_owner_id:
785 user_ids.add(rule.default_owner_id)
786 user_ids.update(rule.add_cc_ids)
787 return user_ids
788
789
790def BuildFilterRuleStrings(filter_rules, emails_by_id):
791 """Builds strings that represent filter rules.
792
793 Args:
794 filter_rules: a list of FilterRule PBs.
795 emails_by_id: a dict of {user_id: email, ..} of user_ids in the FilterRules.
796
797 Returns:
798 A list of strings each representing a FilterRule.
799 eg. "if predicate then consequence"
800 """
801 rule_strs = []
802 for rule in filter_rules:
803 cons = ""
804 if rule.add_labels:
805 cons = 'add label(s): %s' % ', '.join(rule.add_labels)
806 elif rule.default_status:
807 cons = 'set default status: %s' % rule.default_status
808 elif rule.default_owner_id:
809 cons = 'set default owner: %s' % emails_by_id.get(
810 rule.default_owner_id, 'user not found')
811 elif rule.add_cc_ids:
812 cons = 'add cc(s): %s' % ', '.join(
813 [emails_by_id.get(user_id, 'user not found')
814 for user_id in rule.add_cc_ids])
815 elif rule.add_notify_addrs:
816 cons = 'notify: %s' % ', '.join(rule.add_notify_addrs)
817
818 rule_strs.append('if %s then %s' % (rule.predicate, cons))
819
820 return rule_strs
821
822
823def BuildRedactedFilterRuleStrings(
824 cnxn, rules_by_project, user_service, hide_emails):
825 """Converts FilterRule PBs in strings that hide references to hide_emails.
826
827 Args:
828 rules_by_project: a dict of {project_id, [filter_rule, ...], ...}
829 with FilterRule PBs.
830 user_service:
831 hide_emails: a list of emails that should not be shown in rule strings.
832 """
833 rule_strs_by_project = {}
834 prohibited_re = re.compile(
835 r'\b%s\b' % r'\b|\b'.join(map(re.escape, hide_emails)))
836 for project_id, rules in rules_by_project.items():
837 user_ids_in_rules = OwnerCcsInvolvedInFilterRules(rules)
838 emails_by_id = user_service.LookupUserEmails(
839 cnxn, user_ids_in_rules, ignore_missed=True)
840 rule_strs = BuildFilterRuleStrings(rules, emails_by_id)
841 censored_strs = [
842 prohibited_re.sub(framework_constants.DELETED_USER_NAME, rule_str)
843 for rule_str in rule_strs]
844
845 rule_strs_by_project[project_id] = censored_strs
846
847 return rule_strs_by_project