blob: 9d38b6751adc310e3368000a72144e26dab4ddc4 [file] [log] [blame]
# Copyright 2016 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Implementation of the filter rules helper functions."""
from __future__ import print_function
from __future__ import division
from __future__ import absolute_import
import logging
import re
import six
from six import string_types
import settings
from features import features_constants
from framework import cloud_tasks_helpers
from framework import exceptions
from framework import framework_bizobj
from framework import framework_constants
from framework import urls
from framework import validate
from mrproto import ast_pb2
from mrproto import tracker_pb2
from search import query2ast
from search import searchpipeline
from tracker import component_helpers
from tracker import tracker_bizobj
from tracker import tracker_constants
from tracker import tracker_helpers
# Maximum number of filer rules that can be specified in a given
# project. This helps us bound the amount of time needed to
# (re)compute derived fields.
MAX_RULES = 250
BLOCK = tracker_constants.RECOMPUTE_DERIVED_FIELDS_BLOCK_SIZE
# TODO(jrobbins): implement a more efficient way to update just those
# issues affected by a specific component change.
def RecomputeAllDerivedFields(cnxn, services, project, config):
"""Create work items to update all issues after filter rule changes.
Args:
cnxn: connection to SQL database.
services: connections to backend services.
project: Project PB for the project that was edited.
config: ProjectIssueConfig PB for the project that was edited,
including the edits made.
"""
if not settings.recompute_derived_fields_in_worker:
# Background tasks are not enabled, just do everything in the servlet.
RecomputeAllDerivedFieldsNow(cnxn, services, project, config)
return
highest_id = services.issue.GetHighestLocalID(cnxn, project.project_id)
if highest_id == 0:
return # No work to do.
# Enqueue work items for blocks of issues to recompute.
steps = list(range(1, highest_id + 1, BLOCK))
steps.reverse() # Update higher numbered issues sooner, old issues last.
# Cycle through shard_ids just to load-balance among the replicas. Each
# block includes all issues in that local_id range, not just 1/10 of them.
shard_id = 0
for step in steps:
params = {
'project_id': project.project_id,
'lower_bound': step,
'upper_bound': min(step + BLOCK, highest_id + 1),
'shard_id': shard_id,
}
task = cloud_tasks_helpers.generate_simple_task(
urls.RECOMPUTE_DERIVED_FIELDS_TASK + '.do', params)
cloud_tasks_helpers.create_task(
task, queue=features_constants.QUEUE_RECOMPUTE_DERIVED_FIELDS)
shard_id = (shard_id + 1) % settings.num_logical_shards
def RecomputeAllDerivedFieldsNow(
cnxn, services, project, config, lower_bound=None, upper_bound=None):
"""Re-apply all filter rules to all issues in a project.
Args:
cnxn: connection to SQL database.
services: connections to persistence layer.
project: Project PB for the project that was changed.
config: ProjectIssueConfig for that project.
lower_bound: optional int lowest issue ID to consider, inclusive.
upper_bound: optional int highest issue ID to consider, exclusive.
SIDE-EFFECT: updates all issues in the project. Stores and re-indexes
all those that were changed.
"""
if lower_bound is not None and upper_bound is not None:
issues = services.issue.GetIssuesByLocalIDs(
cnxn, project.project_id, list(range(lower_bound, upper_bound)),
use_cache=False)
else:
issues = services.issue.GetAllIssuesInProject(
cnxn, project.project_id, use_cache=False)
rules = services.features.GetFilterRules(cnxn, project.project_id)
predicate_asts = ParsePredicateASTs(rules, config, [])
modified_issues = []
for issue in issues:
any_change, _traces = ApplyGivenRules(
cnxn, services, issue, config, rules, predicate_asts)
if any_change:
modified_issues.append(issue)
services.issue.UpdateIssues(cnxn, modified_issues, just_derived=True)
# Doing the FTS indexing can be too slow, so queue up the issues
# that need to be re-indexed by a cron-job later.
services.issue.EnqueueIssuesForIndexing(
cnxn, [issue.issue_id for issue in modified_issues])
def ParsePredicateASTs(rules, config, me_user_ids):
"""Parse the given rules in QueryAST PBs."""
predicates = [rule.predicate for rule in rules]
if me_user_ids:
predicates = [
searchpipeline.ReplaceKeywordsWithUserIDs(me_user_ids, pred)[0]
for pred in predicates]
predicate_asts = [
query2ast.ParseUserQuery(pred, '', query2ast.BUILTIN_ISSUE_FIELDS, config)
for pred in predicates]
return predicate_asts
def ApplyFilterRules(cnxn, services, issue, config):
"""Apply the filter rules for this project to the given issue.
Args:
cnxn: database connection, used to look up user IDs.
services: persistence layer for users, issues, and projects.
issue: An Issue PB that has just been updated with new explicit values.
config: The project's issue tracker config PB.
Returns:
A pair (any_changes, traces) where any_changes is true if any changes
were made to the issue derived fields, and traces is a dictionary
{(field_id, new_value): explanation_str} of traces that
explain which rule generated each derived value.
SIDE-EFFECT: update the derived_* fields of the Issue PB.
"""
rules = services.features.GetFilterRules(cnxn, issue.project_id)
predicate_asts = ParsePredicateASTs(rules, config, [])
return ApplyGivenRules(cnxn, services, issue, config, rules, predicate_asts)
def ApplyGivenRules(cnxn, services, issue, config, rules, predicate_asts):
"""Apply the filter rules for this project to the given issue.
Args:
cnxn: database connection, used to look up user IDs.
services: persistence layer for users, issues, and projects.
issue: An Issue PB that has just been updated with new explicit values.
config: The project's issue tracker config PB.
rules: list of FilterRule PBs.
Returns:
A pair (any_changes, traces) where any_changes is true if any changes
were made to the issue derived fields, and traces is a dictionary
{(field_id, new_value): explanation_str} of traces that
explain which rule generated each derived value.
SIDE-EFFECT: update the derived_* fields of the Issue PB.
"""
(derived_owner_id, derived_status, derived_cc_ids,
derived_labels, derived_notify_addrs, traces,
new_warnings, new_errors) = _ComputeDerivedFields(
cnxn, services, issue, config, rules, predicate_asts)
any_change = (derived_owner_id != issue.derived_owner_id or
derived_status != issue.derived_status or
derived_cc_ids != issue.derived_cc_ids or
derived_labels != issue.derived_labels or
derived_notify_addrs != issue.derived_notify_addrs)
# Remember any derived values.
issue.derived_owner_id = derived_owner_id
issue.derived_status = derived_status
issue.derived_cc_ids = derived_cc_ids
issue.derived_labels = derived_labels
issue.derived_notify_addrs = derived_notify_addrs
issue.derived_warnings = new_warnings
issue.derived_errors = new_errors
return any_change, traces
def _ComputeDerivedFields(cnxn, services, issue, config, rules, predicate_asts):
"""Compute derived field values for an issue based on filter rules.
Args:
cnxn: database connection, used to look up user IDs.
services: persistence layer for users, issues, and projects.
issue: the issue to examine.
config: ProjectIssueConfig for the project containing the issue.
rules: list of FilterRule PBs.
predicate_asts: QueryAST PB for each rule.
Returns:
A 8-tuple of derived values for owner_id, status, cc_ids, labels,
notify_addrs, traces, warnings, and errors. These values are the result
of applying all rules in order. Filter rules only produce derived values
that do not conflict with the explicit field values of the issue.
"""
excl_prefixes = [
prefix.lower() for prefix in config.exclusive_label_prefixes]
# Examine the explicit labels and Cc's on the issue.
lower_labels = [lab.lower() for lab in issue.labels]
label_set = set(lower_labels)
cc_set = set(issue.cc_ids)
excl_prefixes_used = set()
for lab in lower_labels:
prefix = lab.split('-')[0]
if prefix in excl_prefixes:
excl_prefixes_used.add(prefix)
prefix_values_added = {}
# Start with the assumption that rules don't change anything, then
# accumulate changes.
derived_owner_id = framework_constants.NO_USER_SPECIFIED
derived_status = ''
derived_cc_ids = []
derived_labels = []
derived_notify_addrs = []
traces = {} # {(field_id, new_value): explanation_str}
new_warnings = []
new_errors = []
def AddLabelConsideringExclusivePrefixes(label):
lab_lower = label.lower()
if lab_lower in label_set:
return False # We already have that label.
prefix = lab_lower.split('-')[0]
if '-' in lab_lower and prefix in excl_prefixes:
if prefix in excl_prefixes_used:
return False # Issue already has that prefix.
# Replace any earlied-added label that had the same exclusive prefix.
if prefix in prefix_values_added:
label_set.remove(prefix_values_added[prefix].lower())
derived_labels.remove(prefix_values_added[prefix])
prefix_values_added[prefix] = label
derived_labels.append(label)
label_set.add(lab_lower)
return True
# Apply component labels and auto-cc's before doing the rules.
components = tracker_bizobj.GetIssueComponentsAndAncestors(issue, config)
for cd in components:
for cc_id in cd.cc_ids:
if cc_id not in cc_set:
derived_cc_ids.append(cc_id)
cc_set.add(cc_id)
traces[(tracker_pb2.FieldID.CC, cc_id)] = (
'Added by component %s' % cd.path)
for label_id in cd.label_ids:
lab = services.config.LookupLabel(cnxn, config.project_id, label_id)
if AddLabelConsideringExclusivePrefixes(lab):
traces[(tracker_pb2.FieldID.LABELS, lab)] = (
'Added by component %s' % cd.path)
# Apply each rule in order. Later rules see the results of earlier rules.
# Later rules can overwrite or add to results of earlier rules.
# TODO(jrobbins): also pass in in-progress values for owner and CCs so
# that early rules that set those can affect later rules that check them.
for rule, predicate_ast in zip(rules, predicate_asts):
(rule_owner_id, rule_status, rule_add_cc_ids,
rule_add_labels, rule_add_notify, rule_add_warning,
rule_add_error) = _ApplyRule(
cnxn, services, rule, predicate_ast, issue, label_set, config)
# logging.info(
# 'rule "%s" gave %r, %r, %r, %r, %r',
# rule.predicate, rule_owner_id, rule_status, rule_add_cc_ids,
# rule_add_labels, rule_add_notify)
if rule_owner_id and not issue.owner_id:
derived_owner_id = rule_owner_id
traces[(tracker_pb2.FieldID.OWNER, rule_owner_id)] = (
'Added by rule: IF %s THEN SET DEFAULT OWNER' % rule.predicate)
if rule_status and not issue.status:
derived_status = rule_status
traces[(tracker_pb2.FieldID.STATUS, rule_status)] = (
'Added by rule: IF %s THEN SET DEFAULT STATUS' % rule.predicate)
for cc_id in rule_add_cc_ids:
if cc_id not in cc_set:
derived_cc_ids.append(cc_id)
cc_set.add(cc_id)
traces[(tracker_pb2.FieldID.CC, cc_id)] = (
'Added by rule: IF %s THEN ADD CC' % rule.predicate)
for lab in rule_add_labels:
if AddLabelConsideringExclusivePrefixes(lab):
traces[(tracker_pb2.FieldID.LABELS, lab)] = (
'Added by rule: IF %s THEN ADD LABEL' % rule.predicate)
for addr in rule_add_notify:
if addr not in derived_notify_addrs:
derived_notify_addrs.append(addr)
# Note: No trace because also-notify addresses are not shown in the UI.
if rule_add_warning:
new_warnings.append(rule_add_warning)
traces[(tracker_pb2.FieldID.WARNING, rule_add_warning)] = (
'Added by rule: IF %s THEN ADD WARNING' % rule.predicate)
if rule_add_error:
new_errors.append(rule_add_error)
traces[(tracker_pb2.FieldID.ERROR, rule_add_error)] = (
'Added by rule: IF %s THEN ADD ERROR' % rule.predicate)
return (derived_owner_id, derived_status, derived_cc_ids, derived_labels,
derived_notify_addrs, traces, new_warnings, new_errors)
def EvalPredicate(
cnxn, services, predicate_ast, issue, label_set, config, owner_id, cc_ids,
status):
"""Return True if the given issue satisfies the given predicate.
Args:
cnxn: Connection to SQL database.
services: persistence layer for users and issues.
predicate_ast: QueryAST for rule or saved query string.
issue: Issue PB of the issue to evaluate.
label_set: set of lower-cased labels on the issue.
config: ProjectIssueConfig for the project that contains the issue.
owner_id: int user ID of the issue owner.
cc_ids: list of int user IDs of the users Cc'd on the issue.
status: string status value of the issue.
Returns:
True if the issue satisfies the predicate.
Note: filter rule evaluation passes in only the explicit owner_id,
cc_ids, and status whereas subscription evaluation passes in the
combination of explicit values and derived values.
"""
# TODO(jrobbins): Call ast2ast to simplify the predicate and do
# most lookups. Refactor to allow that to be done once.
project = services.project.GetProject(cnxn, config.project_id)
for conj in predicate_ast.conjunctions:
if all(_ApplyCond(cnxn, services, project, cond, issue, label_set, config,
owner_id, cc_ids, status)
for cond in conj.conds):
return True
# All OR-clauses were evaluated, but none of them was matched.
return False
def _ApplyRule(
cnxn, services, rule_pb, predicate_ast, issue, label_set, config):
"""Test if the given rule should fire and return its result.
Args:
cnxn: database connection, used to look up user IDs.
services: persistence layer for users and issues.
rule_pb: FilterRule PB instance with a predicate and various actions.
predicate_ast: QueryAST for the rule predicate.
issue: The Issue PB to be considered.
label_set: set of lowercased labels from an issue's explicit
label_list plus and labels that have accumlated from previous rules.
config: ProjectIssueConfig for the project containing the issue.
Returns:
A 6-tuple of the results from this rule: derived owner id, status,
cc_ids to add, labels to add, notify addresses to add, and a warning
string. Currently only one will be set and the others will all be
None or an empty list.
"""
if EvalPredicate(
cnxn, services, predicate_ast, issue, label_set, config,
issue.owner_id, issue.cc_ids, issue.status):
logging.info('rule adds: %r', rule_pb.add_labels)
return (rule_pb.default_owner_id, rule_pb.default_status,
rule_pb.add_cc_ids, rule_pb.add_labels,
rule_pb.add_notify_addrs, rule_pb.warning, rule_pb.error)
else:
return None, None, [], [], [], None, None
def _ApplyCond(
cnxn, services, project, term, issue, label_set, config, owner_id, cc_ids,
status):
"""Return True if the given issue satisfied the given predicate term."""
op = term.op
vals = term.str_values or term.int_values
# Since rules are per-project, there'll be exactly 1 field
fd = term.field_defs[0]
field = fd.field_name
if field == 'label':
return _Compare(op, vals, label_set)
if field == 'component':
return _CompareComponents(config, op, vals, issue.component_ids)
if field == 'any_field':
return _Compare(op, vals, label_set) or _Compare(op, vals, [issue.summary])
if field == 'attachments':
return _Compare(op, term.int_values, [issue.attachment_count])
if field == 'blocked':
return _Compare(op, vals, issue.blocked_on_iids)
if field == 'blockedon':
return _CompareIssueRefs(
cnxn, services, project, op, term.str_values, issue.blocked_on_iids)
if field == 'blocking':
return _CompareIssueRefs(
cnxn, services, project, op, term.str_values, issue.blocking_iids)
if field == 'cc':
return _CompareUsers(cnxn, services.user, op, vals, cc_ids)
if field == 'closed':
return (issue.closed_timestamp and
_Compare(op, vals, [issue.closed_timestamp]))
if field == 'id':
return _Compare(op, vals, [issue.local_id])
if field == 'mergedinto':
return _CompareIssueRefs(
cnxn, services, project, op, term.str_values, [issue.merged_into or 0])
if field == 'modified':
return (issue.modified_timestamp and
_Compare(op, vals, [issue.modified_timestamp]))
if field == 'open':
# TODO(jrobbins): this just checks the explicit status, not the result
# of any previous rules.
return tracker_helpers.MeansOpenInProject(status, config)
if field == 'opened':
return (issue.opened_timestamp and
_Compare(op, vals, [issue.opened_timestamp]))
if field == 'owner':
return _CompareUsers(cnxn, services.user, op, vals, [owner_id])
if field == 'reporter':
return _CompareUsers(cnxn, services.user, op, vals, [issue.reporter_id])
if field == 'stars':
return _Compare(op, term.int_values, [issue.star_count])
if field == 'status':
return _Compare(op, vals, [status.lower()])
if field == 'summary':
return _Compare(op, vals, [issue.summary])
# Since rules are per-project, it makes no sense to support field project.
# We would need to load comments to support fields comment, commentby,
# description, attachment.
# Supporting starredby is probably not worth the complexity.
logging.info('Rule with unsupported field %r was False', field)
return False
def _CheckTrivialCases(op, issue_values):
"""Check has:x and -has:x terms and no values. Otherwise, return None."""
# We can do these operators without looking up anything or even knowing
# which field is being checked.
issue_values_exist = bool(
issue_values and issue_values != [''] and issue_values != [0])
if op == ast_pb2.QueryOp.IS_DEFINED:
return issue_values_exist
elif op == ast_pb2.QueryOp.IS_NOT_DEFINED:
return not issue_values_exist
elif not issue_values_exist:
# No other operator can match empty values.
return op in (ast_pb2.QueryOp.NE, ast_pb2.QueryOp.NOT_TEXT_HAS)
return None # Caller should continue processing the term.
def _CompareComponents(config, op, rule_values, issue_values):
"""Compare the components specified in the rule vs those in the issue."""
trivial_result = _CheckTrivialCases(op, issue_values)
if trivial_result is not None:
return trivial_result
exact = op in (ast_pb2.QueryOp.EQ, ast_pb2.QueryOp.NE)
rule_component_ids = set()
for path in rule_values:
rule_component_ids.update(tracker_bizobj.FindMatchingComponentIDs(
path, config, exact=exact))
if op == ast_pb2.QueryOp.TEXT_HAS or op == ast_pb2.QueryOp.EQ:
return any(rv in issue_values for rv in rule_component_ids)
elif op == ast_pb2.QueryOp.NOT_TEXT_HAS or op == ast_pb2.QueryOp.NE:
return all(rv not in issue_values for rv in rule_component_ids)
return False
def _CompareIssueRefs(
cnxn, services, project, op, rule_str_values, issue_values):
"""Compare the issues specified in the rule vs referenced in the issue."""
trivial_result = _CheckTrivialCases(op, issue_values)
if trivial_result is not None:
return trivial_result
rule_refs = []
for str_val in rule_str_values:
ref = tracker_bizobj.ParseIssueRef(str_val)
if ref:
rule_refs.append(ref)
rule_ref_project_names = set(
pn for pn, local_id in rule_refs if pn)
rule_ref_projects_dict = services.project.GetProjectsByName(
cnxn, rule_ref_project_names)
rule_ref_projects_dict[project.project_name] = project
rule_iids, _misses = services.issue.ResolveIssueRefs(
cnxn, rule_ref_projects_dict, project.project_name, rule_refs)
if op == ast_pb2.QueryOp.TEXT_HAS:
op = ast_pb2.QueryOp.EQ
if op == ast_pb2.QueryOp.NOT_TEXT_HAS:
op = ast_pb2.QueryOp.NE
return _Compare(op, rule_iids, issue_values)
def _CompareUsers(cnxn, user_service, op, rule_values, issue_values):
"""Compare the user(s) specified in the rule and the issue."""
# Note that all occurances of "me" in rule_values should have already
# been resolved to str(user_id) of the subscribing user.
# TODO(jrobbins): Project filter rules should not be allowed to have "me".
trivial_result = _CheckTrivialCases(op, issue_values)
if trivial_result is not None:
return trivial_result
try:
return _CompareUserIDs(op, rule_values, issue_values)
except ValueError:
return _CompareEmails(cnxn, user_service, op, rule_values, issue_values)
def _CompareUserIDs(op, rule_values, issue_values):
"""Compare users according to specified user ID integer strings."""
rule_user_ids = [int(uid_str) for uid_str in rule_values]
if op == ast_pb2.QueryOp.TEXT_HAS or op == ast_pb2.QueryOp.EQ:
return any(rv in issue_values for rv in rule_user_ids)
elif op == ast_pb2.QueryOp.NOT_TEXT_HAS or op == ast_pb2.QueryOp.NE:
return all(rv not in issue_values for rv in rule_user_ids)
logging.info('unexpected numeric user operator %r %r %r',
op, rule_values, issue_values)
return False
def _CompareEmails(cnxn, user_service, op, rule_values, issue_values):
"""Compare users based on email addresses."""
issue_emails = list(
user_service.LookupUserEmails(cnxn, issue_values).values())
if op == ast_pb2.QueryOp.TEXT_HAS:
return any(_HasText(rv, issue_emails) for rv in rule_values)
elif op == ast_pb2.QueryOp.NOT_TEXT_HAS:
return all(not _HasText(rv, issue_emails) for rv in rule_values)
elif op == ast_pb2.QueryOp.EQ:
return any(rv in issue_emails for rv in rule_values)
elif op == ast_pb2.QueryOp.NE:
return all(rv not in issue_emails for rv in rule_values)
logging.info('unexpected user operator %r %r %r',
op, rule_values, issue_values)
return False
def _Compare(op, rule_values, issue_values):
"""Compare the values specified in the rule and the issue."""
trivial_result = _CheckTrivialCases(op, issue_values)
if trivial_result is not None:
return trivial_result
if (op in [ast_pb2.QueryOp.TEXT_HAS, ast_pb2.QueryOp.NOT_TEXT_HAS] and
issue_values and not isinstance(min(issue_values), string_types)):
return False # Empty or numeric fields cannot match substrings
elif op == ast_pb2.QueryOp.TEXT_HAS:
return any(_HasText(rv, issue_values) for rv in rule_values)
elif op == ast_pb2.QueryOp.NOT_TEXT_HAS:
return all(not _HasText(rv, issue_values) for rv in rule_values)
val_type = type(min(issue_values))
if val_type in six.integer_types:
try:
rule_values = [int(rv) for rv in rule_values]
except ValueError:
logging.info('rule value conversion to int failed: %r', rule_values)
return False
if op == ast_pb2.QueryOp.EQ:
return any(rv in issue_values for rv in rule_values)
elif op == ast_pb2.QueryOp.NE:
return all(rv not in issue_values for rv in rule_values)
if val_type not in six.integer_types:
return False # Inequalities only work on numeric fields
if op == ast_pb2.QueryOp.GT:
return min(issue_values) > min(rule_values)
elif op == ast_pb2.QueryOp.GE:
return min(issue_values) >= min(rule_values)
elif op == ast_pb2.QueryOp.LT:
return max(issue_values) < max(rule_values)
elif op == ast_pb2.QueryOp.LE:
return max(issue_values) <= max(rule_values)
logging.info('unexpected operator %r %r %r', op, rule_values, issue_values)
return False
def _HasText(rule_text, issue_values):
"""Return True if the issue contains the rule text, case insensitive."""
rule_lower = rule_text.lower()
for iv in issue_values:
if iv is not None and rule_lower in iv.lower():
return True
return False
def MakeRule(
predicate, default_status=None, default_owner_id=None, add_cc_ids=None,
add_labels=None, add_notify=None, warning=None, error=None):
"""Make a FilterRule PB with the supplied information.
Args:
predicate: string query that will trigger the rule if satisfied.
default_status: optional default status to set if rule fires.
default_owner_id: optional default owner_id to set if rule fires.
add_cc_ids: optional cc ids to set if rule fires.
add_labels: optional label strings to set if rule fires.
add_notify: optional notify email addresses to set if rule fires.
warning: optional string for a software development process warning.
error: optional string for a software development process error.
Returns:
A new FilterRule PB.
"""
rule_pb = tracker_pb2.FilterRule()
rule_pb.predicate = predicate
if add_labels:
rule_pb.add_labels = add_labels
if default_status:
rule_pb.default_status = default_status
if default_owner_id:
rule_pb.default_owner_id = default_owner_id
if add_cc_ids:
rule_pb.add_cc_ids = add_cc_ids
if add_notify:
rule_pb.add_notify_addrs = add_notify
if warning:
rule_pb.warning = warning
if error:
rule_pb.error = error
return rule_pb
def ParseRules(cnxn, post_data, user_service, errors, prefix=''):
"""Parse rules from the user and return a list of FilterRule PBs.
Args:
cnxn: connection to database.
post_data: dictionary of html form data.
user_service: connection to user backend services.
errors: EZTErrors message used to display field validation errors.
prefix: optional string prefix used to differentiate the form fields
for existing rules from the form fields for new rules.
Returns:
A list of FilterRule PBs
"""
rules = []
# The best we can do for now is show all validation errors at the bottom of
# the filter rules section, not directly on the rule that had the error :(.
error_list = []
for i in range(1, MAX_RULES + 1):
if ('%spredicate%s' % (prefix, i)) not in post_data:
continue # skip any entries that are blank or have no predicate.
predicate = post_data['%spredicate%s' % (prefix, i)].strip()
action_type = post_data.get('%saction_type%s' % (prefix, i),
'add_labels').strip()
action_value = post_data.get('%saction_value%s' % (prefix, i),
'').strip()
if predicate:
# Note: action_value may be '', meaning no-op.
rules.append(_ParseOneRule(
cnxn, predicate, action_type, action_value, user_service, i,
error_list))
if error_list:
errors.rules = error_list
return rules
def _ParseOneRule(
cnxn, predicate, action_type, action_value, user_service,
rule_num, error_list):
"""Parse one FilterRule based on the action type."""
if action_type == 'default_status':
status = framework_bizobj.CanonicalizeLabel(action_value)
rule = MakeRule(predicate, default_status=status)
elif action_type == 'default_owner':
if action_value:
try:
user_id = user_service.LookupUserID(cnxn, action_value)
except exceptions.NoSuchUserException:
user_id = framework_constants.NO_USER_SPECIFIED
error_list.append(
'Rule %d: No such user: %s' % (rule_num, action_value))
else:
user_id = framework_constants.NO_USER_SPECIFIED
rule = MakeRule(predicate, default_owner_id=user_id)
elif action_type == 'add_ccs':
cc_ids = []
for email in re.split(r'[,;\s]+', action_value):
if not email.strip():
continue
try:
user_id = user_service.LookupUserID(
cnxn, email.strip(), autocreate=True)
cc_ids.append(user_id)
except exceptions.NoSuchUserException:
error_list.append(
'Rule %d: No such user: %s' % (rule_num, email.strip()))
rule = MakeRule(predicate, add_cc_ids=cc_ids)
elif action_type == 'add_labels':
add_labels = framework_constants.IDENTIFIER_RE.findall(action_value)
rule = MakeRule(predicate, add_labels=add_labels)
elif action_type == 'also_notify':
add_notify = []
for addr in re.split(r'[,;\s]+', action_value):
if validate.IsValidEmail(addr.strip()):
add_notify.append(addr.strip())
else:
error_list.append(
'Rule %d: Invalid email address: %s' % (rule_num, addr.strip()))
rule = MakeRule(predicate, add_notify=add_notify)
elif action_type == 'warning':
rule = MakeRule(predicate, warning=action_value)
elif action_type == 'error':
rule = MakeRule(predicate, error=action_value)
else:
logging.info('unexpected action type, probably tampering:%r', action_type)
raise exceptions.InputException()
return rule
def OwnerCcsInvolvedInFilterRules(rules):
"""Finds all user_ids in the given rules and returns them.
Args:
rules: a list of FilterRule PBs.
Returns:
A set of user_ids.
"""
user_ids = set()
for rule in rules:
if rule.default_owner_id:
user_ids.add(rule.default_owner_id)
user_ids.update(rule.add_cc_ids)
return user_ids
def BuildFilterRuleStrings(filter_rules, emails_by_id):
"""Builds strings that represent filter rules.
Args:
filter_rules: a list of FilterRule PBs.
emails_by_id: a dict of {user_id: email, ..} of user_ids in the FilterRules.
Returns:
A list of strings each representing a FilterRule.
eg. "if predicate then consequence"
"""
rule_strs = []
for rule in filter_rules:
cons = ""
if rule.add_labels:
cons = 'add label(s): %s' % ', '.join(rule.add_labels)
elif rule.default_status:
cons = 'set default status: %s' % rule.default_status
elif rule.default_owner_id:
cons = 'set default owner: %s' % emails_by_id.get(
rule.default_owner_id, 'user not found')
elif rule.add_cc_ids:
cons = 'add cc(s): %s' % ', '.join(
[emails_by_id.get(user_id, 'user not found')
for user_id in rule.add_cc_ids])
elif rule.add_notify_addrs:
cons = 'notify: %s' % ', '.join(rule.add_notify_addrs)
rule_strs.append('if %s then %s' % (rule.predicate, cons))
return rule_strs
def BuildRedactedFilterRuleStrings(
cnxn, rules_by_project, user_service, hide_emails):
"""Converts FilterRule PBs in strings that hide references to hide_emails.
Args:
rules_by_project: a dict of {project_id, [filter_rule, ...], ...}
with FilterRule PBs.
user_service:
hide_emails: a list of emails that should not be shown in rule strings.
"""
rule_strs_by_project = {}
prohibited_re = re.compile(
r'\b%s\b' % r'\b|\b'.join(map(re.escape, hide_emails)))
for project_id, rules in rules_by_project.items():
user_ids_in_rules = OwnerCcsInvolvedInFilterRules(rules)
emails_by_id = user_service.LookupUserEmails(
cnxn, user_ids_in_rules, ignore_missed=True)
rule_strs = BuildFilterRuleStrings(rules, emails_by_id)
censored_strs = [
prohibited_re.sub(framework_constants.DELETED_USER_NAME, rule_str)
for rule_str in rule_strs]
rule_strs_by_project[project_id] = censored_strs
return rule_strs_by_project