Project import generated by Copybara.
GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/features/__init__.py b/features/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/features/__init__.py
@@ -0,0 +1 @@
+
diff --git a/features/activities.py b/features/activities.py
new file mode 100644
index 0000000..35c6a64
--- /dev/null
+++ b/features/activities.py
@@ -0,0 +1,285 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Code to support project and user activies pages."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import time
+
+import ezt
+
+from framework import framework_constants
+from framework import framework_helpers
+from framework import framework_views
+from framework import sql
+from framework import template_helpers
+from framework import timestr
+from project import project_views
+from proto import tracker_pb2
+from tracker import tracker_helpers
+from tracker import tracker_views
+
+UPDATES_PER_PAGE = 50
+MAX_UPDATES_PER_PAGE = 200
+
+
+class ActivityView(template_helpers.PBProxy):
+ """EZT-friendly wrapper for Activities."""
+
+ _TITLE_TEMPLATE = template_helpers.MonorailTemplate(
+ framework_constants.TEMPLATE_PATH + 'features/activity-title.ezt',
+ compress_whitespace=True, base_format=ezt.FORMAT_HTML)
+
+ _BODY_TEMPLATE = template_helpers.MonorailTemplate(
+ framework_constants.TEMPLATE_PATH + 'features/activity-body.ezt',
+ compress_whitespace=True, base_format=ezt.FORMAT_HTML)
+
+ def __init__(
+ self, pb, mr, prefetched_issues, users_by_id,
+ prefetched_projects, prefetched_configs,
+ autolink=None, all_ref_artifacts=None, ending=None, highlight=None):
+ """Constructs an ActivityView out of an Activity protocol buffer.
+
+ Args:
+ pb: an IssueComment or Activity protocol buffer.
+ mr: HTTP request info, used by the artifact autolink.
+ prefetched_issues: dictionary of the issues for the comments being shown.
+ users_by_id: dict {user_id: UserView} for all relevant users.
+ prefetched_projects: dict {project_id: project} including all the projects
+ that we might need.
+ prefetched_configs: dict {project_id: config} for those projects.
+ autolink: Autolink instance.
+ all_ref_artifacts: list of all artifacts in the activity stream.
+ ending: ending type for activity titles, 'in_project' or 'by_user'
+ highlight: what to highlight in the middle column on user updates pages
+ i.e. 'project', 'user', or None
+ """
+ template_helpers.PBProxy.__init__(self, pb)
+
+ activity_type = 'ProjectIssueUpdate' # TODO(jrobbins): more types
+
+ self.comment = None
+ self.issue = None
+ self.field_changed = None
+ self.multiple_fields_changed = ezt.boolean(False)
+ self.project = None
+ self.user = None
+ self.timestamp = time.time() # Bogus value makes bad ones highly visible.
+
+ if isinstance(pb, tracker_pb2.IssueComment):
+ self.timestamp = pb.timestamp
+ issue = prefetched_issues[pb.issue_id]
+ if self.timestamp == issue.opened_timestamp:
+ issue_change_id = None # This comment is the description.
+ else:
+ issue_change_id = pb.timestamp # instead of seq num.
+
+ self.comment = tracker_views.IssueCommentView(
+ mr.project_name, pb, users_by_id, autolink,
+ all_ref_artifacts, mr, issue)
+
+ # TODO(jrobbins): pass effective_ids of the commenter so that they
+ # can be identified as a project member or not.
+ config = prefetched_configs[issue.project_id]
+ self.issue = tracker_views.IssueView(issue, users_by_id, config)
+ self.user = self.comment.creator
+ project = prefetched_projects[issue.project_id]
+ self.project_name = project.project_name
+ self.project = project_views.ProjectView(project)
+
+ else:
+ logging.warn('unknown activity object %r', pb)
+
+ nested_page_data = {
+ 'activity_type': activity_type,
+ 'issue_change_id': issue_change_id,
+ 'comment': self.comment,
+ 'issue': self.issue,
+ 'project': self.project,
+ 'user': self.user,
+ 'timestamp': self.timestamp,
+ 'ending_type': ending,
+ }
+
+ self.escaped_title = self._TITLE_TEMPLATE.GetResponse(
+ nested_page_data).strip()
+ self.escaped_body = self._BODY_TEMPLATE.GetResponse(
+ nested_page_data).strip()
+
+ if autolink is not None and all_ref_artifacts is not None:
+ # TODO(jrobbins): actually parse the comment text. Actually render runs.
+ runs = autolink.MarkupAutolinks(
+ mr, [template_helpers.TextRun(self.escaped_body)], all_ref_artifacts)
+ self.escaped_body = ''.join(run.content for run in runs)
+
+ self.date_bucket, self.date_relative = timestr.GetHumanScaleDate(
+ self.timestamp)
+ time_tuple = time.localtime(self.timestamp)
+ self.date_tooltip = time.asctime(time_tuple)
+
+ # We always highlight the user for starring activities
+ if activity_type.startswith('UserStar'):
+ self.highlight = 'user'
+ else:
+ self.highlight = highlight
+
+
+def GatherUpdatesData(
+ services, mr, project_ids=None, user_ids=None, ending=None,
+ updates_page_url=None, autolink=None, highlight=None):
+ """Gathers and returns updates data.
+
+ Args:
+ services: Connections to backend services.
+ mr: HTTP request info, used by the artifact autolink.
+ project_ids: List of project IDs we want updates for.
+ user_ids: List of user IDs we want updates for.
+ ending: Ending type for activity titles, 'in_project' or 'by_user'.
+ updates_page_url: The URL that will be used to create pagination links from.
+ autolink: Autolink instance.
+ highlight: What to highlight in the middle column on user updates pages
+ i.e. 'project', 'user', or None.
+ """
+ # num should be non-negative number
+ num = mr.GetPositiveIntParam('num', UPDATES_PER_PAGE)
+ num = min(num, MAX_UPDATES_PER_PAGE)
+
+ updates_data = {
+ 'no_stars': None,
+ 'no_activities': None,
+ 'pagination': None,
+ 'updates_data': None,
+ 'ending_type': ending,
+ }
+
+ if not user_ids and not project_ids:
+ updates_data['no_stars'] = ezt.boolean(True)
+ return updates_data
+
+ ascending = bool(mr.after)
+ with mr.profiler.Phase('get activities'):
+ comments = services.issue.GetIssueActivity(mr.cnxn, num=num,
+ before=mr.before, after=mr.after, project_ids=project_ids,
+ user_ids=user_ids, ascending=ascending)
+ # Filter the comments based on permission to view the issue.
+ # TODO(jrobbins): push permission checking in the query so that
+ # pagination pages never become underfilled, or use backends to shard.
+ # TODO(jrobbins): come back to this when I implement private comments.
+ # TODO(jrobbins): it would be better if we could just get the dict directly.
+ prefetched_issues_list = services.issue.GetIssues(
+ mr.cnxn, {c.issue_id for c in comments})
+ prefetched_issues = {
+ issue.issue_id: issue for issue in prefetched_issues_list}
+ needed_project_ids = {issue.project_id for issue
+ in prefetched_issues_list}
+ prefetched_projects = services.project.GetProjects(
+ mr.cnxn, needed_project_ids)
+ prefetched_configs = services.config.GetProjectConfigs(
+ mr.cnxn, needed_project_ids)
+ viewable_issues_list = tracker_helpers.FilterOutNonViewableIssues(
+ mr.auth.effective_ids, mr.auth.user_pb, prefetched_projects,
+ prefetched_configs, prefetched_issues_list)
+ viewable_iids = {issue.issue_id for issue in viewable_issues_list}
+ comments = [
+ c for c in comments if c.issue_id in viewable_iids]
+ if ascending:
+ comments.reverse()
+
+ amendment_user_ids = []
+ for comment in comments:
+ for amendment in comment.amendments:
+ amendment_user_ids.extend(amendment.added_user_ids)
+ amendment_user_ids.extend(amendment.removed_user_ids)
+
+ users_by_id = framework_views.MakeAllUserViews(
+ mr.cnxn, services.user, [c.user_id for c in comments],
+ amendment_user_ids)
+ framework_views.RevealAllEmailsToMembers(
+ mr.cnxn, services, mr.auth, users_by_id, mr.project)
+
+ num_results_returned = len(comments)
+ displayed_activities = comments[:UPDATES_PER_PAGE]
+
+ if not num_results_returned:
+ updates_data['no_activities'] = ezt.boolean(True)
+ return updates_data
+
+ # Get all referenced artifacts first
+ all_ref_artifacts = None
+ if autolink is not None:
+ content_list = []
+ for activity in comments:
+ content_list.append(activity.content)
+
+ all_ref_artifacts = autolink.GetAllReferencedArtifacts(
+ mr, content_list)
+
+ # Now process content and gather activities
+ today = []
+ yesterday = []
+ pastweek = []
+ pastmonth = []
+ thisyear = []
+ older = []
+
+ with mr.profiler.Phase('rendering activities'):
+ for activity in displayed_activities:
+ entry = ActivityView(
+ activity, mr, prefetched_issues, users_by_id,
+ prefetched_projects, prefetched_configs,
+ autolink=autolink, all_ref_artifacts=all_ref_artifacts, ending=ending,
+ highlight=highlight)
+
+ if entry.date_bucket == 'Today':
+ today.append(entry)
+ elif entry.date_bucket == 'Yesterday':
+ yesterday.append(entry)
+ elif entry.date_bucket == 'Last 7 days':
+ pastweek.append(entry)
+ elif entry.date_bucket == 'Last 30 days':
+ pastmonth.append(entry)
+ elif entry.date_bucket == 'Earlier this year':
+ thisyear.append(entry)
+ elif entry.date_bucket == 'Before this year':
+ older.append(entry)
+
+ new_after = None
+ new_before = None
+ if displayed_activities:
+ new_after = displayed_activities[0].timestamp
+ new_before = displayed_activities[-1].timestamp
+
+ prev_url = None
+ next_url = None
+ if updates_page_url:
+ list_servlet_rel_url = updates_page_url.split('/')[-1]
+ recognized_params = [(name, mr.GetParam(name))
+ for name in framework_helpers.RECOGNIZED_PARAMS]
+ if displayed_activities and (mr.before or mr.after):
+ prev_url = framework_helpers.FormatURL(
+ recognized_params, list_servlet_rel_url, after=new_after)
+ if mr.after or len(comments) > UPDATES_PER_PAGE:
+ next_url = framework_helpers.FormatURL(
+ recognized_params, list_servlet_rel_url, before=new_before)
+
+ if prev_url or next_url:
+ pagination = template_helpers.EZTItem(
+ start=None, last=None, prev_url=prev_url, next_url=next_url,
+ reload_url=None, visible=ezt.boolean(True), total_count=None)
+ else:
+ pagination = None
+
+ updates_data.update({
+ 'no_activities': ezt.boolean(False),
+ 'pagination': pagination,
+ 'updates_data': template_helpers.EZTItem(
+ today=today, yesterday=yesterday, pastweek=pastweek,
+ pastmonth=pastmonth, thisyear=thisyear, older=older),
+ })
+
+ return updates_data
diff --git a/features/alert2issue.py b/features/alert2issue.py
new file mode 100644
index 0000000..fbaf5d9
--- /dev/null
+++ b/features/alert2issue.py
@@ -0,0 +1,305 @@
+# Copyright 2019 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Handlers to process alert notification messages."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import itertools
+import logging
+import rfc822
+
+import settings
+from businesslogic import work_env
+from features import commitlogcommands
+from framework import framework_constants
+from framework import monorailcontext
+from framework import emailfmt
+from tracker import tracker_constants
+from tracker import tracker_helpers
+
+AlertEmailHeader = emailfmt.AlertEmailHeader
+
+
+def IsAllowlisted(email_addr):
+ """Returns whether a given email is from one of the allowlisted domains."""
+ return email_addr.endswith(settings.alert_allowlisted_suffixes)
+
+
+def IsCommentSizeReasonable(comment):
+ # type: str -> bool
+ """Returns whether a given comment string is a reasonable size."""
+ return len(comment) <= tracker_constants.MAX_COMMENT_CHARS
+
+
+def FindAlertIssue(services, cnxn, project_id, incident_label):
+ """Find the existing issue with the incident_label."""
+ if not incident_label:
+ return None
+
+ label_id = services.config.LookupLabelID(
+ cnxn, project_id, incident_label)
+ if not label_id:
+ return None
+
+ # If a new notification is sent with an existing incident ID, then it
+ # should be added as a new comment into the existing issue.
+ #
+ # If there are more than one issues with a given incident ID, then
+ # it's either
+ # - there is a bug in this module,
+ # - the issues were manually updated with the same incident ID, OR
+ # - an issue auto update program updated the issues with the same
+ # incident ID, which also sounds like a bug.
+ #
+ # In any cases, the latest issue should be used, whichever status it has.
+ # - The issue of an ongoing incident can be mistakenly closed by
+ # engineers.
+ # - A closed incident can be reopened, and, therefore, the issue also
+ # needs to be re-opened.
+ issue_ids = services.issue.GetIIDsByLabelIDs(
+ cnxn, [label_id], project_id, None)
+ issues = services.issue.GetIssues(cnxn, issue_ids)
+ if issues:
+ return max(issues, key=lambda issue: issue.modified_timestamp)
+ return None
+
+
+def GetAlertProperties(services, cnxn, project_id, incident_id, trooper_queue,
+ msg):
+ """Create a dict of issue property values for the alert to be created with.
+
+ Args:
+ cnxn: connection to SQL database.
+ project_id: the ID of the Monorail project, in which the alert should
+ be created in.
+ incident_id: string containing an optional unique incident used to
+ de-dupe alert issues.
+ trooper_queue: the label specifying the trooper queue to add an issue into.
+ msg: the email.Message object containing the alert notification.
+
+ Returns:
+ A dict of issue property values to be used for issue creation.
+ """
+ proj_config = services.config.GetProjectConfig(cnxn, project_id)
+ user_svc = services.user
+ known_labels = set(wkl.label.lower() for wkl in proj_config.well_known_labels)
+
+ props = dict(
+ owner_id=_GetOwnerID(user_svc, cnxn, msg.get(AlertEmailHeader.OWNER)),
+ cc_ids=_GetCCIDs(user_svc, cnxn, msg.get(AlertEmailHeader.CC)),
+ component_ids=_GetComponentIDs(
+ proj_config, msg.get(AlertEmailHeader.COMPONENT)),
+
+ # Props that are added as labels.
+ trooper_queue=(trooper_queue or 'Infra-Troopers-Alerts'),
+ incident_label=_GetIncidentLabel(incident_id),
+ priority=_GetPriority(known_labels, msg.get(AlertEmailHeader.PRIORITY)),
+ oses=_GetOSes(known_labels, msg.get(AlertEmailHeader.OS)),
+ issue_type=_GetIssueType(known_labels, msg.get(AlertEmailHeader.TYPE)),
+
+ field_values=[],
+ )
+
+ # Props that depend on other props.
+ props.update(
+ status=_GetStatus(proj_config, props['owner_id'],
+ msg.get(AlertEmailHeader.STATUS)),
+ labels=_GetLabels(msg.get(AlertEmailHeader.LABEL),
+ props['trooper_queue'], props['incident_label'],
+ props['priority'], props['issue_type'], props['oses']),
+ )
+
+ return props
+
+
+def ProcessEmailNotification(
+ services, cnxn, project, project_addr, from_addr, auth, subject, body,
+ incident_id, msg, trooper_queue=None):
+ # type: (...) -> None
+ """Process an alert notification email to create or update issues.""
+
+ Args:
+ cnxn: connection to SQL database.
+ project: Project PB for the project containing the issue.
+ project_addr: string email address the alert email was sent to.
+ from_addr: string email address of the user who sent the alert email
+ to our server.
+ auth: AuthData object with user_id and email address of the user who
+ will file the alert issue.
+ subject: the subject of the email message
+ body: the body text of the email message
+ incident_id: string containing an optional unique incident used to
+ de-dupe alert issues.
+ msg: the email.Message object that the notification was delivered via.
+ trooper_queue: the label specifying the trooper queue that the alert
+ notification was sent to. If not given, the notification is sent to
+ Infra-Troopers-Alerts.
+
+ Side-effect:
+ Creates an issue or issue comment, if no error was reported.
+ """
+ # Make sure the email address is allowlisted.
+ if not IsAllowlisted(from_addr):
+ logging.info('Unauthorized %s tried to send alert to %s',
+ from_addr, project_addr)
+ return
+
+ formatted_body = 'Filed by %s on behalf of %s\n\n%s' % (
+ auth.email, from_addr, body)
+ if not IsCommentSizeReasonable(formatted_body):
+ logging.info(
+ '%s tried to send an alert comment that is too long in %s', from_addr,
+ project_addr)
+ return
+
+ mc = monorailcontext.MonorailContext(services, auth=auth, cnxn=cnxn)
+ mc.LookupLoggedInUserPerms(project)
+ with work_env.WorkEnv(mc, services) as we:
+ alert_props = GetAlertProperties(
+ services, cnxn, project.project_id, incident_id, trooper_queue, msg)
+ alert_issue = FindAlertIssue(
+ services, cnxn, project.project_id, alert_props['incident_label'])
+
+ if alert_issue:
+ # Add a reply to the existing issue for this incident.
+ services.issue.CreateIssueComment(
+ cnxn, alert_issue, auth.user_id, formatted_body)
+ else:
+ # Create a new issue for this incident. To preserve previous behavior do
+ # not raise filter rule errors.
+ alert_issue, _ = we.CreateIssue(
+ project.project_id,
+ subject,
+ alert_props['status'],
+ alert_props['owner_id'],
+ alert_props['cc_ids'],
+ alert_props['labels'],
+ alert_props['field_values'],
+ alert_props['component_ids'],
+ formatted_body,
+ raise_filter_errors=False)
+
+ # Update issue using commands.
+ lines = body.strip().split('\n')
+ uia = commitlogcommands.UpdateIssueAction(alert_issue.local_id)
+ commands_found = uia.Parse(
+ cnxn, project.project_name, auth.user_id, lines,
+ services, strip_quoted_lines=True)
+
+ if commands_found:
+ uia.Run(mc, services)
+
+
+def _GetComponentIDs(proj_config, components):
+ comps = ['Infra']
+ if components:
+ components = components.strip()
+ if components:
+ comps = [c.strip() for c in components.split(',')]
+ return tracker_helpers.LookupComponentIDs(comps, proj_config)
+
+
+def _GetIncidentLabel(incident_id):
+ return 'Incident-Id-%s'.strip().lower() % incident_id if incident_id else ''
+
+
+def _GetLabels(custom_labels, trooper_queue, incident_label, priority,
+ issue_type, oses):
+ labels = set(['Restrict-View-Google'.lower()])
+ labels.update(
+ # Whitespaces in a label can cause UI rendering each of the words as
+ # a separate label.
+ ''.join(label.split()).lower() for label in itertools.chain(
+ custom_labels.split(',') if custom_labels else [],
+ [trooper_queue, incident_label, priority, issue_type],
+ oses)
+ if label
+ )
+ return list(labels)
+
+
+def _GetOwnerID(user_svc, cnxn, owner_email):
+ if owner_email:
+ owner_email = owner_email.strip()
+ if not owner_email:
+ return framework_constants.NO_USER_SPECIFIED
+ emails = [addr for _, addr in rfc822.AddressList(owner_email)]
+ return user_svc.LookupExistingUserIDs(
+ cnxn, emails).get(owner_email) or framework_constants.NO_USER_SPECIFIED
+
+
+def _GetCCIDs(user_svc, cnxn, cc_emails):
+ if cc_emails:
+ cc_emails = cc_emails.strip()
+ if not cc_emails:
+ return []
+ emails = [addr for _, addr in rfc822.AddressList(cc_emails)]
+ return [userID for _, userID
+ in user_svc.LookupExistingUserIDs(cnxn, emails).iteritems()
+ if userID is not None]
+
+
+def _GetPriority(known_labels, priority):
+ priority_label = ('Pri-%s' % priority).strip().lower()
+ if priority:
+ if priority_label in known_labels:
+ return priority_label
+ logging.info('invalid priority %s for alerts; default to pri-2', priority)
+
+ # XXX: what if 'Pri-2' doesn't exist in known_labels?
+ return 'pri-2'
+
+
+def _GetStatus(proj_config, owner_id, status):
+ # XXX: what if assigned and available are not in known_statuses?
+ if status:
+ status = status.strip().lower()
+ if owner_id:
+ # If there is an owner, the status must be 'Assigned'.
+ if status and status != 'assigned':
+ logging.info(
+ 'invalid status %s for an alert with an owner; default to assigned',
+ status)
+ return 'assigned'
+
+ if status:
+ if tracker_helpers.MeansOpenInProject(status, proj_config):
+ return status
+ logging.info('invalid status %s for an alert; default to available', status)
+
+ return 'available'
+
+
+def _GetOSes(known_labels, oses):
+ if oses:
+ oses = oses.strip().lower()
+ if not oses:
+ return []
+
+ os_labels_to_lookup = {
+ ('os-%s' % os).strip() for os in oses.split(',') if os
+ }
+ os_labels_to_return = os_labels_to_lookup & known_labels
+ invalid_os_labels = os_labels_to_lookup - os_labels_to_return
+ if invalid_os_labels:
+ logging.info('invalid OSes %s', ','.join(invalid_os_labels))
+
+ return list(os_labels_to_return)
+
+
+def _GetIssueType(known_labels, issue_type):
+ if issue_type:
+ issue_type = issue_type.strip().lower()
+ if issue_type is None:
+ return None
+
+ issue_type_label = 'type-%s' % issue_type
+ if issue_type_label in known_labels:
+ return issue_type_label
+
+ logging.info('invalid type %s for an alert; default to None', issue_type)
+ return None
diff --git a/features/autolink.py b/features/autolink.py
new file mode 100644
index 0000000..2787b9c
--- /dev/null
+++ b/features/autolink.py
@@ -0,0 +1,624 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Autolink helps auto-link references to artifacts in text.
+
+This class maintains a registry of artifact autolink syntax specs and
+callbacks. The structure of that registry is:
+ { component_name: (lookup_callback, match_to_reference_function,
+ { regex: substitution_callback, ...}),
+ ...
+ }
+
+For example:
+ { 'tracker':
+ (GetReferencedIssues,
+ ExtractProjectAndIssueIds,
+ {_ISSUE_REF_RE: ReplaceIssueRef}),
+ 'versioncontrol':
+ (GetReferencedRevisions,
+ ExtractProjectAndRevNum,
+ {_GIT_HASH_RE: ReplaceRevisionRef}),
+ }
+
+The dictionary of regexes is used here because, in the future, we
+might add more regexes for each component rather than have one complex
+regex per component.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import re
+import urllib
+import urlparse
+
+import settings
+from features import autolink_constants
+from framework import template_helpers
+from framework import validate
+from proto import project_pb2
+from tracker import tracker_helpers
+
+
+# If the total length of all comments is too large, we don't autolink.
+_MAX_TOTAL_LENGTH = 150 * 1024 # 150KB
+# Special all_referenced_artifacts value used to indicate that the
+# text content is too big to lookup all referenced artifacts quickly.
+SKIP_LOOKUPS = 'skip lookups'
+
+_CLOSING_TAG_RE = re.compile('</[a-z0-9]+>$', re.IGNORECASE)
+
+# These are allowed in links, but if any of closing delimiters appear
+# at the end of the link, and the opening one is not part of the link,
+# then trim off the closing delimiters.
+_LINK_TRAILING_CHARS = [
+ (None, ':'),
+ (None, '.'),
+ (None, ','),
+ ('(', ')'),
+ ('[', ']'),
+ ('{', '}'),
+ ('<', '>'),
+ ("'", "'"),
+ ('"', '"'),
+ ]
+
+
+def LinkifyEmail(_mr, autolink_regex_match, component_ref_artifacts):
+ """Examine a textual reference and replace it with a hyperlink or not.
+
+ This is a callback for use with the autolink feature. The function
+ parameters are standard for this type of callback.
+
+ Args:
+ _mr: unused information parsed from the HTTP request.
+ autolink_regex_match: regex match for the textual reference.
+ component_ref_artifacts: result of call to GetReferencedUsers.
+
+ Returns:
+ A list of TextRuns with tag=a linking to the user profile page of
+ any defined users, otherwise a mailto: link is generated.
+ """
+ email = autolink_regex_match.group(0)
+
+ if not validate.IsValidEmail(email):
+ return [template_helpers.TextRun(email)]
+
+ if component_ref_artifacts and email in component_ref_artifacts:
+ href = '/u/%s' % email
+ else:
+ href = 'mailto:' + email
+
+ result = [template_helpers.TextRun(email, tag='a', href=href)]
+ return result
+
+
+def CurryGetReferencedUsers(services):
+ """Return a function to get ref'd users with these services objects bound.
+
+ Currying is a convienent way to give the callback access to the services
+ objects, but without requiring that all possible services objects be passed
+ through the autolink registry and functions.
+
+ Args:
+ services: connection to the user persistence layer.
+
+ Returns:
+ A ready-to-use function that accepts the arguments that autolink
+ expects to pass to it.
+ """
+
+ def GetReferencedUsers(mr, emails):
+ """Return a dict of users referenced by these comments.
+
+ Args:
+ mr: commonly used info parsed from the request.
+ ref_tuples: email address strings for each user
+ that is mentioned in the comment text.
+
+ Returns:
+ A dictionary {email: user_pb} including all existing users.
+ """
+ user_id_dict = services.user.LookupExistingUserIDs(mr.cnxn, emails)
+ users_by_id = services.user.GetUsersByIDs(mr.cnxn,
+ list(user_id_dict.values()))
+ users_by_email = {
+ email: users_by_id[user_id]
+ for email, user_id in user_id_dict.items()}
+ return users_by_email
+
+ return GetReferencedUsers
+
+
+def Linkify(_mr, autolink_regex_match, _component_ref_artifacts):
+ """Examine a textual reference and replace it with a hyperlink or not.
+
+ This is a callback for use with the autolink feature. The function
+ parameters are standard for this type of callback.
+
+ Args:
+ _mr: unused information parsed from the HTTP request.
+ autolink_regex_match: regex match for the textual reference.
+ _component_ref_artifacts: unused result of call to GetReferencedIssues.
+
+ Returns:
+ A list of TextRuns with tag=a for all matched ftp, http, https and mailto
+ links converted into HTML hyperlinks.
+ """
+ hyperlink = autolink_regex_match.group(0)
+
+ trailing = ''
+ for begin, end in _LINK_TRAILING_CHARS:
+ if hyperlink.endswith(end):
+ if not begin or hyperlink[:-len(end)].find(begin) == -1:
+ trailing = end + trailing
+ hyperlink = hyperlink[:-len(end)]
+
+ tag_match = _CLOSING_TAG_RE.search(hyperlink)
+ if tag_match:
+ trailing = hyperlink[tag_match.start(0):] + trailing
+ hyperlink = hyperlink[:tag_match.start(0)]
+
+ href = hyperlink
+ if not href.lower().startswith(('http', 'ftp', 'mailto')):
+ # We use http because redirects for https are not all set up.
+ href = 'http://' + href
+
+ if (not validate.IsValidURL(href) and
+ not (href.startswith('mailto') and validate.IsValidEmail(href[7:]))):
+ return [template_helpers.TextRun(autolink_regex_match.group(0))]
+
+ result = [template_helpers.TextRun(hyperlink, tag='a', href=href)]
+ if trailing:
+ result.append(template_helpers.TextRun(trailing))
+
+ return result
+
+
+# Regular expression to detect git hashes.
+# Used to auto-link to Git hashes on crrev.com when displaying issue details.
+# Matches "rN", "r#N", and "revision N" when "rN" is not part of a larger word
+# and N is a hexadecimal string of 40 chars.
+_GIT_HASH_RE = re.compile(
+ r'\b(?P<prefix>r(evision\s+#?)?)?(?P<revnum>([a-f0-9]{40}))\b',
+ re.IGNORECASE | re.MULTILINE)
+
+# This is for SVN revisions and Git commit posisitons.
+_SVN_REF_RE = re.compile(
+ r'\b(?P<prefix>r(evision\s+#?)?)(?P<revnum>([0-9]{4,7}))\b',
+ re.IGNORECASE | re.MULTILINE)
+
+
+def GetReferencedRevisions(_mr, _refs):
+ """Load the referenced revision objects."""
+ # For now we just autolink any revision hash without actually
+ # checking that such a revision exists,
+ # TODO(jrobbins): Hit crrev.com and check that the revision exists
+ # and show a rollover with revision info.
+ return None
+
+
+def ExtractRevNums(_mr, autolink_regex_match):
+ """Return internal representation of a rev reference."""
+ ref = autolink_regex_match.group('revnum')
+ logging.debug('revision ref = %s', ref)
+ return [ref]
+
+
+def ReplaceRevisionRef(
+ mr, autolink_regex_match, _component_ref_artifacts):
+ """Return HTML markup for an autolink reference."""
+ prefix = autolink_regex_match.group('prefix')
+ revnum = autolink_regex_match.group('revnum')
+ url = _GetRevisionURLFormat(mr.project).format(revnum=revnum)
+ content = revnum
+ if prefix:
+ content = '%s%s' % (prefix, revnum)
+ return [template_helpers.TextRun(content, tag='a', href=url)]
+
+
+def _GetRevisionURLFormat(project):
+ # TODO(jrobbins): Expose a UI to customize it to point to whatever site
+ # hosts the source code. Also, site-wide default.
+ return (project.revision_url_format or settings.revision_url_format)
+
+
+# Regular expression to detect issue references.
+# Used to auto-link to other issues when displaying issue details.
+# Matches "issue " when "issue" is not part of a larger word, or
+# "issue #", or just a "#" when it is preceeded by a space.
+_ISSUE_REF_RE = re.compile(r"""
+ (?P<prefix>\b(issues?|bugs?)[ \t]*(:|=)?)
+ ([ \t]*(?P<project_name>\b[-a-z0-9]+[:\#])?
+ (?P<number_sign>\#?)
+ (?P<local_id>\d+)\b
+ (,?[ \t]*(and|or)?)?)+""", re.IGNORECASE | re.VERBOSE)
+
+# This is for chromium.org's crbug.com shorthand domain.
+_CRBUG_REF_RE = re.compile(r"""
+ (?P<prefix>\b(https?://)?crbug.com/)
+ ((?P<project_name>\b[-a-z0-9]+)(?P<separator>/))?
+ (?P<local_id>\d+)\b
+ (?P<anchor>\#c[0-9]+)?""", re.IGNORECASE | re.VERBOSE)
+
+# Once the overall issue reference has been detected, pick out the specific
+# issue project:id items within it. Often there is just one, but the "and|or"
+# syntax can allow multiple issues.
+_SINGLE_ISSUE_REF_RE = re.compile(r"""
+ (?P<prefix>\b(issue|bug)[ \t]*)?
+ (?P<project_name>\b[-a-z0-9]+[:\#])?
+ (?P<number_sign>\#?)
+ (?P<local_id>\d+)\b""", re.IGNORECASE | re.VERBOSE)
+
+
+def CurryGetReferencedIssues(services):
+ """Return a function to get ref'd issues with these services objects bound.
+
+ Currying is a convienent way to give the callback access to the services
+ objects, but without requiring that all possible services objects be passed
+ through the autolink registry and functions.
+
+ Args:
+ services: connection to issue, config, and project persistence layers.
+
+ Returns:
+ A ready-to-use function that accepts the arguments that autolink
+ expects to pass to it.
+ """
+
+ def GetReferencedIssues(mr, ref_tuples):
+ """Return lists of open and closed issues referenced by these comments.
+
+ Args:
+ mr: commonly used info parsed from the request.
+ ref_tuples: list of (project_name, local_id) tuples for each issue
+ that is mentioned in the comment text. The project_name may be None,
+ in which case the issue is assumed to be in the current project.
+
+ Returns:
+ A list of open and closed issue dicts.
+ """
+ ref_projects = services.project.GetProjectsByName(
+ mr.cnxn,
+ [(ref_pn or mr.project_name) for ref_pn, _ in ref_tuples])
+ issue_ids, _misses = services.issue.ResolveIssueRefs(
+ mr.cnxn, ref_projects, mr.project_name, ref_tuples)
+ open_issues, closed_issues = (
+ tracker_helpers.GetAllowedOpenedAndClosedIssues(
+ mr, issue_ids, services))
+
+ open_dict = {}
+ for issue in open_issues:
+ open_dict[_IssueProjectKey(issue.project_name, issue.local_id)] = issue
+
+ closed_dict = {}
+ for issue in closed_issues:
+ closed_dict[_IssueProjectKey(issue.project_name, issue.local_id)] = issue
+
+ logging.info('autolinking dicts %r and %r', open_dict, closed_dict)
+
+ return open_dict, closed_dict
+
+ return GetReferencedIssues
+
+
+def _ParseProjectNameMatch(project_name):
+ """Process the passed project name and determine the best representation.
+
+ Args:
+ project_name: a string with the project name matched in a regex
+
+ Returns:
+ A minimal representation of the project name, None if no valid content.
+ """
+ if not project_name:
+ return None
+ return project_name.lstrip().rstrip('#: \t\n')
+
+
+def _ExtractProjectAndIssueIds(
+ autolink_regex_match, subregex, default_project_name=None):
+ """Convert a regex match for a textual reference into our internal form."""
+ whole_str = autolink_regex_match.group(0)
+ refs = []
+ for submatch in subregex.finditer(whole_str):
+ project_name = (
+ _ParseProjectNameMatch(submatch.group('project_name')) or
+ default_project_name)
+ ref = (project_name, int(submatch.group('local_id')))
+ refs.append(ref)
+ logging.info('issue ref = %s', ref)
+
+ return refs
+
+
+def ExtractProjectAndIssueIdsNormal(_mr, autolink_regex_match):
+ """Convert a regex match for a textual reference into our internal form."""
+ return _ExtractProjectAndIssueIds(
+ autolink_regex_match, _SINGLE_ISSUE_REF_RE)
+
+
+def ExtractProjectAndIssueIdsCrBug(_mr, autolink_regex_match):
+ """Convert a regex match for a textual reference into our internal form."""
+ return _ExtractProjectAndIssueIds(
+ autolink_regex_match, _CRBUG_REF_RE, default_project_name='chromium')
+
+
+# This uses project name to avoid a lookup on project ID in a function
+# that has no services object.
+def _IssueProjectKey(project_name, local_id):
+ """Make a dictionary key to identify a referenced issue."""
+ return '%s:%d' % (project_name, local_id)
+
+
+class IssueRefRun(object):
+ """A text run that links to a referenced issue."""
+
+ def __init__(self, issue, is_closed, project_name, content, anchor):
+ self.tag = 'a'
+ self.css_class = 'closed_ref' if is_closed else None
+ self.title = issue.summary
+ self.href = '/p/%s/issues/detail?id=%d%s' % (
+ project_name, issue.local_id, anchor)
+
+ self.content = content
+ if is_closed:
+ self.content = ' %s ' % self.content
+
+
+def _ReplaceIssueRef(
+ autolink_regex_match, component_ref_artifacts, single_issue_regex,
+ default_project_name):
+ """Examine a textual reference and replace it with an autolink or not.
+
+ Args:
+ autolink_regex_match: regex match for the textual reference.
+ component_ref_artifacts: result of earlier call to GetReferencedIssues.
+ single_issue_regex: regular expression to parse individual issue references
+ out of a multi-issue-reference phrase. E.g., "issues 12 and 34".
+ default_project_name: project name to use when not specified.
+
+ Returns:
+ A list of IssueRefRuns and TextRuns to replace the textual
+ reference. If there is an issue to autolink to, we return an HTML
+ hyperlink. Otherwise, we the run will have the original plain
+ text.
+ """
+ open_dict, closed_dict = {}, {}
+ if component_ref_artifacts:
+ open_dict, closed_dict = component_ref_artifacts
+ original = autolink_regex_match.group(0)
+ logging.info('called ReplaceIssueRef on %r', original)
+ result_runs = []
+ pos = 0
+ for submatch in single_issue_regex.finditer(original):
+ if submatch.start() >= pos:
+ if original[pos: submatch.start()]:
+ result_runs.append(template_helpers.TextRun(
+ original[pos: submatch.start()]))
+ replacement_run = _ReplaceSingleIssueRef(
+ submatch, open_dict, closed_dict, default_project_name)
+ result_runs.append(replacement_run)
+ pos = submatch.end()
+
+ if original[pos:]:
+ result_runs.append(template_helpers.TextRun(original[pos:]))
+
+ return result_runs
+
+
+def ReplaceIssueRefNormal(mr, autolink_regex_match, component_ref_artifacts):
+ """Replaces occurances of 'issue 123' with link TextRuns as needed."""
+ return _ReplaceIssueRef(
+ autolink_regex_match, component_ref_artifacts,
+ _SINGLE_ISSUE_REF_RE, mr.project_name)
+
+
+def ReplaceIssueRefCrBug(_mr, autolink_regex_match, component_ref_artifacts):
+ """Replaces occurances of 'crbug.com/123' with link TextRuns as needed."""
+ return _ReplaceIssueRef(
+ autolink_regex_match, component_ref_artifacts,
+ _CRBUG_REF_RE, 'chromium')
+
+
+def _ReplaceSingleIssueRef(
+ submatch, open_dict, closed_dict, default_project_name):
+ """Replace one issue reference with a link, or the original text."""
+ content = submatch.group(0)
+ project_name = submatch.group('project_name')
+ anchor = submatch.groupdict().get('anchor') or ''
+ if project_name:
+ project_name = project_name.lstrip().rstrip(':#')
+ else:
+ # We need project_name for the URL, even if it is not in the text.
+ project_name = default_project_name
+
+ local_id = int(submatch.group('local_id'))
+ issue_key = _IssueProjectKey(project_name, local_id)
+ if issue_key in open_dict:
+ return IssueRefRun(
+ open_dict[issue_key], False, project_name, content, anchor)
+ elif issue_key in closed_dict:
+ return IssueRefRun(
+ closed_dict[issue_key], True, project_name, content, anchor)
+ else: # Don't link to non-existent issues.
+ return template_helpers.TextRun(content)
+
+
+class Autolink(object):
+ """Maintains a registry of autolink syntax and can apply it to comments."""
+
+ def __init__(self):
+ self.registry = {}
+
+ def RegisterComponent(self, component_name, artifact_lookup_function,
+ match_to_reference_function, autolink_re_subst_dict):
+ """Register all the autolink info for a software component.
+
+ Args:
+ component_name: string name of software component, must be unique.
+ artifact_lookup_function: function to batch lookup all artifacts that
+ might have been referenced in a set of comments:
+ function(all_matches) -> referenced_artifacts
+ the referenced_artifacts will be pased to each subst function.
+ match_to_reference_function: convert a regex match object to
+ some internal representation of the artifact reference.
+ autolink_re_subst_dict: dictionary of regular expressions and
+ the substitution function that should be called for each match:
+ function(match, referenced_artifacts) -> replacement_markup
+ """
+ self.registry[component_name] = (artifact_lookup_function,
+ match_to_reference_function,
+ autolink_re_subst_dict)
+
+ def GetAllReferencedArtifacts(
+ self, mr, comment_text_list, max_total_length=_MAX_TOTAL_LENGTH):
+ """Call callbacks to lookup all artifacts possibly referenced.
+
+ Args:
+ mr: information parsed out of the user HTTP request.
+ comment_text_list: list of comment content strings.
+ max_total_length: int max number of characters to accept:
+ if more than this, then skip autolinking entirely.
+
+ Returns:
+ Opaque object that can be pased to MarkupAutolinks. It's
+ structure happens to be {component_name: artifact_list, ...},
+ or the special value SKIP_LOOKUPS.
+ """
+ total_len = sum(len(comment_text) for comment_text in comment_text_list)
+ if total_len > max_total_length:
+ return SKIP_LOOKUPS
+
+ all_referenced_artifacts = {}
+ for comp, (lookup, match_to_refs, re_dict) in self.registry.items():
+ refs = set()
+ for comment_text in comment_text_list:
+ for regex in re_dict:
+ for match in regex.finditer(comment_text):
+ additional_refs = match_to_refs(mr, match)
+ if additional_refs:
+ refs.update(additional_refs)
+
+ all_referenced_artifacts[comp] = lookup(mr, refs)
+
+ return all_referenced_artifacts
+
+ def MarkupAutolinks(self, mr, text_runs, all_referenced_artifacts):
+ """Loop over components and regexes, applying all substitutions.
+
+ Args:
+ mr: info parsed from the user's HTTP request.
+ text_runs: List of text runs for the user's comment.
+ all_referenced_artifacts: result of previous call to
+ GetAllReferencedArtifacts.
+
+ Returns:
+ List of text runs for the entire user comment, some of which may have
+ attribures that cause them to render as links in render-rich-text.ezt.
+ """
+ items = list(self.registry.items())
+ items.sort() # Process components in determinate alphabetical order.
+ for component, (_lookup, _match_ref, re_subst_dict) in items:
+ if all_referenced_artifacts == SKIP_LOOKUPS:
+ component_ref_artifacts = None
+ else:
+ component_ref_artifacts = all_referenced_artifacts[component]
+ for regex, subst_fun in re_subst_dict.items():
+ text_runs = self._ApplySubstFunctionToRuns(
+ text_runs, regex, subst_fun, mr, component_ref_artifacts)
+
+ return text_runs
+
+ def _ApplySubstFunctionToRuns(
+ self, text_runs, regex, subst_fun, mr, component_ref_artifacts):
+ """Apply autolink regex and substitution function to each text run.
+
+ Args:
+ text_runs: list of TextRun objects with parts of the original comment.
+ regex: Regular expression for detecting textual references to artifacts.
+ subst_fun: function to return autolink markup, or original text.
+ mr: common info parsed from the user HTTP request.
+ component_ref_artifacts: already-looked-up destination artifacts to use
+ when computing substitution text.
+
+ Returns:
+ A new list with more and smaller runs, some of which may have tag
+ and link attributes set.
+ """
+ result_runs = []
+ for run in text_runs:
+ content = run.content
+ if run.tag:
+ # This chunk has already been substituted, don't allow nested
+ # autolinking to mess up our output.
+ result_runs.append(run)
+ else:
+ pos = 0
+ for match in regex.finditer(content):
+ if match.start() > pos:
+ result_runs.append(template_helpers.TextRun(
+ content[pos: match.start()]))
+ replacement_runs = subst_fun(mr, match, component_ref_artifacts)
+ result_runs.extend(replacement_runs)
+ pos = match.end()
+
+ if run.content[pos:]: # Keep any text that came after the last match
+ result_runs.append(template_helpers.TextRun(run.content[pos:]))
+
+ # TODO(jrobbins): ideally we would merge consecutive plain text runs
+ # so that regexes can match across those run boundaries.
+
+ return result_runs
+
+
+def RegisterAutolink(services):
+ """Register all the autolink hooks."""
+ # The order of the RegisterComponent() calls does not matter so that we could
+ # do this registration from separate modules in the future if needed.
+ # Priority order of application is determined by the names of the registered
+ # handers, which are sorted in MarkupAutolinks().
+
+ services.autolink.RegisterComponent(
+ '01-tracker-crbug',
+ CurryGetReferencedIssues(services),
+ ExtractProjectAndIssueIdsCrBug,
+ {_CRBUG_REF_RE: ReplaceIssueRefCrBug})
+
+ services.autolink.RegisterComponent(
+ '02-linkify-full-urls',
+ lambda request, mr: None,
+ lambda mr, match: None,
+ {autolink_constants.IS_A_LINK_RE: Linkify})
+
+ services.autolink.RegisterComponent(
+ '03-linkify-user-profiles-or-mailto',
+ CurryGetReferencedUsers(services),
+ lambda _mr, match: [match.group(0)],
+ {autolink_constants.IS_IMPLIED_EMAIL_RE: LinkifyEmail})
+
+ services.autolink.RegisterComponent(
+ '04-tracker-regular',
+ CurryGetReferencedIssues(services),
+ ExtractProjectAndIssueIdsNormal,
+ {_ISSUE_REF_RE: ReplaceIssueRefNormal})
+
+ services.autolink.RegisterComponent(
+ '05-linkify-shorthand',
+ lambda request, mr: None,
+ lambda mr, match: None,
+ {autolink_constants.IS_A_SHORT_LINK_RE: Linkify,
+ autolink_constants.IS_A_NUMERIC_SHORT_LINK_RE: Linkify,
+ autolink_constants.IS_IMPLIED_LINK_RE: Linkify,
+ })
+
+ services.autolink.RegisterComponent(
+ '06-versioncontrol',
+ GetReferencedRevisions,
+ ExtractRevNums,
+ {_GIT_HASH_RE: ReplaceRevisionRef,
+ _SVN_REF_RE: ReplaceRevisionRef})
diff --git a/features/autolink_constants.py b/features/autolink_constants.py
new file mode 100644
index 0000000..ddb9bb3
--- /dev/null
+++ b/features/autolink_constants.py
@@ -0,0 +1,58 @@
+# Copyright 2017 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Some constants of regexes used in Monorail to validate urls and emails."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import re
+import settings
+
+# We linkify http, https, ftp, and mailto schemes only.
+LINKIFY_SCHEMES = r'https?://|ftp://|mailto:'
+
+# This regex matches shorthand URLs that we know are valid.
+# Example: go/monorail
+# The scheme is optional, and if it is missing we add it to the link.
+IS_A_SHORT_LINK_RE = re.compile(
+ r'(?<![-/._])\b(%s)?' # Scheme is optional for short links.
+ r'(%s)' # The list of know shorthand links from settings.py
+ r'/([^\s<]+)' # Allow anything, checked with validation code.
+ % (LINKIFY_SCHEMES, '|'.join(settings.autolink_shorthand_hosts)),
+ re.UNICODE)
+IS_A_NUMERIC_SHORT_LINK_RE = re.compile(
+ r'(?<![-/._])\b(%s)?' # Scheme is optional for short links.
+ r'(%s)' # The list of know shorthand links from settings.py
+ r'/([0-9]+)' # Allow digits only for these domains.
+ % (LINKIFY_SCHEMES, '|'.join(settings.autolink_numeric_shorthand_hosts)),
+ re.UNICODE)
+
+# This regex matches fully-formed URLs, starting with a scheme.
+# Example: http://chromium.org or mailto:user@example.com
+# We link to the specified URL without adding anything.
+# Also count a start-tag '<' as a url delimeter, since the autolinker
+# is sometimes run against html fragments.
+IS_A_LINK_RE = re.compile(
+ r'\b(%s)' # Scheme must be a whole word.
+ r'([^\s<]+)' # Allow anything, checked with validation code.
+ % LINKIFY_SCHEMES, re.UNICODE)
+
+# This regex matches text that looks like a URL despite lacking a scheme.
+# Example: crrev.com
+# Since the scheme is not specified, we prepend "http://".
+IS_IMPLIED_LINK_RE = re.compile(
+ r'(?<![-/._])\b[a-z]((-|\.)?[a-z0-9])+\.(com|net|org|edu)\b' # Domain.
+ r'(/[^\s<]*)?', # Allow anything, check with validation code.
+ re.UNICODE)
+
+# This regex matches text that looks like an email address.
+# Example: user@example.com
+# These get linked to the user profile page if it exists, otherwise
+# they become a mailto:.
+IS_IMPLIED_EMAIL_RE = re.compile(
+ r'\b[a-z]((-|\.)?[a-z0-9])+@' # Username@
+ r'[a-z]((-|\.)?[a-z0-9])+\.(com|net|org|edu)\b', # Domain
+ re.UNICODE)
diff --git a/features/banspammer.py b/features/banspammer.py
new file mode 100644
index 0000000..4b66251
--- /dev/null
+++ b/features/banspammer.py
@@ -0,0 +1,97 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Classes for banning spammer users"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import json
+import time
+
+from framework import cloud_tasks_helpers
+from framework import framework_helpers
+from framework import permissions
+from framework import jsonfeed
+from framework import servlet
+from framework import urls
+
+class BanSpammer(servlet.Servlet):
+ """Ban a user and mark their content as spam"""
+
+ def AssertBasePermission(self, mr):
+ """Check whether the user has any permission to ban users.
+
+ Args:
+ mr: commonly used info parsed from the request.
+ """
+ super(BanSpammer, self).AssertBasePermission(mr)
+ if not permissions.CanBan(mr, self.services):
+ raise permissions.PermissionException(
+ 'User is not allowed to ban users.')
+
+ def ProcessFormData(self, mr, post_data):
+ self.AssertBasePermission(mr)
+ viewed_user_id = mr.viewed_user_auth.user_pb.user_id
+ reporter_id = mr.auth.user_id
+
+ # First ban or un-ban the user as a spammer.
+ framework_helpers.UserSettings.ProcessBanForm(
+ mr.cnxn, self.services.user, post_data, mr.viewed_user_auth.user_id,
+ mr.viewed_user_auth.user_pb)
+
+ params = {
+ 'spammer_id': viewed_user_id,
+ 'reporter_id': reporter_id,
+ 'is_spammer': 'banned' in post_data
+ }
+ task = cloud_tasks_helpers.generate_simple_task(
+ urls.BAN_SPAMMER_TASK + '.do', params)
+ cloud_tasks_helpers.create_task(task)
+
+ return framework_helpers.FormatAbsoluteURL(
+ mr, mr.viewed_user_auth.user_view.profile_url, include_project=False,
+ saved=1, ts=int(time.time()))
+
+
+class BanSpammerTask(jsonfeed.InternalTask):
+ """This task will update all of the comments and issues created by the
+ target user with is_spam=True, and also add a manual verdict attached
+ to the user who originated the ban request. This is a potentially long
+ running operation, so it is implemented as an async task.
+ """
+
+ def HandleRequest(self, mr):
+ spammer_id = mr.GetPositiveIntParam('spammer_id')
+ reporter_id = mr.GetPositiveIntParam('reporter_id')
+ is_spammer = mr.GetBoolParam('is_spammer')
+
+ # Get all of the issues reported by the spammer.
+ issue_ids = self.services.issue.GetIssueIDsReportedByUser(mr.cnxn,
+ spammer_id)
+
+ issues = []
+
+ if len(issue_ids) > 0:
+ issues = self.services.issue.GetIssues(
+ mr.cnxn, issue_ids, use_cache=False)
+
+ # Mark them as spam/ham in bulk.
+ self.services.spam.RecordManualIssueVerdicts(mr.cnxn, self.services.issue,
+ issues, reporter_id, is_spammer)
+
+ # Get all of the comments
+ comments = self.services.issue.GetCommentsByUser(mr.cnxn, spammer_id)
+
+ for comment in comments:
+ self.services.spam.RecordManualCommentVerdict(mr.cnxn,
+ self.services.issue, self.services.user, comment.id,
+ reporter_id, is_spammer)
+
+ self.response.body = json.dumps({
+ 'comments': len(comments),
+ 'issues': len(issues),
+ })
diff --git a/features/commands.py b/features/commands.py
new file mode 100644
index 0000000..3ba376e
--- /dev/null
+++ b/features/commands.py
@@ -0,0 +1,306 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Classes and functions that implement command-line-like issue updates."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import re
+
+from framework import exceptions
+from framework import framework_bizobj
+from framework import framework_constants
+from tracker import tracker_constants
+
+
+def ParseQuickEditCommand(
+ cnxn, cmd, issue, config, logged_in_user_id, services):
+ """Parse a quick edit command into assignments and labels."""
+ parts = _BreakCommandIntoParts(cmd)
+ parser = AssignmentParser(None, easier_kv_labels=True)
+
+ for key, value in parts:
+ if key: # A key=value assignment.
+ valid_assignment = parser.ParseAssignment(
+ cnxn, key, value, config, services, logged_in_user_id)
+ if not valid_assignment:
+ logging.info('ignoring assignment: %r, %r', key, value)
+
+ elif value.startswith('-'): # Removing a label.
+ parser.labels_remove.append(_StandardizeLabel(value[1:], config))
+
+ else: # Adding a label.
+ value = value.strip('+')
+ parser.labels_add.append(_StandardizeLabel(value, config))
+
+ new_summary = parser.summary or issue.summary
+
+ if parser.status is None:
+ new_status = issue.status
+ else:
+ new_status = parser.status
+
+ if parser.owner_id is None:
+ new_owner_id = issue.owner_id
+ else:
+ new_owner_id = parser.owner_id
+
+ new_cc_ids = [cc for cc in list(issue.cc_ids) + list(parser.cc_add)
+ if cc not in parser.cc_remove]
+ (new_labels, _update_add,
+ _update_remove) = framework_bizobj.MergeLabels(
+ issue.labels, parser.labels_add, parser.labels_remove, config)
+
+ return new_summary, new_status, new_owner_id, new_cc_ids, new_labels
+
+
+ASSIGN_COMMAND_RE = re.compile(
+ r'(?P<key>\w+(?:-|\w)*)(?:=|:)'
+ r'(?:(?P<value1>(?:-|\+|\.|%|@|=|,|\w)+)|'
+ r'"(?P<value2>[^"]+)"|'
+ r"'(?P<value3>[^']+)')",
+ re.UNICODE | re.IGNORECASE)
+
+LABEL_COMMAND_RE = re.compile(
+ r'(?P<label>(?:\+|-)?\w(?:-|\w)*)',
+ re.UNICODE | re.IGNORECASE)
+
+
+def _BreakCommandIntoParts(cmd):
+ """Break a quick edit command into assignment and label parts.
+
+ Args:
+ cmd: string command entered by the user.
+
+ Returns:
+ A list of (key, value) pairs where key is the name of the field
+ being assigned or None for OneWord labels, and value is the value
+ to assign to it, or the whole label. Value may begin with a "+"
+ which is just ignored, or a "-" meaning that the label should be
+ removed, or neither.
+ """
+ parts = []
+ cmd = cmd.strip()
+ m = True
+
+ while m:
+ m = ASSIGN_COMMAND_RE.match(cmd)
+ if m:
+ key = m.group('key')
+ value = m.group('value1') or m.group('value2') or m.group('value3')
+ parts.append((key, value))
+ cmd = cmd[len(m.group(0)):].strip()
+ else:
+ m = LABEL_COMMAND_RE.match(cmd)
+ if m:
+ parts.append((None, m.group('label')))
+ cmd = cmd[len(m.group(0)):].strip()
+
+ return parts
+
+
+def _ParsePlusMinusList(value):
+ """Parse a string containing a series of plus/minuse values.
+
+ Strings are seprated by whitespace, comma and/or semi-colon.
+
+ Example:
+ value = "one +two -three"
+ plus = ['one', 'two']
+ minus = ['three']
+
+ Args:
+ value: string containing unparsed plus minus values.
+
+ Returns:
+ A tuple of (plus, minus) string values.
+ """
+ plus = []
+ minus = []
+ # Treat ';' and ',' as separators (in addition to SPACE)
+ for ch in [',', ';']:
+ value = value.replace(ch, ' ')
+ terms = [i.strip() for i in value.split()]
+ for item in terms:
+ if item.startswith('-'):
+ minus.append(item.lstrip('-'))
+ else:
+ plus.append(item.lstrip('+')) # optional leading '+'
+
+ return plus, minus
+
+
+class AssignmentParser(object):
+ """Class to parse assignment statements in quick edits or email replies."""
+
+ def __init__(self, template, easier_kv_labels=False):
+ self.cc_list = []
+ self.cc_add = []
+ self.cc_remove = []
+ self.owner_id = None
+ self.status = None
+ self.summary = None
+ self.labels_list = []
+ self.labels_add = []
+ self.labels_remove = []
+ self.branch = None
+
+ # Accept "Anything=Anything" for quick-edit, but not in commit-log-commands
+ # because it would be too error-prone when mixed with plain text comment
+ # text and without autocomplete to help users triggering it via typos.
+ self.easier_kv_labels = easier_kv_labels
+
+ if template:
+ if template.owner_id:
+ self.owner_id = template.owner_id
+ if template.summary:
+ self.summary = template.summary
+ if template.labels:
+ self.labels_list = template.labels
+ # Do not have a similar check as above for status because it could be an
+ # empty string.
+ self.status = template.status
+
+ def ParseAssignment(self, cnxn, key, value, config, services, user_id):
+ """Parse command-style text entered by the user to update an issue.
+
+ E.g., The user may want to set the issue status to "reviewed", or
+ set the owner to "me".
+
+ Args:
+ cnxn: connection to SQL database.
+ key: string name of the field to set.
+ value: string value to be interpreted.
+ config: Projects' issue tracker configuration PB.
+ services: connections to backends.
+ user_id: int user ID of the user making the change.
+
+ Returns:
+ True if the line could be parsed as an assigment, False otherwise.
+ Also, as a side-effect, the assigned values are built up in the instance
+ variables of the parser.
+ """
+ valid_line = True
+
+ if key == 'owner':
+ if framework_constants.NO_VALUE_RE.match(value):
+ self.owner_id = framework_constants.NO_USER_SPECIFIED
+ else:
+ try:
+ self.owner_id = _LookupMeOrUsername(cnxn, value, services, user_id)
+ except exceptions.NoSuchUserException:
+ logging.warning('bad owner: %r when committing to project_id %r',
+ value, config.project_id)
+ valid_line = False
+
+ elif key == 'cc':
+ try:
+ add, remove = _ParsePlusMinusList(value)
+ self.cc_add = [_LookupMeOrUsername(cnxn, cc, services, user_id)
+ for cc in add if cc]
+ self.cc_remove = [_LookupMeOrUsername(cnxn, cc, services, user_id)
+ for cc in remove if cc]
+ for user_id in self.cc_add:
+ if user_id not in self.cc_list:
+ self.cc_list.append(user_id)
+ self.cc_list = [user_id for user_id in self.cc_list
+ if user_id not in self.cc_remove]
+ except exceptions.NoSuchUserException:
+ logging.warning('bad cc: %r when committing to project_id %r',
+ value, config.project_id)
+ valid_line = False
+
+ elif key == 'summary':
+ self.summary = value
+
+ elif key == 'status':
+ if framework_constants.NO_VALUE_RE.match(value):
+ self.status = ''
+ else:
+ self.status = _StandardizeStatus(value, config)
+
+ elif key == 'label' or key == 'labels':
+ self.labels_add, self.labels_remove = _ParsePlusMinusList(value)
+ self.labels_add = [_StandardizeLabel(lab, config)
+ for lab in self.labels_add]
+ self.labels_remove = [_StandardizeLabel(lab, config)
+ for lab in self.labels_remove]
+ (self.labels_list, _update_add,
+ _update_remove) = framework_bizobj.MergeLabels(
+ self.labels_list, self.labels_add, self.labels_remove, config)
+
+ elif (self.easier_kv_labels and
+ key not in tracker_constants.RESERVED_PREFIXES and
+ key and value):
+ if key.startswith('-'):
+ self.labels_remove.append(_StandardizeLabel(
+ '%s-%s' % (key[1:], value), config))
+ else:
+ self.labels_add.append(_StandardizeLabel(
+ '%s-%s' % (key, value), config))
+
+ else:
+ valid_line = False
+
+ return valid_line
+
+
+def _StandardizeStatus(status, config):
+ """Attempt to match a user-supplied status with standard status values.
+
+ Args:
+ status: User-supplied status string.
+ config: Project's issue tracker configuration PB.
+
+ Returns:
+ A canonicalized status string, that matches a standard project
+ value, if found.
+ """
+ well_known_statuses = [wks.status for wks in config.well_known_statuses]
+ return _StandardizeArtifact(status, well_known_statuses)
+
+
+def _StandardizeLabel(label, config):
+ """Attempt to match a user-supplied label with standard label values.
+
+ Args:
+ label: User-supplied label string.
+ config: Project's issue tracker configuration PB.
+
+ Returns:
+ A canonicalized label string, that matches a standard project
+ value, if found.
+ """
+ well_known_labels = [wkl.label for wkl in config.well_known_labels]
+ return _StandardizeArtifact(label, well_known_labels)
+
+
+def _StandardizeArtifact(artifact, well_known_artifacts):
+ """Attempt to match a user-supplied artifact with standard artifact values.
+
+ Args:
+ artifact: User-supplied artifact string.
+ well_known_artifacts: List of well known values of the artifact.
+
+ Returns:
+ A canonicalized artifact string, that matches a standard project
+ value, if found.
+ """
+ artifact = framework_bizobj.CanonicalizeLabel(artifact)
+ for wka in well_known_artifacts:
+ if artifact.lower() == wka.lower():
+ return wka
+ # No match - use user-supplied artifact.
+ return artifact
+
+
+def _LookupMeOrUsername(cnxn, username, services, user_id):
+ """Handle the 'me' syntax or lookup a user's user ID."""
+ if username.lower() == 'me':
+ return user_id
+
+ return services.user.LookupUserID(cnxn, username)
diff --git a/features/commitlogcommands.py b/features/commitlogcommands.py
new file mode 100644
index 0000000..f570ae3
--- /dev/null
+++ b/features/commitlogcommands.py
@@ -0,0 +1,162 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Implements processing of issue update command lines.
+
+This currently processes the leading command-lines that appear
+at the top of inbound email messages to update existing issues.
+
+It could also be expanded to allow new issues to be created. Or, to
+handle commands in commit-log messages if the version control system
+invokes a webhook.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import re
+
+from businesslogic import work_env
+from features import commands
+from features import send_notifications
+from framework import emailfmt
+from framework import exceptions
+from framework import framework_bizobj
+from framework import framework_helpers
+from framework import permissions
+from proto import tracker_pb2
+
+
+# Actions have separate 'Parse' and 'Run' implementations to allow better
+# testing coverage.
+class IssueAction(object):
+ """Base class for all issue commands."""
+
+ def __init__(self):
+ self.parser = commands.AssignmentParser(None)
+ self.description = ''
+ self.inbound_message = None
+ self.commenter_id = None
+ self.project = None
+ self.config = None
+ self.hostport = framework_helpers.GetHostPort()
+
+ def Parse(
+ self, cnxn, project_name, commenter_id, lines, services,
+ strip_quoted_lines=False, hostport=None):
+ """Populate object from raw user input.
+
+ Args:
+ cnxn: connection to SQL database.
+ project_name: Name of the project containing the issue.
+ commenter_id: int user ID of user creating comment.
+ lines: list of strings containing test to be parsed.
+ services: References to existing objects from Monorail's service layer.
+ strip_quoted_lines: boolean for whether to remove quoted lines from text.
+ hostport: Optionally override the current instance's hostport variable.
+
+ Returns:
+ A boolean for whether any command lines were found while parsing.
+
+ Side-effect:
+ Edits the values of instance variables in this class with parsing output.
+ """
+ self.project = services.project.GetProjectByName(cnxn, project_name)
+ self.config = services.config.GetProjectConfig(
+ cnxn, self.project.project_id)
+ self.commenter_id = commenter_id
+
+ has_commands = False
+
+ # Process all valid key-value lines. Once we find a non key-value line,
+ # treat the rest as the 'description'.
+ for idx, line in enumerate(lines):
+ valid_line = False
+ m = re.match(r'^\s*(\w+)\s*\:\s*(.*?)\s*$', line)
+ if m:
+ has_commands = True
+ # Process Key-Value
+ key = m.group(1).lower()
+ value = m.group(2)
+ valid_line = self.parser.ParseAssignment(
+ cnxn, key, value, self.config, services, self.commenter_id)
+
+ if not valid_line:
+ # Not Key-Value. Treat this line and remaining as 'description'.
+ # First strip off any trailing blank lines.
+ while lines and not lines[-1].strip():
+ lines.pop()
+ if lines:
+ self.description = '\n'.join(lines[idx:])
+ break
+
+ if strip_quoted_lines:
+ self.inbound_message = '\n'.join(lines)
+ self.description = emailfmt.StripQuotedText(self.description)
+
+ if hostport:
+ self.hostport = hostport
+
+ for key in ['owner_id', 'cc_add', 'cc_remove', 'summary',
+ 'status', 'labels_add', 'labels_remove', 'branch']:
+ logging.info('\t%s: %s', key, self.parser.__dict__[key])
+
+ for key in ['commenter_id', 'description', 'hostport']:
+ logging.info('\t%s: %s', key, self.__dict__[key])
+
+ return has_commands
+
+ def Run(self, mc, services):
+ """Execute this action."""
+ raise NotImplementedError()
+
+
+class UpdateIssueAction(IssueAction):
+ """Implements processing email replies or the "update issue" command."""
+
+ def __init__(self, local_id):
+ super(UpdateIssueAction, self).__init__()
+ self.local_id = local_id
+
+ def Run(self, mc, services):
+ """Updates an issue based on the parsed commands."""
+ try:
+ issue = services.issue.GetIssueByLocalID(
+ mc.cnxn, self.project.project_id, self.local_id, use_cache=False)
+ except exceptions.NoSuchIssueException:
+ return # Issue does not exist, so do nothing
+
+ delta = tracker_pb2.IssueDelta()
+
+ allow_edit = permissions.CanEditIssue(
+ mc.auth.effective_ids, mc.perms, self.project, issue)
+
+ if allow_edit:
+ delta.summary = self.parser.summary or issue.summary
+ if self.parser.status is None:
+ delta.status = issue.status
+ else:
+ delta.status = self.parser.status
+
+ if self.parser.owner_id is None:
+ delta.owner_id = issue.owner_id
+ else:
+ delta.owner_id = self.parser.owner_id
+
+ delta.cc_ids_add = list(self.parser.cc_add)
+ delta.cc_ids_remove = list(self.parser.cc_remove)
+ delta.labels_add = self.parser.labels_add
+ delta.labels_remove = self.parser.labels_remove
+ # TODO(jrobbins): allow editing of custom fields
+
+ with work_env.WorkEnv(mc, services) as we:
+ we.UpdateIssue(
+ issue, delta, self.description, inbound_message=self.inbound_message)
+
+ logging.info('Updated issue %s:%s',
+ self.project.project_name, issue.local_id)
+
+ # Note: notifications are generated in work_env.
diff --git a/features/component_helpers.py b/features/component_helpers.py
new file mode 100644
index 0000000..1392f0b
--- /dev/null
+++ b/features/component_helpers.py
@@ -0,0 +1,127 @@
+# Copyright 2018 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import json
+import logging
+import re
+
+import settings
+import cloudstorage
+
+from features import generate_dataset
+from framework import framework_helpers
+from services import ml_helpers
+from tracker import tracker_bizobj
+
+from googleapiclient import discovery
+from oauth2client.client import GoogleCredentials
+
+
+MODEL_NAME = 'projects/{}/models/{}'.format(
+ settings.classifier_project_id, settings.component_model_name)
+
+
+def _GetTopWords(trainer_name): # pragma: no cover
+ # TODO(carapew): Use memcache to get top words rather than storing as a
+ # variable.
+ credentials = GoogleCredentials.get_application_default()
+ storage = discovery.build('storage', 'v1', credentials=credentials)
+ request = storage.objects().get_media(
+ bucket=settings.component_ml_bucket,
+ object=trainer_name + '/topwords.txt')
+ response = request.execute()
+
+ # This turns the top words list into a dictionary for faster feature
+ # generation.
+ return {word: idx for idx, word in enumerate(response.split())}
+
+
+def _GetComponentsByIndex(trainer_name):
+ # TODO(carapew): Memcache the index mapping file.
+ mapping_path = '/%s/%s/component_index.json' % (
+ settings.component_ml_bucket, trainer_name)
+ logging.info('Mapping path full name: %r', mapping_path)
+
+ with cloudstorage.open(mapping_path, 'r') as index_mapping_file:
+ logging.info('Index component mapping opened')
+ mapping = index_mapping_file.read()
+ logging.info(mapping)
+ return json.loads(mapping)
+
+
+@framework_helpers.retry(3)
+def _GetComponentPrediction(ml_engine, instance):
+ """Predict the component from the default model based on the provided text.
+
+ Args:
+ ml_engine: An ML Engine instance for making predictions.
+ instance: The dict object returned from ml_helpers.GenerateFeaturesRaw
+ containing the features generated from the provided text.
+
+ Returns:
+ The index of the component with the highest score. ML engine's predict
+ api returns a dict of the format
+ {'predictions': [{'classes': ['0', '1', ...], 'scores': [.00234, ...]}]}
+ where each class has a score at the same index. Classes are sequential,
+ so the index of the highest score also happens to be the component's
+ index.
+ """
+ body = {'instances': [{'inputs': instance['word_features']}]}
+ request = ml_engine.projects().predict(name=MODEL_NAME, body=body)
+ response = request.execute()
+
+ logging.info('ML Engine API response: %r' % response)
+ scores = response['predictions'][0]['scores']
+
+ return scores.index(max(scores))
+
+
+def PredictComponent(raw_text, config):
+ """Get the component ID predicted for the given text.
+
+ Args:
+ raw_text: The raw text for which we want to predict a component.
+ config: The config of the project. Used to decide if the predicted component
+ is valid.
+
+ Returns:
+ The component ID predicted for the provided component, or None if no
+ component was predicted.
+ """
+ # Set-up ML engine.
+ ml_engine = ml_helpers.setup_ml_engine()
+
+ # Gets the timestamp number from the folder containing the model's trainer
+ # in order to get the correct files for mappings and features.
+ request = ml_engine.projects().models().get(name=MODEL_NAME)
+ response = request.execute()
+
+ version = re.search(r'v_(\d+)', response['defaultVersion']['name']).group(1)
+ trainer_name = 'component_trainer_%s' % version
+
+ top_words = _GetTopWords(trainer_name)
+ components_by_index = _GetComponentsByIndex(trainer_name)
+ logging.info('Length of top words list: %s', len(top_words))
+
+ clean_text = generate_dataset.CleanText(raw_text)
+ instance = ml_helpers.GenerateFeaturesRaw(
+ [clean_text], settings.component_features, top_words)
+
+ # Get the component id with the highest prediction score. Component ids are
+ # stored in GCS as strings, but represented in the app as longs.
+ best_score_index = _GetComponentPrediction(ml_engine, instance)
+ component_id = components_by_index.get(str(best_score_index))
+ if component_id:
+ component_id = int(component_id)
+
+ # The predicted component id might not exist.
+ if tracker_bizobj.FindComponentDefByID(component_id, config) is None:
+ return None
+
+ return component_id
diff --git a/features/componentexport.py b/features/componentexport.py
new file mode 100644
index 0000000..cadb6a8
--- /dev/null
+++ b/features/componentexport.py
@@ -0,0 +1,59 @@
+# Copyright 2018 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+""" Tasks and handlers for maintaining the spam classifier model. These
+ should be run via cron and task queue rather than manually.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import cloudstorage
+import datetime
+import logging
+import webapp2
+
+from google.appengine.api import app_identity
+
+from features.generate_dataset import build_component_dataset
+from framework import cloud_tasks_helpers
+from framework import servlet
+from framework import urls
+
+
+class ComponentTrainingDataExport(webapp2.RequestHandler):
+ """Trigger a training data export task"""
+ def get(self):
+ logging.info('Training data export requested.')
+ task = {
+ 'app_engine_http_request':
+ {
+ 'http_method': 'GET',
+ 'relative_uri': urls.COMPONENT_DATA_EXPORT_TASK,
+ }
+ }
+ cloud_tasks_helpers.create_task(task, queue='componentexport')
+
+
+class ComponentTrainingDataExportTask(servlet.Servlet):
+ """Export training data for issues and their assigned components, to be used
+ to train a model later.
+ """
+ def get(self):
+ logging.info('Training data export initiated.')
+ bucket_name = app_identity.get_default_gcs_bucket_name()
+ logging.info('Bucket name: %s', bucket_name)
+ date_str = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
+
+ logging.info('Opening cloud storage')
+ gcs_file = cloudstorage.open('/' + bucket_name
+ + '/component_training_data/'
+ + date_str + '.csv',
+ content_type='text/csv', mode='w')
+
+ logging.info('GCS file opened')
+
+ gcs_file = build_component_dataset(self.services.issue, gcs_file)
+
+ gcs_file.close()
diff --git a/features/dateaction.py b/features/dateaction.py
new file mode 100644
index 0000000..a525db1
--- /dev/null
+++ b/features/dateaction.py
@@ -0,0 +1,227 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Cron and task handlers for email notifications of issue date value arrival.
+
+If an issue has a date-type custom field, and that custom field is configured
+to perform an action when that date arrives, then this cron handler and the
+associated tasks carry out those actions on that issue.
+"""
+
+from __future__ import division
+from __future__ import print_function
+from __future__ import absolute_import
+
+import logging
+import time
+
+import ezt
+
+import settings
+
+from features import notify_helpers
+from features import notify_reasons
+from framework import cloud_tasks_helpers
+from framework import framework_constants
+from framework import framework_helpers
+from framework import framework_views
+from framework import jsonfeed
+from framework import permissions
+from framework import timestr
+from framework import urls
+from proto import tracker_pb2
+from tracker import tracker_bizobj
+from tracker import tracker_helpers
+from tracker import tracker_views
+
+
+TEMPLATE_PATH = framework_constants.TEMPLATE_PATH
+
+class DateActionCron(jsonfeed.InternalTask):
+ """Find and process issues with date-type values that arrived today."""
+
+ def HandleRequest(self, mr):
+ """Find issues with date-type-fields that arrived and spawn tasks."""
+ highest_iid_so_far = 0
+ capped = True
+ timestamp_min, timestamp_max = _GetTimestampRange(int(time.time()))
+ left_joins = [
+ ('Issue2FieldValue ON Issue.id = Issue2FieldValue.issue_id', []),
+ ('FieldDef ON Issue2FieldValue.field_id = FieldDef.id', []),
+ ]
+ where = [
+ ('FieldDef.field_type = %s', ['date_type']),
+ ('FieldDef.date_action IN (%s,%s)',
+ ['ping_owner_only', 'ping_participants']),
+ ('Issue2FieldValue.date_value >= %s', [timestamp_min]),
+ ('Issue2FieldValue.date_value < %s', [timestamp_max]),
+ ]
+ order_by = [
+ ('Issue.id', []),
+ ]
+ while capped:
+ chunk_issue_ids, capped = self.services.issue.RunIssueQuery(
+ mr.cnxn, left_joins,
+ where + [('Issue.id > %s', [highest_iid_so_far])], order_by)
+ if chunk_issue_ids:
+ logging.info('chunk_issue_ids = %r', chunk_issue_ids)
+ highest_iid_so_far = max(highest_iid_so_far, max(chunk_issue_ids))
+ for issue_id in chunk_issue_ids:
+ self.EnqueueDateAction(issue_id)
+
+ def EnqueueDateAction(self, issue_id):
+ """Create a task to notify users that an issue's date has arrived.
+
+ Args:
+ issue_id: int ID of the issue that was changed.
+
+ Returns nothing.
+ """
+ params = {'issue_id': issue_id}
+ task = cloud_tasks_helpers.generate_simple_task(
+ urls.ISSUE_DATE_ACTION_TASK + '.do', params)
+ cloud_tasks_helpers.create_task(task)
+
+
+def _GetTimestampRange(now):
+ """Return a (min, max) timestamp range for today."""
+ timestamp_min = (now // framework_constants.SECS_PER_DAY *
+ framework_constants.SECS_PER_DAY)
+ timestamp_max = timestamp_min + framework_constants.SECS_PER_DAY
+ return timestamp_min, timestamp_max
+
+
+class IssueDateActionTask(notify_helpers.NotifyTaskBase):
+ """JSON servlet that notifies appropriate users after an issue change."""
+
+ _EMAIL_TEMPLATE = 'features/auto-ping-email.ezt'
+ _LINK_ONLY_EMAIL_TEMPLATE = (
+ 'tracker/issue-change-notification-email-link-only.ezt')
+
+ def HandleRequest(self, mr):
+ """Process the task to process an issue date action.
+
+ Args:
+ mr: common information parsed from the HTTP request.
+
+ Returns:
+ Results dictionary in JSON format which is useful just for debugging.
+ The main goal is the side-effect of sending emails.
+ """
+ issue_id = mr.GetPositiveIntParam('issue_id')
+ issue = self.services.issue.GetIssue(mr.cnxn, issue_id, use_cache=False)
+ project = self.services.project.GetProject(mr.cnxn, issue.project_id)
+ hostport = framework_helpers.GetHostPort(project_name=project.project_name)
+ config = self.services.config.GetProjectConfig(mr.cnxn, issue.project_id)
+ pings = self._CalculateIssuePings(issue, config)
+ if not pings:
+ logging.warning('Issue %r has no dates to ping afterall?', issue_id)
+ return
+ comment = self._CreatePingComment(mr.cnxn, issue, pings, hostport)
+ starrer_ids = self.services.issue_star.LookupItemStarrers(
+ mr.cnxn, issue.issue_id)
+
+ users_by_id = framework_views.MakeAllUserViews(
+ mr.cnxn, self.services.user,
+ tracker_bizobj.UsersInvolvedInIssues([issue]),
+ tracker_bizobj.UsersInvolvedInComment(comment),
+ starrer_ids)
+ logging.info('users_by_id is %r', users_by_id)
+ tasks = self._MakeEmailTasks(
+ mr.cnxn, issue, project, config, comment, starrer_ids,
+ hostport, users_by_id, pings)
+
+ notified = notify_helpers.AddAllEmailTasks(tasks)
+ return {
+ 'notified': notified,
+ }
+
+ def _CreatePingComment(self, cnxn, issue, pings, hostport):
+ """Create an issue comment saying that some dates have arrived."""
+ content = '\n'.join(self._FormatPingLine(ping) for ping in pings)
+ author_email_addr = '%s@%s' % (settings.date_action_ping_author, hostport)
+ date_action_user_id = self.services.user.LookupUserID(
+ cnxn, author_email_addr, autocreate=True)
+ comment = self.services.issue.CreateIssueComment(
+ cnxn, issue, date_action_user_id, content)
+ return comment
+
+ def _MakeEmailTasks(
+ self, cnxn, issue, project, config, comment, starrer_ids,
+ hostport, users_by_id, pings):
+ """Return a list of dicts for tasks to notify people."""
+ detail_url = framework_helpers.IssueCommentURL(
+ hostport, project, issue.local_id, seq_num=comment.sequence)
+ fields = sorted((field_def for (field_def, _date_value) in pings),
+ key=lambda fd: fd.field_name)
+ email_data = {
+ 'issue': tracker_views.IssueView(issue, users_by_id, config),
+ 'summary': issue.summary,
+ 'ping_comment_content': comment.content,
+ 'detail_url': detail_url,
+ 'fields': fields,
+ }
+
+ # Generate three versions of email body with progressively more info.
+ body_link_only = self.link_only_email_template.GetResponse(
+ {'detail_url': detail_url, 'was_created': ezt.boolean(False)})
+ body_for_non_members = self.email_template.GetResponse(email_data)
+ framework_views.RevealAllEmails(users_by_id)
+ body_for_members = self.email_template.GetResponse(email_data)
+ logging.info('body for non-members is:\n%r' % body_for_non_members)
+ logging.info('body for members is:\n%r' % body_for_members)
+
+ contributor_could_view = permissions.CanViewIssue(
+ set(), permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET,
+ project, issue)
+
+ group_reason_list = notify_reasons.ComputeGroupReasonList(
+ cnxn, self.services, project, issue, config, users_by_id,
+ [], contributor_could_view, starrer_ids=starrer_ids,
+ commenter_in_project=True, include_subscribers=False,
+ include_notify_all=False,
+ starrer_pref_check_function=lambda u: u.notify_starred_ping)
+
+ commenter_view = users_by_id[comment.user_id]
+ email_tasks = notify_helpers.MakeBulletedEmailWorkItems(
+ group_reason_list, issue, body_link_only, body_for_non_members,
+ body_for_members, project, hostport, commenter_view, detail_url,
+ seq_num=comment.sequence, subject_prefix='Follow up on issue ',
+ compact_subject_prefix='Follow up ')
+
+ return email_tasks
+
+ def _CalculateIssuePings(self, issue, config):
+ """Return a list of (field, timestamp) pairs for dates that should ping."""
+ timestamp_min, timestamp_max = _GetTimestampRange(int(time.time()))
+ arrived_dates_by_field_id = {
+ fv.field_id: fv.date_value
+ for fv in issue.field_values
+ if timestamp_min <= fv.date_value < timestamp_max}
+ logging.info('arrived_dates_by_field_id = %r', arrived_dates_by_field_id)
+ # TODO(jrobbins): Lookup field defs regardless of project_id to better
+ # handle foreign fields in issues that have been moved between projects.
+ pings = [
+ (field, arrived_dates_by_field_id[field.field_id])
+ for field in config.field_defs
+ if (field.field_id in arrived_dates_by_field_id and
+ field.date_action in (tracker_pb2.DateAction.PING_OWNER_ONLY,
+ tracker_pb2.DateAction.PING_PARTICIPANTS))]
+
+ # TODO(jrobbins): For now, assume all pings apply only to open issues.
+ # Later, allow each date action to specify whether it applies to open
+ # issues or all issues.
+ means_open = tracker_helpers.MeansOpenInProject(
+ tracker_bizobj.GetStatus(issue), config)
+ pings = [ping for ping in pings if means_open]
+
+ pings = sorted(pings, key=lambda ping: ping[0].field_name)
+ return pings
+
+ def _FormatPingLine(self, ping):
+ """Return a one-line string describing the date that arrived."""
+ field, timestamp = ping
+ date_str = timestr.TimestampToDateWidgetStr(timestamp)
+ return 'The %s date has arrived: %s' % (field.field_name, date_str)
diff --git a/features/features_bizobj.py b/features/features_bizobj.py
new file mode 100644
index 0000000..804e6a4
--- /dev/null
+++ b/features/features_bizobj.py
@@ -0,0 +1,109 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Business objects for the Monorail features.
+
+These are classes and functions that operate on the objects that users care
+about in features (eg. hotlists).
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+
+from framework import framework_bizobj
+from framework import urls
+from proto import features_pb2
+
+
+def GetOwnerIds(hotlist):
+ """Returns the list of ids for the given hotlist's owners."""
+ return hotlist.owner_ids
+
+
+def UsersInvolvedInHotlists(hotlists):
+ """Returns a set of all users who have roles in the given hotlists."""
+ result = set()
+ for hotlist in hotlists:
+ result.update(hotlist.owner_ids)
+ result.update(hotlist.editor_ids)
+ result.update(hotlist.follower_ids)
+ return result
+
+
+def UserOwnsHotlist(hotlist, effective_ids):
+ """Returns T/F if the user is the owner/not the owner of the hotlist."""
+ return not effective_ids.isdisjoint(hotlist.owner_ids or set())
+
+
+def IssueIsInHotlist(hotlist, issue_id):
+ """Returns T/F if the issue is in the hotlist."""
+ return any(issue_id == hotlist_issue.issue_id
+ for hotlist_issue in hotlist.items)
+
+
+def UserIsInHotlist(hotlist, effective_ids):
+ """Returns T/F if the user is involved/not involved in the hotlist."""
+ return (UserOwnsHotlist(hotlist, effective_ids) or
+ not effective_ids.isdisjoint(hotlist.editor_ids or set()) or
+ not effective_ids.isdisjoint(hotlist.follower_ids or set()))
+
+
+def SplitHotlistIssueRanks(target_iid, split_above, iid_rank_pairs):
+ """Splits hotlist issue relation rankings by some target issue's rank.
+
+ Hotlists issues are sorted Low to High. When split_above is true,
+ the split should occur before the target object and the objects
+ should be moved above the target, with lower ranks than the target.
+
+ Args:
+ target_iid: the global ID of the issue to split rankings about.
+ split_above: False to split below the target issue, True to split above.
+ iid_rank_pairs: a list tuples [(issue_id, rank_in_hotlist),...} for all
+ issues in a hotlist excluding the one being moved.
+
+ Returns:
+ A tuple (lower, higher) where both are lists of [(issue_iid, rank), ...]
+ of issues in rank order. If split_above is False the target issue is
+ included in higher, otherwise it is included in lower.
+ """
+ iid_rank_pairs.reverse()
+ offset = int(not split_above)
+ for i, (issue_id, _) in enumerate(iid_rank_pairs):
+ if issue_id == target_iid:
+ return iid_rank_pairs[:i + offset], iid_rank_pairs[i + offset:]
+ logging.error(
+ 'Target issue %r was not found in the list of issue_id rank pairs',
+ target_iid)
+ return iid_rank_pairs, []
+
+
+def DetermineHotlistIssuePosition(issue, issue_ids):
+ """Find position of an issue in a hotlist for a flipper.
+
+ Args:
+ issue: The issue PB currently being viewed
+ issue_ids: list of issue_id's
+
+ Returns:
+ A 3-tuple (prev_iid, index, next_iid) where prev_iid is the
+ IID of the previous issue in the total ordering (or None),
+ index is the index that the current issue has in the sorted
+ list of issues in the hotlist,
+ next_iid is the next issue (or None).
+ """
+
+ prev_iid, next_iid = None, None
+ total_issues = len(issue_ids)
+ for i, issue_id in enumerate(issue_ids):
+ if issue_id == issue.issue_id:
+ index = i
+ if i < total_issues - 1:
+ next_iid = issue_ids[i + 1]
+ if i > 0:
+ prev_iid = issue_ids[i - 1]
+ return prev_iid, index, next_iid
+ return None, None, None
diff --git a/features/features_constants.py b/features/features_constants.py
new file mode 100644
index 0000000..b21a7f5
--- /dev/null
+++ b/features/features_constants.py
@@ -0,0 +1,44 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Some constants used in Monorail hotlist pages."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+from tracker import tracker_constants
+from project import project_constants
+
+DEFAULT_COL_SPEC = 'Rank Project Status Type ID Stars Owner Summary Modified'
+DEFAULT_RESULTS_PER_PAGE = 100
+OTHER_BUILT_IN_COLS = (
+ tracker_constants.OTHER_BUILT_IN_COLS + ['Adder', 'Added', 'Note'])
+# pylint: disable=line-too-long
+ISSUE_INPUT_REGEX = '%s:\d+(([,]|\s)+%s:\d+)*' % (
+ project_constants.PROJECT_NAME_PATTERN,
+ project_constants.PROJECT_NAME_PATTERN)
+FIELD_DEF_NAME_PATTERN = '[a-zA-Z]([_-]?[a-zA-Z0-9])*'
+
+QUEUE_NOTIFICATIONS = 'notifications'
+QUEUE_OUTBOUND_EMAIL = 'outboundemail'
+QUEUE_PUBSUB = 'pubsub-issueupdates'
+QUEUE_RECOMPUTE_DERIVED_FIELDS = 'recomputederivedfields'
+
+KNOWN_CUES = (
+ 'privacy_click_through',
+ 'code_of_conduct',
+ 'how_to_join_project',
+ 'search_for_numbers',
+ 'dit_keystrokes',
+ 'italics_mean_derived',
+ 'availability_msgs',
+ 'stale_fulltext',
+ 'document_team_duties',
+ 'showing_ids_instead_of_tiles',
+ 'issue_timestamps',
+ 'you_are_on_vacation',
+ 'your_email_bounced',
+ 'explain_hotlist_starring',
+)
diff --git a/features/federated.py b/features/federated.py
new file mode 100644
index 0000000..2e4486a
--- /dev/null
+++ b/features/federated.py
@@ -0,0 +1,78 @@
+# Copyright 2019 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Logic for storing and representing issues from external trackers."""
+
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import re
+
+from framework.exceptions import InvalidExternalIssueReference
+
+
+class FederatedIssue(object):
+ """Abstract base class for holding one federated issue.
+
+ Each distinct external tracker should subclass this.
+ """
+ shortlink_re = None
+
+ def __init__(self, shortlink):
+ if not self.IsShortlinkValid(shortlink):
+ raise InvalidExternalIssueReference(
+ 'Shortlink does not match any valid tracker: %s' % shortlink)
+
+ self.shortlink = shortlink
+
+ @classmethod
+ def IsShortlinkValid(cls, shortlink):
+ """Returns whether given shortlink is correctly formatted."""
+ if not cls.shortlink_re:
+ raise NotImplementedError()
+ return re.match(cls.shortlink_re, shortlink)
+
+
+class GoogleIssueTrackerIssue(FederatedIssue):
+ """Holds one Google Issue Tracker issue.
+
+ URL: https://issuetracker.google.com/
+ """
+ shortlink_re = r'^b\/\d+$'
+ url_format = 'https://issuetracker.google.com/issues/{issue_id}'
+
+ def __init__(self, shortlink):
+ super(GoogleIssueTrackerIssue, self).__init__(shortlink)
+ self.issue_id = int(self.shortlink[2:])
+
+ def ToURL(self):
+ return self.url_format.format(issue_id=self.issue_id)
+
+ def Summary(self):
+ """Returns a short string description for UI."""
+ return 'Google Issue Tracker issue %s.' % self.issue_id
+
+
+# All supported tracker classes.
+_federated_issue_classes = [GoogleIssueTrackerIssue]
+
+
+def IsShortlinkValid(shortlink):
+ """Returns whether the given string is valid for any issue tracker."""
+ return any(tracker_class.IsShortlinkValid(shortlink)
+ for tracker_class in _federated_issue_classes)
+
+
+def FromShortlink(shortlink):
+ """Returns a FederatedIssue for the first matching tracker.
+
+ If no matching tracker is found, returns None.
+ """
+ for tracker_class in _federated_issue_classes:
+ if tracker_class.IsShortlinkValid(shortlink):
+ return tracker_class(shortlink)
+
+ return None
diff --git a/features/filterrules.py b/features/filterrules.py
new file mode 100644
index 0000000..3b1277e
--- /dev/null
+++ b/features/filterrules.py
@@ -0,0 +1,50 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Implementation of the filter rules feature."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+
+from features import filterrules_helpers
+from framework import jsonfeed
+from tracker import tracker_constants
+
+
+class RecomputeDerivedFieldsTask(jsonfeed.InternalTask):
+ """JSON servlet that recomputes derived fields on a batch of issues."""
+
+ def HandleRequest(self, mr):
+ """Recompute derived field values on one range of issues in a shard."""
+ logging.info(
+ 'params are %r %r %r %r', mr.specified_project_id, mr.lower_bound,
+ mr.upper_bound, mr.shard_id)
+ project = self.services.project.GetProject(
+ mr.cnxn, mr.specified_project_id)
+ config = self.services.config.GetProjectConfig(
+ mr.cnxn, mr.specified_project_id)
+ filterrules_helpers.RecomputeAllDerivedFieldsNow(
+ mr.cnxn, self.services, project, config, lower_bound=mr.lower_bound,
+ upper_bound=mr.upper_bound)
+
+ return {
+ 'success': True,
+ }
+
+
+class ReindexQueueCron(jsonfeed.InternalTask):
+ """JSON servlet that reindexes some issues each minute, as needed."""
+
+ def HandleRequest(self, mr):
+ """Reindex issues that are listed in the reindex table."""
+ num_reindexed = self.services.issue.ReindexIssues(
+ mr.cnxn, tracker_constants.MAX_ISSUES_TO_REINDEX_PER_MINUTE,
+ self.services.user)
+
+ return {
+ 'num_reindexed': num_reindexed,
+ }
diff --git a/features/filterrules_helpers.py b/features/filterrules_helpers.py
new file mode 100644
index 0000000..22acc7d
--- /dev/null
+++ b/features/filterrules_helpers.py
@@ -0,0 +1,848 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""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 proto import ast_pb2
+from proto 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 = 200
+
+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('[,;\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('[,;\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
diff --git a/features/filterrules_views.py b/features/filterrules_views.py
new file mode 100644
index 0000000..75fb425
--- /dev/null
+++ b/features/filterrules_views.py
@@ -0,0 +1,53 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Classes to display filter rules in templates."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+
+from framework import template_helpers
+
+
+class RuleView(template_helpers.PBProxy):
+ """Wrapper class that makes it easier to display a Rule via EZT."""
+
+ def __init__(self, rule_pb, users_by_id):
+ super(RuleView, self).__init__(rule_pb)
+
+ self.action_type = ''
+ self.action_value = ''
+
+ if rule_pb is None:
+ return # Just leave everything as ''
+
+ # self.predicate is automatically available.
+
+ # For the current UI, we assume that each rule has exactly
+ # one action, so we can determine the text value for it here.
+ if rule_pb.default_status:
+ self.action_type = 'default_status'
+ self.action_value = rule_pb.default_status
+ elif rule_pb.default_owner_id:
+ self.action_type = 'default_owner'
+ self.action_value = users_by_id[rule_pb.default_owner_id].email
+ elif rule_pb.add_cc_ids:
+ self.action_type = 'add_ccs'
+ usernames = [users_by_id[cc_id].email for cc_id in rule_pb.add_cc_ids]
+ self.action_value = ', '.join(usernames)
+ elif rule_pb.add_labels:
+ self.action_type = 'add_labels'
+ self.action_value = ', '.join(rule_pb.add_labels)
+ elif rule_pb.add_notify_addrs:
+ self.action_type = 'also_notify'
+ self.action_value = ', '.join(rule_pb.add_notify_addrs)
+ elif rule_pb.warning:
+ self.action_type = 'warning'
+ self.action_value = rule_pb.warning
+ elif rule_pb.error:
+ self.action_type = 'error'
+ self.action_value = rule_pb.error
diff --git a/features/generate_dataset.py b/features/generate_dataset.py
new file mode 100644
index 0000000..b13ae88
--- /dev/null
+++ b/features/generate_dataset.py
@@ -0,0 +1,123 @@
+"""This module is used to go from raw data to a csv dataset to build models for
+ component prediction.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import argparse
+import string
+import sys
+import csv
+import re
+import logging
+import random
+import time
+import os
+import settings
+from framework import sql
+from framework import servlet
+
+if not settings.unit_test_mode:
+ import MySQLdb as mdb
+ISSUE_LIMIT = 7000
+ISSUES_PER_RUN = 50
+COMPONENT_PREDICTOR_PROJECT = 16
+
+def build_component_dataset(issue, csv_file):
+ """Main function to build dataset for training models.
+
+ Args:
+ issue: The issue service with set up data.
+ csv_file: The csv file path to store the dataset.
+ """
+
+ logging.info('Building dataset')
+ con = sql.MonorailConnection()
+
+ csv_writer = csv.writer(csv_file)
+
+ logging.info('Downloading the dataset from database.')
+
+ issue_table = sql.SQLTableManager('Issue')
+ issue_component_table = sql.SQLTableManager('Issue2Component')
+ closed_index_table = sql.SQLTableManager('ComponentIssueClosedIndex')
+
+ close = closed_index_table.SelectValue(con, col='closed_index')
+
+ last_close = issue_table.Select(con,
+ cols=['closed'],
+ where=[('closed > %s', [str(close)]),
+ ('project_id = %s',
+ [str(COMPONENT_PREDICTOR_PROJECT)])],
+ order_by=[('closed', [])],
+ limit=ISSUE_LIMIT)[-1][0]
+
+ issue_ids = issue_table.Select(con,
+ cols=['id'],
+ where=[('closed > %s', [str(close)]),
+ ('closed <= %s', [str(last_close)]),
+ ('project_id = %s',
+ [str(COMPONENT_PREDICTOR_PROJECT)])])
+
+
+ logging.info('Close: ' + str(close))
+ logging.info('Last close: ' + str(last_close))
+
+ # Get the comments and components for 50 issues at a time so as to not
+ # overwhelm a single shard with all 7000 issues at once
+ for i in range(0, len(issue_ids), ISSUES_PER_RUN):
+ issue_list = [str(x[0]) for x in issue_ids[i:i+ISSUES_PER_RUN]]
+
+ comments = issue.GetCommentsForIssues(con, issue_list, content_only=True)
+
+ shard_id = random.randint(0, settings.num_logical_shards - 1)
+
+ components = issue_component_table.Select(con,
+ cols=['issue_id',
+ 'GROUP_CONCAT(component_id '
+ + 'SEPARATOR \',\')'],
+ joins=[('ComponentDef ON '
+ 'ComponentDef.id = '
+ 'Issue2Component.component_id',
+ [])],
+ where=[('(deprecated = %s OR deprecated'
+ ' IS NULL)', [False]),
+ ('is_deleted = %s', [False])],
+ group_by=['issue_id'],
+ shard_id=shard_id,
+ issue_id=issue_list)
+
+ for issue_id, component_ids in components:
+ comment_string = ' '.join(
+ [comment.content for comment in comments[issue_id]])
+
+ final_text = CleanText(comment_string)
+
+ final_issue = component_ids, final_text
+ csv_writer.writerow(final_issue)
+
+ closed_index_table.Update(con, delta={'closed_index' : last_close})
+
+ return csv_file
+
+
+def CleanText(text):
+ """Cleans provided text by lower casing words, removing punctuation, and
+ normalizing spacing so that there is exactly one space between each word.
+
+ Args:
+ text: Raw text to be cleaned.
+
+ Returns:
+ Cleaned version of text.
+
+ """
+
+ pretty_issue = text.lower().strip()
+
+ quoteless_issue = re.sub('\'', '', pretty_issue)
+ no_punctuation_issue = re.sub('[^\w\s]|_+', ' ', quoteless_issue)
+ one_space_issue = ' '.join(no_punctuation_issue.split())
+
+ return one_space_issue
diff --git a/features/hotlist_helpers.py b/features/hotlist_helpers.py
new file mode 100644
index 0000000..f23f72e
--- /dev/null
+++ b/features/hotlist_helpers.py
@@ -0,0 +1,473 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Helper functions and classes used by the hotlist pages."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import collections
+
+from features import features_constants
+from framework import framework_views
+from framework import framework_helpers
+from framework import sorting
+from framework import table_view_helpers
+from framework import timestr
+from framework import paginate
+from framework import permissions
+from framework import urls
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+from tracker import tracker_helpers
+from tracker import tablecell
+
+
+# Type to hold a HotlistRef
+HotlistRef = collections.namedtuple('HotlistRef', 'user_id, hotlist_name')
+
+
+def GetSortedHotlistIssues(
+ cnxn, hotlist_items, issues, auth, can, sort_spec, group_by_spec,
+ harmonized_config, services, profiler):
+ # type: (MonorailConnection, List[HotlistItem], List[Issue], AuthData,
+ # ProjectIssueConfig, Services, Profiler) -> (List[Issue], Dict, Dict)
+ """Sorts the given HotlistItems and Issues and filters out Issues that
+ the user cannot view.
+
+ Args:
+ cnxn: MonorailConnection for connection to the SQL database.
+ hotlist_items: list of HotlistItems in the Hotlist we want to sort.
+ issues: list of Issues in the Hotlist we want to sort.
+ auth: AuthData object that identifies the logged in user.
+ can: int "canned query" number to scope the visible issues.
+ sort_spec: string that lists the sort order.
+ group_by_spec: string that lists the grouping order.
+ harmonized_config: ProjectIssueConfig created from all configs of projects
+ with issues in the issues list.
+ services: Services object for connections to backend services.
+ profiler: Profiler object to display and record processes.
+
+ Returns:
+ A tuple of (sorted_issues, hotlist_items_context, issues_users_by_id) where:
+
+ sorted_issues: list of Issues that are sorted and issues the user cannot
+ view are filtered out.
+ hotlist_items_context: a dict of dicts providing HotlistItem values that
+ are associated with each Hotlist Issue. E.g:
+ {issue.issue_id: {'issue_rank': hotlist item rank,
+ 'adder_id': hotlist item adder's user_id,
+ 'date_added': timestamp when this issue was added to the
+ hotlist,
+ 'note': note for this issue in the hotlist,},
+ issue.issue_id: {...}}
+ issues_users_by_id: dict of {user_id: UserView, ...} for all users involved
+ in the hotlist items and issues.
+ """
+ with profiler.Phase('Checking issue permissions and getting ranks'):
+
+ allowed_issues = FilterIssues(cnxn, auth, can, issues, services)
+ allowed_iids = [issue.issue_id for issue in allowed_issues]
+ # The values for issues in a hotlist are specific to the hotlist
+ # (rank, adder, added) without invalidating the keys, an issue will retain
+ # the rank value it has in one hotlist when navigating to another hotlist.
+ sorting.InvalidateArtValuesKeys(
+ cnxn, [issue.issue_id for issue in allowed_issues])
+ sorted_ranks = sorted(
+ [hotlist_item.rank for hotlist_item in hotlist_items if
+ hotlist_item.issue_id in allowed_iids])
+ friendly_ranks = {
+ rank: friendly for friendly, rank in enumerate(sorted_ranks, 1)}
+ issue_adders = framework_views.MakeAllUserViews(
+ cnxn, services.user, [hotlist_item.adder_id for
+ hotlist_item in hotlist_items])
+ hotlist_items_context = {
+ hotlist_item.issue_id: {'issue_rank':
+ friendly_ranks[hotlist_item.rank],
+ 'adder_id': hotlist_item.adder_id,
+ 'date_added': timestr.FormatAbsoluteDate(
+ hotlist_item.date_added),
+ 'note': hotlist_item.note}
+ for hotlist_item in hotlist_items if
+ hotlist_item.issue_id in allowed_iids}
+
+ with profiler.Phase('Making user views'):
+ issues_users_by_id = framework_views.MakeAllUserViews(
+ cnxn, services.user,
+ tracker_bizobj.UsersInvolvedInIssues(allowed_issues or []))
+ issues_users_by_id.update(issue_adders)
+
+ with profiler.Phase('Sorting issues'):
+ sortable_fields = tracker_helpers.SORTABLE_FIELDS.copy()
+ sortable_fields.update(
+ {'rank': lambda issue: hotlist_items_context[
+ issue.issue_id]['issue_rank'],
+ 'adder': lambda issue: hotlist_items_context[
+ issue.issue_id]['adder_id'],
+ 'added': lambda issue: hotlist_items_context[
+ issue.issue_id]['date_added'],
+ 'note': lambda issue: hotlist_items_context[
+ issue.issue_id]['note']})
+ sortable_postproc = tracker_helpers.SORTABLE_FIELDS_POSTPROCESSORS.copy()
+ sortable_postproc.update(
+ {'adder': lambda user_view: user_view.email,
+ })
+
+ sorted_issues = sorting.SortArtifacts(
+ allowed_issues, harmonized_config, sortable_fields,
+ sortable_postproc, group_by_spec, sort_spec,
+ users_by_id=issues_users_by_id, tie_breakers=['rank', 'id'])
+ return sorted_issues, hotlist_items_context, issues_users_by_id
+
+
+def CreateHotlistTableData(mr, hotlist_issues, services):
+ """Creates the table data for the hotlistissues table."""
+ with mr.profiler.Phase('getting stars'):
+ starred_iid_set = set(services.issue_star.LookupStarredItemIDs(
+ mr.cnxn, mr.auth.user_id))
+
+ with mr.profiler.Phase('Computing col_spec'):
+ mr.ComputeColSpec(mr.hotlist)
+
+ issues_list = services.issue.GetIssues(
+ mr.cnxn,
+ [hotlist_issue.issue_id for hotlist_issue in hotlist_issues])
+ with mr.profiler.Phase('Getting config'):
+ hotlist_issues_project_ids = GetAllProjectsOfIssues(
+ [issue for issue in issues_list])
+ is_cross_project = len(hotlist_issues_project_ids) > 1
+ config_list = GetAllConfigsOfProjects(
+ mr.cnxn, hotlist_issues_project_ids, services)
+ harmonized_config = tracker_bizobj.HarmonizeConfigs(config_list)
+
+ # With no sort_spec specified, a hotlist should default to be sorted by
+ # 'rank'. sort_spec needs to be modified because hotlistissues.py
+ # checks for 'rank' in sort_spec to set 'allow_rerank' which determines if
+ # drag and drop reranking should be enabled.
+ if not mr.sort_spec:
+ mr.sort_spec = 'rank'
+ (sorted_issues, hotlist_issues_context,
+ issues_users_by_id) = GetSortedHotlistIssues(
+ mr.cnxn, hotlist_issues, issues_list, mr.auth, mr.can, mr.sort_spec,
+ mr.group_by_spec, harmonized_config, services, mr.profiler)
+
+ with mr.profiler.Phase("getting related issues"):
+ related_iids = set()
+ results_needing_related = sorted_issues
+ lower_cols = mr.col_spec.lower().split()
+ for issue in results_needing_related:
+ if 'blockedon' in lower_cols:
+ related_iids.update(issue.blocked_on_iids)
+ if 'blocking' in lower_cols:
+ related_iids.update(issue.blocking_iids)
+ if 'mergedinto' in lower_cols:
+ related_iids.add(issue.merged_into)
+ related_issues_list = services.issue.GetIssues(
+ mr.cnxn, list(related_iids))
+ related_issues = {issue.issue_id: issue for issue in related_issues_list}
+
+ with mr.profiler.Phase('filtering unviewable issues'):
+ viewable_iids_set = {issue.issue_id
+ for issue in tracker_helpers.GetAllowedIssues(
+ mr, [related_issues.values()], services)[0]}
+
+ with mr.profiler.Phase('building table'):
+ context_for_all_issues = {
+ issue.issue_id: hotlist_issues_context[issue.issue_id]
+ for issue in sorted_issues}
+
+ column_values = table_view_helpers.ExtractUniqueValues(
+ mr.col_spec.lower().split(), sorted_issues, issues_users_by_id,
+ harmonized_config, related_issues,
+ hotlist_context_dict=context_for_all_issues)
+ unshown_columns = table_view_helpers.ComputeUnshownColumns(
+ sorted_issues, mr.col_spec.split(), harmonized_config,
+ features_constants.OTHER_BUILT_IN_COLS)
+ url_params = [(name, mr.GetParam(name)) for name in
+ framework_helpers.RECOGNIZED_PARAMS]
+ # We are passing in None for the project_name because we are not operating
+ # under any project.
+ pagination = paginate.ArtifactPagination(
+ sorted_issues, mr.num, mr.GetPositiveIntParam('start'),
+ None, GetURLOfHotlist(mr.cnxn, mr.hotlist, services.user),
+ total_count=len(sorted_issues), url_params=url_params)
+
+ sort_spec = '%s %s %s' % (
+ mr.group_by_spec, mr.sort_spec, harmonized_config.default_sort_spec)
+
+ table_data = _MakeTableData(
+ pagination.visible_results, starred_iid_set,
+ mr.col_spec.lower().split(), mr.group_by_spec.lower().split(),
+ issues_users_by_id, tablecell.CELL_FACTORIES, related_issues,
+ viewable_iids_set, harmonized_config, context_for_all_issues,
+ mr.hotlist_id, sort_spec)
+
+ table_related_dict = {
+ 'column_values': column_values, 'unshown_columns': unshown_columns,
+ 'pagination': pagination, 'is_cross_project': is_cross_project }
+ return table_data, table_related_dict
+
+
+def _MakeTableData(issues, starred_iid_set, lower_columns,
+ lower_group_by, users_by_id, cell_factories,
+ related_issues, viewable_iids_set, config,
+ context_for_all_issues,
+ hotlist_id, sort_spec):
+ """Returns data from MakeTableData after adding additional information."""
+ table_data = table_view_helpers.MakeTableData(
+ issues, starred_iid_set, lower_columns, lower_group_by,
+ users_by_id, cell_factories, lambda issue: issue.issue_id,
+ related_issues, viewable_iids_set, config, context_for_all_issues)
+
+ for row, art in zip(table_data, issues):
+ row.issue_id = art.issue_id
+ row.local_id = art.local_id
+ row.project_name = art.project_name
+ row.project_url = framework_helpers.FormatURL(
+ None, '/p/%s' % row.project_name)
+ row.issue_ref = '%s:%d' % (art.project_name, art.local_id)
+ row.issue_clean_url = tracker_helpers.FormatRelativeIssueURL(
+ art.project_name, urls.ISSUE_DETAIL, id=art.local_id)
+ row.issue_ctx_url = tracker_helpers.FormatRelativeIssueURL(
+ art.project_name, urls.ISSUE_DETAIL,
+ id=art.local_id, sort=sort_spec, hotlist_id=hotlist_id)
+
+ return table_data
+
+
+def FilterIssues(cnxn, auth, can, issues, services):
+ # (MonorailConnection, AuthData, int, List[Issue], Services) -> List[Issue]
+ """Return a list of issues that the user is allowed to view.
+
+ Args:
+ cnxn: MonorailConnection for connection to the SQL database.
+ auth: AuthData object that identifies the logged in user.
+ can: in "canned_query" number to scope the visible issues.
+ issues: list of Issues to be filtered.
+ services: Services object for connections to backend services.
+
+ Returns:
+ A list of Issues that the user has permissions to view.
+ """
+ allowed_issues = []
+ project_ids = GetAllProjectsOfIssues(issues)
+ issue_projects = services.project.GetProjects(cnxn, project_ids)
+ configs_by_project_id = services.config.GetProjectConfigs(cnxn, project_ids)
+ perms_by_project_id = {
+ pid: permissions.GetPermissions(auth.user_pb, auth.effective_ids, p)
+ for pid, p in issue_projects.items()}
+ for issue in issues:
+ if (can == 1) or not issue.closed_timestamp:
+ issue_project = issue_projects[issue.project_id]
+ config = configs_by_project_id[issue.project_id]
+ perms = perms_by_project_id[issue.project_id]
+ granted_perms = tracker_bizobj.GetGrantedPerms(
+ issue, auth.effective_ids, config)
+ permit_view = permissions.CanViewIssue(
+ auth.effective_ids, perms,
+ issue_project, issue, granted_perms=granted_perms)
+ if permit_view:
+ allowed_issues.append(issue)
+
+ return allowed_issues
+
+
+def GetAllConfigsOfProjects(cnxn, project_ids, services):
+ """Returns a list of configs for the given list of projects."""
+ config_dict = services.config.GetProjectConfigs(cnxn, project_ids)
+ config_list = [config_dict[project_id] for project_id in project_ids]
+ return config_list
+
+
+def GetAllProjectsOfIssues(issues):
+ """Returns a list of all projects that the given issues are in."""
+ project_ids = set()
+ for issue in issues:
+ project_ids.add(issue.project_id)
+ return project_ids
+
+
+def MembersWithoutGivenIDs(hotlist, exclude_ids):
+ """Return three lists of member user IDs, with exclude_ids not in them."""
+ owner_ids = [user_id for user_id in hotlist.owner_ids
+ if user_id not in exclude_ids]
+ editor_ids = [user_id for user_id in hotlist.editor_ids
+ if user_id not in exclude_ids]
+ follower_ids = [user_id for user_id in hotlist.follower_ids
+ if user_id not in exclude_ids]
+
+ return owner_ids, editor_ids, follower_ids
+
+
+def MembersWithGivenIDs(hotlist, new_member_ids, role):
+ """Return three lists of member IDs with the new IDs in the right one.
+
+ Args:
+ hotlist: Hotlist PB for the project to get current members from.
+ new_member_ids: set of user IDs for members being added.
+ role: string name of the role that new_member_ids should be granted.
+
+ Returns:
+ Three lists of member IDs with new_member_ids added to the appropriate
+ list and removed from any other role.
+
+ Raises:
+ ValueError: if the role is not one of owner, committer, or contributor.
+ """
+ owner_ids, editor_ids, follower_ids = MembersWithoutGivenIDs(
+ hotlist, new_member_ids)
+
+ if role == 'owner':
+ owner_ids.extend(new_member_ids)
+ elif role == 'editor':
+ editor_ids.extend(new_member_ids)
+ elif role == 'follower':
+ follower_ids.extend(new_member_ids)
+ else:
+ raise ValueError()
+
+ return owner_ids, editor_ids, follower_ids
+
+
+def GetURLOfHotlist(cnxn, hotlist, user_service, url_for_token=False):
+ """Determines the url to be used to access the given hotlist.
+
+ Args:
+ cnxn: connection to SQL database
+ hotlist: the hotlist_pb
+ user_service: interface to user data storage
+ url_for_token: if true, url returned will use user's id
+ regardless of their user settings, for tokenization.
+
+ Returns:
+ The string url to be used when accessing this hotlist.
+ """
+ if not hotlist.owner_ids: # Should never happen.
+ logging.error('Unowned Hotlist: id:%r, name:%r', hotlist.hotlist_id,
+ hotlist.name)
+ return ''
+ owner_id = hotlist.owner_ids[0] # only one owner allowed
+ owner = user_service.GetUser(cnxn, owner_id)
+ if owner.obscure_email or url_for_token:
+ return '/u/%d/hotlists/%s' % (owner_id, hotlist.name)
+ return (
+ '/u/%s/hotlists/%s' % (
+ owner.email, hotlist.name))
+
+
+def RemoveHotlist(cnxn, hotlist_id, services):
+ """Removes the given hotlist from the database.
+ Args:
+ hotlist_id: the id of the hotlist to be removed.
+ services: interfaces to data storage.
+ """
+ services.hotlist_star.ExpungeStars(cnxn, hotlist_id)
+ services.user.ExpungeHotlistsFromHistory(cnxn, [hotlist_id])
+ services.features.DeleteHotlist(cnxn, hotlist_id)
+
+
+# The following are used by issueentry.
+
+def InvalidParsedHotlistRefsNames(parsed_hotlist_refs, user_hotlist_pbs):
+ """Find and return all names without a corresponding hotlist so named.
+
+ Args:
+ parsed_hotlist_refs: a list of ParsedHotlistRef objects
+ user_hotlist_pbs: the hotlist protobuf objects of all hotlists
+ belonging to the user
+
+ Returns:
+ a list of invalid names; if none are found, the empty list
+ """
+ user_hotlist_names = {hotlist.name for hotlist in user_hotlist_pbs}
+ invalid_names = list()
+ for parsed_ref in parsed_hotlist_refs:
+ if parsed_ref.hotlist_name not in user_hotlist_names:
+ invalid_names.append(parsed_ref.hotlist_name)
+ return invalid_names
+
+
+def AmbiguousShortrefHotlistNames(short_refs, user_hotlist_pbs):
+ """Find and return ambiguous hotlist shortrefs' hotlist names.
+
+ A hotlist shortref is ambiguous iff there exists more than
+ hotlist with that name in the user's hotlists.
+
+ Args:
+ short_refs: a list of ParsedHotlistRef object specifying only
+ a hotlist name (user_email being none)
+ user_hotlist_pbs: the hotlist protobuf objects of all hotlists
+ belonging to the user
+
+ Returns:
+ a list of ambiguous hotlist names; if none are found, the empty list
+ """
+ ambiguous_names = set()
+ seen = set()
+ for hotlist in user_hotlist_pbs:
+ if hotlist.name in seen:
+ ambiguous_names.add(hotlist.name)
+ seen.add(hotlist.name)
+ ambiguous_from_refs = list()
+ for ref in short_refs:
+ if ref.hotlist_name in ambiguous_names:
+ ambiguous_from_refs.append(ref.hotlist_name)
+ return ambiguous_from_refs
+
+
+def InvalidParsedHotlistRefsEmails(full_refs, user_hotlist_emails_to_owners):
+ """Find and return invalid e-mails in hotlist full refs.
+
+ Args:
+ full_refs: a list of ParsedHotlistRef object specifying both
+ user_email and hotlist_name
+ user_hotlist_emails_to_owners: a dictionary having for its keys only
+ the e-mails of the owners of the hotlists the user had edit permission
+ over. (Could also be a set containing these e-mails.)
+
+ Returns:
+ A list of invalid e-mails; if none are found, the empty list.
+ """
+ parsed_emails = [pref.user_email for pref in full_refs]
+ invalid_emails = list()
+ for email in parsed_emails:
+ if email not in user_hotlist_emails_to_owners:
+ invalid_emails.append(email)
+ return invalid_emails
+
+
+def GetHotlistsOfParsedHotlistFullRefs(
+ full_refs, user_hotlist_emails_to_owners, user_hotlist_refs_to_pbs):
+ """Check that all full refs are valid.
+
+ A ref is 'invalid' if it doesn't specify one of the user's hotlists.
+
+ Args:
+ full_refs: a list of ParsedHotlistRef object specifying both
+ user_email and hotlist_name
+ user_hotlist_emails_to_owners: a dictionary having for its keys only
+ the e-mails of the owners of the hotlists the user had edit permission
+ over.
+ user_hotlist_refs_to_pbs: a dictionary mapping HotlistRefs
+ (owner_id, hotlist_name) to the corresponding hotlist protobuf object for
+ the user's hotlists
+
+ Returns:
+ A two-tuple: (list of valid refs' corresponding hotlist protobuf objects,
+ list of invalid refs)
+
+ """
+ invalid_refs = list()
+ valid_pbs = list()
+ for parsed_ref in full_refs:
+ hotlist_ref = HotlistRef(
+ user_hotlist_emails_to_owners[parsed_ref.user_email],
+ parsed_ref.hotlist_name)
+ if hotlist_ref not in user_hotlist_refs_to_pbs:
+ invalid_refs.append(parsed_ref)
+ else:
+ valid_pbs.append(user_hotlist_refs_to_pbs[hotlist_ref])
+ return valid_pbs, invalid_refs
diff --git a/features/hotlist_views.py b/features/hotlist_views.py
new file mode 100644
index 0000000..8b17bbb
--- /dev/null
+++ b/features/hotlist_views.py
@@ -0,0 +1,92 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Classes to display hotlists in templates."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import ezt
+
+import logging
+
+from framework import framework_helpers
+from framework import permissions
+from framework import template_helpers
+
+
+class MemberView(object):
+ """EZT-view of details of how a person is participating in a project."""
+
+ def __init__(self, logged_in_user_id, member_id, user_view, hotlist,
+ effective_ids=None):
+ """Initialize a MemberView with the given information.
+
+ Args:
+ logged_in_user_id: int user ID of the viewing user, or 0 for anon.
+ member_id: int user ID of the hotlist member being viewed.
+ user_ivew: UserView object for this member
+ hotlist: Hotlist PB for the currently viewed hotlist
+ effective_ids: optional set of user IDs for this user, if supplied
+ we show the highest role that they have via any group membership.
+ """
+
+ self.viewing_self = ezt.boolean(logged_in_user_id == member_id)
+
+ self.user = user_view
+ member_qs_param = user_view.user_id
+ self.detail_url = '/u/%s/' % member_qs_param
+ self.role = framework_helpers.GetHotlistRoleName(
+ effective_ids or {member_id}, hotlist)
+
+
+class HotlistView(template_helpers.PBProxy):
+ """Wrapper class that makes it easier to display a hotlist via EZT."""
+
+ def __init__(
+ self, hotlist_pb, perms, user_auth=None,
+ viewed_user_id=None, users_by_id=None, is_starred=False):
+ super(HotlistView, self).__init__(hotlist_pb)
+
+ self.visible = permissions.CanViewHotlist(
+ user_auth.effective_ids, perms, hotlist_pb)
+
+ self.access_is_private = ezt.boolean(hotlist_pb.is_private)
+ if not hotlist_pb.owner_ids: # Should never happen.
+ logging.error('Unowned Hotlist: id:%r, name:%r',
+ hotlist_pb.hotlist_id,
+ hotlist_pb.name)
+ self.url = ''
+ return
+ owner_id = hotlist_pb.owner_ids[0] # only one owner allowed
+ owner = users_by_id[owner_id]
+ if owner.user.banned:
+ self.visible = False
+ if owner.obscure_email or not self.visible:
+ self.url = (
+ '/u/%d/hotlists/%s' % (owner_id, hotlist_pb.name))
+ else:
+ self.url = (
+ '/u/%s/hotlists/%s' % (
+ owner.email, hotlist_pb.name))
+
+ self.role_name = ''
+ if viewed_user_id in hotlist_pb.owner_ids:
+ self.role_name = 'owner'
+ elif any(effective_id in hotlist_pb.editor_ids for
+ effective_id in user_auth.effective_ids):
+ self.role_name = 'editor'
+
+ if users_by_id:
+ self.owners = [users_by_id[owner_id] for
+ owner_id in hotlist_pb.owner_ids]
+ self.editors = [users_by_id[editor_id] for
+ editor_id in hotlist_pb.editor_ids]
+ self.num_issues = len(hotlist_pb.items)
+ self.is_followed = ezt.boolean(user_auth.user_id in hotlist_pb.follower_ids)
+ # TODO(jojwang): if hotlist follower's will not be used, perhaps change
+ # from is_followed to is_member or just use is_starred
+ self.num_followers = len(hotlist_pb.follower_ids)
+ self.is_starred = ezt.boolean(is_starred)
diff --git a/features/hotlistcreate.py b/features/hotlistcreate.py
new file mode 100644
index 0000000..448697b
--- /dev/null
+++ b/features/hotlistcreate.py
@@ -0,0 +1,117 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Servlet for creating new hotlists."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import time
+import re
+
+from features import features_constants
+from features import hotlist_helpers
+from framework import exceptions
+from framework import framework_bizobj
+from framework import framework_helpers
+from framework import permissions
+from framework import servlet
+from framework import urls
+from services import features_svc
+from proto import api_pb2_v1
+
+
+_MSG_HOTLIST_NAME_NOT_AVAIL = 'You already have a hotlist with that name.'
+_MSG_MISSING_HOTLIST_NAME = 'Missing hotlist name'
+_MSG_INVALID_HOTLIST_NAME = 'Invalid hotlist name'
+_MSG_MISSING_HOTLIST_SUMMARY = 'Missing hotlist summary'
+_MSG_INVALID_ISSUES_INPUT = 'Issues input is invalid'
+_MSG_INVALID_MEMBERS_INPUT = 'One or more editor emails is not valid.'
+
+
+class HotlistCreate(servlet.Servlet):
+ """HotlistCreate shows a simple page with a form to create a hotlist."""
+
+ _PAGE_TEMPLATE = 'features/hotlist-create-page.ezt'
+
+ def AssertBasePermission(self, mr):
+ """Check whether the user has any permission to visit this page.
+
+ Args:
+ mr: commonly used info parsed from the request.
+ """
+ super(HotlistCreate, self).AssertBasePermission(mr)
+ if not permissions.CanCreateHotlist(mr.perms):
+ raise permissions.PermissionException(
+ 'User is not allowed to create a hotlist.')
+
+ def GatherPageData(self, mr):
+ return {
+ 'user_tab_mode': 'st6',
+ 'initial_name': '',
+ 'initial_summary': '',
+ 'initial_description': '',
+ 'initial_editors': '',
+ 'initial_privacy': 'no',
+ }
+
+ def ProcessFormData(self, mr, post_data):
+ """Process the hotlist create form.
+
+ Args:
+ mr: commonly used info parsed from the request.
+ post_data: The post_data dict for the current request.
+
+ Returns:
+ String URL to redirect the user to after processing.
+ """
+ hotlist_name = post_data.get('hotlistname')
+ if not hotlist_name:
+ mr.errors.hotlistname = _MSG_MISSING_HOTLIST_NAME
+ elif not framework_bizobj.IsValidHotlistName(hotlist_name):
+ mr.errors.hotlistname = _MSG_INVALID_HOTLIST_NAME
+
+ summary = post_data.get('summary')
+ if not summary:
+ mr.errors.summary = _MSG_MISSING_HOTLIST_SUMMARY
+
+ description = post_data.get('description', '')
+
+ editors = post_data.get('editors', '')
+ editor_ids = []
+ if editors:
+ editor_emails = [
+ email.strip() for email in editors.split(',')]
+ try:
+ editor_dict = self.services.user.LookupUserIDs(mr.cnxn, editor_emails)
+ editor_ids = list(editor_dict.values())
+ except exceptions.NoSuchUserException:
+ mr.errors.editors = _MSG_INVALID_MEMBERS_INPUT
+ # In case the logged-in user specifies themselves as an editor, ignore it.
+ editor_ids = [eid for eid in editor_ids if eid != mr.auth.user_id]
+
+ is_private = post_data.get('is_private')
+
+ if not mr.errors.AnyErrors():
+ try:
+ hotlist = self.services.features.CreateHotlist(
+ mr.cnxn, hotlist_name, summary, description,
+ owner_ids=[mr.auth.user_id], editor_ids=editor_ids,
+ is_private=(is_private == 'yes'),
+ ts=int(time.time()))
+ except features_svc.HotlistAlreadyExists:
+ mr.errors.hotlistname = _MSG_HOTLIST_NAME_NOT_AVAIL
+
+ if mr.errors.AnyErrors():
+ self.PleaseCorrect(
+ mr, initial_name=hotlist_name, initial_summary=summary,
+ initial_description=description,
+ initial_editors=editors, initial_privacy=is_private)
+ else:
+ return framework_helpers.FormatAbsoluteURL(
+ mr, hotlist_helpers.GetURLOfHotlist(
+ mr.cnxn, hotlist, self.services.user),
+ include_project=False)
diff --git a/features/hotlistdetails.py b/features/hotlistdetails.py
new file mode 100644
index 0000000..d3bf3b2
--- /dev/null
+++ b/features/hotlistdetails.py
@@ -0,0 +1,123 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Servlets for hotlist details main subtab."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import time
+
+import ezt
+
+from features import hotlist_helpers
+from framework import framework_bizobj
+from framework import framework_helpers
+from framework import servlet
+from framework import permissions
+from framework import urls
+
+_MSG_DESCRIPTION_MISSING = 'Description is missing.'
+_MSG_SUMMARY_MISSING = 'Summary is missing.'
+_MSG_NAME_MISSING = 'Hotlist name is missing.'
+_MSG_COL_SPEC_MISSING = 'Hotlist default columns are missing.'
+_MSG_HOTLIST_NAME_NOT_AVAIL = 'You already have a hotlist with that name.'
+# pylint: disable=line-too-long
+_MSG_INVALID_HOTLIST_NAME = "Invalid hotlist name. Please make sure your hotlist name begins with a letter followed by any number of letters, numbers, -'s, and .'s"
+
+
+class HotlistDetails(servlet.Servlet):
+ """A page with hotlist details and editing options."""
+
+ _PAGE_TEMPLATE = 'features/hotlist-details-page.ezt'
+ _MAIN_TAB_MODE = servlet.Servlet.HOTLIST_TAB_DETAILS
+
+ def AssertBasePermission(self, mr):
+ super(HotlistDetails, self).AssertBasePermission(mr)
+ if not permissions.CanViewHotlist(
+ mr.auth.effective_ids, mr.perms, mr.hotlist):
+ raise permissions.PermissionException(
+ 'User is not allowed to view the hotlist details')
+
+ def GatherPageData(self, mr):
+ """Buil up a dictionary of data values to use when rendering the page."""
+ if mr.auth.user_id:
+ self.services.user.AddVisitedHotlist(
+ mr.cnxn, mr.auth.user_id, mr.hotlist_id)
+ cant_administer_hotlist = not permissions.CanAdministerHotlist(
+ mr.auth.effective_ids, mr.perms, mr.hotlist)
+
+ return {
+ 'initial_summary': mr.hotlist.summary,
+ 'initial_description': mr.hotlist.description,
+ 'initial_name': mr.hotlist.name,
+ 'initial_default_col_spec': mr.hotlist.default_col_spec,
+ 'initial_is_private': ezt.boolean(mr.hotlist.is_private),
+ 'cant_administer_hotlist': ezt.boolean(cant_administer_hotlist),
+ 'viewing_user_page': ezt.boolean(True),
+ 'new_ui_url': '%s/%s/settings' % (urls.HOTLISTS, mr.hotlist_id),
+ }
+
+ def ProcessFormData(self, mr, post_data):
+ """Process the posted form."""
+ if not permissions.CanAdministerHotlist(
+ mr.auth.effective_ids, mr.perms, mr.hotlist):
+ raise permissions.PermissionException(
+ 'User is not allowed to update hotlist settings.')
+
+ if post_data.get('deletestate') == 'true':
+ hotlist_helpers.RemoveHotlist(mr.cnxn, mr.hotlist_id, self.services)
+ return framework_helpers.FormatAbsoluteURL(
+ mr, '/u/%s/hotlists' % mr.auth.email,
+ saved=1, ts=int(time.time()), include_project=False)
+
+ (summary, description, name, default_col_spec) = self._ParseMetaData(
+ post_data, mr)
+ is_private = post_data.get('is_private') != 'no'
+
+ if not mr.errors.AnyErrors():
+ self.services.features.UpdateHotlist(
+ mr.cnxn, mr.hotlist.hotlist_id, name=name, summary=summary,
+ description=description, is_private=is_private,
+ default_col_spec=default_col_spec)
+
+ if mr.errors.AnyErrors():
+ self.PleaseCorrect(
+ mr, initial_summary=summary, initial_description=description,
+ initial_name=name, initial_default_col_spec=default_col_spec)
+ else:
+ return framework_helpers.FormatAbsoluteURL(
+ mr, '/u/%s/hotlists/%s%s' % (
+ mr.auth.user_id, mr.hotlist_id, urls.HOTLIST_DETAIL),
+ saved=1, ts=int(time.time()),
+ include_project=False)
+
+ def _ParseMetaData(self, post_data, mr):
+ """Process a POST on the hotlist metadata."""
+ summary = None
+ description = ''
+ name = None
+ default_col_spec = None
+
+ if 'summary' in post_data:
+ summary = post_data['summary']
+ if not summary:
+ mr.errors.summary = _MSG_SUMMARY_MISSING
+ if 'description' in post_data:
+ description = post_data['description']
+ if 'name' in post_data:
+ name = post_data['name']
+ if not name:
+ mr.errors.name = _MSG_NAME_MISSING
+ else:
+ if not framework_bizobj.IsValidHotlistName(name):
+ mr.errors.name = _MSG_INVALID_HOTLIST_NAME
+ elif self.services.features.LookupHotlistIDs(
+ mr.cnxn, [name], [mr.auth.user_id]) and (
+ mr.hotlist.name.lower() != name.lower()):
+ mr.errors.name = _MSG_HOTLIST_NAME_NOT_AVAIL
+ if 'default_col_spec' in post_data:
+ default_col_spec = post_data['default_col_spec']
+ return summary, description, name, default_col_spec
diff --git a/features/hotlistissues.py b/features/hotlistissues.py
new file mode 100644
index 0000000..8743772
--- /dev/null
+++ b/features/hotlistissues.py
@@ -0,0 +1,349 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Classes that implement the hotlistissues page and related forms."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import ezt
+
+import settings
+import time
+import re
+
+from businesslogic import work_env
+from features import features_bizobj
+from features import features_constants
+from features import hotlist_helpers
+from framework import exceptions
+from framework import servlet
+from framework import sorting
+from framework import permissions
+from framework import framework_helpers
+from framework import paginate
+from framework import framework_constants
+from framework import framework_views
+from framework import grid_view_helpers
+from framework import template_helpers
+from framework import timestr
+from framework import urls
+from framework import xsrf
+from services import features_svc
+from tracker import tracker_bizobj
+
+_INITIAL_ADD_ISSUES_MESSAGE = 'projectname:localID, projectname:localID, etc.'
+_MSG_INVALID_ISSUES_INPUT = (
+ 'Please follow project_name:issue_id, project_name:issue_id..')
+_MSG_ISSUES_NOT_FOUND = 'One or more of your issues were not found.'
+_MSG_ISSUES_NOT_VIEWABLE = 'You lack permission to view one or more issues.'
+
+
+class HotlistIssues(servlet.Servlet):
+ """HotlistIssues is a page that shows the issues of one hotlist."""
+
+ _PAGE_TEMPLATE = 'features/hotlist-issues-page.ezt'
+ _MAIN_TAB_MODE = servlet.Servlet.HOTLIST_TAB_ISSUES
+
+ def AssertBasePermission(self, mr):
+ """Check that the user has permission to even visit this page."""
+ super(HotlistIssues, self).AssertBasePermission(mr)
+ try:
+ hotlist = self._GetHotlist(mr)
+ except features_svc.NoSuchHotlistException:
+ return
+ permit_view = permissions.CanViewHotlist(
+ mr.auth.effective_ids, mr.perms, hotlist)
+ if not permit_view:
+ raise permissions.PermissionException(
+ 'User is not allowed to view this hotlist')
+
+ def GatherPageData(self, mr):
+ """Build up a dictionary of data values to use when rendering the page.
+
+ Args:
+ mr: commonly usef info parsed from the request.
+
+ Returns:
+ Dict of values used by EZT for rendering the page.
+ """
+ with mr.profiler.Phase('getting hotlist'):
+ if mr.hotlist_id is None:
+ self.abort(404, 'no hotlist specified')
+ if mr.auth.user_id:
+ self.services.user.AddVisitedHotlist(
+ mr.cnxn, mr.auth.user_id, mr.hotlist_id)
+
+ if mr.mode == 'grid':
+ page_data = self.GetGridViewData(mr)
+ else:
+ page_data = self.GetTableViewData(mr)
+
+ with mr.profiler.Phase('making page perms'):
+ owner_permissions = permissions.CanAdministerHotlist(
+ mr.auth.effective_ids, mr.perms, mr.hotlist)
+ editor_permissions = permissions.CanEditHotlist(
+ mr.auth.effective_ids, mr.perms, mr.hotlist)
+ # TODO(jojwang): each issue should have an individual
+ # SetStar status based on its project to indicate whether or not
+ # the star icon should be shown to the user.
+ page_perms = template_helpers.EZTItem(
+ EditIssue=None, SetStar=mr.auth.user_id)
+
+ allow_rerank = (not mr.group_by_spec and mr.sort_spec.startswith(
+ 'rank') and (owner_permissions or editor_permissions))
+
+ user_hotlists = self.services.features.GetHotlistsByUserID(
+ mr.cnxn, mr.auth.user_id)
+ try:
+ user_hotlists.remove(self.services.features.GetHotlist(
+ mr.cnxn, mr.hotlist_id))
+ except ValueError:
+ pass
+
+ new_ui_url = '%s/%s/issues' % (urls.HOTLISTS, mr.hotlist_id)
+
+ # Note: The HotlistView is created and returned in servlet.py
+ page_data.update(
+ {
+ 'owner_permissions':
+ ezt.boolean(owner_permissions),
+ 'editor_permissions':
+ ezt.boolean(editor_permissions),
+ 'issue_tab_mode':
+ 'issueList',
+ 'grid_mode':
+ ezt.boolean(mr.mode == 'grid'),
+ 'list_mode':
+ ezt.boolean(mr.mode == 'list'),
+ 'chart_mode':
+ ezt.boolean(mr.mode == 'chart'),
+ 'page_perms':
+ page_perms,
+ 'colspec':
+ mr.col_spec,
+ # monorail:6336, used in <ezt-show-columns-connector>
+ 'phasespec':
+ "",
+ 'allow_rerank':
+ ezt.boolean(allow_rerank),
+ 'csv_link':
+ framework_helpers.FormatURL(
+ [
+ (name, mr.GetParam(name))
+ for name in framework_helpers.RECOGNIZED_PARAMS
+ ],
+ '%d/csv' % mr.hotlist_id,
+ num=100),
+ 'is_hotlist':
+ ezt.boolean(True),
+ 'col_spec':
+ mr.col_spec.lower(),
+ 'viewing_user_page':
+ ezt.boolean(True),
+ # for update-issues-hotlists-dialog in
+ # issue-list-controls-top.
+ 'user_issue_hotlists': [],
+ 'user_remaining_hotlists':
+ user_hotlists,
+ 'new_ui_url':
+ new_ui_url,
+ })
+ return page_data
+ # TODO(jojwang): implement peek issue on hover, implement starring issues
+
+ def _GetHotlist(self, mr):
+ """Retrieve the current hotlist."""
+ if mr.hotlist_id is None:
+ return None
+ try:
+ hotlist = self.services.features.GetHotlist(mr.cnxn, mr.hotlist_id)
+ except features_svc.NoSuchHotlistException:
+ self.abort(404, 'hotlist not found')
+ return hotlist
+
+ def GetTableViewData(self, mr):
+ """EZT template values to render a Table View of issues.
+
+ Args:
+ mr: commonly used info parsed from the request.
+
+ Returns:
+ Dictionary of page data for rendering of the Table View.
+ """
+ table_data, table_related_dict = hotlist_helpers.CreateHotlistTableData(
+ mr, mr.hotlist.items, self.services)
+ columns = mr.col_spec.split()
+ ordered_columns = [template_helpers.EZTItem(col_index=i, name=col)
+ for i, col in enumerate(columns)]
+ table_view_data = {
+ 'table_data': table_data,
+ 'panels': [template_helpers.EZTItem(ordered_columns=ordered_columns)],
+ 'cursor': mr.cursor or mr.preview,
+ 'preview': mr.preview,
+ 'default_colspec': features_constants.DEFAULT_COL_SPEC,
+ 'default_results_per_page': 10,
+ 'preview_on_hover': (
+ settings.enable_quick_edit and mr.auth.user_pb.preview_on_hover),
+ # token must be generated using url with userid to accommodate
+ # multiple urls for one hotlist
+ 'edit_hotlist_token': xsrf.GenerateToken(
+ mr.auth.user_id,
+ hotlist_helpers.GetURLOfHotlist(
+ mr.cnxn, mr.hotlist, self.services.user,
+ url_for_token=True) + '.do'),
+ 'add_local_ids': '',
+ 'placeholder': _INITIAL_ADD_ISSUES_MESSAGE,
+ 'add_issues_selected': ezt.boolean(False),
+ 'col_spec': ''
+ }
+ table_view_data.update(table_related_dict)
+
+ return table_view_data
+
+ def ProcessFormData(self, mr, post_data):
+ if not permissions.CanEditHotlist(
+ mr.auth.effective_ids, mr.perms, mr.hotlist):
+ raise permissions.PermissionException(
+ 'User is not allowed to edit this hotlist.')
+
+ hotlist_view_url = hotlist_helpers.GetURLOfHotlist(
+ mr.cnxn, mr.hotlist, self.services.user)
+ current_col_spec = post_data.get('current_col_spec')
+ default_url = framework_helpers.FormatAbsoluteURL(
+ mr, hotlist_view_url,
+ include_project=False, colspec=current_col_spec)
+ sorting.InvalidateArtValuesKeys(
+ mr.cnxn,
+ [hotlist_item.issue_id for hotlist_item
+ in mr.hotlist.items])
+
+ if post_data.get('remove') == 'true':
+ project_and_local_ids = post_data.get('remove_local_ids')
+ else:
+ project_and_local_ids = post_data.get('add_local_ids')
+ if not project_and_local_ids:
+ return default_url
+
+ selected_iids = []
+ if project_and_local_ids:
+ pattern = re.compile(features_constants.ISSUE_INPUT_REGEX)
+ if pattern.match(project_and_local_ids):
+ issue_refs_tuples = [(pair.split(':')[0].strip(),
+ int(pair.split(':')[1].strip()))
+ for pair in project_and_local_ids.split(',')
+ if pair.strip()]
+ project_names = {project_name for (project_name, _) in
+ issue_refs_tuples}
+ projects_dict = self.services.project.GetProjectsByName(
+ mr.cnxn, project_names)
+ selected_iids, _misses = self.services.issue.ResolveIssueRefs(
+ mr.cnxn, projects_dict, mr.project_name, issue_refs_tuples)
+ if (not selected_iids) or len(issue_refs_tuples) > len(selected_iids):
+ mr.errors.issues = _MSG_ISSUES_NOT_FOUND
+ # TODO(jojwang): give issues that were not found.
+ else:
+ mr.errors.issues = _MSG_INVALID_ISSUES_INPUT
+
+ try:
+ with work_env.WorkEnv(mr, self.services) as we:
+ we.GetIssuesDict(selected_iids)
+ except exceptions.NoSuchIssueException:
+ mr.errors.issues = _MSG_ISSUES_NOT_FOUND
+ except permissions.PermissionException:
+ mr.errors.issues = _MSG_ISSUES_NOT_VIEWABLE
+
+ # TODO(jojwang): fix: when there are errors, hidden column come back on
+ # the .do page but go away once the errors are fixed and the form
+ # is submitted again
+ if mr.errors.AnyErrors():
+ self.PleaseCorrect(
+ mr, add_local_ids=project_and_local_ids,
+ add_issues_selected=ezt.boolean(True), col_spec=current_col_spec)
+
+ else:
+ with work_env.WorkEnv(mr, self.services) as we:
+ if post_data.get('remove') == 'true':
+ we.RemoveIssuesFromHotlists([mr.hotlist_id], selected_iids)
+ else:
+ we.AddIssuesToHotlists([mr.hotlist_id], selected_iids, '')
+ return framework_helpers.FormatAbsoluteURL(
+ mr, hotlist_view_url, saved=1, ts=int(time.time()),
+ include_project=False, colspec=current_col_spec)
+
+ def GetGridViewData(self, mr):
+ """EZT template values to render a Table View of issues.
+
+ Args:
+ mr: commonly used info parsed from the request.
+
+ Returns:
+ Dictionary of page data for rendering of the Table View.
+ """
+ mr.ComputeColSpec(mr.hotlist)
+ starred_iid_set = set(self.services.issue_star.LookupStarredItemIDs(
+ mr.cnxn, mr.auth.user_id))
+ issues_list = self.services.issue.GetIssues(
+ mr.cnxn,
+ [hotlist_issue.issue_id for hotlist_issue
+ in mr.hotlist.items])
+ allowed_issues = hotlist_helpers.FilterIssues(
+ mr.cnxn, mr.auth, mr.can, issues_list, self.services)
+ issue_and_hotlist_users = tracker_bizobj.UsersInvolvedInIssues(
+ allowed_issues or []).union(features_bizobj.UsersInvolvedInHotlists(
+ [mr.hotlist]))
+ users_by_id = framework_views.MakeAllUserViews(
+ mr.cnxn, self.services.user,
+ issue_and_hotlist_users)
+ hotlist_issues_project_ids = hotlist_helpers.GetAllProjectsOfIssues(
+ [issue for issue in issues_list])
+ config_list = hotlist_helpers.GetAllConfigsOfProjects(
+ mr.cnxn, hotlist_issues_project_ids, self.services)
+ harmonized_config = tracker_bizobj.HarmonizeConfigs(config_list)
+ limit = settings.max_issues_in_grid
+ grid_limited = len(allowed_issues) > limit
+ lower_cols = mr.col_spec.lower().split()
+ grid_x = (mr.x or harmonized_config.default_x_attr or '--').lower()
+ grid_y = (mr.y or harmonized_config.default_y_attr or '--').lower()
+ lower_cols.append(grid_x)
+ lower_cols.append(grid_y)
+ related_iids = set()
+ for issue in allowed_issues:
+ if 'blockedon' in lower_cols:
+ related_iids.update(issue.blocked_on_iids)
+ if 'blocking' in lower_cols:
+ related_iids.update(issue.blocking_iids)
+ if 'mergedinto' in lower_cols:
+ related_iids.add(issue.merged_into)
+ related_issues_list = self.services.issue.GetIssues(
+ mr.cnxn, list(related_iids))
+ related_issues = {issue.issue_id: issue for issue in related_issues_list}
+
+ hotlist_context_dict = {
+ hotlist_issue.issue_id: {'adder_id': hotlist_issue.adder_id,
+ 'date_added': timestr.FormatRelativeDate(
+ hotlist_issue.date_added),
+ 'note': hotlist_issue.note}
+ for hotlist_issue in mr.hotlist.items}
+
+ grid_view_data = grid_view_helpers.GetGridViewData(
+ mr, allowed_issues, harmonized_config,
+ users_by_id, starred_iid_set, grid_limited, related_issues,
+ hotlist_context_dict=hotlist_context_dict)
+
+ url_params = [(name, mr.GetParam(name)) for name in
+ framework_helpers.RECOGNIZED_PARAMS]
+ # We are passing in None for the project_name in ArtifactPagination
+ # because we are not operating under any project.
+ grid_view_data.update({'pagination': paginate.ArtifactPagination(
+ allowed_issues,
+ mr.GetPositiveIntParam(
+ 'num', features_constants.DEFAULT_RESULTS_PER_PAGE),
+ mr.GetPositiveIntParam('start'), None,
+ urls.HOTLIST_ISSUES, total_count=len(allowed_issues),
+ url_params=url_params)})
+
+ return grid_view_data
diff --git a/features/hotlistissuescsv.py b/features/hotlistissuescsv.py
new file mode 100644
index 0000000..3ae3f3b
--- /dev/null
+++ b/features/hotlistissuescsv.py
@@ -0,0 +1,62 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Implemention of the hotlist issues list output as a CSV file."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+from features import hotlistissues
+from framework import framework_views
+from framework import csv_helpers
+from framework import permissions
+from framework import xsrf
+
+
+# TODO(jojwang): can be refactored even more, see similarities with
+# IssueListCsv
+class HotlistIssuesCsv(hotlistissues.HotlistIssues):
+ """HotlistIssuesCsv provides to the user a list of issues as a CSV document.
+
+ Overrides the standard HotlistIssues servlet but uses a different EZT template
+ to provide the same content as the HotlistIssues only as CSV. Adds the HTTP
+ header to offer the result as a download.
+ """
+
+ _PAGE_TEMPLATE = 'tracker/issue-list-csv.ezt'
+
+ def GatherPageData(self, mr):
+ if not mr.auth.user_id:
+ raise permissions.PermissionException(
+ 'Anonymous users are not allowed to download hotlist CSV')
+
+ owner_id = mr.hotlist.owner_ids[0] # only one owner allowed
+ users_by_id = framework_views.MakeAllUserViews(
+ mr.cnxn, self.services.user,
+ [owner_id])
+ owner = users_by_id[owner_id]
+
+ # Try to validate XSRF by either user email or user ID.
+ try:
+ xsrf.ValidateToken(
+ mr.token, mr.auth.user_id,
+ '/u/%s/hotlists/%s.do' % (owner.email, mr.hotlist.name))
+ except xsrf.TokenIncorrect:
+ xsrf.ValidateToken(
+ mr.token, mr.auth.user_id,
+ '/u/%s/hotlists/%s.do' % (owner.user_id, mr.hotlist.name))
+
+ # Sets headers to allow the response to be downloaded.
+ self.content_type = 'text/csv; charset=UTF-8'
+ download_filename = 'hotlist_%d-issues.csv' % mr.hotlist_id
+ self.response.headers.add(
+ 'Content-Disposition', 'attachment; filename=%s' % download_filename)
+ self.response.headers.add('X-Content-Type-Options', 'nosniff')
+
+ mr.ComputeColSpec(mr.hotlist)
+ mr.col_spec = csv_helpers.RewriteColspec(mr.col_spec)
+ page_data = hotlistissues.HotlistIssues.GatherPageData(self, mr)
+ return csv_helpers.ReformatRowsForCSV(
+ mr, page_data, '%d/csv' % mr.hotlist_id)
diff --git a/features/hotlistpeople.py b/features/hotlistpeople.py
new file mode 100644
index 0000000..1eb00ff
--- /dev/null
+++ b/features/hotlistpeople.py
@@ -0,0 +1,252 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Classes to implement the hotlistpeople page and related forms."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import time
+
+import ezt
+
+from features import hotlist_helpers
+from features import hotlist_views
+from framework import framework_helpers
+from framework import framework_views
+from framework import paginate
+from framework import permissions
+from framework import servlet
+from framework import urls
+from project import project_helpers
+
+MEMBERS_PER_PAGE = 50
+
+
+class HotlistPeopleList(servlet.Servlet):
+ _PAGE_TEMPLATE = 'project/people-list-page.ezt'
+ # Note: using the project's peoplelist page template. minor edits were
+ # to make it compatible with HotlistPeopleList
+ _MAIN_TAB_MODE = servlet.Servlet.HOTLIST_TAB_PEOPLE
+
+ def AssertBasePermission(self, mr):
+ super(HotlistPeopleList, self).AssertBasePermission(mr)
+ if not permissions.CanViewHotlist(
+ mr.auth.effective_ids, mr.perms, mr.hotlist):
+ raise permissions.PermissionException(
+ 'User is now allowed to view the hotlist people list')
+
+ def GatherPageData(self, mr):
+ """Build up a dictionary of data values to use when rendering the page."""
+ if mr.auth.user_id:
+ self.services.user.AddVisitedHotlist(
+ mr.cnxn, mr.auth.user_id, mr.hotlist_id)
+
+ all_members = (mr.hotlist.owner_ids +
+ mr.hotlist.editor_ids + mr.hotlist.follower_ids)
+
+ hotlist_url = hotlist_helpers.GetURLOfHotlist(
+ mr.cnxn, mr.hotlist, self.services.user)
+
+ with mr.profiler.Phase('gathering members on this page'):
+ users_by_id = framework_views.MakeAllUserViews(
+ mr.cnxn, self.services.user, all_members)
+ framework_views.RevealAllEmailsToMembers(
+ mr.cnxn, self.services, mr.auth, users_by_id, mr.project)
+
+ untrusted_user_group_proxies = []
+ # TODO(jojwang): implement FindUntrustedGroups()
+
+ with mr.profiler.Phase('making member views'):
+ owner_views = self._MakeMemberViews(mr, mr.hotlist.owner_ids, users_by_id)
+ editor_views = self._MakeMemberViews(mr, mr.hotlist.editor_ids,
+ users_by_id)
+ follower_views = self._MakeMemberViews(mr, mr.hotlist.follower_ids,
+ users_by_id)
+ all_member_views = owner_views + editor_views + follower_views
+
+ url_params = [(name, mr.GetParam(name)) for name in
+ framework_helpers.RECOGNIZED_PARAMS]
+ # We are passing in None for the project_name because we are not operating
+ # under any project.
+ pagination = paginate.ArtifactPagination(
+ all_member_views, mr.GetPositiveIntParam('num', MEMBERS_PER_PAGE),
+ mr.GetPositiveIntParam('start'), None,
+ '%s%s' % (hotlist_url, urls.HOTLIST_PEOPLE), url_params=url_params)
+
+ offer_membership_editing = permissions.CanAdministerHotlist(
+ mr.auth.effective_ids, mr.perms, mr.hotlist)
+
+ offer_remove_self = (
+ not offer_membership_editing and
+ mr.auth.user_id and
+ mr.auth.user_id in mr.hotlist.editor_ids)
+
+ newly_added_views = [mv for mv in all_member_views
+ if str(mv.user.user_id) in mr.GetParam('new', [])]
+
+ return {
+ 'is_hotlist': ezt.boolean(True),
+ 'untrusted_user_groups': untrusted_user_group_proxies,
+ 'pagination': pagination,
+ 'initial_add_members': '',
+ 'subtab_mode': None,
+ 'initially_expand_form': ezt.boolean(False),
+ 'newly_added_views': newly_added_views,
+ 'offer_membership_editing': ezt.boolean(offer_membership_editing),
+ 'offer_remove_self': ezt.boolean(offer_remove_self),
+ 'total_num_owners': len(mr.hotlist.owner_ids),
+ 'check_abandonment': ezt.boolean(True),
+ 'initial_new_owner_username': '',
+ 'placeholder': 'new-owner-username',
+ 'open_dialog': ezt.boolean(False),
+ 'viewing_user_page': ezt.boolean(True),
+ 'new_ui_url': '%s/%s/people' % (urls.HOTLISTS, mr.hotlist_id),
+ }
+
+ def ProcessFormData(self, mr, post_data):
+ """Process the posted form."""
+ permit_edit = permissions.CanAdministerHotlist(
+ mr.auth.effective_ids, mr.perms, mr.hotlist)
+ can_remove_self = (
+ not permit_edit and
+ mr.auth.user_id and
+ mr.auth.user_id in mr.hotlist.editor_ids)
+ if not can_remove_self and not permit_edit:
+ raise permissions.PermissionException(
+ 'User is not permitted to edit hotlist membership')
+ hotlist_url = hotlist_helpers.GetURLOfHotlist(
+ mr.cnxn, mr.hotlist, self.services.user)
+ if permit_edit:
+ if 'addbtn' in post_data:
+ return self.ProcessAddMembers(mr, post_data, hotlist_url)
+ elif 'removebtn' in post_data:
+ return self.ProcessRemoveMembers(mr, post_data, hotlist_url)
+ elif 'changeowners' in post_data:
+ return self.ProcessChangeOwnership(mr, post_data)
+ if can_remove_self:
+ if 'removeself' in post_data:
+ return self.ProcessRemoveSelf(mr, hotlist_url)
+
+ def _MakeMemberViews(self, mr, member_ids, users_by_id):
+ """Return a sorted list of MemberViews for display by EZT."""
+ member_views = [hotlist_views.MemberView(
+ mr.auth.user_id, member_id, users_by_id[member_id],
+ mr.hotlist) for member_id in member_ids]
+ member_views.sort(key=lambda mv: mv.user.email)
+ return member_views
+
+ def ProcessChangeOwnership(self, mr, post_data):
+ new_owner_id_set = project_helpers.ParseUsernames(
+ mr.cnxn, self.services.user, post_data.get('changeowners'))
+ remain_as_editor = post_data.get('becomeeditor') == 'on'
+ if len(new_owner_id_set) != 1:
+ mr.errors.transfer_ownership = (
+ 'Please add one valid user email.')
+ else:
+ new_owner_id = new_owner_id_set.pop()
+ if self.services.features.LookupHotlistIDs(
+ mr.cnxn, [mr.hotlist.name], [new_owner_id]):
+ mr.errors.transfer_ownership = (
+ 'This user already owns a hotlist with the same name')
+
+ if mr.errors.AnyErrors():
+ self.PleaseCorrect(
+ mr, initial_new_owner_username=post_data.get('changeowners'),
+ open_dialog=ezt.boolean(True))
+ else:
+ old_and_new_owner_ids = [new_owner_id] + mr.hotlist.owner_ids
+ (_, editor_ids, follower_ids) = hotlist_helpers.MembersWithoutGivenIDs(
+ mr.hotlist, old_and_new_owner_ids)
+ if remain_as_editor and mr.hotlist.owner_ids:
+ editor_ids.append(mr.hotlist.owner_ids[0])
+
+ self.services.features.UpdateHotlistRoles(
+ mr.cnxn, mr.hotlist_id, [new_owner_id], editor_ids, follower_ids)
+
+ hotlist = self.services.features.GetHotlist(mr.cnxn, mr.hotlist_id)
+ hotlist_url = hotlist_helpers.GetURLOfHotlist(
+ mr.cnxn, hotlist, self.services.user)
+ return framework_helpers.FormatAbsoluteURL(
+ mr,'%s%s' % (hotlist_url, urls.HOTLIST_PEOPLE),
+ saved=1, ts=int(time.time()),
+ include_project=False)
+
+ def ProcessAddMembers(self, mr, post_data, hotlist_url):
+ """Process the user's request to add members.
+
+ Args:
+ mr: common information parsed from the HTTP request.
+ post_data: dictionary of form data
+ hotlist_url: hotlist_url to return to after data has been processed.
+
+ Returns:
+ String URL to redirect the user to after processing
+ """
+ # NOTE: using project_helpers function
+ new_member_ids = project_helpers.ParseUsernames(
+ mr.cnxn, self.services.user, post_data.get('addmembers'))
+ if not new_member_ids or not post_data.get('addmembers'):
+ mr.errors.incorrect_email_input = (
+ 'Please give full emails seperated by commas.')
+ role = post_data['role']
+
+ (owner_ids, editor_ids, follower_ids) = hotlist_helpers.MembersWithGivenIDs(
+ mr.hotlist, new_member_ids, role)
+ # TODO(jojwang): implement MAX_HOTLIST_PEOPLE
+
+ if not owner_ids:
+ mr.errors.addmembers = (
+ 'Cannot have a hotlist without an owner; please leave at least one.')
+
+ if mr.errors.AnyErrors():
+ add_members_str = post_data.get('addmembers', '')
+ self.PleaseCorrect(
+ mr, initial_add_members=add_members_str, initially_expand_form=True)
+ else:
+ self.services.features.UpdateHotlistRoles(
+ mr.cnxn, mr.hotlist_id, owner_ids, editor_ids, follower_ids)
+ return framework_helpers.FormatAbsoluteURL(
+ mr, '%s%s' % (
+ hotlist_url, urls.HOTLIST_PEOPLE),
+ saved=1, ts=int(time.time()),
+ new=','.join([str(u) for u in new_member_ids]),
+ include_project=False)
+
+ def ProcessRemoveMembers(self, mr, post_data, hotlist_url):
+ """Process the user's request to remove members."""
+ remove_strs = post_data.getall('remove')
+ logging.info('remove_strs = %r', remove_strs)
+ remove_ids = set(
+ self.services.user.LookupUserIDs(mr.cnxn, remove_strs).values())
+ (owner_ids, editor_ids,
+ follower_ids) = hotlist_helpers.MembersWithoutGivenIDs(
+ mr.hotlist, remove_ids)
+
+ self.services.features.UpdateHotlistRoles(
+ mr.cnxn, mr.hotlist_id, owner_ids, editor_ids, follower_ids)
+
+ return framework_helpers.FormatAbsoluteURL(
+ mr, '%s%s' % (
+ hotlist_url, urls.HOTLIST_PEOPLE),
+ saved=1, ts=int(time.time()), include_project=False)
+
+ def ProcessRemoveSelf(self, mr, hotlist_url):
+ """Process the request to remove the logged-in user."""
+ remove_ids = [mr.auth.user_id]
+
+ # This function does no permission checking; that's done by the caller.
+ (owner_ids, editor_ids,
+ follower_ids) = hotlist_helpers.MembersWithoutGivenIDs(
+ mr.hotlist, remove_ids)
+
+ self.services.features.UpdateHotlistRoles(
+ mr.cnxn, mr.hotlist_id, owner_ids, editor_ids, follower_ids)
+
+ return framework_helpers.FormatAbsoluteURL(
+ mr, '%s%s' % (
+ hotlist_url, urls.HOTLIST_PEOPLE),
+ saved=1, ts=int(time.time()), include_project=False)
diff --git a/features/inboundemail.py b/features/inboundemail.py
new file mode 100644
index 0000000..8ae095e
--- /dev/null
+++ b/features/inboundemail.py
@@ -0,0 +1,339 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Handler to process inbound email with issue comments and commands."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import email
+import logging
+import os
+import re
+import time
+import urllib
+
+import ezt
+
+from google.appengine.api import mail
+from google.appengine.ext.webapp.mail_handlers import BounceNotificationHandler
+
+import webapp2
+
+import settings
+from businesslogic import work_env
+from features import alert2issue
+from features import commitlogcommands
+from features import notify_helpers
+from framework import authdata
+from framework import emailfmt
+from framework import exceptions
+from framework import framework_constants
+from framework import monorailcontext
+from framework import permissions
+from framework import sql
+from framework import template_helpers
+from proto import project_pb2
+from tracker import tracker_helpers
+
+
+TEMPLATE_PATH_BASE = framework_constants.TEMPLATE_PATH
+
+MSG_TEMPLATES = {
+ 'banned': 'features/inboundemail-banned.ezt',
+ 'body_too_long': 'features/inboundemail-body-too-long.ezt',
+ 'project_not_found': 'features/inboundemail-project-not-found.ezt',
+ 'not_a_reply': 'features/inboundemail-not-a-reply.ezt',
+ 'no_account': 'features/inboundemail-no-account.ezt',
+ 'no_artifact': 'features/inboundemail-no-artifact.ezt',
+ 'no_perms': 'features/inboundemail-no-perms.ezt',
+ 'replies_disabled': 'features/inboundemail-replies-disabled.ezt',
+ }
+
+
+class InboundEmail(webapp2.RequestHandler):
+ """Servlet to handle inbound email messages."""
+
+ def __init__(self, request, response, services=None, *args, **kwargs):
+ super(InboundEmail, self).__init__(request, response, *args, **kwargs)
+ self.services = services or self.app.config.get('services')
+ self._templates = {}
+ for name, template_path in MSG_TEMPLATES.items():
+ self._templates[name] = template_helpers.MonorailTemplate(
+ TEMPLATE_PATH_BASE + template_path,
+ compress_whitespace=False, base_format=ezt.FORMAT_RAW)
+
+ def get(self, project_addr=None):
+ logging.info('\n\n\nGET for InboundEmail and project_addr is %r',
+ project_addr)
+ self.Handler(mail.InboundEmailMessage(self.request.body),
+ urllib.unquote(project_addr))
+
+ def post(self, project_addr=None):
+ logging.info('\n\n\nPOST for InboundEmail and project_addr is %r',
+ project_addr)
+ self.Handler(mail.InboundEmailMessage(self.request.body),
+ urllib.unquote(project_addr))
+
+ def Handler(self, inbound_email_message, project_addr):
+ """Process an inbound email message."""
+ msg = inbound_email_message.original
+ email_tasks = self.ProcessMail(msg, project_addr)
+
+ if email_tasks:
+ notify_helpers.AddAllEmailTasks(email_tasks)
+
+ def ProcessMail(self, msg, project_addr):
+ """Process an inbound email message."""
+ # TODO(jrobbins): If the message is HUGE, don't even try to parse
+ # it. Silently give up.
+
+ (from_addr, to_addrs, cc_addrs, references, incident_id, subject,
+ body) = emailfmt.ParseEmailMessage(msg)
+
+ logging.info('Proj addr: %r', project_addr)
+ logging.info('From addr: %r', from_addr)
+ logging.info('Subject: %r', subject)
+ logging.info('To: %r', to_addrs)
+ logging.info('Cc: %r', cc_addrs)
+ logging.info('References: %r', references)
+ logging.info('Incident Id: %r', incident_id)
+ logging.info('Body: %r', body)
+
+ # If message body is very large, reject it and send an error email.
+ if emailfmt.IsBodyTooBigToParse(body):
+ return _MakeErrorMessageReplyTask(
+ project_addr, from_addr, self._templates['body_too_long'])
+
+ # Make sure that the project reply-to address is in the To: line.
+ if not emailfmt.IsProjectAddressOnToLine(project_addr, to_addrs):
+ return None
+
+ project_name, verb, trooper_queue = emailfmt.IdentifyProjectVerbAndLabel(
+ project_addr)
+
+ is_alert = bool(verb and verb.lower() == 'alert')
+ error_addr = from_addr
+ local_id = None
+ author_addr = from_addr
+
+ if is_alert:
+ error_addr = settings.alert_escalation_email
+ author_addr = settings.alert_service_account
+ else:
+ local_id = emailfmt.IdentifyIssue(project_name, subject)
+ if not local_id:
+ logging.info('Could not identify issue: %s %s', project_addr, subject)
+ # No error message, because message was probably not intended for us.
+ return None
+
+ cnxn = sql.MonorailConnection()
+ if self.services.cache_manager:
+ self.services.cache_manager.DoDistributedInvalidation(cnxn)
+
+ project = self.services.project.GetProjectByName(cnxn, project_name)
+ # Authenticate the author_addr and perm check.
+ try:
+ mc = monorailcontext.MonorailContext(
+ self.services, cnxn=cnxn, requester=author_addr, autocreate=is_alert)
+ mc.LookupLoggedInUserPerms(project)
+ except exceptions.NoSuchUserException:
+ return _MakeErrorMessageReplyTask(
+ project_addr, error_addr, self._templates['no_account'])
+
+ # TODO(zhangtiff): Add separate email templates for alert error cases.
+ if not project or project.state != project_pb2.ProjectState.LIVE:
+ return _MakeErrorMessageReplyTask(
+ project_addr, error_addr, self._templates['project_not_found'])
+
+ if not project.process_inbound_email:
+ return _MakeErrorMessageReplyTask(
+ project_addr, error_addr, self._templates['replies_disabled'],
+ project_name=project_name)
+
+ # Verify that this is a reply to a notification that we could have sent.
+ is_development = os.environ['SERVER_SOFTWARE'].startswith('Development')
+ if not (is_alert or is_development):
+ for ref in references:
+ if emailfmt.ValidateReferencesHeader(ref, project, from_addr, subject):
+ break # Found a message ID that we could have sent.
+ if emailfmt.ValidateReferencesHeader(
+ ref, project, from_addr.lower(), subject):
+ break # Also match all-lowercase from-address.
+ else: # for-else: if loop completes with no valid reference found.
+ return _MakeErrorMessageReplyTask(
+ project_addr, from_addr, self._templates['not_a_reply'])
+
+ # Note: If the issue summary line is changed, a new thread is created,
+ # and replies to the old thread will no longer work because the subject
+ # line hash will not match, which seems reasonable.
+
+ if mc.auth.user_pb.banned:
+ logging.info('Banned user %s tried to post to %s',
+ from_addr, project_addr)
+ return _MakeErrorMessageReplyTask(
+ project_addr, error_addr, self._templates['banned'])
+
+ # If the email is an alert, switch to the alert handling path.
+ if is_alert:
+ alert2issue.ProcessEmailNotification(
+ self.services, cnxn, project, project_addr, from_addr,
+ mc.auth, subject, body, incident_id, msg, trooper_queue)
+ return None
+
+ # This email is a response to an email about a comment.
+ self.ProcessIssueReply(
+ mc, project, local_id, project_addr, body)
+
+ return None
+
+ def ProcessIssueReply(
+ self, mc, project, local_id, project_addr, body):
+ """Examine an issue reply email body and add a comment to the issue.
+
+ Args:
+ mc: MonorailContext with cnxn and the requester email, user_id, perms.
+ project: Project PB for the project containing the issue.
+ local_id: int ID of the issue being replied to.
+ project_addr: string email address used for outbound emails from
+ that project.
+ body: string email body text of the reply email.
+
+ Returns:
+ A list of follow-up work items, e.g., to notify other users of
+ the new comment, or to notify the user that their reply was not
+ processed.
+
+ Side-effect:
+ Adds a new comment to the issue, if no error is reported.
+ """
+ try:
+ issue = self.services.issue.GetIssueByLocalID(
+ mc.cnxn, project.project_id, local_id)
+ except exceptions.NoSuchIssueException:
+ issue = None
+
+ if not issue or issue.deleted:
+ # The referenced issue was not found, e.g., it might have been
+ # deleted, or someone messed with the subject line. Reject it.
+ return _MakeErrorMessageReplyTask(
+ project_addr, mc.auth.email, self._templates['no_artifact'],
+ artifact_phrase='issue %d' % local_id,
+ project_name=project.project_name)
+
+ can_view = mc.perms.CanUsePerm(
+ permissions.VIEW, mc.auth.effective_ids, project,
+ permissions.GetRestrictions(issue))
+ can_comment = mc.perms.CanUsePerm(
+ permissions.ADD_ISSUE_COMMENT, mc.auth.effective_ids, project,
+ permissions.GetRestrictions(issue))
+ if not can_view or not can_comment:
+ return _MakeErrorMessageReplyTask(
+ project_addr, mc.auth.email, self._templates['no_perms'],
+ artifact_phrase='issue %d' % local_id,
+ project_name=project.project_name)
+
+ # TODO(jrobbins): if the user does not have EDIT_ISSUE and the inbound
+ # email tries to make an edit, send back an error message.
+
+ lines = body.strip().split('\n')
+ uia = commitlogcommands.UpdateIssueAction(local_id)
+ uia.Parse(mc.cnxn, project.project_name, mc.auth.user_id, lines,
+ self.services, strip_quoted_lines=True)
+ uia.Run(mc, self.services)
+
+
+def _MakeErrorMessageReplyTask(
+ project_addr, sender_addr, template, **callers_page_data):
+ """Return a new task to send an error message email.
+
+ Args:
+ project_addr: string email address that the inbound email was delivered to.
+ sender_addr: string email address of user who sent the email that we could
+ not process.
+ template: EZT template used to generate the email error message. The
+ first line of this generated text will be used as the subject line.
+ callers_page_data: template data dict for body of the message.
+
+ Returns:
+ A list with a single Email task that can be enqueued to
+ actually send the email.
+
+ Raises:
+ ValueError: if the template does begin with a "Subject:" line.
+ """
+ email_data = {
+ 'project_addr': project_addr,
+ 'sender_addr': sender_addr
+ }
+ email_data.update(callers_page_data)
+
+ generated_lines = template.GetResponse(email_data)
+ subject, body = generated_lines.split('\n', 1)
+ if subject.startswith('Subject: '):
+ subject = subject[len('Subject: '):]
+ else:
+ raise ValueError('Email template does not begin with "Subject:" line.')
+
+ email_task = dict(to=sender_addr, subject=subject, body=body,
+ from_addr=emailfmt.NoReplyAddress())
+ logging.info('sending email error reply: %r', email_task)
+
+ return [email_task]
+
+
+BAD_WRAP_RE = re.compile('=\r\n')
+BAD_EQ_RE = re.compile('=3D')
+
+class BouncedEmail(BounceNotificationHandler):
+ """Handler to notice when email to given user is bouncing."""
+
+ # For docs on AppEngine's bounce email handling, see:
+ # https://cloud.google.com/appengine/docs/python/mail/bounce
+ # Source code is in file:
+ # google_appengine/google/appengine/ext/webapp/mail_handlers.py
+
+ def post(self):
+ try:
+ super(BouncedEmail, self).post()
+ except AttributeError:
+ # Work-around for
+ # https://code.google.com/p/googleappengine/issues/detail?id=13512
+ raw_message = self.request.POST.get('raw-message')
+ logging.info('raw_message %r', raw_message)
+ raw_message = BAD_WRAP_RE.sub('', raw_message)
+ raw_message = BAD_EQ_RE.sub('=', raw_message)
+ logging.info('fixed raw_message %r', raw_message)
+ mime_message = email.message_from_string(raw_message)
+ logging.info('get_payload gives %r', mime_message.get_payload())
+ self.request.POST['raw-message'] = mime_message
+ super(BouncedEmail, self).post() # Retry with mime_message
+
+
+ def receive(self, bounce_message):
+ email_addr = bounce_message.original.get('to')
+ logging.info('Bounce was sent to: %r', email_addr)
+
+ # TODO(crbug.com/monorail/8727): The problem is likely no longer happening.
+ # but we are adding permanent logging so we don't have to keep adding
+ # expriring logpoints.
+ if '@intel' in email_addr: # both intel.com and intel-partner.
+ logging.info(
+ 'bounce message: %s', bounce_message.notification.get('text'))
+
+ app_config = webapp2.WSGIApplication.app.config
+ services = app_config['services']
+ cnxn = sql.MonorailConnection()
+
+ try:
+ user_id = services.user.LookupUserID(cnxn, email_addr)
+ user = services.user.GetUser(cnxn, user_id)
+ user.email_bounce_timestamp = int(time.time())
+ services.user.UpdateUser(cnxn, user_id, user)
+ except exceptions.NoSuchUserException:
+ logging.info('User %r not found, ignoring', email_addr)
+ logging.info('Received bounce post ... [%s]', self.request)
+ logging.info('Bounce original: %s', bounce_message.original)
+ logging.info('Bounce notification: %s', bounce_message.notification)
diff --git a/features/notify.py b/features/notify.py
new file mode 100644
index 0000000..c285c76
--- /dev/null
+++ b/features/notify.py
@@ -0,0 +1,1055 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Task handlers for email notifications of issue changes.
+
+Email notificatons are sent when an issue changes, an issue that is blocking
+another issue changes, or a bulk edit is done. The users notified include
+the project-wide mailing list, issue owners, cc'd users, starrers,
+also-notify addresses, and users who have saved queries with email notification
+set.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import collections
+import json
+import logging
+import os
+
+import ezt
+
+from google.appengine.api import mail
+from google.appengine.runtime import apiproxy_errors
+
+import settings
+from features import autolink
+from features import notify_helpers
+from features import notify_reasons
+from framework import authdata
+from framework import emailfmt
+from framework import exceptions
+from framework import framework_bizobj
+from framework import framework_constants
+from framework import framework_helpers
+from framework import framework_views
+from framework import jsonfeed
+from framework import monorailrequest
+from framework import permissions
+from framework import template_helpers
+from framework import urls
+from tracker import tracker_bizobj
+from tracker import tracker_helpers
+from tracker import tracker_views
+from proto import tracker_pb2
+
+
+class NotifyIssueChangeTask(notify_helpers.NotifyTaskBase):
+ """JSON servlet that notifies appropriate users after an issue change."""
+
+ _EMAIL_TEMPLATE = 'tracker/issue-change-notification-email.ezt'
+ _LINK_ONLY_EMAIL_TEMPLATE = (
+ 'tracker/issue-change-notification-email-link-only.ezt')
+
+ def HandleRequest(self, mr):
+ """Process the task to notify users after an issue change.
+
+ Args:
+ mr: common information parsed from the HTTP request.
+
+ Returns:
+ Results dictionary in JSON format which is useful just for debugging.
+ The main goal is the side-effect of sending emails.
+ """
+ issue_id = mr.GetPositiveIntParam('issue_id')
+ if not issue_id:
+ return {
+ 'params': {},
+ 'notified': [],
+ 'message': 'Cannot proceed without a valid issue ID.',
+ }
+ commenter_id = mr.GetPositiveIntParam('commenter_id')
+ seq_num = mr.seq
+ omit_ids = [commenter_id]
+ hostport = mr.GetParam('hostport')
+ try:
+ old_owner_id = mr.GetPositiveIntParam('old_owner_id')
+ except Exception:
+ old_owner_id = framework_constants.NO_USER_SPECIFIED
+ send_email = bool(mr.GetIntParam('send_email'))
+ comment_id = mr.GetPositiveIntParam('comment_id')
+ params = dict(
+ issue_id=issue_id, commenter_id=commenter_id,
+ seq_num=seq_num, hostport=hostport, old_owner_id=old_owner_id,
+ omit_ids=omit_ids, send_email=send_email, comment_id=comment_id)
+
+ logging.info('issue change params are %r', params)
+ # TODO(jrobbins): Re-enable the issue cache for notifications after
+ # the stale issue defect (monorail:2514) is 100% resolved.
+ issue = self.services.issue.GetIssue(mr.cnxn, issue_id, use_cache=False)
+ project = self.services.project.GetProject(mr.cnxn, issue.project_id)
+ config = self.services.config.GetProjectConfig(mr.cnxn, issue.project_id)
+
+ if issue.is_spam:
+ # Don't send email for spam issues.
+ return {
+ 'params': params,
+ 'notified': [],
+ }
+
+ all_comments = self.services.issue.GetCommentsForIssue(
+ mr.cnxn, issue.issue_id)
+ if comment_id:
+ logging.info('Looking up comment by comment_id')
+ for c in all_comments:
+ if c.id == comment_id:
+ comment = c
+ logging.info('Comment was found by comment_id')
+ break
+ else:
+ raise ValueError('Comment %r was not found' % comment_id)
+ else:
+ logging.info('Looking up comment by seq_num')
+ comment = all_comments[seq_num]
+
+ # Only issues that any contributor could view sent to mailing lists.
+ contributor_could_view = permissions.CanViewIssue(
+ set(), permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET,
+ project, issue)
+ starrer_ids = self.services.issue_star.LookupItemStarrers(
+ mr.cnxn, issue.issue_id)
+ users_by_id = framework_views.MakeAllUserViews(
+ mr.cnxn, self.services.user,
+ tracker_bizobj.UsersInvolvedInIssues([issue]), [old_owner_id],
+ tracker_bizobj.UsersInvolvedInComment(comment),
+ issue.cc_ids, issue.derived_cc_ids, starrer_ids, omit_ids)
+
+ # Make followup tasks to send emails
+ tasks = []
+ if send_email:
+ tasks = self._MakeEmailTasks(
+ mr.cnxn, project, issue, config, old_owner_id, users_by_id,
+ all_comments, comment, starrer_ids, contributor_could_view,
+ hostport, omit_ids, mr.perms)
+
+ notified = notify_helpers.AddAllEmailTasks(tasks)
+
+ return {
+ 'params': params,
+ 'notified': notified,
+ }
+
+ def _MakeEmailTasks(
+ self, cnxn, project, issue, config, old_owner_id,
+ users_by_id, all_comments, comment, starrer_ids,
+ contributor_could_view, hostport, omit_ids, perms):
+ """Formulate emails to be sent."""
+ detail_url = framework_helpers.IssueCommentURL(
+ hostport, project, issue.local_id, seq_num=comment.sequence)
+
+ # TODO(jrobbins): avoid the need to make a MonorailRequest object.
+ mr = monorailrequest.MonorailRequest(self.services)
+ mr.project_name = project.project_name
+ mr.project = project
+ mr.perms = perms
+
+ # We do not autolink in the emails, so just use an empty
+ # registry of autolink rules.
+ # TODO(jrobbins): offer users an HTML email option w/ autolinks.
+ autolinker = autolink.Autolink()
+ was_created = ezt.boolean(comment.sequence == 0)
+
+ email_data = {
+ # Pass open_related and closed_related into this method and to
+ # the issue view so that we can show it on new issue email.
+ 'issue': tracker_views.IssueView(issue, users_by_id, config),
+ 'summary': issue.summary,
+ 'comment': tracker_views.IssueCommentView(
+ project.project_name, comment, users_by_id,
+ autolinker, {}, mr, issue),
+ 'comment_text': comment.content,
+ 'detail_url': detail_url,
+ 'was_created': was_created,
+ }
+
+ # Generate three versions of email body: link-only is just the link,
+ # non-members see some obscured email addresses, and members version has
+ # all full email addresses exposed.
+ body_link_only = self.link_only_email_template.GetResponse(
+ {'detail_url': detail_url, 'was_created': was_created})
+ body_for_non_members = self.email_template.GetResponse(email_data)
+ framework_views.RevealAllEmails(users_by_id)
+ email_data['comment'] = tracker_views.IssueCommentView(
+ project.project_name, comment, users_by_id,
+ autolinker, {}, mr, issue)
+ body_for_members = self.email_template.GetResponse(email_data)
+
+ logging.info('link-only body is:\n%r' % body_link_only)
+ logging.info('body for non-members is:\n%r' % body_for_non_members)
+ logging.info('body for members is:\n%r' % body_for_members)
+
+ commenter_email = users_by_id[comment.user_id].email
+ omit_addrs = set([commenter_email] +
+ [users_by_id[omit_id].email for omit_id in omit_ids])
+
+ auth = authdata.AuthData.FromUserID(
+ cnxn, comment.user_id, self.services)
+ commenter_in_project = framework_bizobj.UserIsInProject(
+ project, auth.effective_ids)
+ noisy = tracker_helpers.IsNoisy(len(all_comments) - 1, len(starrer_ids))
+
+ # Give each user a bullet-list of all the reasons that apply for that user.
+ group_reason_list = notify_reasons.ComputeGroupReasonList(
+ cnxn, self.services, project, issue, config, users_by_id,
+ omit_addrs, contributor_could_view, noisy=noisy,
+ starrer_ids=starrer_ids, old_owner_id=old_owner_id,
+ commenter_in_project=commenter_in_project)
+
+ commenter_view = users_by_id[comment.user_id]
+ detail_url = framework_helpers.FormatAbsoluteURLForDomain(
+ hostport, issue.project_name, urls.ISSUE_DETAIL,
+ id=issue.local_id)
+ email_tasks = notify_helpers.MakeBulletedEmailWorkItems(
+ group_reason_list, issue, body_link_only, body_for_non_members,
+ body_for_members, project, hostport, commenter_view, detail_url,
+ seq_num=comment.sequence)
+
+ return email_tasks
+
+
+class NotifyBlockingChangeTask(notify_helpers.NotifyTaskBase):
+ """JSON servlet that notifies appropriate users after a blocking change."""
+
+ _EMAIL_TEMPLATE = 'tracker/issue-blocking-change-notification-email.ezt'
+ _LINK_ONLY_EMAIL_TEMPLATE = (
+ 'tracker/issue-change-notification-email-link-only.ezt')
+
+ def HandleRequest(self, mr):
+ """Process the task to notify users after an issue blocking change.
+
+ Args:
+ mr: common information parsed from the HTTP request.
+
+ Returns:
+ Results dictionary in JSON format which is useful just for debugging.
+ The main goal is the side-effect of sending emails.
+ """
+ issue_id = mr.GetPositiveIntParam('issue_id')
+ if not issue_id:
+ return {
+ 'params': {},
+ 'notified': [],
+ 'message': 'Cannot proceed without a valid issue ID.',
+ }
+ commenter_id = mr.GetPositiveIntParam('commenter_id')
+ omit_ids = [commenter_id]
+ hostport = mr.GetParam('hostport')
+ delta_blocker_iids = mr.GetIntListParam('delta_blocker_iids')
+ send_email = bool(mr.GetIntParam('send_email'))
+ params = dict(
+ issue_id=issue_id, commenter_id=commenter_id,
+ hostport=hostport, delta_blocker_iids=delta_blocker_iids,
+ omit_ids=omit_ids, send_email=send_email)
+
+ logging.info('blocking change params are %r', params)
+ issue = self.services.issue.GetIssue(mr.cnxn, issue_id)
+ if issue.is_spam:
+ return {
+ 'params': params,
+ 'notified': [],
+ }
+
+ upstream_issues = self.services.issue.GetIssues(
+ mr.cnxn, delta_blocker_iids)
+ logging.info('updating ids %r', [up.local_id for up in upstream_issues])
+ upstream_projects = tracker_helpers.GetAllIssueProjects(
+ mr.cnxn, upstream_issues, self.services.project)
+ upstream_configs = self.services.config.GetProjectConfigs(
+ mr.cnxn, list(upstream_projects.keys()))
+
+ users_by_id = framework_views.MakeAllUserViews(
+ mr.cnxn, self.services.user, [commenter_id])
+ commenter_view = users_by_id[commenter_id]
+
+ tasks = []
+ if send_email:
+ for upstream_issue in upstream_issues:
+ one_issue_email_tasks = self._ProcessUpstreamIssue(
+ mr.cnxn, upstream_issue,
+ upstream_projects[upstream_issue.project_id],
+ upstream_configs[upstream_issue.project_id],
+ issue, omit_ids, hostport, commenter_view)
+ tasks.extend(one_issue_email_tasks)
+
+ notified = notify_helpers.AddAllEmailTasks(tasks)
+
+ return {
+ 'params': params,
+ 'notified': notified,
+ }
+
+ def _ProcessUpstreamIssue(
+ self, cnxn, upstream_issue, upstream_project, upstream_config,
+ issue, omit_ids, hostport, commenter_view):
+ """Compute notifications for one upstream issue that is now blocking."""
+ upstream_detail_url = framework_helpers.FormatAbsoluteURLForDomain(
+ hostport, upstream_issue.project_name, urls.ISSUE_DETAIL,
+ id=upstream_issue.local_id)
+ logging.info('upstream_detail_url = %r', upstream_detail_url)
+ detail_url = framework_helpers.FormatAbsoluteURLForDomain(
+ hostport, issue.project_name, urls.ISSUE_DETAIL,
+ id=issue.local_id)
+
+ # Only issues that any contributor could view are sent to mailing lists.
+ contributor_could_view = permissions.CanViewIssue(
+ set(), permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET,
+ upstream_project, upstream_issue)
+
+ # Now construct the e-mail to send
+
+ # Note: we purposely do not notify users who starred an issue
+ # about changes in blocking.
+ users_by_id = framework_views.MakeAllUserViews(
+ cnxn, self.services.user,
+ tracker_bizobj.UsersInvolvedInIssues([upstream_issue]), omit_ids)
+
+ is_blocking = upstream_issue.issue_id in issue.blocked_on_iids
+
+ email_data = {
+ 'issue': tracker_views.IssueView(
+ upstream_issue, users_by_id, upstream_config),
+ 'summary': upstream_issue.summary,
+ 'detail_url': upstream_detail_url,
+ 'is_blocking': ezt.boolean(is_blocking),
+ 'downstream_issue_ref': tracker_bizobj.FormatIssueRef(
+ (None, issue.local_id)),
+ 'downstream_issue_url': detail_url,
+ }
+
+ # TODO(jrobbins): Generate two versions of email body: members
+ # vesion has other member full email addresses exposed. But, don't
+ # expose too many as we iterate through upstream projects.
+ body_link_only = self.link_only_email_template.GetResponse(
+ {'detail_url': upstream_detail_url, 'was_created': ezt.boolean(False)})
+ body = self.email_template.GetResponse(email_data)
+
+ omit_addrs = {users_by_id[omit_id].email for omit_id in omit_ids}
+
+ # Get the transitive set of owners and Cc'd users, and their UserView's.
+ # Give each user a bullet-list of all the reasons that apply for that user.
+ # Starrers are not notified of blocking changes to reduce noise.
+ group_reason_list = notify_reasons.ComputeGroupReasonList(
+ cnxn, self.services, upstream_project, upstream_issue,
+ upstream_config, users_by_id, omit_addrs, contributor_could_view)
+ one_issue_email_tasks = notify_helpers.MakeBulletedEmailWorkItems(
+ group_reason_list, upstream_issue, body_link_only, body, body,
+ upstream_project, hostport, commenter_view, detail_url)
+
+ return one_issue_email_tasks
+
+
+class NotifyBulkChangeTask(notify_helpers.NotifyTaskBase):
+ """JSON servlet that notifies appropriate users after a bulk edit."""
+
+ _EMAIL_TEMPLATE = 'tracker/issue-bulk-change-notification-email.ezt'
+
+ def HandleRequest(self, mr):
+ """Process the task to notify users after an issue blocking change.
+
+ Args:
+ mr: common information parsed from the HTTP request.
+
+ Returns:
+ Results dictionary in JSON format which is useful just for debugging.
+ The main goal is the side-effect of sending emails.
+ """
+ issue_ids = mr.GetIntListParam('issue_ids')
+ hostport = mr.GetParam('hostport')
+ if not issue_ids:
+ return {
+ 'params': {},
+ 'notified': [],
+ 'message': 'Cannot proceed without a valid issue IDs.',
+ }
+
+ old_owner_ids = mr.GetIntListParam('old_owner_ids')
+ comment_text = mr.GetParam('comment_text')
+ commenter_id = mr.GetPositiveIntParam('commenter_id')
+ amendments = mr.GetParam('amendments')
+ send_email = bool(mr.GetIntParam('send_email'))
+ params = dict(
+ issue_ids=issue_ids, commenter_id=commenter_id, hostport=hostport,
+ old_owner_ids=old_owner_ids, comment_text=comment_text,
+ send_email=send_email, amendments=amendments)
+
+ logging.info('bulk edit params are %r', params)
+ issues = self.services.issue.GetIssues(mr.cnxn, issue_ids)
+ # TODO(jrobbins): For cross-project bulk edits, prefetch all relevant
+ # projects and configs and pass a dict of them to subroutines. For
+ # now, all issue must be in the same project.
+ project_id = issues[0].project_id
+ project = self.services.project.GetProject(mr.cnxn, project_id)
+ config = self.services.config.GetProjectConfig(mr.cnxn, project_id)
+ issues = [issue for issue in issues if not issue.is_spam]
+ anon_perms = permissions.GetPermissions(None, set(), project)
+
+ users_by_id = framework_views.MakeAllUserViews(
+ mr.cnxn, self.services.user, [commenter_id])
+ ids_in_issues = {}
+ starrers = {}
+
+ non_private_issues = []
+ for issue, old_owner_id in zip(issues, old_owner_ids):
+ # TODO(jrobbins): use issue_id consistently rather than local_id.
+ starrers[issue.local_id] = self.services.issue_star.LookupItemStarrers(
+ mr.cnxn, issue.issue_id)
+ named_ids = set() # users named in user-value fields that notify.
+ for fd in config.field_defs:
+ named_ids.update(notify_reasons.ComputeNamedUserIDsToNotify(
+ issue.field_values, fd))
+ direct, indirect = self.services.usergroup.ExpandAnyGroupEmailRecipients(
+ mr.cnxn,
+ list(issue.cc_ids) + list(issue.derived_cc_ids) +
+ [issue.owner_id, old_owner_id, issue.derived_owner_id] +
+ list(named_ids))
+ ids_in_issues[issue.local_id] = set(starrers[issue.local_id])
+ ids_in_issues[issue.local_id].update(direct)
+ ids_in_issues[issue.local_id].update(indirect)
+ ids_in_issue_needing_views = (
+ ids_in_issues[issue.local_id] |
+ tracker_bizobj.UsersInvolvedInIssues([issue]))
+ new_ids_in_issue = [user_id for user_id in ids_in_issue_needing_views
+ if user_id not in users_by_id]
+ users_by_id.update(
+ framework_views.MakeAllUserViews(
+ mr.cnxn, self.services.user, new_ids_in_issue))
+
+ anon_can_view = permissions.CanViewIssue(
+ set(), anon_perms, project, issue)
+ if anon_can_view:
+ non_private_issues.append(issue)
+
+ commenter_view = users_by_id[commenter_id]
+ omit_addrs = {commenter_view.email}
+
+ tasks = []
+ if send_email:
+ email_tasks = self._BulkEditEmailTasks(
+ mr.cnxn, issues, old_owner_ids, omit_addrs, project,
+ non_private_issues, users_by_id, ids_in_issues, starrers,
+ commenter_view, hostport, comment_text, amendments, config)
+ tasks = email_tasks
+
+ notified = notify_helpers.AddAllEmailTasks(tasks)
+ return {
+ 'params': params,
+ 'notified': notified,
+ }
+
+ def _BulkEditEmailTasks(
+ self, cnxn, issues, old_owner_ids, omit_addrs, project,
+ non_private_issues, users_by_id, ids_in_issues, starrers,
+ commenter_view, hostport, comment_text, amendments, config):
+ """Generate Email PBs to notify interested users after a bulk edit."""
+ # 1. Get the user IDs of everyone who could be notified,
+ # and make all their user proxies. Also, build a dictionary
+ # of all the users to notify and the issues that they are
+ # interested in. Also, build a dictionary of additional email
+ # addresses to notify and the issues to notify them of.
+ users_by_id = {}
+ ids_to_notify_of_issue = {}
+ additional_addrs_to_notify_of_issue = collections.defaultdict(list)
+
+ users_to_queries = notify_reasons.GetNonOmittedSubscriptions(
+ cnxn, self.services, [project.project_id], {})
+ config = self.services.config.GetProjectConfig(
+ cnxn, project.project_id)
+ for issue, old_owner_id in zip(issues, old_owner_ids):
+ issue_participants = set(
+ [tracker_bizobj.GetOwnerId(issue), old_owner_id] +
+ tracker_bizobj.GetCcIds(issue))
+ # users named in user-value fields that notify.
+ for fd in config.field_defs:
+ issue_participants.update(
+ notify_reasons.ComputeNamedUserIDsToNotify(issue.field_values, fd))
+ for user_id in ids_in_issues[issue.local_id]:
+ # TODO(jrobbins): implement batch GetUser() for speed.
+ if not user_id:
+ continue
+ auth = authdata.AuthData.FromUserID(
+ cnxn, user_id, self.services)
+ if (auth.user_pb.notify_issue_change and
+ not auth.effective_ids.isdisjoint(issue_participants)):
+ ids_to_notify_of_issue.setdefault(user_id, []).append(issue)
+ elif (auth.user_pb.notify_starred_issue_change and
+ user_id in starrers[issue.local_id]):
+ # Skip users who have starred issues that they can no longer view.
+ starrer_perms = permissions.GetPermissions(
+ auth.user_pb, auth.effective_ids, project)
+ granted_perms = tracker_bizobj.GetGrantedPerms(
+ issue, auth.effective_ids, config)
+ starrer_can_view = permissions.CanViewIssue(
+ auth.effective_ids, starrer_perms, project, issue,
+ granted_perms=granted_perms)
+ if starrer_can_view:
+ ids_to_notify_of_issue.setdefault(user_id, []).append(issue)
+ logging.info(
+ 'ids_to_notify_of_issue[%s] = %s',
+ user_id,
+ [i.local_id for i in ids_to_notify_of_issue.get(user_id, [])])
+
+ # Find all subscribers that should be notified.
+ subscribers_to_consider = notify_reasons.EvaluateSubscriptions(
+ cnxn, issue, users_to_queries, self.services, config)
+ for sub_id in subscribers_to_consider:
+ auth = authdata.AuthData.FromUserID(cnxn, sub_id, self.services)
+ sub_perms = permissions.GetPermissions(
+ auth.user_pb, auth.effective_ids, project)
+ granted_perms = tracker_bizobj.GetGrantedPerms(
+ issue, auth.effective_ids, config)
+ sub_can_view = permissions.CanViewIssue(
+ auth.effective_ids, sub_perms, project, issue,
+ granted_perms=granted_perms)
+ if sub_can_view:
+ ids_to_notify_of_issue.setdefault(sub_id, [])
+ if issue not in ids_to_notify_of_issue[sub_id]:
+ ids_to_notify_of_issue[sub_id].append(issue)
+
+ if issue in non_private_issues:
+ for notify_addr in issue.derived_notify_addrs:
+ additional_addrs_to_notify_of_issue[notify_addr].append(issue)
+
+ # 2. Compose an email specifically for each user, and one email to each
+ # notify_addr with all the issues that it.
+ # Start from non-members first, then members to reveal email addresses.
+ email_tasks = []
+ needed_user_view_ids = [uid for uid in ids_to_notify_of_issue
+ if uid not in users_by_id]
+ users_by_id.update(framework_views.MakeAllUserViews(
+ cnxn, self.services.user, needed_user_view_ids))
+ member_ids_to_notify_of_issue = {}
+ non_member_ids_to_notify_of_issue = {}
+ member_additional_addrs = {}
+ non_member_additional_addrs = {}
+ addr_to_addrperm = {} # {email_address: AddrPerm object}
+ all_user_prefs = self.services.user.GetUsersPrefs(
+ cnxn, ids_to_notify_of_issue)
+
+ # TODO(jrobbins): Merge ids_to_notify_of_issue entries for linked accounts.
+
+ for user_id in ids_to_notify_of_issue:
+ if not user_id:
+ continue # Don't try to notify NO_USER_SPECIFIED
+ if users_by_id[user_id].email in omit_addrs:
+ logging.info('Omitting %s', user_id)
+ continue
+ user_issues = ids_to_notify_of_issue[user_id]
+ if not user_issues:
+ continue # user's prefs indicate they don't want these notifications
+ auth = authdata.AuthData.FromUserID(
+ cnxn, user_id, self.services)
+ is_member = bool(framework_bizobj.UserIsInProject(
+ project, auth.effective_ids))
+ if is_member:
+ member_ids_to_notify_of_issue[user_id] = user_issues
+ else:
+ non_member_ids_to_notify_of_issue[user_id] = user_issues
+ addr = users_by_id[user_id].email
+ omit_addrs.add(addr)
+ addr_to_addrperm[addr] = notify_reasons.AddrPerm(
+ is_member, addr, users_by_id[user_id].user,
+ notify_reasons.REPLY_NOT_ALLOWED, all_user_prefs[user_id])
+
+ for addr, addr_issues in additional_addrs_to_notify_of_issue.items():
+ auth = None
+ try:
+ auth = authdata.AuthData.FromEmail(cnxn, addr, self.services)
+ except: # pylint: disable=bare-except
+ logging.warning('Cannot find user of email %s ', addr)
+ if auth:
+ is_member = bool(framework_bizobj.UserIsInProject(
+ project, auth.effective_ids))
+ else:
+ is_member = False
+ if is_member:
+ member_additional_addrs[addr] = addr_issues
+ else:
+ non_member_additional_addrs[addr] = addr_issues
+ omit_addrs.add(addr)
+ addr_to_addrperm[addr] = notify_reasons.AddrPerm(
+ is_member, addr, None, notify_reasons.REPLY_NOT_ALLOWED, None)
+
+ for user_id, user_issues in non_member_ids_to_notify_of_issue.items():
+ addr = users_by_id[user_id].email
+ email = self._FormatBulkIssuesEmail(
+ addr_to_addrperm[addr], user_issues, users_by_id,
+ commenter_view, hostport, comment_text, amendments, config, project)
+ email_tasks.append(email)
+ logging.info('about to bulk notify non-member %s (%s) of %s',
+ users_by_id[user_id].email, user_id,
+ [issue.local_id for issue in user_issues])
+
+ for addr, addr_issues in non_member_additional_addrs.items():
+ email = self._FormatBulkIssuesEmail(
+ addr_to_addrperm[addr], addr_issues, users_by_id, commenter_view,
+ hostport, comment_text, amendments, config, project)
+ email_tasks.append(email)
+ logging.info('about to bulk notify non-member additional addr %s of %s',
+ addr, [addr_issue.local_id for addr_issue in addr_issues])
+
+ framework_views.RevealAllEmails(users_by_id)
+ commenter_view.RevealEmail()
+
+ for user_id, user_issues in member_ids_to_notify_of_issue.items():
+ addr = users_by_id[user_id].email
+ email = self._FormatBulkIssuesEmail(
+ addr_to_addrperm[addr], user_issues, users_by_id,
+ commenter_view, hostport, comment_text, amendments, config, project)
+ email_tasks.append(email)
+ logging.info('about to bulk notify member %s (%s) of %s',
+ addr, user_id, [issue.local_id for issue in user_issues])
+
+ for addr, addr_issues in member_additional_addrs.items():
+ email = self._FormatBulkIssuesEmail(
+ addr_to_addrperm[addr], addr_issues, users_by_id, commenter_view,
+ hostport, comment_text, amendments, config, project)
+ email_tasks.append(email)
+ logging.info('about to bulk notify member additional addr %s of %s',
+ addr, [addr_issue.local_id for addr_issue in addr_issues])
+
+ # 4. Add in the project's issue_notify_address. This happens even if it
+ # is the same as the commenter's email address (which would be an unusual
+ # but valid project configuration). Only issues that any contributor could
+ # view are included in emails to the all-issue-activity mailing lists.
+ if (project.issue_notify_address
+ and project.issue_notify_address not in omit_addrs):
+ non_private_issues_live = []
+ for issue in issues:
+ contributor_could_view = permissions.CanViewIssue(
+ set(), permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET,
+ project, issue)
+ if contributor_could_view:
+ non_private_issues_live.append(issue)
+
+ if non_private_issues_live:
+ project_notify_addrperm = notify_reasons.AddrPerm(
+ True, project.issue_notify_address, None,
+ notify_reasons.REPLY_NOT_ALLOWED, None)
+ email = self._FormatBulkIssuesEmail(
+ project_notify_addrperm, non_private_issues_live,
+ users_by_id, commenter_view, hostport, comment_text, amendments,
+ config, project)
+ email_tasks.append(email)
+ omit_addrs.add(project.issue_notify_address)
+ logging.info('about to bulk notify all-issues %s of %s',
+ project.issue_notify_address,
+ [issue.local_id for issue in non_private_issues])
+
+ return email_tasks
+
+ def _FormatBulkIssuesEmail(
+ self, addr_perm, issues, users_by_id, commenter_view,
+ hostport, comment_text, amendments, config, project):
+ """Format an email to one user listing many issues."""
+
+ from_addr = emailfmt.FormatFromAddr(
+ project, commenter_view=commenter_view, reveal_addr=addr_perm.is_member,
+ can_reply_to=False)
+
+ subject, body = self._FormatBulkIssues(
+ issues, users_by_id, commenter_view, hostport, comment_text,
+ amendments, config, addr_perm)
+ body = notify_helpers._TruncateBody(body)
+
+ return dict(from_addr=from_addr, to=addr_perm.address, subject=subject,
+ body=body)
+
+ def _FormatBulkIssues(
+ self, issues, users_by_id, commenter_view, hostport, comment_text,
+ amendments, config, addr_perm):
+ """Format a subject and body for a bulk issue edit."""
+ project_name = issues[0].project_name
+
+ any_link_only = False
+ issue_views = []
+ for issue in issues:
+ # TODO(jrobbins): choose config from dict of prefetched configs.
+ issue_view = tracker_views.IssueView(issue, users_by_id, config)
+ issue_view.link_only = ezt.boolean(False)
+ if addr_perm and notify_helpers.ShouldUseLinkOnly(addr_perm, issue):
+ issue_view.link_only = ezt.boolean(True)
+ any_link_only = True
+ issue_views.append(issue_view)
+
+ email_data = {
+ 'any_link_only': ezt.boolean(any_link_only),
+ 'hostport': hostport,
+ 'num_issues': len(issues),
+ 'issues': issue_views,
+ 'comment_text': comment_text,
+ 'commenter': commenter_view,
+ 'amendments': amendments,
+ }
+
+ if len(issues) == 1:
+ # TODO(jrobbins): use compact email subject lines based on user pref.
+ if addr_perm and notify_helpers.ShouldUseLinkOnly(addr_perm, issues[0]):
+ subject = 'issue %s in %s' % (issues[0].local_id, project_name)
+ else:
+ subject = 'issue %s in %s: %s' % (
+ issues[0].local_id, project_name, issues[0].summary)
+ # TODO(jrobbins): Look up the sequence number instead and treat this
+ # more like an individual change for email threading. For now, just
+ # add "Re:" because bulk edits are always replies.
+ subject = 'Re: ' + subject
+ else:
+ subject = '%d issues changed in %s' % (len(issues), project_name)
+
+ body = self.email_template.GetResponse(email_data)
+
+ return subject, body
+
+
+# For now, this class will not be used to send approval comment notifications
+# TODO(jojwang): monorail:3588, it might make sense for this class to handle
+# sending comment notifications for approval custom_subfield changes.
+class NotifyApprovalChangeTask(notify_helpers.NotifyTaskBase):
+ """JSON servlet that notifies appropriate users after an approval change."""
+
+ _EMAIL_TEMPLATE = 'tracker/approval-change-notification-email.ezt'
+
+ def HandleRequest(self, mr):
+ """Process the task to notify users after an approval change.
+
+ Args:
+ mr: common information parsed from the HTTP request.
+
+ Returns:
+ Results dictionary in JSON format which is useful just for debugging.
+ The main goal is the side-effect of sending emails.
+ """
+
+ send_email = bool(mr.GetIntParam('send_email'))
+ issue_id = mr.GetPositiveIntParam('issue_id')
+ approval_id = mr.GetPositiveIntParam('approval_id')
+ comment_id = mr.GetPositiveIntParam('comment_id')
+ hostport = mr.GetParam('hostport')
+
+ params = dict(
+ temporary='',
+ hostport=hostport,
+ issue_id=issue_id
+ )
+ logging.info('approval change params are %r', params)
+
+ issue, approval_value = self.services.issue.GetIssueApproval(
+ mr.cnxn, issue_id, approval_id, use_cache=False)
+ project = self.services.project.GetProject(mr.cnxn, issue.project_id)
+ config = self.services.config.GetProjectConfig(mr.cnxn, issue.project_id)
+
+ approval_fd = tracker_bizobj.FindFieldDefByID(approval_id, config)
+ if approval_fd is None:
+ raise exceptions.NoSuchFieldDefException()
+
+ # GetCommentsForIssue will fill the sequence for all comments, while
+ # other method for getting a single comment will not.
+ # The comment sequence is especially useful for Approval issues with
+ # many comment sections.
+ comment = None
+ all_comments = self.services.issue.GetCommentsForIssue(mr.cnxn, issue_id)
+ for c in all_comments:
+ if c.id == comment_id:
+ comment = c
+ break
+ if not comment:
+ raise exceptions.NoSuchCommentException()
+
+ field_user_ids = set()
+ relevant_fds = [fd for fd in config.field_defs if
+ not fd.approval_id or
+ fd.approval_id is approval_value.approval_id]
+ for fd in relevant_fds:
+ field_user_ids.update(
+ notify_reasons.ComputeNamedUserIDsToNotify(issue.field_values, fd))
+ users_by_id = framework_views.MakeAllUserViews(
+ mr.cnxn, self.services.user, [issue.owner_id],
+ approval_value.approver_ids,
+ tracker_bizobj.UsersInvolvedInComment(comment),
+ list(field_user_ids))
+
+ tasks = []
+ if send_email:
+ tasks = self._MakeApprovalEmailTasks(
+ hostport, issue, project, approval_value, approval_fd.field_name,
+ comment, users_by_id, list(field_user_ids), mr.perms)
+
+ notified = notify_helpers.AddAllEmailTasks(tasks)
+
+ return {
+ 'params': params,
+ 'notified': notified,
+ 'tasks': tasks,
+ }
+
+ def _MakeApprovalEmailTasks(
+ self, hostport, issue, project, approval_value, approval_name,
+ comment, users_by_id, user_ids_from_fields, perms):
+ """Formulate emails to be sent."""
+
+ # TODO(jojwang): avoid need to make MonorailRequest and autolinker
+ # for comment_view OR make make tracker_views._ParseTextRuns public
+ # and only pass text_runs to email_data.
+ mr = monorailrequest.MonorailRequest(self.services)
+ mr.project_name = project.project_name
+ mr.project = project
+ mr.perms = perms
+ autolinker = autolink.Autolink()
+
+ approval_url = framework_helpers.IssueCommentURL(
+ hostport, project, issue.local_id, seq_num=comment.sequence)
+
+ comment_view = tracker_views.IssueCommentView(
+ project.project_name, comment, users_by_id, autolinker, {}, mr, issue)
+ domain_url = framework_helpers.FormatAbsoluteURLForDomain(
+ hostport, project.project_name, '/issues/')
+
+ commenter_view = users_by_id[comment.user_id]
+ email_data = {
+ 'domain_url': domain_url,
+ 'approval_url': approval_url,
+ 'comment': comment_view,
+ 'issue_local_id': issue.local_id,
+ 'summary': issue.summary,
+ }
+ subject = '%s Approval: %s (Issue %s)' % (
+ approval_name, issue.summary, issue.local_id)
+ email_body = self.email_template.GetResponse(email_data)
+ body = notify_helpers._TruncateBody(email_body)
+
+ recipient_ids = self._GetApprovalEmailRecipients(
+ approval_value, comment, issue, user_ids_from_fields,
+ omit_ids=[comment.user_id])
+ direct, indirect = self.services.usergroup.ExpandAnyGroupEmailRecipients(
+ mr.cnxn, recipient_ids)
+ # group ids were found in recipient_ids.
+ # Re-set recipient_ids to remove group_ids
+ if indirect:
+ recipient_ids = set(direct + indirect)
+ users_by_id.update(framework_views.MakeAllUserViews(
+ mr.cnxn, self.services.user, indirect)) # already contains direct
+
+ # TODO(crbug.com/monorail/9104): Compute notify_reasons.AddrPerms based on
+ # project settings and recipient permissions so `reply_to` can be accurately
+ # set.
+
+ email_tasks = []
+ for rid in recipient_ids:
+ from_addr = emailfmt.FormatFromAddr(
+ project, commenter_view=commenter_view, can_reply_to=False)
+ dest_email = users_by_id[rid].email
+
+ refs = emailfmt.GetReferences(
+ dest_email, subject, comment.sequence,
+ '%s@%s' % (project.project_name, emailfmt.MailDomain()))
+ reply_to = emailfmt.NoReplyAddress()
+ email_tasks.append(
+ dict(
+ from_addr=from_addr,
+ to=dest_email,
+ subject=subject,
+ body=body,
+ reply_to=reply_to,
+ references=refs))
+
+ return email_tasks
+
+ def _GetApprovalEmailRecipients(
+ self, approval_value, comment, issue, user_ids_from_fields,
+ omit_ids=None):
+ # TODO(jojwang): monorail:3588, reorganize this, since now, comment_content
+ # and approval amendments happen in the same comment.
+ # NOTE: user_ids_from_fields are all the notify_on=ANY_COMMENT users.
+ # However, these users will still be excluded from notifications
+ # meant for approvers only eg. (status changed to REVIEW_REQUESTED).
+ recipient_ids = []
+ if comment.amendments:
+ for amendment in comment.amendments:
+ if amendment.custom_field_name == 'Status':
+ if (approval_value.status is
+ tracker_pb2.ApprovalStatus.REVIEW_REQUESTED):
+ recipient_ids = approval_value.approver_ids
+ else:
+ recipient_ids.extend([issue.owner_id])
+ recipient_ids.extend(user_ids_from_fields)
+
+ elif amendment.custom_field_name == 'Approvers':
+ recipient_ids.extend(approval_value.approver_ids)
+ recipient_ids.append(issue.owner_id)
+ recipient_ids.extend(user_ids_from_fields)
+ recipient_ids.extend(amendment.removed_user_ids)
+ else:
+ # No amendments, just a comment.
+ recipient_ids.extend(approval_value.approver_ids)
+ recipient_ids.append(issue.owner_id)
+ recipient_ids.extend(user_ids_from_fields)
+
+ if omit_ids:
+ recipient_ids = [rid for rid in recipient_ids if rid not in omit_ids]
+
+ return list(set(recipient_ids))
+
+
+class NotifyRulesDeletedTask(notify_helpers.NotifyTaskBase):
+ """JSON servlet that sends one email."""
+
+ _EMAIL_TEMPLATE = 'project/rules-deleted-notification-email.ezt'
+
+ def HandleRequest(self, mr):
+ """Process the task to notify project owners after a filter rule
+ has been deleted.
+
+ Args:
+ mr: common information parsed from the HTTP request.
+
+ Returns:
+ Results dictionary in JSON format which is useful for debugging.
+ """
+ project_id = mr.GetPositiveIntParam('project_id')
+ rules = mr.GetListParam('filter_rules')
+ hostport = mr.GetParam('hostport')
+
+ params = dict(
+ project_id=project_id,
+ rules=rules,
+ hostport=hostport,
+ )
+ logging.info('deleted filter rules params are %r', params)
+
+ project = self.services.project.GetProject(mr.cnxn, project_id)
+ emails_by_id = self.services.user.LookupUserEmails(
+ mr.cnxn, project.owner_ids, ignore_missed=True)
+
+ tasks = self._MakeRulesDeletedEmailTasks(
+ hostport, project, emails_by_id, rules)
+ notified = notify_helpers.AddAllEmailTasks(tasks)
+
+ return {
+ 'params': params,
+ 'notified': notified,
+ 'tasks': tasks,
+ }
+
+ def _MakeRulesDeletedEmailTasks(self, hostport, project, emails_by_id, rules):
+
+ rules_url = framework_helpers.FormatAbsoluteURLForDomain(
+ hostport, project.project_name, urls.ADMIN_RULES)
+
+ email_data = {
+ 'project_name': project.project_name,
+ 'rules': rules,
+ 'rules_url': rules_url,
+ }
+ logging.info(email_data)
+ subject = '%s Project: Deleted Filter Rules' % project.project_name
+ email_body = self.email_template.GetResponse(email_data)
+ body = notify_helpers._TruncateBody(email_body)
+
+ email_tasks = []
+ for rid in project.owner_ids:
+ from_addr = emailfmt.FormatFromAddr(
+ project, reveal_addr=True, can_reply_to=False)
+ dest_email = emails_by_id.get(rid)
+ email_tasks.append(
+ dict(from_addr=from_addr, to=dest_email, subject=subject, body=body))
+
+ return email_tasks
+
+
+class OutboundEmailTask(jsonfeed.InternalTask):
+ """JSON servlet that sends one email.
+
+ Handles tasks enqueued from notify_helpers._EnqueueOutboundEmail.
+ """
+
+ def HandleRequest(self, mr):
+ """Process the task to send one email message.
+
+ Args:
+ mr: common information parsed from the HTTP request.
+
+ Returns:
+ Results dictionary in JSON format which is useful just for debugging.
+ The main goal is the side-effect of sending emails.
+ """
+ # To avoid urlencoding the email body, the most salient parameters to this
+ # method are passed as a json-encoded POST body.
+ try:
+ email_params = json.loads(self.request.body)
+ except ValueError:
+ logging.error(self.request.body)
+ raise
+ # If running on a GAFYD domain, you must define an app alias on the
+ # Application Settings admin web page.
+ sender = email_params.get('from_addr')
+ reply_to = email_params.get('reply_to')
+ to = email_params.get('to')
+ if not to:
+ # Cannot proceed if we cannot create a valid EmailMessage.
+ return {'note': 'Skipping because no "to" address found.'}
+
+ # Don't send emails to any banned users.
+ try:
+ user_id = self.services.user.LookupUserID(mr.cnxn, to)
+ user = self.services.user.GetUser(mr.cnxn, user_id)
+ if user.banned:
+ logging.info('Not notifying banned user %r', user.email)
+ return {'note': 'Skipping because user is banned.'}
+ except exceptions.NoSuchUserException:
+ pass
+
+ references = email_params.get('references')
+ subject = email_params.get('subject')
+ body = email_params.get('body')
+ html_body = email_params.get('html_body')
+
+ if settings.local_mode:
+ to_format = settings.send_local_email_to
+ else:
+ to_format = settings.send_all_email_to
+
+ if to_format:
+ to_user, to_domain = to.split('@')
+ to = to_format % {'user': to_user, 'domain': to_domain}
+
+ logging.info(
+ 'Email:\n sender: %s\n reply_to: %s\n to: %s\n references: %s\n '
+ 'subject: %s\n body: %s\n html body: %s',
+ sender, reply_to, to, references, subject, body, html_body)
+ if html_body:
+ logging.info('Readable HTML:\n%s', html_body.replace('<br/>', '<br/>\n'))
+ message = mail.EmailMessage(
+ sender=sender, to=to, subject=subject, body=body)
+ if html_body:
+ message.html = html_body
+ if reply_to:
+ message.reply_to = reply_to
+ if references:
+ message.headers = {'References': references}
+ if settings.unit_test_mode:
+ logging.info('Sending message "%s" in test mode.', message.subject)
+ else:
+ retry_count = 3
+ for i in range(retry_count):
+ try:
+ message.send()
+ break
+ except apiproxy_errors.DeadlineExceededError as ex:
+ logging.warning('Sending email timed out on try: %d', i)
+ logging.warning(str(ex))
+
+ return dict(
+ sender=sender, to=to, subject=subject, body=body, html_body=html_body,
+ reply_to=reply_to, references=references)
diff --git a/features/notify_helpers.py b/features/notify_helpers.py
new file mode 100644
index 0000000..f22ed38
--- /dev/null
+++ b/features/notify_helpers.py
@@ -0,0 +1,440 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Helper functions for email notifications of issue changes."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import json
+import logging
+
+import ezt
+import six
+
+from features import autolink
+from features import autolink_constants
+from features import features_constants
+from features import filterrules_helpers
+from features import savedqueries_helpers
+from features import notify_reasons
+from framework import cloud_tasks_helpers
+from framework import emailfmt
+from framework import framework_bizobj
+from framework import framework_constants
+from framework import framework_helpers
+from framework import framework_views
+from framework import jsonfeed
+from framework import monorailrequest
+from framework import permissions
+from framework import template_helpers
+from framework import urls
+from proto import tracker_pb2
+from search import query2ast
+from search import searchpipeline
+from tracker import tracker_bizobj
+
+
+# Email tasks can get too large for AppEngine to handle. In order to prevent
+# that, we set a maximum body size, and may truncate messages to that length.
+# We set this value to 35k so that the total of 35k body + 35k html_body +
+# metadata does not exceed AppEngine's limit of 100k.
+MAX_EMAIL_BODY_SIZE = 35 * 1024
+
+# This HTML template adds mark up which enables Gmail/Inbox to display a
+# convenient link that takes users to the CL directly from the inbox without
+# having to click on the email.
+# Documentation for this schema.org markup is here:
+# https://developers.google.com/gmail/markup/reference/go-to-action
+HTML_BODY_WITH_GMAIL_ACTION_TEMPLATE = """
+<html>
+<body>
+<script type="application/ld+json">
+{
+ "@context": "http://schema.org",
+ "@type": "EmailMessage",
+ "potentialAction": {
+ "@type": "ViewAction",
+ "name": "View Issue",
+ "url": "%(url)s"
+ },
+ "description": ""
+}
+</script>
+
+<div style="font-family: arial, sans-serif; white-space:pre">%(body)s</div>
+</body>
+</html>
+"""
+
+HTML_BODY_WITHOUT_GMAIL_ACTION_TEMPLATE = """
+<html>
+<body>
+<div style="font-family: arial, sans-serif; white-space:pre">%(body)s</div>
+</body>
+</html>
+"""
+
+
+NOTIFY_RESTRICTED_ISSUES_PREF_NAME = 'notify_restricted_issues'
+NOTIFY_WITH_DETAILS = 'notify with details'
+NOTIFY_WITH_DETAILS_GOOGLE = 'notify with details: Google'
+NOTIFY_WITH_LINK_ONLY = 'notify with link only'
+
+
+def _EnqueueOutboundEmail(message_dict):
+ """Create a task to send one email message, all fields are in the dict.
+
+ We use a separate task for each outbound email to isolate errors.
+
+ Args:
+ message_dict: dict with all needed info for the task.
+ """
+ # We use a JSON-encoded payload because it ensures that the task size is
+ # effectively the same as the sum of the email bodies. Using params results
+ # in the dict being urlencoded, which can (worst case) triple the size of
+ # an email body containing many characters which need to be escaped.
+ payload = json.dumps(message_dict)
+ task = {
+ 'app_engine_http_request':
+ {
+ 'relative_uri': urls.OUTBOUND_EMAIL_TASK + '.do',
+ # Cloud Tasks expects body to be in bytes.
+ 'body': payload.encode(),
+ # Cloud tasks default body content type is octet-stream.
+ 'headers': {
+ 'Content-type': 'application/json'
+ }
+ }
+ }
+ cloud_tasks_helpers.create_task(
+ task, queue=features_constants.QUEUE_OUTBOUND_EMAIL)
+
+
+def AddAllEmailTasks(tasks):
+ """Add one GAE task for each email to be sent."""
+ notified = []
+ for task in tasks:
+ _EnqueueOutboundEmail(task)
+ notified.append(task['to'])
+
+ return notified
+
+
+class NotifyTaskBase(jsonfeed.InternalTask):
+ """Abstract base class for notification task handler."""
+
+ _EMAIL_TEMPLATE = None # Subclasses must override this.
+ _LINK_ONLY_EMAIL_TEMPLATE = None # Subclasses may override this.
+
+ CHECK_SECURITY_TOKEN = False
+
+ def __init__(self, *args, **kwargs):
+ super(NotifyTaskBase, self).__init__(*args, **kwargs)
+
+ if not self._EMAIL_TEMPLATE:
+ raise Exception('Subclasses must override _EMAIL_TEMPLATE.'
+ ' This class must not be called directly.')
+ # We use FORMAT_RAW for emails because they are plain text, not HTML.
+ # TODO(jrobbins): consider sending HTML formatted emails someday.
+ self.email_template = template_helpers.MonorailTemplate(
+ framework_constants.TEMPLATE_PATH + self._EMAIL_TEMPLATE,
+ compress_whitespace=False, base_format=ezt.FORMAT_RAW)
+
+ if self._LINK_ONLY_EMAIL_TEMPLATE:
+ self.link_only_email_template = template_helpers.MonorailTemplate(
+ framework_constants.TEMPLATE_PATH + self._LINK_ONLY_EMAIL_TEMPLATE,
+ compress_whitespace=False, base_format=ezt.FORMAT_RAW)
+
+
+def _MergeLinkedAccountReasons(addr_to_addrperm, addr_to_reasons):
+ """Return an addr_reasons_dict where parents omit child accounts."""
+ all_ids = set(addr_perm.user.user_id
+ for addr_perm in addr_to_addrperm.values()
+ if addr_perm.user)
+ merged_ids = set()
+
+ result = {}
+ for addr, reasons in addr_to_reasons.items():
+ addr_perm = addr_to_addrperm[addr]
+ parent_id = addr_perm.user.linked_parent_id if addr_perm.user else None
+ if parent_id and parent_id in all_ids:
+ # The current user is a child account and the parent would be notified,
+ # so only notify the parent.
+ merged_ids.add(parent_id)
+ else:
+ result[addr] = reasons
+
+ for addr, reasons in result.items():
+ addr_perm = addr_to_addrperm[addr]
+ if addr_perm.user and addr_perm.user.user_id in merged_ids:
+ reasons.append(notify_reasons.REASON_LINKED_ACCOUNT)
+
+ return result
+
+
+def MakeBulletedEmailWorkItems(
+ group_reason_list, issue, body_link_only, body_for_non_members,
+ body_for_members, project, hostport, commenter_view, detail_url,
+ seq_num=None, subject_prefix=None, compact_subject_prefix=None):
+ """Make a list of dicts describing email-sending tasks to notify users.
+
+ Args:
+ group_reason_list: list of (addr_perm_list, reason) tuples.
+ issue: Issue that was updated.
+ body_link_only: string body of email with minimal information.
+ body_for_non_members: string body of email to send to non-members.
+ body_for_members: string body of email to send to members.
+ project: Project that contains the issue.
+ hostport: string hostname and port number for links to the site.
+ commenter_view: UserView for the user who made the comment.
+ detail_url: str direct link to the issue.
+ seq_num: optional int sequence number of the comment.
+ subject_prefix: optional string to customize the email subject line.
+ compact_subject_prefix: optional string to customize the email subject line.
+
+ Returns:
+ A list of dictionaries, each with all needed info to send an individual
+ email to one user. Each email contains a footer that lists all the
+ reasons why that user received the email.
+ """
+ logging.info('group_reason_list is %r', group_reason_list)
+ addr_to_addrperm = {} # {email_address: AddrPerm object}
+ addr_to_reasons = {} # {email_address: [reason, ...]}
+ for group, reason in group_reason_list:
+ for memb_addr_perm in group:
+ addr = memb_addr_perm.address
+ addr_to_addrperm[addr] = memb_addr_perm
+ addr_to_reasons.setdefault(addr, []).append(reason)
+
+ addr_to_reasons = _MergeLinkedAccountReasons(
+ addr_to_addrperm, addr_to_reasons)
+ logging.info('addr_to_reasons is %r', addr_to_reasons)
+
+ email_tasks = []
+ for addr, reasons in addr_to_reasons.items():
+ memb_addr_perm = addr_to_addrperm[addr]
+ email_tasks.append(_MakeEmailWorkItem(
+ memb_addr_perm, reasons, issue, body_link_only, body_for_non_members,
+ body_for_members, project, hostport, commenter_view, detail_url,
+ seq_num=seq_num, subject_prefix=subject_prefix,
+ compact_subject_prefix=compact_subject_prefix))
+
+ return email_tasks
+
+
+def _TruncateBody(body):
+ """Truncate body string if it exceeds size limit."""
+ if len(body) > MAX_EMAIL_BODY_SIZE:
+ logging.info('Truncate body since its size %d exceeds limit', len(body))
+ return body[:MAX_EMAIL_BODY_SIZE] + '...'
+ return body
+
+
+def _GetNotifyRestrictedIssues(user_prefs, email, user):
+ """Return the notify_restricted_issues pref or a calculated default value."""
+ # If we explicitly set a pref for this address, use it.
+ if user_prefs:
+ for pref in user_prefs.prefs:
+ if pref.name == NOTIFY_RESTRICTED_ISSUES_PREF_NAME:
+ return pref.value
+
+ # Mailing lists cannot visit the site, so if it visited, it is a person.
+ if user and user.last_visit_timestamp:
+ return NOTIFY_WITH_DETAILS
+
+ # If it is a google.com mailing list, allow details for R-V-G issues.
+ if email.endswith('@google.com'):
+ return NOTIFY_WITH_DETAILS_GOOGLE
+
+ # It might be a public mailing list, so don't risk leaking any details.
+ return NOTIFY_WITH_LINK_ONLY
+
+
+def ShouldUseLinkOnly(addr_perm, issue, always_detailed=False):
+ """Return true when there is a risk of leaking a restricted issue.
+
+ We send notifications that contain only a link to the issue with no other
+ details about the change when:
+ - The issue is R-V-G and the address may be a non-google.com mailing list, or
+ - The issue is restricted with something other than R-V-G, and the user
+ may be a mailing list, or
+ - The user has a preference set.
+ """
+ if always_detailed:
+ return False
+
+ restrictions = permissions.GetRestrictions(issue, perm=permissions.VIEW)
+ if not restrictions:
+ return False
+
+ pref = _GetNotifyRestrictedIssues(
+ addr_perm.user_prefs, addr_perm.address, addr_perm.user)
+ if pref == NOTIFY_WITH_DETAILS:
+ return False
+ if (pref == NOTIFY_WITH_DETAILS_GOOGLE and
+ restrictions == ['restrict-view-google']):
+ return False
+
+ # If NOTIFY_WITH_LINK_ONLY or any unexpected value:
+ return True
+
+
+def _MakeEmailWorkItem(
+ addr_perm, reasons, issue, body_link_only,
+ body_for_non_members, body_for_members, project, hostport, commenter_view,
+ detail_url, seq_num=None, subject_prefix=None, compact_subject_prefix=None):
+ """Make one email task dict for one user, includes a detailed reason."""
+ should_use_link_only = ShouldUseLinkOnly(
+ addr_perm, issue, always_detailed=project.issue_notify_always_detailed)
+ subject_format = (
+ (subject_prefix or 'Issue ') +
+ '%(local_id)d in %(project_name)s')
+ if addr_perm.user and addr_perm.user.email_compact_subject:
+ subject_format = (
+ (compact_subject_prefix or '') +
+ '%(project_name)s:%(local_id)d')
+
+ subject = subject_format % {
+ 'local_id': issue.local_id,
+ 'project_name': issue.project_name,
+ }
+ if not should_use_link_only:
+ subject += ': ' + issue.summary
+
+ footer = _MakeNotificationFooter(reasons, addr_perm.reply_perm, hostport)
+ if isinstance(footer, six.text_type):
+ footer = footer.encode('utf-8')
+ if should_use_link_only:
+ body = _TruncateBody(body_link_only) + footer
+ elif addr_perm.is_member:
+ logging.info('got member %r, sending body for members', addr_perm.address)
+ body = _TruncateBody(body_for_members) + footer
+ else:
+ logging.info(
+ 'got non-member %r, sending body for non-members', addr_perm.address)
+ body = _TruncateBody(body_for_non_members) + footer
+ logging.info('sending message footer:\n%r', footer)
+
+ can_reply_to = (
+ addr_perm.reply_perm != notify_reasons.REPLY_NOT_ALLOWED and
+ project.process_inbound_email)
+ from_addr = emailfmt.FormatFromAddr(
+ project, commenter_view=commenter_view, reveal_addr=addr_perm.is_member,
+ can_reply_to=can_reply_to)
+ if can_reply_to:
+ reply_to = '%s@%s' % (project.project_name, emailfmt.MailDomain())
+ else:
+ reply_to = emailfmt.NoReplyAddress()
+ refs = emailfmt.GetReferences(
+ addr_perm.address, subject, seq_num,
+ '%s@%s' % (project.project_name, emailfmt.MailDomain()))
+ # We use markup to display a convenient link that takes users directly to the
+ # issue without clicking on the email.
+ html_body = None
+ template = HTML_BODY_WITH_GMAIL_ACTION_TEMPLATE
+ if addr_perm.user and not addr_perm.user.email_view_widget:
+ template = HTML_BODY_WITHOUT_GMAIL_ACTION_TEMPLATE
+ body_with_tags = _AddHTMLTags(body.decode('utf-8'))
+ # Escape single quotes which are occasionally used to contain HTML
+ # attributes and event handler definitions.
+ body_with_tags = body_with_tags.replace("'", ''')
+ html_body = template % {
+ 'url': detail_url,
+ 'body': body_with_tags,
+ }
+ return dict(
+ to=addr_perm.address, subject=subject, body=body, html_body=html_body,
+ from_addr=from_addr, reply_to=reply_to, references=refs)
+
+
+def _AddHTMLTags(body):
+ """Adds HMTL tags in the specified email body.
+
+ Specifically does the following:
+ * Detects links and adds <a href>s around the links.
+ * Substitutes <br/> for all occurrences of "\n".
+
+ See crbug.com/582463 for context.
+ """
+ # Convert all URLs into clickable links.
+ body = _AutolinkBody(body)
+
+ # Convert all "\n"s into "<br/>"s.
+ body = body.replace('\r\n', '<br/>')
+ body = body.replace('\n', '<br/>')
+ return body
+
+
+def _AutolinkBody(body):
+ """Convert text that looks like URLs into <a href=...>.
+
+ This uses autolink.py, but it does not register all the autolink components
+ because some of them depend on the current user's permissions which would
+ not make sense for an email body that will be sent to several different users.
+ """
+ email_autolink = autolink.Autolink()
+ email_autolink.RegisterComponent(
+ '01-linkify-user-profiles-or-mailto',
+ lambda request, mr: None,
+ lambda _mr, match: [match.group(0)],
+ {autolink_constants.IS_IMPLIED_EMAIL_RE: autolink.LinkifyEmail})
+ email_autolink.RegisterComponent(
+ '02-linkify-full-urls',
+ lambda request, mr: None,
+ lambda mr, match: None,
+ {autolink_constants.IS_A_LINK_RE: autolink.Linkify})
+ email_autolink.RegisterComponent(
+ '03-linkify-shorthand',
+ lambda request, mr: None,
+ lambda mr, match: None,
+ {autolink_constants.IS_A_SHORT_LINK_RE: autolink.Linkify,
+ autolink_constants.IS_A_NUMERIC_SHORT_LINK_RE: autolink.Linkify,
+ autolink_constants.IS_IMPLIED_LINK_RE: autolink.Linkify,
+ })
+
+ input_run = template_helpers.TextRun(body)
+ output_runs = email_autolink.MarkupAutolinks(
+ None, [input_run], autolink.SKIP_LOOKUPS)
+ output_strings = [run.FormatForHTMLEmail() for run in output_runs]
+ return ''.join(output_strings)
+
+
+def _MakeNotificationFooter(reasons, reply_perm, hostport):
+ """Make an informative footer for a notification email.
+
+ Args:
+ reasons: a list of strings to be used as the explanation. Empty if no
+ reason is to be given.
+ reply_perm: string which is one of REPLY_NOT_ALLOWED, REPLY_MAY_COMMENT,
+ REPLY_MAY_UPDATE.
+ hostport: string with domain_name:port_number to be used in linking to
+ the user preferences page.
+
+ Returns:
+ A string to be used as the email footer.
+ """
+ if not reasons:
+ return ''
+
+ domain_port = hostport.split(':')
+ domain_port[0] = framework_helpers.GetPreferredDomain(domain_port[0])
+ hostport = ':'.join(domain_port)
+
+ prefs_url = 'https://%s%s' % (hostport, urls.USER_SETTINGS)
+ lines = ['-- ']
+ lines.append('You received this message because:')
+ lines.extend(' %d. %s' % (idx + 1, reason)
+ for idx, reason in enumerate(reasons))
+
+ lines.extend(['', 'You may adjust your notification preferences at:',
+ prefs_url])
+
+ if reply_perm == notify_reasons.REPLY_MAY_COMMENT:
+ lines.extend(['', 'Reply to this email to add a comment.'])
+ elif reply_perm == notify_reasons.REPLY_MAY_UPDATE:
+ lines.extend(['', 'Reply to this email to add a comment or make updates.'])
+
+ return '\n'.join(lines)
diff --git a/features/notify_reasons.py b/features/notify_reasons.py
new file mode 100644
index 0000000..436f975
--- /dev/null
+++ b/features/notify_reasons.py
@@ -0,0 +1,438 @@
+# Copyright 2017 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Helper functions for deciding who to notify and why.."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import collections
+import logging
+
+import settings
+from features import filterrules_helpers
+from features import savedqueries_helpers
+from framework import authdata
+from framework import framework_bizobj
+from framework import framework_constants
+from framework import framework_helpers
+from framework import framework_views
+from framework import permissions
+from proto import tracker_pb2
+from search import query2ast
+from search import searchpipeline
+from tracker import component_helpers
+from tracker import tracker_bizobj
+
+# When sending change notification emails, choose the reply-to header and
+# footer message based on three levels of the recipient's permissions
+# for that issue.
+REPLY_NOT_ALLOWED = 'REPLY_NOT_ALLOWED'
+REPLY_MAY_COMMENT = 'REPLY_MAY_COMMENT'
+REPLY_MAY_UPDATE = 'REPLY_MAY_UPDATE'
+
+# These are strings describing the various reasons that we send notifications.
+REASON_REPORTER = 'You reported this issue'
+REASON_OWNER = 'You are the owner of the issue'
+REASON_OLD_OWNER = 'You were the issue owner before this change'
+REASON_DEFAULT_OWNER = 'A rule made you owner of the issue'
+REASON_CCD = 'You were specifically CC\'d on the issue'
+REASON_DEFAULT_CCD = 'A rule CC\'d you on the issue'
+# TODO(crbug.com/monorail/2857): separate reasons for notification to group
+# members resulting from component and rules derived ccs.
+REASON_GROUP_CCD = (
+ 'A group you\'re a member of was specifically CC\'d on the issue')
+REASON_STARRER = 'You starred the issue'
+REASON_SUBSCRIBER = 'Your saved query matched the issue'
+REASON_ALSO_NOTIFY = 'A rule was set up to notify you'
+REASON_ALL_NOTIFICATIONS = (
+ 'The project was configured to send all issue notifications '
+ 'to this address')
+REASON_LINKED_ACCOUNT = 'Your linked account would have been notified'
+
+# An AddrPerm is how we represent our decision to notify a given
+# email address, which version of the email body to send to them, and
+# whether to offer them the option to reply to the notification. Many
+# of the functions in this file pass around AddrPerm lists (an "APL").
+# is_member is a boolean
+# address is a string email address
+# user is a User PB, including built-in user preference fields.
+# reply_perm is one of REPLY_NOT_ALLOWED, REPLY_MAY_COMMENT,
+# REPLY_MAY_UPDATE.
+# user_prefs is a UserPrefs object with string->string user prefs.
+AddrPerm = collections.namedtuple(
+ 'AddrPerm', 'is_member, address, user, reply_perm, user_prefs')
+
+
+
+def ComputeIssueChangeAddressPermList(
+ cnxn, ids_to_consider, project, issue, services, omit_addrs,
+ users_by_id, pref_check_function=lambda u: u.notify_issue_change):
+ """Return a list of user email addresses to notify of an issue change.
+
+ User email addresses are determined by looking up the given user IDs
+ in the given users_by_id dict.
+
+ Args:
+ cnxn: connection to SQL database.
+ ids_to_consider: list of user IDs for users interested in this issue.
+ project: Project PB for the project containing this issue.
+ issue: Issue PB for the issue that was updated.
+ services: Services.
+ omit_addrs: set of strings for email addresses to not notify because
+ they already know.
+ users_by_id: dict {user_id: user_view} user info.
+ pref_check_function: optional function to use to check if a certain
+ User PB has a preference set to receive the email being sent. It
+ defaults to "If I am in the issue's owner or cc field", but it
+ can be set to check "If I starred the issue."
+
+ Returns:
+ A list of AddrPerm objects.
+ """
+ memb_addr_perm_list = []
+ logging.info('Considering %r ', ids_to_consider)
+ all_user_prefs = services.user.GetUsersPrefs(cnxn, ids_to_consider)
+ for user_id in ids_to_consider:
+ if user_id == framework_constants.NO_USER_SPECIFIED:
+ continue
+ user = services.user.GetUser(cnxn, user_id)
+ # Notify people who have a pref set, or if they have no User PB
+ # because the pref defaults to True.
+ if user and not pref_check_function(user):
+ logging.info('Not notifying %r: user preference', user.email)
+ continue
+ # TODO(jrobbins): doing a bulk operation would reduce DB load.
+ auth = authdata.AuthData.FromUserID(cnxn, user_id, services)
+ perms = permissions.GetPermissions(user, auth.effective_ids, project)
+ config = services.config.GetProjectConfig(cnxn, project.project_id)
+ granted_perms = tracker_bizobj.GetGrantedPerms(
+ issue, auth.effective_ids, config)
+
+ if not permissions.CanViewIssue(
+ auth.effective_ids, perms, project, issue,
+ granted_perms=granted_perms):
+ logging.info('Not notifying %r: user cannot view issue', user.email)
+ continue
+
+ addr = users_by_id[user_id].email
+ if addr in omit_addrs:
+ logging.info('Not notifying %r: user already knows', user.email)
+ continue
+
+ recipient_is_member = bool(framework_bizobj.UserIsInProject(
+ project, auth.effective_ids))
+
+ reply_perm = REPLY_NOT_ALLOWED
+ if project.process_inbound_email:
+ if permissions.CanEditIssue(auth.effective_ids, perms, project, issue):
+ reply_perm = REPLY_MAY_UPDATE
+ elif permissions.CanCommentIssue(
+ auth.effective_ids, perms, project, issue):
+ reply_perm = REPLY_MAY_COMMENT
+
+ memb_addr_perm_list.append(
+ AddrPerm(recipient_is_member, addr, user, reply_perm,
+ all_user_prefs[user_id]))
+
+ logging.info('For %s %s, will notify: %r',
+ project.project_name, issue.local_id,
+ [ap.address for ap in memb_addr_perm_list])
+
+ return memb_addr_perm_list
+
+
+def ComputeProjectNotificationAddrList(
+ cnxn, services, project, contributor_could_view, omit_addrs):
+ """Return a list of non-user addresses to notify of an issue change.
+
+ The non-user addresses are specified by email address strings, not
+ user IDs. One such address can be specified in the project PB.
+ It is not assumed to have permission to see all issues.
+
+ Args:
+ cnxn: connection to SQL database.
+ services: A Services object.
+ project: Project PB containing the issue that was updated.
+ contributor_could_view: True if any project contributor should be able to
+ see the notification email, e.g., in a mailing list archive or feed.
+ omit_addrs: set of strings for email addresses to not notify because
+ they already know.
+
+ Returns:
+ A list of tuples: [(False, email_addr, None, reply_permission_level), ...],
+ where reply_permission_level is always REPLY_NOT_ALLOWED for now.
+ """
+ memb_addr_perm_list = []
+ if contributor_could_view:
+ ml_addr = project.issue_notify_address
+ ml_user_prefs = services.user.GetUserPrefsByEmail(cnxn, ml_addr)
+
+ if ml_addr and ml_addr not in omit_addrs:
+ memb_addr_perm_list.append(
+ AddrPerm(False, ml_addr, None, REPLY_NOT_ALLOWED, ml_user_prefs))
+
+ return memb_addr_perm_list
+
+
+def ComputeIssueNotificationAddrList(cnxn, services, issue, omit_addrs):
+ """Return a list of non-user addresses to notify of an issue change.
+
+ The non-user addresses are specified by email address strings, not
+ user IDs. They can be set by filter rules with the "Also notify" action.
+ "Also notify" addresses are assumed to have permission to see any issue,
+ even a restricted one.
+
+ Args:
+ cnxn: connection to SQL database.
+ services: A Services object.
+ issue: Issue PB for the issue that was updated.
+ omit_addrs: set of strings for email addresses to not notify because
+ they already know.
+
+ Returns:
+ A list of tuples: [(False, email_addr, None, reply_permission_level), ...],
+ where reply_permission_level is always REPLY_NOT_ALLOWED for now.
+ """
+ addr_perm_list = []
+ for addr in issue.derived_notify_addrs:
+ if addr not in omit_addrs:
+ notify_user_prefs = services.user.GetUserPrefsByEmail(cnxn, addr)
+ addr_perm_list.append(
+ AddrPerm(False, addr, None, REPLY_NOT_ALLOWED, notify_user_prefs))
+
+ return addr_perm_list
+
+
+def _GetSubscribersAddrPermList(
+ cnxn, services, issue, project, config, omit_addrs, users_by_id):
+ """Lookup subscribers, evaluate their saved queries, and decide to notify."""
+ users_to_queries = GetNonOmittedSubscriptions(
+ cnxn, services, [project.project_id], omit_addrs)
+ # TODO(jrobbins): need to pass through the user_id to use for "me".
+ subscribers_to_notify = EvaluateSubscriptions(
+ cnxn, issue, users_to_queries, services, config)
+ # TODO(jrobbins): expand any subscribers that are user groups.
+ subs_needing_user_views = [
+ uid for uid in subscribers_to_notify if uid not in users_by_id]
+ users_by_id.update(framework_views.MakeAllUserViews(
+ cnxn, services.user, subs_needing_user_views))
+ sub_addr_perm_list = ComputeIssueChangeAddressPermList(
+ cnxn, subscribers_to_notify, project, issue, services, omit_addrs,
+ users_by_id, pref_check_function=lambda *args: True)
+
+ return sub_addr_perm_list
+
+
+def EvaluateSubscriptions(
+ cnxn, issue, users_to_queries, services, config):
+ """Determine subscribers who have subs that match the given issue."""
+ # Note: unlike filter rule, subscriptions see explicit & derived values.
+ lower_labels = [lab.lower() for lab in tracker_bizobj.GetLabels(issue)]
+ label_set = set(lower_labels)
+
+ subscribers_to_notify = []
+ for uid, saved_queries in users_to_queries.items():
+ for sq in saved_queries:
+ if sq.subscription_mode != 'immediate':
+ continue
+ if issue.project_id not in sq.executes_in_project_ids:
+ continue
+ cond = savedqueries_helpers.SavedQueryToCond(sq)
+ # TODO(jrobbins): Support linked accounts me_user_ids.
+ cond, _warnings = searchpipeline.ReplaceKeywordsWithUserIDs([uid], cond)
+ cond_ast = query2ast.ParseUserQuery(
+ cond, '', query2ast.BUILTIN_ISSUE_FIELDS, config)
+
+ if filterrules_helpers.EvalPredicate(
+ cnxn, services, cond_ast, issue, label_set, config,
+ tracker_bizobj.GetOwnerId(issue), tracker_bizobj.GetCcIds(issue),
+ tracker_bizobj.GetStatus(issue)):
+ subscribers_to_notify.append(uid)
+ break # Don't bother looking at the user's other saved quereies.
+
+ return subscribers_to_notify
+
+
+def GetNonOmittedSubscriptions(cnxn, services, project_ids, omit_addrs):
+ """Get a dict of users w/ subscriptions in those projects."""
+ users_to_queries = services.features.GetSubscriptionsInProjects(
+ cnxn, project_ids)
+ user_emails = services.user.LookupUserEmails(
+ cnxn, list(users_to_queries.keys()))
+ for user_id, email in user_emails.items():
+ if email in omit_addrs:
+ del users_to_queries[user_id]
+ return users_to_queries
+
+
+def ComputeCustomFieldAddrPerms(
+ cnxn, config, issue, project, services, omit_addrs, users_by_id):
+ """Check the reasons to notify users named in custom fields."""
+ group_reason_list = []
+ for fd in config.field_defs:
+ (direct_named_ids,
+ transitive_named_ids) = services.usergroup.ExpandAnyGroupEmailRecipients(
+ cnxn, ComputeNamedUserIDsToNotify(issue.field_values, fd))
+ named_user_ids = direct_named_ids + transitive_named_ids
+ if named_user_ids:
+ named_addr_perms = ComputeIssueChangeAddressPermList(
+ cnxn, named_user_ids, project, issue, services, omit_addrs,
+ users_by_id, pref_check_function=lambda u: True)
+ group_reason_list.append(
+ (named_addr_perms, 'You are named in the %s field' % fd.field_name))
+
+ return group_reason_list
+
+
+def ComputeNamedUserIDsToNotify(field_values, fd):
+ """Give a list of user IDs to notify because they're in a field."""
+ if (fd.field_type == tracker_pb2.FieldTypes.USER_TYPE and
+ fd.notify_on == tracker_pb2.NotifyTriggers.ANY_COMMENT):
+ return [fv.user_id for fv in field_values
+ if fv.field_id == fd.field_id]
+
+ return []
+
+
+def ComputeComponentFieldAddrPerms(
+ cnxn, config, issue, project, services, omit_addrs, users_by_id):
+ """Return [(addr_perm_list, reason),...] for users auto-cc'd by components."""
+ component_ids = set(issue.component_ids)
+ group_reason_list = []
+ for cd in config.component_defs:
+ if cd.component_id in component_ids:
+ (direct_ccs,
+ transitive_ccs) = services.usergroup.ExpandAnyGroupEmailRecipients(
+ cnxn, component_helpers.GetCcIDsForComponentAndAncestors(config, cd))
+ cc_ids = direct_ccs + transitive_ccs
+ comp_addr_perms = ComputeIssueChangeAddressPermList(
+ cnxn, cc_ids, project, issue, services, omit_addrs,
+ users_by_id, pref_check_function=lambda u: True)
+ group_reason_list.append(
+ (comp_addr_perms,
+ 'You are auto-CC\'d on all issues in component %s' % cd.path))
+
+ return group_reason_list
+
+
+def ComputeGroupReasonList(
+ cnxn, services, project, issue, config, users_by_id, omit_addrs,
+ contributor_could_view, starrer_ids=None, noisy=False,
+ old_owner_id=None, commenter_in_project=True, include_subscribers=True,
+ include_notify_all=True,
+ starrer_pref_check_function=lambda u: u.notify_starred_issue_change):
+ """Return a list [(addr_perm_list, reason),...] of addrs to notify."""
+ # Get the transitive set of owners and Cc'd users, and their UserViews.
+ starrer_ids = starrer_ids or []
+ reporter = [issue.reporter_id] if issue.reporter_id in starrer_ids else []
+ if old_owner_id:
+ old_direct_owners, old_transitive_owners = (
+ services.usergroup.ExpandAnyGroupEmailRecipients(cnxn, [old_owner_id]))
+ else:
+ old_direct_owners, old_transitive_owners = [], []
+
+ direct_owners, transitive_owners = (
+ services.usergroup.ExpandAnyGroupEmailRecipients(cnxn, [issue.owner_id]))
+ der_direct_owners, der_transitive_owners = (
+ services.usergroup.ExpandAnyGroupEmailRecipients(
+ cnxn, [issue.derived_owner_id]))
+ direct_comp, trans_comp = services.usergroup.ExpandAnyGroupEmailRecipients(
+ cnxn, component_helpers.GetComponentCcIDs(issue, config))
+ direct_ccs, transitive_ccs = services.usergroup.ExpandAnyGroupEmailRecipients(
+ cnxn, list(issue.cc_ids))
+ der_direct_ccs, der_transitive_ccs = (
+ services.usergroup.ExpandAnyGroupEmailRecipients(
+ cnxn, list(issue.derived_cc_ids)))
+ # Remove cc's derived from components, which are grouped into their own
+ # notify-reason-group in ComputeComponentFieldAddrPerms().
+ # This means that an exact email cc'd by both a component and a rule will
+ # get an email that says they are only being notified because of the
+ # component.
+ # Note that a user directly cc'd due to a rule who is also part of a
+ # group cc'd due to a component, will get a message saying they're cc'd for
+ # both the rule and the component.
+ der_direct_ccs = list(set(der_direct_ccs).difference(set(direct_comp)))
+ der_transitive_ccs = list(set(der_transitive_ccs).difference(set(trans_comp)))
+
+ users_by_id.update(framework_views.MakeAllUserViews(
+ cnxn, services.user, transitive_owners, der_transitive_owners,
+ direct_comp, trans_comp, transitive_ccs, der_transitive_ccs))
+
+ # Notify interested people according to the reason for their interest:
+ # owners, component auto-cc'd users, cc'd users, starrers, and
+ # other notification addresses.
+ reporter_addr_perm_list = ComputeIssueChangeAddressPermList(
+ cnxn, reporter, project, issue, services, omit_addrs, users_by_id)
+ owner_addr_perm_list = ComputeIssueChangeAddressPermList(
+ cnxn, direct_owners + transitive_owners, project, issue,
+ services, omit_addrs, users_by_id)
+ old_owner_addr_perm_list = ComputeIssueChangeAddressPermList(
+ cnxn, old_direct_owners + old_transitive_owners, project, issue,
+ services, omit_addrs, users_by_id)
+ owner_addr_perm_set = set(owner_addr_perm_list)
+ old_owner_addr_perm_list = [ap for ap in old_owner_addr_perm_list
+ if ap not in owner_addr_perm_set]
+ der_owner_addr_perm_list = ComputeIssueChangeAddressPermList(
+ cnxn, der_direct_owners + der_transitive_owners, project, issue,
+ services, omit_addrs, users_by_id)
+ cc_addr_perm_list = ComputeIssueChangeAddressPermList(
+ cnxn, direct_ccs, project, issue, services, omit_addrs, users_by_id)
+ transitive_cc_addr_perm_list = ComputeIssueChangeAddressPermList(
+ cnxn, transitive_ccs, project, issue, services, omit_addrs, users_by_id)
+ der_cc_addr_perm_list = ComputeIssueChangeAddressPermList(
+ cnxn, der_direct_ccs + der_transitive_ccs, project, issue,
+ services, omit_addrs, users_by_id)
+
+ starrer_addr_perm_list = []
+ sub_addr_perm_list = []
+ if not noisy or commenter_in_project:
+ # Avoid an OOM by only notifying a number of starrers that we can handle.
+ # And, we really should limit the number of emails that we send anyway.
+ max_starrers = settings.max_starrers_to_notify
+ starrer_ids = starrer_ids[-max_starrers:]
+ # Note: starrers can never be user groups.
+ starrer_addr_perm_list = (
+ ComputeIssueChangeAddressPermList(
+ cnxn, starrer_ids, project, issue,
+ services, omit_addrs, users_by_id,
+ pref_check_function=starrer_pref_check_function))
+
+ if include_subscribers:
+ sub_addr_perm_list = _GetSubscribersAddrPermList(
+ cnxn, services, issue, project, config, omit_addrs,
+ users_by_id)
+
+ # Get the list of addresses to notify based on filter rules.
+ issue_notify_addr_list = ComputeIssueNotificationAddrList(
+ cnxn, services, issue, omit_addrs)
+ # Get the list of addresses to notify based on project settings.
+ proj_notify_addr_list = []
+ if include_notify_all:
+ proj_notify_addr_list = ComputeProjectNotificationAddrList(
+ cnxn, services, project, contributor_could_view, omit_addrs)
+
+ group_reason_list = [
+ (reporter_addr_perm_list, REASON_REPORTER),
+ (owner_addr_perm_list, REASON_OWNER),
+ (old_owner_addr_perm_list, REASON_OLD_OWNER),
+ (der_owner_addr_perm_list, REASON_DEFAULT_OWNER),
+ (cc_addr_perm_list, REASON_CCD),
+ (transitive_cc_addr_perm_list, REASON_GROUP_CCD),
+ (der_cc_addr_perm_list, REASON_DEFAULT_CCD),
+ ]
+ group_reason_list.extend(ComputeComponentFieldAddrPerms(
+ cnxn, config, issue, project, services, omit_addrs,
+ users_by_id))
+ group_reason_list.extend(ComputeCustomFieldAddrPerms(
+ cnxn, config, issue, project, services, omit_addrs,
+ users_by_id))
+ group_reason_list.extend([
+ (starrer_addr_perm_list, REASON_STARRER),
+ (sub_addr_perm_list, REASON_SUBSCRIBER),
+ (issue_notify_addr_list, REASON_ALSO_NOTIFY),
+ (proj_notify_addr_list, REASON_ALL_NOTIFICATIONS),
+ ])
+ return group_reason_list
diff --git a/features/prettify.py b/features/prettify.py
new file mode 100644
index 0000000..bc64282
--- /dev/null
+++ b/features/prettify.py
@@ -0,0 +1,76 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Helper functions for source code syntax highlighting."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import ezt
+
+from framework import framework_constants
+
+
+# We only attempt to do client-side syntax highlighting on files that we
+# expect to be source code in languages that we support, and that are
+# reasonably sized.
+MAX_PRETTIFY_LINES = 3000
+
+
+def PrepareSourceLinesForHighlighting(file_contents):
+ """Parse a file into lines for highlighting.
+
+ Args:
+ file_contents: string contents of the source code file.
+
+ Returns:
+ A list of _SourceLine objects, one for each line in the source file.
+ """
+ return [_SourceLine(num + 1, line) for num, line
+ in enumerate(file_contents.splitlines())]
+
+
+class _SourceLine(object):
+ """Convenience class to represent one line of the source code display.
+
+ Attributes:
+ num: The line's location in the source file.
+ line: String source code line to display.
+ """
+
+ def __init__(self, num, line):
+ self.num = num
+ self.line = line
+
+ def __repr__(self):
+ return '%d: %s' % (self.num, self.line)
+
+
+def BuildPrettifyData(num_lines, path):
+ """Return page data to help configure google-code-prettify.
+
+ Args:
+ num_lines: int number of lines of source code in the file.
+ path: string path to the file, or just the filename.
+
+ Returns:
+ Dictionary that can be passed to EZT to render a page.
+ """
+ reasonable_size = num_lines < MAX_PRETTIFY_LINES
+
+ filename_lower = path[path.rfind('/') + 1:].lower()
+ ext = filename_lower[filename_lower.rfind('.') + 1:]
+
+ # Note that '' might be a valid entry in these maps.
+ prettify_class = framework_constants.PRETTIFY_CLASS_MAP.get(ext)
+ if prettify_class is None:
+ prettify_class = framework_constants.PRETTIFY_FILENAME_CLASS_MAP.get(
+ filename_lower)
+ supported_lang = prettify_class is not None
+
+ return {
+ 'should_prettify': ezt.boolean(supported_lang and reasonable_size),
+ 'prettify_class': prettify_class,
+ }
diff --git a/features/pubsub.py b/features/pubsub.py
new file mode 100644
index 0000000..a74ff22
--- /dev/null
+++ b/features/pubsub.py
@@ -0,0 +1,81 @@
+# Copyright 2019 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Task handlers for publishing issue updates onto a pub/sub topic.
+
+The pub/sub topic name is: `projects/{project-id}/topics/issue-updates`.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import httplib2
+import logging
+import sys
+
+import settings
+
+from googleapiclient.discovery import build
+from apiclient.errors import Error as ApiClientError
+from oauth2client.client import GoogleCredentials
+from oauth2client.client import Error as Oauth2ClientError
+
+from framework import exceptions
+from framework import jsonfeed
+
+
+class PublishPubsubIssueChangeTask(jsonfeed.InternalTask):
+ """JSON servlet that pushes issue update messages onto a pub/sub topic."""
+
+ def HandleRequest(self, mr):
+ """Push a message onto a pub/sub queue.
+
+ Args:
+ mr: common information parsed from the HTTP request.
+ Returns:
+ A dictionary. If an error occurred, the 'error' field will be a string
+ containing the error message.
+ """
+ pubsub_client = set_up_pubsub_api()
+ if not pubsub_client:
+ return {
+ 'error': 'Pub/Sub API init failure.',
+ }
+
+ issue_id = mr.GetPositiveIntParam('issue_id')
+ if not issue_id:
+ return {
+ 'error': 'Cannot proceed without a valid issue ID.',
+ }
+ try:
+ issue = self.services.issue.GetIssue(mr.cnxn, issue_id, use_cache=False)
+ except exceptions.NoSuchIssueException:
+ return {
+ 'error': 'Could not find issue with ID %s' % issue_id,
+ }
+
+ pubsub_client.projects().topics().publish(
+ topic=settings.pubsub_topic_id,
+ body={
+ 'messages': [{
+ 'attributes': {
+ 'local_id': str(issue.local_id),
+ 'project_name': str(issue.project_name),
+ },
+ }],
+ },
+ ).execute()
+
+ return {}
+
+
+def set_up_pubsub_api():
+ """Attempts to build and return a pub/sub API client."""
+ try:
+ return build('pubsub', 'v1', http=httplib2.Http(),
+ credentials=GoogleCredentials.get_application_default())
+ except (Oauth2ClientError, ApiClientError):
+ logging.error("Error setting up Pub/Sub API: %s" % sys.exc_info()[0])
+ return None
diff --git a/features/rerankhotlist.py b/features/rerankhotlist.py
new file mode 100644
index 0000000..fe235db
--- /dev/null
+++ b/features/rerankhotlist.py
@@ -0,0 +1,136 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Class that implements the reranking on the hotlistissues table page."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+
+from features import features_bizobj
+from features import hotlist_helpers
+from framework import jsonfeed
+from framework import permissions
+from framework import sorting
+from services import features_svc
+from tracker import rerank_helpers
+
+
+class RerankHotlistIssue(jsonfeed.JsonFeed):
+ """Rerank an issue in a hotlist."""
+
+ def AssertBasePermission(self, mr):
+ super(RerankHotlistIssue, self).AssertBasePermission(mr)
+ if mr.target_id and mr.moved_ids and mr.split_above:
+ try:
+ hotlist = self._GetHotlist(mr)
+ except features_svc.NoSuchHotlistException:
+ return
+ edit_perm = permissions.CanEditHotlist(
+ mr.auth.effective_ids, mr.perms, hotlist)
+ if not edit_perm:
+ raise permissions.PermissionException(
+ 'User is not allowed to re-rank this hotlist')
+
+ def HandleRequest(self, mr):
+ changed_ranks = self._GetNewRankings(mr)
+
+ if changed_ranks:
+ relations_to_change = dict(
+ (issue_id, rank) for issue_id, rank in changed_ranks)
+
+ self.services.features.UpdateHotlistItemsFields(
+ mr.cnxn, mr.hotlist_id, new_ranks=relations_to_change)
+
+ hotlist_items = self.services.features.GetHotlist(
+ mr.cnxn, mr.hotlist_id).items
+
+ # Note: Cannot use mr.hotlist because hotlist_issues
+ # of mr.hotlist is not updated
+
+ sorting.InvalidateArtValuesKeys(
+ mr.cnxn, [hotlist_item.issue_id for hotlist_item in hotlist_items])
+ (table_data, _) = hotlist_helpers.CreateHotlistTableData(
+ mr, hotlist_items, self.services)
+
+ json_table_data = [{
+ 'cells': [{
+ 'type': cell.type,
+ 'values': [{
+ 'item': value.item,
+ 'isDerived': value.is_derived,
+ } for value in cell.values],
+ 'colIndex': cell.col_index,
+ 'align': cell.align,
+ 'noWrap': cell.NOWRAP,
+ 'nonColLabels': [{
+ 'value': label.value,
+ 'isDerived': label.is_derived,
+ } for label in cell.non_column_labels],
+ } for cell in table_row.cells],
+ 'issueRef': table_row.issue_ref,
+ 'idx': table_row.idx,
+ 'projectName': table_row.project_name,
+ 'projectURL': table_row.project_url,
+ 'localID': table_row.local_id,
+ 'issueID': table_row.issue_id,
+ 'isStarred': table_row.starred,
+ 'issueCleanURL': table_row.issue_clean_url,
+ 'issueContextURL': table_row.issue_ctx_url,
+ } for table_row in table_data]
+
+ for row, json_row in zip(
+ [table_row for table_row in table_data], json_table_data):
+ if (row.group and row.group.cells):
+ json_row.update({'group': {
+ 'rowsInGroup': row.group.rows_in_group,
+ 'cells': [{'groupName': cell.group_name,
+ 'values': [{
+ # TODO(jojwang): check if this gives error when there
+ # is no value.item
+ 'item': value.item if value.item else 'None',
+ } for value in cell.values],
+ } for cell in row.group.cells],
+ }})
+ else:
+ json_row['group'] = 'no'
+
+ return {'table_data': json_table_data}
+ else:
+ return {'table_data': ''}
+
+ def _GetHotlist(self, mr):
+ """Retrieve the current hotlist."""
+ if mr.hotlist_id is None:
+ return None
+ try:
+ hotlist = self.services.features.GetHotlist( mr.cnxn, mr.hotlist_id)
+ except features_svc.NoSuchHotlistException:
+ self.abort(404, 'hotlist not found')
+ return hotlist
+
+ def _GetNewRankings(self, mr):
+ """Compute new issue reference rankings."""
+ missing = False
+ if not (mr.target_id):
+ logging.info('No target_id.')
+ missing = True
+ if not (mr.moved_ids):
+ logging.info('No moved_ids.')
+ missing = True
+ if mr.split_above is None:
+ logging.info('No split_above.')
+ missing = True
+ if missing:
+ return
+
+ untouched_items = [
+ (item.issue_id, item.rank) for item in
+ mr.hotlist.items if item.issue_id not in mr.moved_ids]
+
+ lower, higher = features_bizobj.SplitHotlistIssueRanks(
+ mr.target_id, mr.split_above, untouched_items)
+ return rerank_helpers.GetInsertRankings(lower, higher, mr.moved_ids)
diff --git a/features/savedqueries.py b/features/savedqueries.py
new file mode 100644
index 0000000..5cc1bc8
--- /dev/null
+++ b/features/savedqueries.py
@@ -0,0 +1,76 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Page for showing a user's saved queries and subscription options."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import time
+
+import ezt
+
+from features import savedqueries_helpers
+from framework import framework_helpers
+from framework import permissions
+from framework import servlet
+from framework import urls
+
+
+class SavedQueries(servlet.Servlet):
+ """A page class that shows the user's saved queries."""
+
+ _PAGE_TEMPLATE = 'features/saved-queries-page.ezt'
+
+ def AssertBasePermission(self, mr):
+ super(SavedQueries, self).AssertBasePermission(mr)
+ viewing_self = mr.viewed_user_auth.user_id == mr.auth.user_id
+ if not mr.auth.user_pb.is_site_admin and not viewing_self:
+ raise permissions.PermissionException(
+ 'User not allowed to edit this user\'s saved queries')
+
+ def GatherPageData(self, mr):
+ """Build up a dictionary of data values to use when rendering the page."""
+ saved_queries = self.services.features.GetSavedQueriesByUserID(
+ mr.cnxn, mr.viewed_user_auth.user_id)
+ saved_query_views = [
+ savedqueries_helpers.SavedQueryView(
+ sq, idx + 1, mr.cnxn, self.services.project)
+ for idx, sq in enumerate(saved_queries)]
+
+ page_data = {
+ 'canned_queries': saved_query_views,
+ 'new_query_indexes': (
+ list(range(len(saved_queries) + 1,
+ savedqueries_helpers.MAX_QUERIES + 1))),
+ 'max_queries': savedqueries_helpers.MAX_QUERIES,
+ 'user_tab_mode': 'st4',
+ 'viewing_user_page': ezt.boolean(True),
+ }
+ return page_data
+
+ def ProcessFormData(self, mr, post_data):
+ """Validate and store the contents of the issues tracker admin page.
+
+ Args:
+ mr: commonly used info parsed from the request.
+ post_data: HTML form data from the request.
+
+ Returns:
+ String URL to redirect the user to, or None if response was already sent.
+ """
+ existing_queries = savedqueries_helpers.ParseSavedQueries(
+ mr.cnxn, post_data, self.services.project)
+ added_queries = savedqueries_helpers.ParseSavedQueries(
+ mr.cnxn, post_data, self.services.project, prefix='new_')
+ saved_queries = existing_queries + added_queries
+
+ self.services.features.UpdateUserSavedQueries(
+ mr.cnxn, mr.viewed_user_auth.user_id, saved_queries)
+
+ return framework_helpers.FormatAbsoluteURL(
+ mr, '/u/%s%s' % (mr.viewed_username, urls.SAVED_QUERIES),
+ include_project=False, saved=1, ts=int(time.time()))
diff --git a/features/savedqueries_helpers.py b/features/savedqueries_helpers.py
new file mode 100644
index 0000000..a6cb46f
--- /dev/null
+++ b/features/savedqueries_helpers.py
@@ -0,0 +1,116 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Utility functions and classes for dealing with saved queries.
+
+Saved queries can be part of the project issue config, where they are
+called "canned queries". Or, they can be personal saved queries that
+may appear in the search scope drop-down, on the user's dashboard, or
+in the user's subscription.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import re
+
+from framework import template_helpers
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+
+
+MAX_QUERIES = 100
+
+
+def ParseSavedQueries(cnxn, post_data, project_service, prefix=''):
+ """Parse form data for the Saved Queries part of an admin form."""
+ saved_queries = []
+ for i in range(1, MAX_QUERIES + 1):
+ if ('%ssavedquery_name_%s' % (prefix, i)) not in post_data:
+ continue # skip any entries that are blank or have no predicate.
+
+ name = post_data['%ssavedquery_name_%s' % (prefix, i)].strip()
+ if not name:
+ continue # skip any blank entries
+
+ if '%ssavedquery_id_%s' % (prefix, i) in post_data:
+ query_id = int(post_data['%ssavedquery_id_%s' % (prefix, i)])
+ else:
+ query_id = None # a new query_id will be generated by the DB.
+
+ project_names_str = post_data.get(
+ '%ssavedquery_projects_%s' % (prefix, i), '')
+ project_names = [pn.strip().lower()
+ for pn in re.split('[],;\s]+', project_names_str)
+ if pn.strip()]
+ project_ids = list(project_service.LookupProjectIDs(
+ cnxn, project_names).values())
+
+ base_id = int(post_data['%ssavedquery_base_%s' % (prefix, i)])
+ query = post_data['%ssavedquery_query_%s' % (prefix, i)].strip()
+
+ subscription_mode_field = '%ssavedquery_sub_mode_%s' % (prefix, i)
+ if subscription_mode_field in post_data:
+ subscription_mode = post_data[subscription_mode_field].strip()
+ else:
+ subscription_mode = None
+
+ saved_queries.append(tracker_bizobj.MakeSavedQuery(
+ query_id, name, base_id, query, subscription_mode=subscription_mode,
+ executes_in_project_ids=project_ids))
+
+ return saved_queries
+
+
+class SavedQueryView(template_helpers.PBProxy):
+ """Wrapper class that makes it easier to display SavedQuery via EZT."""
+
+ def __init__(self, sq, idx, cnxn, project_service):
+ """Store relevant values for later display by EZT.
+
+ Args:
+ sq: A SavedQuery protocol buffer.
+ idx: Int index of this saved query in the list.
+ cnxn: connection to SQL database.
+ project_service: persistence layer for project data.
+ """
+ super(SavedQueryView, self).__init__(sq)
+
+ self.idx = idx
+ base_query_name = 'All issues'
+ for canned in tracker_constants.DEFAULT_CANNED_QUERIES:
+ qid, name, _base_id, _query = canned
+ if qid == sq.base_query_id:
+ base_query_name = name
+
+ if cnxn:
+ project_names = sorted(project_service.LookupProjectNames(
+ cnxn, sq.executes_in_project_ids).values())
+ self.projects = ', '.join(project_names)
+ else:
+ self.projects = ''
+
+ self.docstring = '[%s] %s' % (base_query_name, sq.query)
+
+
+def SavedQueryToCond(saved_query):
+ """Convert a SavedQuery PB to a user query condition string."""
+ if saved_query is None:
+ return ''
+
+ base_cond = tracker_bizobj.GetBuiltInQuery(saved_query.base_query_id)
+ cond = '%s %s' % (base_cond, saved_query.query)
+ return cond.strip()
+
+
+def SavedQueryIDToCond(cnxn, features_service, query_id):
+ """Convert a can/query ID to a user query condition string."""
+ built_in = tracker_bizobj.GetBuiltInQuery(query_id)
+ if built_in:
+ return built_in
+
+ saved_query = features_service.GetSavedQuery(cnxn, query_id)
+ return SavedQueryToCond(saved_query)
diff --git a/features/send_notifications.py b/features/send_notifications.py
new file mode 100644
index 0000000..e7ee4d4
--- /dev/null
+++ b/features/send_notifications.py
@@ -0,0 +1,118 @@
+# Copyright 2018 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Functions that prepare and send email notifications of issue changes."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+
+import logging
+
+from features import features_constants
+from framework import cloud_tasks_helpers
+from framework import framework_constants
+from framework import urls
+from tracker import tracker_bizobj
+
+
+def PrepareAndSendIssueChangeNotification(
+ issue_id, hostport, commenter_id, send_email=True,
+ old_owner_id=framework_constants.NO_USER_SPECIFIED, comment_id=None):
+ """Create a task to notify users that an issue has changed.
+
+ Args:
+ issue_id: int ID of the issue that was changed.
+ hostport: string domain name and port number from the HTTP request.
+ commenter_id: int user ID of the user who made the comment.
+ send_email: True if email notifications should be sent.
+ old_owner_id: optional user ID of owner before the current change took
+ effect. They will also be notified.
+ comment_id: int Comment ID of the comment that was entered.
+
+ Returns nothing.
+ """
+ if old_owner_id is None:
+ old_owner_id = framework_constants.NO_USER_SPECIFIED
+ params = dict(
+ issue_id=issue_id, commenter_id=commenter_id, comment_id=comment_id,
+ hostport=hostport, old_owner_id=old_owner_id, send_email=int(send_email))
+ task = cloud_tasks_helpers.generate_simple_task(
+ urls.NOTIFY_ISSUE_CHANGE_TASK + '.do', params)
+ cloud_tasks_helpers.create_task(
+ task, queue=features_constants.QUEUE_NOTIFICATIONS)
+
+ task = cloud_tasks_helpers.generate_simple_task(
+ urls.PUBLISH_PUBSUB_ISSUE_CHANGE_TASK + '.do', params)
+ cloud_tasks_helpers.create_task(task, queue=features_constants.QUEUE_PUBSUB)
+
+
+def PrepareAndSendIssueBlockingNotification(
+ issue_id, hostport, delta_blocker_iids, commenter_id, send_email=True):
+ """Create a task to follow up on an issue blocked_on change."""
+ if not delta_blocker_iids:
+ return # No notification is needed
+
+ params = dict(
+ issue_id=issue_id, commenter_id=commenter_id, hostport=hostport,
+ send_email=int(send_email),
+ delta_blocker_iids=','.join(str(iid) for iid in delta_blocker_iids))
+
+ task = cloud_tasks_helpers.generate_simple_task(
+ urls.NOTIFY_BLOCKING_CHANGE_TASK + '.do', params)
+ cloud_tasks_helpers.create_task(
+ task, queue=features_constants.QUEUE_NOTIFICATIONS)
+
+
+def PrepareAndSendApprovalChangeNotification(
+ issue_id, approval_id, hostport, comment_id, send_email=True):
+ """Create a task to follow up on an approval change."""
+
+ params = dict(
+ issue_id=issue_id, approval_id=approval_id, hostport=hostport,
+ comment_id=comment_id, send_email=int(send_email))
+
+ task = cloud_tasks_helpers.generate_simple_task(
+ urls.NOTIFY_APPROVAL_CHANGE_TASK + '.do', params)
+ cloud_tasks_helpers.create_task(
+ task, queue=features_constants.QUEUE_NOTIFICATIONS)
+
+
+def SendIssueBulkChangeNotification(
+ issue_ids, hostport, old_owner_ids, comment_text, commenter_id,
+ amendments, send_email, users_by_id):
+ """Create a task to follow up on an issue blocked_on change."""
+ amendment_lines = []
+ for up in amendments:
+ line = ' %s: %s' % (
+ tracker_bizobj.GetAmendmentFieldName(up),
+ tracker_bizobj.AmendmentString(up, users_by_id))
+ if line not in amendment_lines:
+ amendment_lines.append(line)
+
+ params = dict(
+ issue_ids=','.join(str(iid) for iid in issue_ids),
+ commenter_id=commenter_id, hostport=hostport, send_email=int(send_email),
+ old_owner_ids=','.join(str(uid) for uid in old_owner_ids),
+ comment_text=comment_text, amendments='\n'.join(amendment_lines))
+
+ task = cloud_tasks_helpers.generate_simple_task(
+ urls.NOTIFY_BULK_CHANGE_TASK + '.do', params)
+ cloud_tasks_helpers.create_task(
+ task, queue=features_constants.QUEUE_NOTIFICATIONS)
+
+
+def PrepareAndSendDeletedFilterRulesNotification(
+ project_id, hostport, filter_rule_strs):
+ """Create a task to notify project owners of deleted filter rules."""
+
+ params = dict(
+ project_id=project_id, filter_rules=','.join(filter_rule_strs),
+ hostport=hostport)
+
+ task = cloud_tasks_helpers.generate_simple_task(
+ urls.NOTIFY_RULES_DELETED_TASK + '.do', params)
+ cloud_tasks_helpers.create_task(
+ task, queue=features_constants.QUEUE_NOTIFICATIONS)
diff --git a/features/spammodel.py b/features/spammodel.py
new file mode 100644
index 0000000..dc5e715
--- /dev/null
+++ b/features/spammodel.py
@@ -0,0 +1,92 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+""" Tasks and handlers for maintaining the spam classifier model. These
+ should be run via cron and task queue rather than manually.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import csv
+import logging
+import webapp2
+import cloudstorage
+import json
+
+from datetime import date
+from datetime import datetime
+from datetime import timedelta
+from google.appengine.api import app_identity
+
+from framework import cloud_tasks_helpers
+from framework import gcs_helpers
+from framework import servlet
+from framework import urls
+
+class TrainingDataExport(webapp2.RequestHandler):
+ """Trigger a training data export task"""
+ def get(self):
+ task = cloud_tasks_helpers.generate_simple_task(
+ urls.SPAM_DATA_EXPORT_TASK + '.do', {})
+ cloud_tasks_helpers.create_task(task)
+
+
+BATCH_SIZE = 1000
+
+class TrainingDataExportTask(servlet.Servlet):
+ """Export any human-labeled ham or spam from the previous day. These
+ records will be used by a subsequent task to create an updated model.
+ """
+ CHECK_SECURITY_TOKEN = False
+
+ def ProcessFormData(self, mr, post_data):
+ logging.info("Training data export initiated.")
+
+ bucket_name = app_identity.get_default_gcs_bucket_name()
+ date_str = date.today().isoformat()
+ export_target_path = '/' + bucket_name + '/spam_training_data/' + date_str
+ total_issues = 0
+
+ with cloudstorage.open(export_target_path, mode='w',
+ content_type=None, options=None, retry_params=None) as gcs_file:
+
+ csv_writer = csv.writer(gcs_file, delimiter=',', quotechar='"',
+ quoting=csv.QUOTE_ALL, lineterminator='\n')
+
+ since = datetime.now() - timedelta(days=7)
+
+ # TODO: Further pagination.
+ issues, first_comments, _count = (
+ self.services.spam.GetTrainingIssues(
+ mr.cnxn, self.services.issue, since, offset=0, limit=BATCH_SIZE))
+ total_issues += len(issues)
+ for issue in issues:
+ # Cloud Prediction API doesn't allow newlines in the training data.
+ fixed_summary = issue.summary.replace('\r\n', ' ')
+ fixed_comment = first_comments[issue.issue_id].replace('\r\n', ' ')
+ email = self.services.user.LookupUserEmail(mr.cnxn, issue.reporter_id)
+ csv_writer.writerow([
+ 'spam' if issue.is_spam else 'ham',
+ fixed_summary.encode('utf-8'), fixed_comment.encode('utf-8'), email,
+ ])
+
+ comments = (
+ self.services.spam.GetTrainingComments(
+ mr.cnxn, self.services.issue, since, offset=0, limit=BATCH_SIZE))
+ total_comments = len(comments)
+ for comment in comments:
+ # Cloud Prediction API doesn't allow newlines in the training data.
+ fixed_comment = comment.content.replace('\r\n', ' ')
+ email = self.services.user.LookupUserEmail(mr.cnxn, comment.user_id)
+ csv_writer.writerow([
+ 'spam' if comment.is_spam else 'ham',
+ # Comments don't have summaries, so it's blank:
+ '', fixed_comment.encode('utf-8'), email
+ ])
+
+ self.response.body = json.dumps({
+ "exported_issue_count": total_issues,
+ "exported_comment_count": total_comments,
+ })
diff --git a/features/spamtraining.py b/features/spamtraining.py
new file mode 100644
index 0000000..625fa53
--- /dev/null
+++ b/features/spamtraining.py
@@ -0,0 +1,63 @@
+"""Cron job to train spam model with all spam data."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import settings
+import time
+
+from googleapiclient import discovery
+from googleapiclient import errors
+from google.appengine.api import app_identity
+from oauth2client.client import GoogleCredentials
+import webapp2
+
+class TrainSpamModelCron(webapp2.RequestHandler):
+
+ """Submit a job to ML Engine which uploads a spam classification model by
+ training on an already packaged trainer.
+ """
+ def get(self):
+
+ credentials = GoogleCredentials.get_application_default()
+ ml = discovery.build('ml', 'v1', credentials=credentials)
+
+ app_id = app_identity.get_application_id()
+ project_id = 'projects/%s' % (app_id)
+ job_id = 'spam_trainer_%d' % time.time()
+ training_input = {
+ 'scaleTier': 'BASIC',
+ 'packageUris': [
+ settings.trainer_staging
+ if app_id == "monorail-staging" else
+ settings.trainer_prod
+ ],
+ 'pythonModule': 'trainer.task',
+ 'args': [
+ '--train-steps',
+ '1000',
+ '--verbosity',
+ 'DEBUG',
+ '--gcs-bucket',
+ 'monorail-prod.appspot.com',
+ '--gcs-prefix',
+ 'spam_training_data',
+ '--trainer-type',
+ 'spam'
+ ],
+ 'region': 'us-central1',
+ 'jobDir': 'gs://%s-mlengine/%s' % (app_id, job_id),
+ 'runtimeVersion': '1.2'
+ }
+ job_info = {
+ 'jobId': job_id,
+ 'trainingInput': training_input
+ }
+ request = ml.projects().jobs().create(parent=project_id, body=job_info)
+
+ try:
+ response = request.execute()
+ logging.info(response)
+ except errors.HttpError, err:
+ logging.error(err._get_reason())
diff --git a/features/test/__init__.py b/features/test/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/features/test/__init__.py
diff --git a/features/test/activities_test.py b/features/test/activities_test.py
new file mode 100644
index 0000000..4eae1ab
--- /dev/null
+++ b/features/test/activities_test.py
@@ -0,0 +1,143 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unittests for monorail.feature.activities."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+import mox
+
+from features import activities
+from framework import framework_views
+from framework import profiler
+from proto import tracker_pb2
+from proto import user_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+
+
+class ActivitiesTest(unittest.TestCase):
+
+ def setUp(self):
+ self.services = service_manager.Services(
+ config=fake.ConfigService(),
+ issue=fake.IssueService(),
+ user=fake.UserService(),
+ usergroup=fake.UserGroupService(),
+ project=fake.ProjectService(),
+ )
+
+ self.project_name = 'proj'
+ self.project_id = 987
+ self.project = self.services.project.TestAddProject(
+ self.project_name, project_id=self.project_id,
+ process_inbound_email=True)
+
+ self.issue_id = 11
+ self.issue_local_id = 100
+ self.issue = tracker_pb2.Issue()
+ self.issue.issue_id = self.issue_id
+ self.issue.project_id = self.project_id
+ self.issue.local_id = self.issue_local_id
+ self.services.issue.TestAddIssue(self.issue)
+
+ self.comment_id = 123
+ self.comment_timestamp = 120
+ self.user = self.services.user.TestAddUser('testuser@example.com', 2)
+ self.user_id = self.user.user_id
+ self.mr_after = 1234
+
+ self.mox = mox.Mox()
+
+ def tearDown(self):
+ self.mox.UnsetStubs()
+ self.mox.ResetAll()
+
+ def testActivities_NoUpdates(self):
+ mr = testing_helpers.MakeMonorailRequest()
+ updates_data = activities.GatherUpdatesData(
+ self.services, mr, project_ids=[256],
+ user_ids=None, ending=None, updates_page_url=None, autolink=None,
+ highlight=None)
+
+ self.assertIsNone(updates_data['pagination'])
+ self.assertIsNone(updates_data['no_stars'])
+ self.assertIsNone(updates_data['updates_data'])
+ self.assertEqual('yes', updates_data['no_activities'])
+ self.assertIsNone(updates_data['ending_type'])
+
+ def createAndAssertUpdates(self, project_ids=None, user_ids=None,
+ ascending=True):
+ comment_1 = tracker_pb2.IssueComment(
+ id=self.comment_id, issue_id=self.issue_id,
+ project_id=self.project_id, user_id=self.user_id,
+ content='this is the 1st comment',
+ timestamp=self.comment_timestamp)
+ self.mox.StubOutWithMock(self.services.issue, 'GetIssueActivity')
+
+ after = 0
+ if ascending:
+ after = self.mr_after
+ self.services.issue.GetIssueActivity(
+ mox.IgnoreArg(), num=50, before=0, after=after, project_ids=project_ids,
+ user_ids=user_ids, ascending=ascending).AndReturn([comment_1])
+
+ self.mox.ReplayAll()
+
+ mr = testing_helpers.MakeMonorailRequest()
+ if ascending:
+ mr.after = self.mr_after
+
+ updates_page_url='testing/testing'
+ updates_data = activities.GatherUpdatesData(
+ self.services, mr, project_ids=project_ids,
+ user_ids=user_ids, ending=None, autolink=None,
+ highlight='highlightme', updates_page_url=updates_page_url)
+ self.mox.VerifyAll()
+
+ if mr.after:
+ pagination = updates_data['pagination']
+ self.assertIsNone(pagination.last)
+ self.assertEqual(
+ '%s?before=%d' %
+ (updates_page_url.split('/')[-1], self.comment_timestamp),
+ pagination.next_url)
+ self.assertEqual(
+ '%s?after=%d' %
+ (updates_page_url.split('/')[-1], self.comment_timestamp),
+ pagination.prev_url)
+
+ activity_view = updates_data['updates_data'].older[0]
+ self.assertEqual(
+ '<a class="ot-issue-link"\n \n '
+ 'href="/p//issues/detail?id=%s#c_ts%s"\n >'
+ 'issue %s</a>\n\n()\n\n\n\n\n \n commented on' % (
+ self.issue_local_id, self.comment_timestamp, self.issue_local_id),
+ activity_view.escaped_title)
+ self.assertEqual(
+ '<span class="ot-issue-comment">\n this is the 1st comment\n</span>',
+ activity_view.escaped_body)
+ self.assertEqual('highlightme', activity_view.highlight)
+ self.assertEqual(self.project_name, activity_view.project_name)
+
+ def testActivities_AscendingProjectUpdates(self):
+ self.createAndAssertUpdates(project_ids=[self.project_id], ascending=True)
+
+ def testActivities_DescendingProjectUpdates(self):
+ self.createAndAssertUpdates(project_ids=[self.project_id], ascending=False)
+
+ def testActivities_AscendingUserUpdates(self):
+ self.createAndAssertUpdates(user_ids=[self.user_id], ascending=True)
+
+ def testActivities_DescendingUserUpdates(self):
+ self.createAndAssertUpdates(user_ids=[self.user_id], ascending=False)
+
+ def testActivities_SpecifyProjectAndUser(self):
+ self.createAndAssertUpdates(
+ project_ids=[self.project_id], user_ids=[self.user_id], ascending=False)
diff --git a/features/test/alert2issue_test.py b/features/test/alert2issue_test.py
new file mode 100644
index 0000000..3b1b6d1
--- /dev/null
+++ b/features/test/alert2issue_test.py
@@ -0,0 +1,677 @@
+# Copyright 2019 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unittests for monorail.feature.alert2issue."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import email
+import unittest
+from mock import patch
+import mox
+from parameterized import parameterized
+
+from features import alert2issue
+from framework import authdata
+from framework import emailfmt
+from proto import tracker_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+from tracker import tracker_helpers
+
+AlertEmailHeader = emailfmt.AlertEmailHeader
+
+
+class TestData(object):
+ """Contains constants or such objects that are intended to be read-only."""
+ cnxn = 'fake cnxn'
+ test_issue_local_id = 100
+ component_id = 123
+ trooper_queue = 'my-trooper-bug-queue'
+
+ project_name = 'proj'
+ project_addr = '%s+ALERT+%s@monorail.example.com' % (
+ project_name, trooper_queue)
+ project_id = 987
+
+ from_addr = 'user@monorail.example.com'
+ user_id = 111
+
+ msg_body = 'this is the body'
+ msg_subject = 'this is the subject'
+ msg = testing_helpers.MakeMessage(
+ testing_helpers.ALERT_EMAIL_HEADER_LINES, msg_body)
+
+ incident_id = msg.get(AlertEmailHeader.INCIDENT_ID)
+ incident_label = alert2issue._GetIncidentLabel(incident_id)
+
+ # All the tests in this class use the following alert properties, and
+ # the generator functions/logic should be tested in a separate class.
+ alert_props = {
+ 'owner_id': 0,
+ 'cc_ids': [],
+ 'status': 'Available',
+ 'incident_label': incident_label,
+ 'priority': 'Pri-0',
+ 'trooper_queue': trooper_queue,
+ 'field_values': [],
+ 'labels': [
+ 'Restrict-View-Google', 'Pri-0', incident_label, trooper_queue
+ ],
+ 'component_ids': [component_id],
+ }
+
+
+class ProcessEmailNotificationTests(unittest.TestCase, TestData):
+ """Implements unit tests for alert2issue.ProcessEmailNotification."""
+ def setUp(self):
+ # services
+ self.services = service_manager.Services(
+ config=fake.ConfigService(),
+ issue=fake.IssueService(),
+ user=fake.UserService(),
+ usergroup=fake.UserGroupService(),
+ project=fake.ProjectService(),
+ features=fake.FeaturesService())
+
+ # project
+ self.project = self.services.project.TestAddProject(
+ self.project_name, project_id=self.project_id,
+ process_inbound_email=True, contrib_ids=[self.user_id])
+
+ # config
+ proj_config = fake.MakeTestConfig(self.project_id, [], ['Available'])
+ comp_def_1 = tracker_pb2.ComponentDef(
+ component_id=123, project_id=987, path='FOO', docstring='foo docstring')
+ proj_config.component_defs = [comp_def_1]
+ self.services.config.StoreConfig(self.cnxn, proj_config)
+
+ # sender
+ self.auth = authdata.AuthData(user_id=self.user_id, email=self.from_addr)
+
+ # issue
+ self.issue = tracker_pb2.Issue(
+ project_id=self.project_id,
+ local_id=self.test_issue_local_id,
+ summary=self.msg_subject,
+ reporter_id=self.user_id,
+ component_ids=[self.component_id],
+ status=self.alert_props['status'],
+ labels=self.alert_props['labels'],
+ )
+ self.services.issue.TestAddIssue(self.issue)
+
+ # Patch send_notifications functions.
+ self.notification_patchers = [
+ patch('features.send_notifications.%s' % func, spec=True)
+ for func in [
+ 'PrepareAndSendIssueBlockingNotification',
+ 'PrepareAndSendIssueChangeNotification',
+ ]
+ ]
+ self.blocking_notification = self.notification_patchers[0].start()
+ self.blocking_notification = self.notification_patchers[1].start()
+
+ self.mox = mox.Mox()
+
+ def tearDown(self):
+ self.notification_patchers[0].stop()
+ self.notification_patchers[1].stop()
+
+ self.mox.UnsetStubs()
+ self.mox.ResetAll()
+
+ def testGoogleAddrsAreAllowlistedSender(self):
+ self.assertTrue(alert2issue.IsAllowlisted('test@google.com'))
+ self.assertFalse(alert2issue.IsAllowlisted('test@notgoogle.com'))
+
+ def testSkipNotification_IfFromNonAllowlistedSender(self):
+ self.mox.StubOutWithMock(alert2issue, 'IsAllowlisted')
+ alert2issue.IsAllowlisted(self.from_addr).AndReturn(False)
+
+ # None of them should be called, if the sender has not been allowlisted.
+ self.mox.StubOutWithMock(self.services.issue, 'CreateIssueComment')
+ self.mox.StubOutWithMock(self.services.issue, 'CreateIssue')
+ self.mox.ReplayAll()
+
+ alert2issue.ProcessEmailNotification(
+ self.services, self.cnxn, self.project, self.project_addr,
+ self.from_addr, self.auth, self.msg_subject, self.msg_body,
+ self.incident_label, self.msg, self.trooper_queue)
+ self.mox.VerifyAll()
+
+ def testSkipNotification_TooLongComment(self):
+ self.mox.StubOutWithMock(alert2issue, 'IsAllowlisted')
+ alert2issue.IsAllowlisted(self.from_addr).AndReturn(True)
+ self.mox.StubOutWithMock(alert2issue, 'IsCommentSizeReasonable')
+ alert2issue.IsCommentSizeReasonable(
+ 'Filed by %s on behalf of %s\n\n%s' %
+ (self.auth.email, self.from_addr, self.msg_body)).AndReturn(False)
+
+ # None of them should be called, if the comment is too long.
+ self.mox.StubOutWithMock(self.services.issue, 'CreateIssueComment')
+ self.mox.StubOutWithMock(self.services.issue, 'CreateIssue')
+ self.mox.ReplayAll()
+
+ alert2issue.ProcessEmailNotification(
+ self.services, self.cnxn, self.project, self.project_addr,
+ self.from_addr, self.auth, self.msg_subject, self.msg_body,
+ self.incident_label, self.msg, self.trooper_queue)
+ self.mox.VerifyAll()
+
+ def testProcessNotification_IfFromAllowlistedSender(self):
+ self.mox.StubOutWithMock(alert2issue, 'IsAllowlisted')
+ alert2issue.IsAllowlisted(self.from_addr).AndReturn(True)
+
+ self.mox.StubOutWithMock(tracker_helpers, 'LookupComponentIDs')
+ tracker_helpers.LookupComponentIDs(
+ ['Infra'],
+ mox.IgnoreArg()).AndReturn([1])
+ self.mox.StubOutWithMock(self.services.issue, 'CreateIssueComment')
+ self.mox.StubOutWithMock(self.services.issue, 'CreateIssue')
+ self.mox.ReplayAll()
+
+ # Either of the methods should be called, if the sender is allowlisted.
+ with self.assertRaises(mox.UnexpectedMethodCallError):
+ alert2issue.ProcessEmailNotification(
+ self.services, self.cnxn, self.project, self.project_addr,
+ self.from_addr, self.auth, self.msg_subject, self.msg_body,
+ self.incident_label, self.msg, self.trooper_queue)
+
+ self.mox.VerifyAll()
+
+ def testIssueCreated_ForNewIncident(self):
+ """Tests if a new issue is created for a new incident."""
+ self.mox.StubOutWithMock(alert2issue, 'IsAllowlisted')
+ alert2issue.IsAllowlisted(self.from_addr).AndReturn(True)
+
+ # FindAlertIssue() returns None for a new incident.
+ self.mox.StubOutWithMock(alert2issue, 'FindAlertIssue')
+ alert2issue.FindAlertIssue(
+ self.services, self.cnxn, self.project.project_id,
+ self.incident_label).AndReturn(None)
+
+ # Mock GetAlertProperties() to create the issue with the expected
+ # properties.
+ self.mox.StubOutWithMock(alert2issue, 'GetAlertProperties')
+ alert2issue.GetAlertProperties(
+ self.services, self.cnxn, self.project_id, self.incident_id,
+ self.trooper_queue, self.msg).AndReturn(self.alert_props)
+
+ self.mox.ReplayAll()
+ alert2issue.ProcessEmailNotification(
+ self.services, self.cnxn, self.project, self.project_addr,
+ self.from_addr, self.auth, self.msg_subject, self.msg_body,
+ self.incident_id, self.msg, self.trooper_queue)
+
+ # the local ID of the newly created issue should be +1 from the highest ID
+ # in the existing issues.
+ comments = self._verifyIssue(self.test_issue_local_id + 1, self.alert_props)
+ self.assertEqual(comments[0].content,
+ 'Filed by %s on behalf of %s\n\n%s' % (
+ self.from_addr, self.from_addr, self.msg_body))
+
+ self.mox.VerifyAll()
+
+ def testProcessEmailNotification_ExistingIssue(self):
+ """When an alert for an ongoing incident comes in, add a comment."""
+ self.mox.StubOutWithMock(alert2issue, 'IsAllowlisted')
+ alert2issue.IsAllowlisted(self.from_addr).AndReturn(True)
+
+ # FindAlertIssue() returns None for a new incident.
+ self.mox.StubOutWithMock(alert2issue, 'FindAlertIssue')
+ alert2issue.FindAlertIssue(
+ self.services, self.cnxn, self.project.project_id,
+ self.incident_label).AndReturn(self.issue)
+
+ # Mock GetAlertProperties() to create the issue with the expected
+ # properties.
+ self.mox.StubOutWithMock(alert2issue, 'GetAlertProperties')
+ alert2issue.GetAlertProperties(
+ self.services, self.cnxn, self.project_id, self.incident_id,
+ self.trooper_queue, self.msg).AndReturn(self.alert_props)
+
+ self.mox.ReplayAll()
+
+ # Before processing the notification, ensures that there is only 1 comment
+ # in the test issue.
+ comments = self._verifyIssue(self.test_issue_local_id, self.alert_props)
+ self.assertEqual(len(comments), 1)
+
+ # Process
+ alert2issue.ProcessEmailNotification(
+ self.services, self.cnxn, self.project, self.project_addr,
+ self.from_addr, self.auth, self.msg_subject, self.msg_body,
+ self.incident_id, self.msg, self.trooper_queue)
+
+ # Now, it should have a new comment added.
+ comments = self._verifyIssue(self.test_issue_local_id, self.alert_props)
+ self.assertEqual(len(comments), 2)
+ self.assertEqual(comments[1].content,
+ 'Filed by %s on behalf of %s\n\n%s' % (
+ self.from_addr, self.from_addr, self.msg_body))
+
+ self.mox.VerifyAll()
+
+ def _verifyIssue(self, local_issue_id, alert_props):
+ actual_issue = self.services.issue.GetIssueByLocalID(
+ self.cnxn, self.project.project_id, local_issue_id)
+ actual_comments = self.services.issue.GetCommentsForIssue(
+ self.cnxn, actual_issue.issue_id)
+
+ self.assertEqual(actual_issue.summary, self.msg_subject)
+ self.assertEqual(actual_issue.status, alert_props['status'])
+ self.assertEqual(actual_issue.reporter_id, self.user_id)
+ self.assertEqual(actual_issue.component_ids, [self.component_id])
+ if alert_props['owner_id']:
+ self.assertEqual(actual_issue.owner_id, alert_props['owner_id'])
+ self.assertEqual(sorted(actual_issue.labels), sorted(alert_props['labels']))
+ return actual_comments
+
+
+class GetAlertPropertiesTests(unittest.TestCase, TestData):
+ """Implements unit tests for alert2issue.GetAlertProperties."""
+ def assertSubset(self, lhs, rhs):
+ if not (lhs <= rhs):
+ raise AssertionError('%s not a subset of %s' % (lhs, rhs))
+
+ def assertCaseInsensitiveEqual(self, lhs, rhs):
+ self.assertEqual(lhs if lhs is None else lhs.lower(),
+ rhs if lhs is None else rhs.lower())
+
+ def setUp(self):
+ # services
+ self.services = service_manager.Services(
+ config=fake.ConfigService(),
+ issue=fake.IssueService(),
+ user=fake.UserService(),
+ usergroup=fake.UserGroupService(),
+ project=fake.ProjectService())
+
+ # project
+ self.project = self.services.project.TestAddProject(
+ self.project_name, project_id=self.project_id,
+ process_inbound_email=True, contrib_ids=[self.user_id])
+
+ proj_config = fake.MakeTestConfig(
+ self.project_id,
+ [
+ # test labels for Pri field
+ 'Pri-0', 'Pri-1', 'Pri-2', 'Pri-3',
+ # test labels for OS field
+ 'OS-Android', 'OS-Windows',
+ # test labels for Type field
+ 'Type-Bug', 'Type-Bug-Regression', 'Type-Bug-Security', 'Type-Task',
+ ],
+ ['Assigned', 'Available', 'Unconfirmed']
+ )
+ self.services.config.StoreConfig(self.cnxn, proj_config)
+
+ # create a test email message, which tests can alternate the header values
+ # to verify the behaviour of a given parser function.
+ self.test_msg = email.Message.Message()
+ for key, value in self.msg.items():
+ self.test_msg[key] = value
+
+ self.mox = mox.Mox()
+
+ @parameterized.expand([
+ (None,),
+ ('',),
+ (' ',),
+ ])
+ def testDefaultComponent(self, header_value):
+ """Checks if the default component is Infra."""
+ self.test_msg.replace_header(AlertEmailHeader.COMPONENT, header_value)
+ self.mox.StubOutWithMock(tracker_helpers, 'LookupComponentIDs')
+ tracker_helpers.LookupComponentIDs(
+ ['Infra'],
+ mox.IgnoreArg()).AndReturn([self.component_id])
+
+ self.mox.ReplayAll()
+ props = alert2issue.GetAlertProperties(
+ self.services, self.cnxn, self.project_id, self.incident_id,
+ self.trooper_queue, self.test_msg)
+ self.assertEqual(props['component_ids'], [self.component_id])
+ self.mox.VerifyAll()
+
+ @parameterized.expand([
+ # an existing single component with componentID 1
+ ({'Infra': 1}, [1]),
+ # 3 of existing components
+ ({'Infra': 1, 'Foo': 2, 'Bar': 3}, [1, 2, 3]),
+ # a non-existing component
+ ({'Infra': None}, []),
+ # 3 of non-existing components
+ ({'Infra': None, 'Foo': None, 'Bar': None}, []),
+ # a mix of existing and non-existing components
+ ({'Infra': 1, 'Foo': None, 'Bar': 2}, [1, 2]),
+ ])
+ def testGetComponentIDs(self, components, expected_component_ids):
+ """Tests _GetComponentIDs."""
+ self.test_msg.replace_header(
+ AlertEmailHeader.COMPONENT, ','.join(sorted(components.keys())))
+
+ self.mox.StubOutWithMock(tracker_helpers, 'LookupComponentIDs')
+ tracker_helpers.LookupComponentIDs(
+ sorted(components.keys()),
+ mox.IgnoreArg()).AndReturn(
+ [components[key] for key in sorted(components.keys())
+ if components[key]]
+ )
+
+ self.mox.ReplayAll()
+ props = alert2issue.GetAlertProperties(
+ self.services, self.cnxn, self.project_id, self.incident_id,
+ self.trooper_queue, self.test_msg)
+ self.assertEqual(sorted(props['component_ids']),
+ sorted(expected_component_ids))
+ self.mox.VerifyAll()
+
+
+ def testLabelsWithNecessaryValues(self):
+ """Checks if the labels contain all the necessary values."""
+ props = alert2issue.GetAlertProperties(
+ self.services, self.cnxn, self.project_id, self.incident_id,
+ self.trooper_queue, self.test_msg)
+
+ # This test assumes that the test message contains non-empty values for
+ # all the headers.
+ self.assertTrue(props['incident_label'])
+ self.assertTrue(props['priority'])
+ self.assertTrue(props['issue_type'])
+ self.assertTrue(props['oses'])
+
+ # Here are a list of the labels that props['labels'] should contain
+ self.assertIn('Restrict-View-Google'.lower(), props['labels'])
+ self.assertIn(self.trooper_queue, props['labels'])
+ self.assertIn(props['incident_label'], props['labels'])
+ self.assertIn(props['priority'], props['labels'])
+ self.assertIn(props['issue_type'], props['labels'])
+ for os in props['oses']:
+ self.assertIn(os, props['labels'])
+
+ @parameterized.expand([
+ (None, 0),
+ ('', 0),
+ (' ', 0),
+ ])
+ def testDefaultOwnerID(self, header_value, expected_owner_id):
+ """Checks if _GetOwnerID returns None in default."""
+ self.test_msg.replace_header(AlertEmailHeader.OWNER, header_value)
+ props = alert2issue.GetAlertProperties(
+ self.services, self.cnxn, self.project_id, self.incident_id,
+ self.trooper_queue, self.test_msg)
+ self.assertEqual(props['owner_id'], expected_owner_id)
+
+ @parameterized.expand(
+ [
+ # an existing user with userID 1.
+ ('owner@example.org', 1),
+ # a non-existing user.
+ ('owner@example.org', 0),
+ ])
+ def testGetOwnerID(self, owner, expected_owner_id):
+ """Tests _GetOwnerID returns the ID of the owner."""
+ self.test_msg.replace_header(AlertEmailHeader.CC, '')
+ self.test_msg.replace_header(AlertEmailHeader.OWNER, owner)
+
+ self.mox.StubOutWithMock(self.services.user, 'LookupExistingUserIDs')
+ self.services.user.LookupExistingUserIDs(self.cnxn, [owner]).AndReturn(
+ {owner: expected_owner_id})
+
+ self.mox.ReplayAll()
+ props = alert2issue.GetAlertProperties(
+ self.services, self.cnxn, self.project_id, self.incident_id,
+ self.trooper_queue, self.test_msg)
+ self.mox.VerifyAll()
+ self.assertEqual(props['owner_id'], expected_owner_id)
+
+ @parameterized.expand([
+ (None, []),
+ ('', []),
+ (' ', []),
+ ])
+ def testDefaultCCIDs(self, header_value, expected_cc_ids):
+ """Checks if _GetCCIDs returns an empty list in default."""
+ self.test_msg.replace_header(AlertEmailHeader.CC, header_value)
+ props = alert2issue.GetAlertProperties(
+ self.services, self.cnxn, self.project_id, self.incident_id,
+ self.trooper_queue, self.test_msg)
+ self.assertEqual(props['cc_ids'], expected_cc_ids)
+
+ @parameterized.expand([
+ # with one existing user cc-ed.
+ ({'user1@example.org': 1}, [1]),
+ # with two of existing users.
+ ({'user1@example.org': 1, 'user2@example.org': 2}, [1, 2]),
+ # with one non-existing user.
+ ({'user1@example.org': None}, []),
+ # with two of non-existing users.
+ ({'user1@example.org': None, 'user2@example.org': None}, []),
+ # with a mix of existing and non-existing users.
+ ({'user1@example.org': 1, 'user2@example.org': None}, [1]),
+ ])
+ def testGetCCIDs(self, ccers, expected_cc_ids):
+ """Tests _GetCCIDs returns the IDs of the email addresses to be cc-ed."""
+ self.test_msg.replace_header(
+ AlertEmailHeader.CC, ','.join(sorted(ccers.keys())))
+ self.test_msg.replace_header(AlertEmailHeader.OWNER, '')
+
+ self.mox.StubOutWithMock(self.services.user, 'LookupExistingUserIDs')
+ self.services.user.LookupExistingUserIDs(
+ self.cnxn, sorted(ccers.keys())).AndReturn(ccers)
+
+ self.mox.ReplayAll()
+ props = alert2issue.GetAlertProperties(
+ self.services, self.cnxn, self.project_id, self.incident_id,
+ self.trooper_queue, self.test_msg)
+ self.mox.VerifyAll()
+ self.assertEqual(sorted(props['cc_ids']), sorted(expected_cc_ids))
+
+ @parameterized.expand([
+ # None and '' should result in the default priority returned.
+ (None, 'Pri-2'),
+ ('', 'Pri-2'),
+ (' ', 'Pri-2'),
+
+ # Tests for valid priority values
+ ('0', 'Pri-0'),
+ ('1', 'Pri-1'),
+ ('2', 'Pri-2'),
+ ('3', 'Pri-3'),
+
+ # Tests for invalid priority values
+ ('test', 'Pri-2'),
+ ('foo', 'Pri-2'),
+ ('critical', 'Pri-2'),
+ ('4', 'Pri-2'),
+ ('3x', 'Pri-2'),
+ ('00', 'Pri-2'),
+ ('01', 'Pri-2'),
+ ])
+ def testGetPriority(self, header_value, expected_priority):
+ """Tests _GetPriority."""
+ self.test_msg.replace_header(AlertEmailHeader.PRIORITY, header_value)
+ props = alert2issue.GetAlertProperties(
+ self.services, self.cnxn, self.project_id, self.incident_id,
+ self.trooper_queue, self.test_msg)
+ self.assertCaseInsensitiveEqual(props['priority'], expected_priority)
+
+ @parameterized.expand([
+ (None, 'Available'),
+ ('', 'Available'),
+ (' ', 'Available'),
+ ])
+ def testDefaultStatus(self, header_value, expected_status):
+ """Checks if _GetStatus return Available in default."""
+ self.test_msg.replace_header(AlertEmailHeader.STATUS, header_value)
+ props = alert2issue.GetAlertProperties(
+ self.services, self.cnxn, self.project_id, self.incident_id,
+ self.trooper_queue, self.test_msg)
+ self.assertCaseInsensitiveEqual(props['status'], expected_status)
+
+ @parameterized.expand([
+ ('random_status', True, 'random_status'),
+ # If the status is not one of the open statuses, the default status
+ # should be returned instead.
+ ('random_status', False, 'Available'),
+ ])
+ def testGetStatusWithoutOwner(self, status, means_open, expected_status):
+ """Tests GetStatus without an owner."""
+ self.test_msg.replace_header(AlertEmailHeader.STATUS, status)
+ self.mox.StubOutWithMock(tracker_helpers, 'MeansOpenInProject')
+ tracker_helpers.MeansOpenInProject(status, mox.IgnoreArg()).AndReturn(
+ means_open)
+
+ self.mox.ReplayAll()
+ props = alert2issue.GetAlertProperties(
+ self.services, self.cnxn, self.project_id, self.incident_id,
+ self.trooper_queue, self.test_msg)
+ self.assertCaseInsensitiveEqual(props['status'], expected_status)
+ self.mox.VerifyAll()
+
+ @parameterized.expand([
+ # If there is an owner, the status should always be Assigned.
+ (None, 'Assigned'),
+ ('', 'Assigned'),
+ (' ', 'Assigned'),
+
+ ('random_status', 'Assigned'),
+ ('Available', 'Assigned'),
+ ('Unconfirmed', 'Assigned'),
+ ('Fixed', 'Assigned'),
+ ])
+ def testGetStatusWithOwner(self, status, expected_status):
+ """Tests GetStatus with an owner."""
+ owner = 'owner@example.org'
+ self.test_msg.replace_header(AlertEmailHeader.OWNER, owner)
+ self.test_msg.replace_header(AlertEmailHeader.CC, '')
+ self.test_msg.replace_header(AlertEmailHeader.STATUS, status)
+
+ self.mox.StubOutWithMock(self.services.user, 'LookupExistingUserIDs')
+ self.services.user.LookupExistingUserIDs(self.cnxn, [owner]).AndReturn(
+ {owner: 1})
+
+ self.mox.ReplayAll()
+ props = alert2issue.GetAlertProperties(
+ self.services, self.cnxn, self.project_id, self.incident_id,
+ self.trooper_queue, self.test_msg)
+ self.assertCaseInsensitiveEqual(props['status'], expected_status)
+ self.mox.VerifyAll()
+
+ @parameterized.expand(
+ [
+ # None and '' should result in None returned.
+ (None, None),
+ ('', None),
+ (' ', None),
+
+ # allowlisted issue types
+ ('Bug', 'Type-Bug'),
+ ('Bug-Regression', 'Type-Bug-Regression'),
+ ('Bug-Security', 'Type-Bug-Security'),
+ ('Task', 'Type-Task'),
+
+ # non-allowlisted issue types
+ ('foo', None),
+ ('bar', None),
+ ('Bug,Bug-Regression', None),
+ ('Bug,', None),
+ (',Task', None),
+ ])
+ def testGetIssueType(self, header_value, expected_issue_type):
+ """Tests _GetIssueType."""
+ self.test_msg.replace_header(AlertEmailHeader.TYPE, header_value)
+ props = alert2issue.GetAlertProperties(
+ self.services, self.cnxn, self.project_id, self.incident_id,
+ self.trooper_queue, self.test_msg)
+ self.assertCaseInsensitiveEqual(props['issue_type'], expected_issue_type)
+
+ @parameterized.expand(
+ [
+ # None and '' should result in an empty list returned.
+ (None, []),
+ ('', []),
+ (' ', []),
+
+ # a single, allowlisted os
+ ('Android', ['OS-Android']),
+ # a single, non-allowlisted OS
+ ('Bendroid', []),
+ # multiple, allowlisted oses
+ ('Android,Windows', ['OS-Android', 'OS-Windows']),
+ # multiple, non-allowlisted oses
+ ('Bendroid,Findows', []),
+ # a mix of allowlisted and non-allowlisted oses
+ ('Android,Findows,Windows,Bendroid', ['OS-Android', 'OS-Windows']),
+ # a mix of allowlisted and non-allowlisted oses with trailing commas.
+ ('Android,Findows,Windows,Bendroid,,', ['OS-Android', 'OS-Windows']),
+ # a mix of allowlisted and non-allowlisted oses with commas at the
+ # beginning.
+ (
+ ',,Android,Findows,Windows,Bendroid,,',
+ ['OS-Android', 'OS-Windows']),
+ ])
+ def testGetOSes(self, header_value, expected_oses):
+ """Tests _GetOSes."""
+ self.test_msg.replace_header(AlertEmailHeader.OS, header_value)
+ props = alert2issue.GetAlertProperties(
+ self.services, self.cnxn, self.project_id, self.incident_id,
+ self.trooper_queue, self.test_msg)
+ self.assertEqual(sorted(os if os is None else os.lower()
+ for os in props['oses']),
+ sorted(os if os is None else os.lower()
+ for os in expected_oses))
+
+ @parameterized.expand([
+ # None and '' should result in an empty list + RSVG returned.
+ (None, []),
+ ('', []),
+ (' ', []),
+
+ ('Label-1', ['label-1']),
+ ('Label-1,Label-2', ['label-1', 'label-2',]),
+ ('Label-1,Label-2,Label-3', ['label-1', 'label-2', 'label-3']),
+
+ # Duplicates should be removed.
+ ('Label-1,Label-1', ['label-1']),
+ ('Label-1,label-1', ['label-1']),
+ (',Label-1,label-1,', ['label-1']),
+ ('Label-1,label-1,', ['label-1']),
+ (',Label-1,,label-1,,,', ['label-1']),
+ ('Label-1,Label-2,Label-1', ['label-1', 'label-2']),
+
+ # Whitespaces should be removed from labels.
+ ('La bel - 1 ', ['label-1']),
+ ('La bel - 1 , Label- 1', ['label-1']),
+ ('La bel- 1 , Label - 2', ['label-1', 'label-2']),
+
+ # RSVG should be set always.
+ ('Label-1,Label-1,Restrict-View-Google', ['label-1']),
+ ])
+ def testGetLabels(self, header_value, expected_labels):
+ """Tests _GetLabels."""
+ self.test_msg.replace_header(AlertEmailHeader.LABEL, header_value)
+ props = alert2issue.GetAlertProperties(
+ self.services, self.cnxn, self.project_id, self.incident_id,
+ self.trooper_queue, self.test_msg)
+
+ # Check if there are any duplicates
+ labels = set(props['labels'])
+ self.assertEqual(sorted(props['labels']), sorted(list(labels)))
+
+ # Check the labels that shouldb always be included
+ self.assertIn('Restrict-View-Google'.lower(), labels)
+ self.assertIn(props['trooper_queue'], labels)
+ self.assertIn(props['incident_label'], labels)
+ self.assertIn(props['priority'], labels)
+ self.assertIn(props['issue_type'], labels)
+ self.assertSubset(set(props['oses']), labels)
+
+ # All the custom labels should be present.
+ self.assertSubset(set(expected_labels), labels)
diff --git a/features/test/autolink_test.py b/features/test/autolink_test.py
new file mode 100644
index 0000000..a779014
--- /dev/null
+++ b/features/test/autolink_test.py
@@ -0,0 +1,808 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unittest for the autolink feature."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import re
+import unittest
+
+from features import autolink
+from features import autolink_constants
+from framework import template_helpers
+from proto import tracker_pb2
+from testing import fake
+from testing import testing_helpers
+
+
+SIMPLE_EMAIL_RE = re.compile(r'([a-z]+)@([a-z]+)\.com')
+OVER_AMBITIOUS_DOMAIN_RE = re.compile(r'([a-z]+)\.(com|net|org)')
+
+
+class AutolinkTest(unittest.TestCase):
+
+ def RegisterEmailCallbacks(self, aa):
+
+ def LookupUsers(_mr, all_addresses):
+ """Return user objects for only users who are at trusted domains."""
+ return [addr for addr in all_addresses
+ if addr.endswith('@example.com')]
+
+ def Match2Addresses(_mr, match):
+ return [match.group(0)]
+
+ def MakeMailtoLink(_mr, match, comp_ref_artifacts):
+ email = match.group(0)
+ if comp_ref_artifacts and email in comp_ref_artifacts:
+ return [template_helpers.TextRun(
+ tag='a', href='mailto:%s' % email, content=email)]
+ else:
+ return [template_helpers.TextRun('%s AT %s.com' % match.group(1, 2))]
+
+ aa.RegisterComponent('testcomp',
+ LookupUsers,
+ Match2Addresses,
+ {SIMPLE_EMAIL_RE: MakeMailtoLink})
+
+ def RegisterDomainCallbacks(self, aa):
+
+ def LookupDomains(_mr, _all_refs):
+ """Return business objects for only real domains. Always just True."""
+ return True # We don't have domain business objects, accept anything.
+
+ def Match2Domains(_mr, match):
+ return [match.group(0)]
+
+ def MakeHyperLink(_mr, match, _comp_ref_artifacts):
+ domain = match.group(0)
+ return [template_helpers.TextRun(tag='a', href=domain, content=domain)]
+
+ aa.RegisterComponent('testcomp2',
+ LookupDomains,
+ Match2Domains,
+ {OVER_AMBITIOUS_DOMAIN_RE: MakeHyperLink})
+
+ def setUp(self):
+ self.aa = autolink.Autolink()
+ self.RegisterEmailCallbacks(self.aa)
+ self.comment1 = ('Feel free to contact me at a@other.com, '
+ 'or b@example.com, or c@example.org.')
+ self.comment2 = 'no matches in this comment'
+ self.comment3 = 'just matches with no ref: a@other.com, c@example.org'
+ self.comments = [self.comment1, self.comment2, self.comment3]
+
+ def testRegisterComponent(self):
+ self.assertIn('testcomp', self.aa.registry)
+
+ def testGetAllReferencedArtifacts(self):
+ all_ref_artifacts = self.aa.GetAllReferencedArtifacts(
+ None, self.comments)
+
+ self.assertIn('testcomp', all_ref_artifacts)
+ comp_refs = all_ref_artifacts['testcomp']
+ self.assertIn('b@example.com', comp_refs)
+ self.assertTrue(len(comp_refs) == 1)
+
+ def testGetAllReferencedArtifacts_TooBig(self):
+ all_ref_artifacts = self.aa.GetAllReferencedArtifacts(
+ None, self.comments, max_total_length=10)
+
+ self.assertEqual(autolink.SKIP_LOOKUPS, all_ref_artifacts)
+
+ def testMarkupAutolinks(self):
+ all_ref_artifacts = self.aa.GetAllReferencedArtifacts(None, self.comments)
+ result = self.aa.MarkupAutolinks(
+ None, [template_helpers.TextRun(self.comment1)], all_ref_artifacts)
+ self.assertEqual('Feel free to contact me at ', result[0].content)
+ self.assertEqual('a AT other.com', result[1].content)
+ self.assertEqual(', or ', result[2].content)
+ self.assertEqual('b@example.com', result[3].content)
+ self.assertEqual('mailto:b@example.com', result[3].href)
+ self.assertEqual(', or c@example.org.', result[4].content)
+
+ result = self.aa.MarkupAutolinks(
+ None, [template_helpers.TextRun(self.comment2)], all_ref_artifacts)
+ self.assertEqual('no matches in this comment', result[0].content)
+
+ result = self.aa.MarkupAutolinks(
+ None, [template_helpers.TextRun(self.comment3)], all_ref_artifacts)
+ self.assertEqual('just matches with no ref: ', result[0].content)
+ self.assertEqual('a AT other.com', result[1].content)
+ self.assertEqual(', c@example.org', result[2].content)
+
+ def testNonnestedAutolinks(self):
+ """Test that when a substitution yields plain text, others are applied."""
+ self.RegisterDomainCallbacks(self.aa)
+ all_ref_artifacts = self.aa.GetAllReferencedArtifacts(None, self.comments)
+ result = self.aa.MarkupAutolinks(
+ None, [template_helpers.TextRun(self.comment1)], all_ref_artifacts)
+ self.assertEqual('Feel free to contact me at ', result[0].content)
+ self.assertEqual('a AT ', result[1].content)
+ self.assertEqual('other.com', result[2].content)
+ self.assertEqual('other.com', result[2].href)
+ self.assertEqual(', or ', result[3].content)
+ self.assertEqual('b@example.com', result[4].content)
+ self.assertEqual('mailto:b@example.com', result[4].href)
+ self.assertEqual(', or c@', result[5].content)
+ self.assertEqual('example.org', result[6].content)
+ self.assertEqual('example.org', result[6].href)
+ self.assertEqual('.', result[7].content)
+
+ result = self.aa.MarkupAutolinks(
+ None, [template_helpers.TextRun(self.comment2)], all_ref_artifacts)
+ self.assertEqual('no matches in this comment', result[0].content)
+ result = self.aa.MarkupAutolinks(
+ None, [template_helpers.TextRun(self.comment3)], all_ref_artifacts)
+ self.assertEqual('just matches with no ref: ', result[0].content)
+ self.assertEqual('a AT ', result[1].content)
+ self.assertEqual('other.com', result[2].content)
+ self.assertEqual('other.com', result[2].href)
+ self.assertEqual(', c@', result[3].content)
+ self.assertEqual('example.org', result[4].content)
+ self.assertEqual('example.org', result[4].href)
+
+ def testMarkupAutolinks_TooBig(self):
+ """If the issue has too much text, we just do regex-based autolinking."""
+ all_ref_artifacts = self.aa.GetAllReferencedArtifacts(
+ None, self.comments, max_total_length=10)
+ result = self.aa.MarkupAutolinks(
+ None, [template_helpers.TextRun(self.comment1)], all_ref_artifacts)
+ self.assertEqual(5, len(result))
+ self.assertEqual('Feel free to contact me at ', result[0].content)
+ # The test autolink handlers in this file do not link email addresses.
+ self.assertEqual('a AT other.com', result[1].content)
+ self.assertIsNone(result[1].href)
+
+class EmailAutolinkTest(unittest.TestCase):
+
+ def setUp(self):
+ self.user_1 = 'fake user' # Note: no User fields are accessed.
+
+ def DoLinkify(
+ self, content, filter_re=autolink_constants.IS_IMPLIED_EMAIL_RE):
+ """Calls the LinkifyEmail method and returns the result.
+
+ Args:
+ content: string with a hyperlink.
+
+ Returns:
+ A list of TextRuns with some runs having the embedded email hyperlinked.
+ Or, None if no link was detected.
+ """
+ match = filter_re.search(content)
+ if not match:
+ return None
+
+ return autolink.LinkifyEmail(None, match, {'one@example.com': self.user_1})
+
+ def testLinkifyEmail(self):
+ """Test that an address is autolinked when put in the given context."""
+ test = 'one@ or @one'
+ result = self.DoLinkify('Have you met %s' % test)
+ self.assertEqual(None, result)
+
+ test = 'one@example.com'
+ result = self.DoLinkify('Have you met %s' % test)
+ self.assertEqual('/u/' + test, result[0].href)
+ self.assertEqual(test, result[0].content)
+
+ test = 'alias@example.com'
+ result = self.DoLinkify('Please also CC %s' % test)
+ self.assertEqual('mailto:' + test, result[0].href)
+ self.assertEqual(test, result[0].content)
+
+ result = self.DoLinkify('Reviewed-By: Test Person <%s>' % test)
+ self.assertEqual('mailto:' + test, result[0].href)
+ self.assertEqual(test, result[0].content)
+
+
+class URLAutolinkTest(unittest.TestCase):
+
+ def DoLinkify(self, content, filter_re=autolink_constants.IS_A_LINK_RE):
+ """Calls the linkify method and returns the result.
+
+ Args:
+ content: string with a hyperlink.
+
+ Returns:
+ A list of TextRuns with some runs will have the embedded URL hyperlinked.
+ Or, None if no link was detected.
+ """
+ match = filter_re.search(content)
+ if not match:
+ return None
+
+ return autolink.Linkify(None, match, None)
+
+ def testLinkify(self):
+ """Test that given url is autolinked when put in the given context."""
+ # Disallow the linking of URLs with user names and passwords.
+ test = 'http://user:pass@www.yahoo.com'
+ result = self.DoLinkify('What about %s' % test)
+ self.assertEqual(None, result[0].tag)
+ self.assertEqual(None, result[0].href)
+ self.assertEqual(test, result[0].content)
+
+ # Disallow the linking of non-HTTP(S) links
+ test = 'nntp://news.google.com'
+ result = self.DoLinkify('%s' % test)
+ self.assertEqual(None, result)
+
+ # Disallow the linking of file links
+ test = 'file://C:/Windows/System32/cmd.exe'
+ result = self.DoLinkify('%s' % test)
+ self.assertEqual(None, result)
+
+ # Test some known URLs
+ test = 'http://www.example.com'
+ result = self.DoLinkify('What about %s' % test)
+ self.assertEqual(test, result[0].href)
+ self.assertEqual(test, result[0].content)
+
+ def testLinkify_FTP(self):
+ """Test that FTP urls are linked."""
+ # Check for a standard ftp link
+ test = 'ftp://ftp.example.com'
+ result = self.DoLinkify('%s' % test)
+ self.assertEqual(test, result[0].href)
+ self.assertEqual(test, result[0].content)
+
+ def testLinkify_Email(self):
+ """Test that mailto: urls are linked."""
+ test = 'mailto:user@example.com'
+ result = self.DoLinkify('%s' % test)
+ self.assertEqual(test, result[0].href)
+ self.assertEqual(test, result[0].content)
+
+ def testLinkify_ShortLink(self):
+ """Test that shortlinks are linked."""
+ test = 'http://go/monorail'
+ result = self.DoLinkify(
+ '%s' % test, filter_re=autolink_constants.IS_A_SHORT_LINK_RE)
+ self.assertEqual(test, result[0].href)
+ self.assertEqual(test, result[0].content)
+
+ test = 'go/monorail'
+ result = self.DoLinkify(
+ '%s' % test, filter_re=autolink_constants.IS_A_SHORT_LINK_RE)
+ self.assertEqual('http://' + test, result[0].href)
+ self.assertEqual(test, result[0].content)
+
+ test = 'b/12345'
+ result = self.DoLinkify(
+ '%s' % test, filter_re=autolink_constants.IS_A_NUMERIC_SHORT_LINK_RE)
+ self.assertEqual('http://' + test, result[0].href)
+ self.assertEqual(test, result[0].content)
+
+ test = 'http://b/12345'
+ result = self.DoLinkify(
+ '%s' % test, filter_re=autolink_constants.IS_A_NUMERIC_SHORT_LINK_RE)
+ self.assertEqual(test, result[0].href)
+ self.assertEqual(test, result[0].content)
+
+ test = '/b/12345'
+ result = self.DoLinkify(
+ '%s' % test, filter_re=autolink_constants.IS_A_SHORT_LINK_RE)
+ self.assertIsNone(result)
+
+ test = '/b/12345'
+ result = self.DoLinkify(
+ '%s' % test, filter_re=autolink_constants.IS_A_NUMERIC_SHORT_LINK_RE)
+ self.assertIsNone(result)
+
+ test = 'b/secondFileInDiff'
+ result = self.DoLinkify(
+ '%s' % test, filter_re=autolink_constants.IS_A_NUMERIC_SHORT_LINK_RE)
+ self.assertIsNone(result)
+
+ def testLinkify_ImpliedLink(self):
+ """Test that text with .com, .org, .net, and .edu are linked."""
+ test = 'google.org'
+ result = self.DoLinkify(
+ '%s' % test, filter_re=autolink_constants.IS_IMPLIED_LINK_RE)
+ self.assertEqual('http://' + test, result[0].href)
+ self.assertEqual(test, result[0].content)
+
+ test = 'code.google.com/p/chromium'
+ result = self.DoLinkify(
+ '%s' % test, filter_re=autolink_constants.IS_IMPLIED_LINK_RE)
+ self.assertEqual('http://' + test, result[0].href)
+ self.assertEqual(test, result[0].content)
+
+ # This is not a domain, it is a directory or something.
+ test = 'build.out/p/chromium'
+ result = self.DoLinkify(
+ '%s' % test, filter_re=autolink_constants.IS_IMPLIED_LINK_RE)
+ self.assertEqual(None, result)
+
+ # We do not link the NNTP scheme, and the domain name part of it will not
+ # be linked as an HTTP link because it is preceeded by "/".
+ test = 'nntp://news.google.com'
+ result = self.DoLinkify(
+ '%s' % test, filter_re=autolink_constants.IS_IMPLIED_LINK_RE)
+ self.assertIsNone(result)
+
+ def testLinkify_Context(self):
+ """Test that surrounding syntax is not considered part of the url."""
+ test = 'http://www.example.com'
+
+ # Check for a link followed by a comma at end of English phrase.
+ result = self.DoLinkify('The URL %s, points to a great website.' % test)
+ self.assertEqual(test, result[0].href)
+ self.assertEqual(test, result[0].content)
+ self.assertEqual(',', result[1].content)
+
+ # Check for a link followed by a period at end of English sentence.
+ result = self.DoLinkify('The best site ever, %s.' % test)
+ self.assertEqual(test, result[0].href)
+ self.assertEqual(test, result[0].content)
+ self.assertEqual('.', result[1].content)
+
+ # Check for a link in paranthesis (), [], or {}
+ result = self.DoLinkify('My fav site (%s).' % test)
+ self.assertEqual(test, result[0].href)
+ self.assertEqual(test, result[0].content)
+ self.assertEqual(').', result[1].content)
+
+ result = self.DoLinkify('My fav site [%s].' % test)
+ self.assertEqual(test, result[0].href)
+ self.assertEqual(test, result[0].content)
+ self.assertEqual('].', result[1].content)
+
+ result = self.DoLinkify('My fav site {%s}.' % test)
+ self.assertEqual(test, result[0].href)
+ self.assertEqual(test, result[0].content)
+ self.assertEqual('}.', result[1].content)
+
+ # Check for a link with trailing colon
+ result = self.DoLinkify('Hit %s: you will love it.' % test)
+ self.assertEqual(test, result[0].href)
+ self.assertEqual(test, result[0].content)
+ self.assertEqual(':', result[1].content)
+
+ # Check link with commas in query string, but don't include trailing comma.
+ test = 'http://www.example.com/?v=1,2,3'
+ result = self.DoLinkify('Try %s, ok?' % test)
+ self.assertEqual(test, result[0].href)
+ self.assertEqual(test, result[0].content)
+
+ # Check link surrounded by angle-brackets.
+ result = self.DoLinkify('<%s>' % test)
+ self.assertEqual(test, result[0].href)
+ self.assertEqual(test, result[0].content)
+ self.assertEqual('>', result[1].content)
+
+ # Check link surrounded by double-quotes.
+ result = self.DoLinkify('"%s"' % test)
+ self.assertEqual(test, result[0].href)
+ self.assertEqual(test, result[0].content)
+ self.assertEqual('"', result[1].content)
+
+ # Check link with embedded double-quotes.
+ test = 'http://www.example.com/?q="a+b+c"'
+ result = self.DoLinkify('Try %s, ok?' % test)
+ self.assertEqual(test, result[0].href)
+ self.assertEqual(test, result[0].content)
+ self.assertEqual(',', result[1].content)
+
+ # Check link surrounded by single-quotes.
+ result = self.DoLinkify("'%s'" % test)
+ self.assertEqual(test, result[0].href)
+ self.assertEqual(test, result[0].content)
+ self.assertEqual("'", result[1].content)
+
+ # Check link with embedded single-quotes.
+ test = "http://www.example.com/?q='a+b+c'"
+ result = self.DoLinkify('Try %s, ok?' % test)
+ self.assertEqual(test, result[0].href)
+ self.assertEqual(test, result[0].content)
+ self.assertEqual(',', result[1].content)
+
+ # Check link with embedded parens.
+ test = 'http://www.example.com/funky(foo)and(bar).asp'
+ result = self.DoLinkify('Try %s, ok?' % test)
+ self.assertEqual(test, result[0].href)
+ self.assertEqual(test, result[0].content)
+ self.assertEqual(',', result[1].content)
+
+ test = 'http://www.example.com/funky(foo)and(bar).asp'
+ result = self.DoLinkify('My fav site <%s>' % test)
+ self.assertEqual(test, result[0].href)
+ self.assertEqual(test, result[0].content)
+ self.assertEqual('>', result[1].content)
+
+ # Check link with embedded brackets and braces.
+ test = 'http://www.example.com/funky[foo]and{bar}.asp'
+ result = self.DoLinkify('My fav site <%s>' % test)
+ self.assertEqual(test, result[0].href)
+ self.assertEqual(test, result[0].content)
+ self.assertEqual('>', result[1].content)
+
+ # Check link with mismatched delimeters inside it or outside it.
+ test = 'http://www.example.com/funky"(foo]and>bar}.asp'
+ result = self.DoLinkify('My fav site <%s>' % test)
+ self.assertEqual(test, result[0].href)
+ self.assertEqual(test, result[0].content)
+ self.assertEqual('>', result[1].content)
+
+ test = 'http://www.example.com/funky"(foo]and>bar}.asp'
+ result = self.DoLinkify('My fav site {%s' % test)
+ self.assertEqual(test, result[0].href)
+ self.assertEqual(test, result[0].content)
+
+ test = 'http://www.example.com/funky"(foo]and>bar}.asp'
+ result = self.DoLinkify('My fav site %s}' % test)
+ self.assertEqual(test, result[0].href)
+ self.assertEqual(test, result[0].content)
+ self.assertEqual('}', result[1].content)
+
+ # Link as part of an HTML example.
+ test = 'http://www.example.com/'
+ result = self.DoLinkify('<a href="%s">' % test)
+ self.assertEqual(test, result[0].href)
+ self.assertEqual(test, result[0].content)
+ self.assertEqual('">', result[1].content)
+
+ # Link nested in an HTML tag.
+ result = self.DoLinkify('<span>%s</span>' % test)
+ self.assertEqual(test, result[0].href)
+ self.assertEqual(test, result[0].content)
+
+ # Link followed by HTML tag - same bug as above.
+ result = self.DoLinkify('%s<span>foo</span>' % test)
+ self.assertEqual(test, result[0].href)
+ self.assertEqual(test, result[0].content)
+
+ # Link followed by unescaped HTML tag.
+ result = self.DoLinkify('%s<span>foo</span>' % test)
+ self.assertEqual(test, result[0].href)
+ self.assertEqual(test, result[0].content)
+
+ # Link surrounded by multiple delimiters.
+ result = self.DoLinkify('(e.g. <%s>)' % test)
+ self.assertEqual(test, result[0].href)
+ self.assertEqual(test, result[0].content)
+ result = self.DoLinkify('(e.g. <%s>),' % test)
+ self.assertEqual(test, result[0].href)
+ self.assertEqual(test, result[0].content)
+
+ def testLinkify_ContextOnBadLink(self):
+ """Test that surrounding text retained in cases where we don't link url."""
+ test = 'http://bad=example'
+ result = self.DoLinkify('<a href="%s">' % test)
+ self.assertEqual(None, result[0].href)
+ self.assertEqual(test + '">', result[0].content)
+ self.assertEqual(1, len(result))
+
+ def testLinkify_UnicodeContext(self):
+ """Test that unicode context does not mess up the link."""
+ test = 'http://www.example.com'
+
+ # This string has a non-breaking space \xa0.
+ result = self.DoLinkify(u'The correct RFC link is\xa0%s' % test)
+ self.assertEqual(test, result[0].content)
+ self.assertEqual(test, result[0].href)
+
+ def testLinkify_UnicodeLink(self):
+ """Test that unicode in a link is OK."""
+ test = u'http://www.example.com?q=division\xc3\xb7sign'
+
+ # This string has a non-breaking space \xa0.
+ result = self.DoLinkify(u'The unicode link is %s' % test)
+ self.assertEqual(test, result[0].content)
+ self.assertEqual(test, result[0].href)
+
+ def testLinkify_LinkTextEscapingDisabled(self):
+ """Test that url-like things that miss validation aren't linked."""
+ # Link matched by the regex but not accepted by the validator.
+ test = 'http://bad_domain/reportdetail?reportid=35aa03e04772358b'
+ result = self.DoLinkify('<span>%s</span>' % test)
+ self.assertEqual(None, result[0].href)
+ self.assertEqual(test, result[0].content)
+
+
+def _Issue(project_name, local_id, summary, status):
+ issue = tracker_pb2.Issue()
+ issue.project_name = project_name
+ issue.local_id = local_id
+ issue.summary = summary
+ issue.status = status
+ return issue
+
+
+class TrackerAutolinkTest(unittest.TestCase):
+
+ COMMENT_TEXT = (
+ 'This relates to issue 1, issue #2, and issue3 \n'
+ 'as well as bug 4, bug #5, and bug6 \n'
+ 'with issue other-project:12 and issue other-project#13. \n'
+ 'Watch out for issues 21, 22, and 23 with oxford comma. \n'
+ 'And also bugs 31, 32 and 33 with no oxford comma.\n'
+ 'Here comes crbug.com/123 and crbug.com/monorail/456.\n'
+ 'We do not match when an issue\n'
+ '999. Is split across lines.'
+ )
+
+ def testExtractProjectAndIssueIdNormal(self):
+ mr = testing_helpers.MakeMonorailRequest(
+ path='/p/proj/issues/detail?id=1')
+ ref_batches = []
+ for match in autolink._ISSUE_REF_RE.finditer(self.COMMENT_TEXT):
+ new_refs = autolink.ExtractProjectAndIssueIdsNormal(mr, match)
+ ref_batches.append(new_refs)
+
+ self.assertEqual(
+ ref_batches, [
+ [(None, 1)],
+ [(None, 2)],
+ [(None, 3)],
+ [(None, 4)],
+ [(None, 5)],
+ [(None, 6)],
+ [('other-project', 12)],
+ [('other-project', 13)],
+ [(None, 21), (None, 22), (None, 23)],
+ [(None, 31), (None, 32), (None, 33)],
+ ])
+
+
+ def testExtractProjectAndIssueIdCrbug(self):
+ mr = testing_helpers.MakeMonorailRequest(
+ path='/p/proj/issues/detail?id=1')
+ ref_batches = []
+ for match in autolink._CRBUG_REF_RE.finditer(self.COMMENT_TEXT):
+ new_refs = autolink.ExtractProjectAndIssueIdsCrBug(mr, match)
+ ref_batches.append(new_refs)
+
+ self.assertEqual(ref_batches, [
+ [('chromium', 123)],
+ [('monorail', 456)],
+ ])
+
+ def DoReplaceIssueRef(
+ self, content, regex=autolink._ISSUE_REF_RE,
+ single_issue_regex=autolink._SINGLE_ISSUE_REF_RE,
+ default_project_name=None):
+ """Calls the ReplaceIssueRef method and returns the result.
+
+ Args:
+ content: string that may have a textual reference to an issue.
+ regex: optional regex to use instead of _ISSUE_REF_RE.
+
+ Returns:
+ A list of TextRuns with some runs will have the reference hyperlinked.
+ Or, None if no reference detected.
+ """
+ match = regex.search(content)
+ if not match:
+ return None
+
+ open_dict = {'proj:1': _Issue('proj', 1, 'summary-PROJ-1', 'New'),
+ # Assume there is no issue 3 in PROJ
+ 'proj:4': _Issue('proj', 4, 'summary-PROJ-4', 'New'),
+ 'proj:6': _Issue('proj', 6, 'summary-PROJ-6', 'New'),
+ 'other-project:12': _Issue('other-project', 12,
+ 'summary-OP-12', 'Accepted'),
+ }
+ closed_dict = {'proj:2': _Issue('proj', 2, 'summary-PROJ-2', 'Fixed'),
+ 'proj:5': _Issue('proj', 5, 'summary-PROJ-5', 'Fixed'),
+ 'other-project:13': _Issue('other-project', 13,
+ 'summary-OP-12', 'Invalid'),
+ 'chromium:13': _Issue('chromium', 13,
+ 'summary-Cr-13', 'Invalid'),
+ }
+ comp_ref_artifacts = (open_dict, closed_dict,)
+
+ replacement_runs = autolink._ReplaceIssueRef(
+ match, comp_ref_artifacts, single_issue_regex, default_project_name)
+ return replacement_runs
+
+ def testReplaceIssueRef_NoMatch(self):
+ result = self.DoReplaceIssueRef('What is this all about?')
+ self.assertIsNone(result)
+
+ def testReplaceIssueRef_Normal(self):
+ result = self.DoReplaceIssueRef(
+ 'This relates to issue 1', default_project_name='proj')
+ self.assertEqual('/p/proj/issues/detail?id=1', result[0].href)
+ self.assertEqual('issue 1', result[0].content)
+ self.assertEqual(None, result[0].css_class)
+ self.assertEqual('summary-PROJ-1', result[0].title)
+ self.assertEqual('a', result[0].tag)
+
+ result = self.DoReplaceIssueRef(
+ ', issue #2', default_project_name='proj')
+ self.assertEqual('/p/proj/issues/detail?id=2', result[0].href)
+ self.assertEqual(' issue #2 ', result[0].content)
+ self.assertEqual('closed_ref', result[0].css_class)
+ self.assertEqual('summary-PROJ-2', result[0].title)
+ self.assertEqual('a', result[0].tag)
+
+ result = self.DoReplaceIssueRef(
+ ', and issue3 ', default_project_name='proj')
+ self.assertEqual(None, result[0].href) # There is no issue 3
+ self.assertEqual('issue3', result[0].content)
+
+ result = self.DoReplaceIssueRef(
+ 'as well as bug 4', default_project_name='proj')
+ self.assertEqual('/p/proj/issues/detail?id=4', result[0].href)
+ self.assertEqual('bug 4', result[0].content)
+
+ result = self.DoReplaceIssueRef(
+ ', bug #5, ', default_project_name='proj')
+ self.assertEqual('/p/proj/issues/detail?id=5', result[0].href)
+ self.assertEqual(' bug #5 ', result[0].content)
+
+ result = self.DoReplaceIssueRef(
+ 'and bug6', default_project_name='proj')
+ self.assertEqual('/p/proj/issues/detail?id=6', result[0].href)
+ self.assertEqual('bug6', result[0].content)
+
+ result = self.DoReplaceIssueRef(
+ 'with issue other-project:12', default_project_name='proj')
+ self.assertEqual('/p/other-project/issues/detail?id=12', result[0].href)
+ self.assertEqual('issue other-project:12', result[0].content)
+
+ result = self.DoReplaceIssueRef(
+ 'and issue other-project#13', default_project_name='proj')
+ self.assertEqual('/p/other-project/issues/detail?id=13', result[0].href)
+ self.assertEqual(' issue other-project#13 ', result[0].content)
+
+ def testReplaceIssueRef_CrBug(self):
+ result = self.DoReplaceIssueRef(
+ 'and crbug.com/other-project/13', regex=autolink._CRBUG_REF_RE,
+ single_issue_regex=autolink._CRBUG_REF_RE,
+ default_project_name='chromium')
+ self.assertEqual('/p/other-project/issues/detail?id=13', result[0].href)
+ self.assertEqual(' crbug.com/other-project/13 ', result[0].content)
+
+ result = self.DoReplaceIssueRef(
+ 'and http://crbug.com/13', regex=autolink._CRBUG_REF_RE,
+ single_issue_regex=autolink._CRBUG_REF_RE,
+ default_project_name='chromium')
+ self.assertEqual('/p/chromium/issues/detail?id=13', result[0].href)
+ self.assertEqual(' http://crbug.com/13 ', result[0].content)
+
+ result = self.DoReplaceIssueRef(
+ 'and http://crbug.com/13#c17', regex=autolink._CRBUG_REF_RE,
+ single_issue_regex=autolink._CRBUG_REF_RE,
+ default_project_name='chromium')
+ self.assertEqual('/p/chromium/issues/detail?id=13#c17', result[0].href)
+ self.assertEqual(' http://crbug.com/13#c17 ', result[0].content)
+
+ def testParseProjectNameMatch(self):
+ golden = 'project-name'
+ variations = ['%s', ' %s', '%s ', '%s:', '%s#', '%s#:', '%s:#', '%s :#',
+ '\t%s', '%s\t', '\t%s\t', '\t\t%s\t\t', '\n%s', '%s\n',
+ '\n%s\n', '\n\n%s\n\n', '\t\n%s', '\n\t%s', '%s\t\n',
+ '%s\n\t', '\t\n%s#', '\n\t%s#', '%s\t\n#', '%s\n\t#',
+ '\t\n%s:', '\n\t%s:', '%s\t\n:', '%s\n\t:'
+ ]
+
+ # First pass checks all valid project name results
+ for pattern in variations:
+ self.assertEqual(
+ golden, autolink._ParseProjectNameMatch(pattern % golden))
+
+ # Second pass tests all inputs that should result in None
+ for pattern in variations:
+ self.assertTrue(
+ autolink._ParseProjectNameMatch(pattern % '') in [None, ''])
+
+
+class VCAutolinkTest(unittest.TestCase):
+
+ GIT_HASH_1 = '1' * 40
+ GIT_HASH_2 = '2' * 40
+ GIT_HASH_3 = 'a1' * 20
+ GIT_COMMENT_TEXT = (
+ 'This is a fix for r%s and R%s, by r2d2, who also authored revision %s, '
+ 'revision #%s, revision %s, and revision %s' % (
+ GIT_HASH_1, GIT_HASH_2, GIT_HASH_3,
+ GIT_HASH_1.upper(), GIT_HASH_2.upper(), GIT_HASH_3.upper()))
+ SVN_COMMENT_TEXT = (
+ 'This is a fix for r12 and R3400, by r2d2, who also authored '
+ 'revision r4, '
+ 'revision #1234567, revision 789, and revision 9025. If you have '
+ 'questions, call me at 18005551212')
+
+ def testGetReferencedRevisions(self):
+ refs = ['1', '2', '3']
+ # For now, we do not look up revision objects, result is always None
+ self.assertIsNone(autolink.GetReferencedRevisions(None, refs))
+
+ def testExtractGitHashes(self):
+ refs = []
+ for match in autolink._GIT_HASH_RE.finditer(self.GIT_COMMENT_TEXT):
+ new_refs = autolink.ExtractRevNums(None, match)
+ refs.extend(new_refs)
+
+ self.assertEqual(
+ refs, [
+ self.GIT_HASH_1, self.GIT_HASH_2, self.GIT_HASH_3,
+ self.GIT_HASH_1.upper(),
+ self.GIT_HASH_2.upper(),
+ self.GIT_HASH_3.upper()
+ ])
+
+ def testExtractRevNums(self):
+ refs = []
+ for match in autolink._SVN_REF_RE.finditer(self.SVN_COMMENT_TEXT):
+ new_refs = autolink.ExtractRevNums(None, match)
+ refs.extend(new_refs)
+
+ # Note that we only autolink rNNNN with at least 4 digits.
+ self.assertEqual(refs, ['3400', '1234567', '9025'])
+
+
+ def DoReplaceRevisionRef(self, content, project=None):
+ """Calls the ReplaceRevisionRef method and returns the result.
+
+ Args:
+ content: string with a hyperlink.
+ project: optional project.
+
+ Returns:
+ A list of TextRuns with some runs will have the embedded URL hyperlinked.
+ Or, None if no link was detected.
+ """
+ match = autolink._GIT_HASH_RE.search(content)
+ if not match:
+ return None
+
+ mr = testing_helpers.MakeMonorailRequest(
+ path='/p/proj/source/detail?r=1', project=project)
+ replacement_runs = autolink.ReplaceRevisionRef(mr, match, None)
+ return replacement_runs
+
+ def testReplaceRevisionRef(self):
+ result = self.DoReplaceRevisionRef(
+ 'This is a fix for r%s' % self.GIT_HASH_1)
+ self.assertEqual('https://crrev.com/%s' % self.GIT_HASH_1, result[0].href)
+ self.assertEqual('r%s' % self.GIT_HASH_1, result[0].content)
+
+ result = self.DoReplaceRevisionRef(
+ 'and R%s, by r2d2, who ' % self.GIT_HASH_2)
+ self.assertEqual('https://crrev.com/%s' % self.GIT_HASH_2, result[0].href)
+ self.assertEqual('R%s' % self.GIT_HASH_2, result[0].content)
+
+ result = self.DoReplaceRevisionRef('by r2d2, who ')
+ self.assertEqual(None, result)
+
+ result = self.DoReplaceRevisionRef(
+ 'also authored revision %s, ' % self.GIT_HASH_3)
+ self.assertEqual('https://crrev.com/%s' % self.GIT_HASH_3, result[0].href)
+ self.assertEqual('revision %s' % self.GIT_HASH_3, result[0].content)
+
+ result = self.DoReplaceRevisionRef(
+ 'revision #%s, ' % self.GIT_HASH_1.upper())
+ self.assertEqual(
+ 'https://crrev.com/%s' % self.GIT_HASH_1.upper(), result[0].href)
+ self.assertEqual(
+ 'revision #%s' % self.GIT_HASH_1.upper(), result[0].content)
+
+ result = self.DoReplaceRevisionRef(
+ 'revision %s, ' % self.GIT_HASH_2.upper())
+ self.assertEqual(
+ 'https://crrev.com/%s' % self.GIT_HASH_2.upper(), result[0].href)
+ self.assertEqual('revision %s' % self.GIT_HASH_2.upper(), result[0].content)
+
+ result = self.DoReplaceRevisionRef(
+ 'and revision %s' % self.GIT_HASH_3.upper())
+ self.assertEqual(
+ 'https://crrev.com/%s' % self.GIT_HASH_3.upper(), result[0].href)
+ self.assertEqual('revision %s' % self.GIT_HASH_3.upper(), result[0].content)
+
+ def testReplaceRevisionRef_CustomURL(self):
+ """A project can override the URL used for revision links."""
+ project = fake.Project()
+ project.revision_url_format = 'http://example.com/+/{revnum}'
+ result = self.DoReplaceRevisionRef(
+ 'This is a fix for r%s' % self.GIT_HASH_1, project=project)
+ self.assertEqual(
+ 'http://example.com/+/%s' % self.GIT_HASH_1, result[0].href)
+ self.assertEqual('r%s' % self.GIT_HASH_1, result[0].content)
diff --git a/features/test/banspammer_test.py b/features/test/banspammer_test.py
new file mode 100644
index 0000000..e6fceff
--- /dev/null
+++ b/features/test/banspammer_test.py
@@ -0,0 +1,141 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Tests for the ban spammer feature."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import json
+import mock
+import os
+import unittest
+import urllib
+import webapp2
+
+import settings
+from features import banspammer
+from framework import framework_views
+from framework import permissions
+from framework import urls
+from proto import tracker_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+
+class BanSpammerTest(unittest.TestCase):
+
+ def setUp(self):
+ self.cnxn = 'fake cnxn'
+ self.mr = testing_helpers.MakeMonorailRequest()
+ self.services = service_manager.Services(
+ issue=fake.IssueService(),
+ project=fake.ProjectService(),
+ spam=fake.SpamService(),
+ user=fake.UserService())
+ self.servlet = banspammer.BanSpammer('req', 'res', services=self.services)
+
+ @mock.patch('framework.cloud_tasks_helpers._get_client')
+ def testProcessFormData_noPermission(self, get_client_mock):
+ self.servlet.services.user.TestAddUser('member', 222)
+ self.servlet.services.user.TestAddUser('spammer@domain.com', 111)
+ mr = testing_helpers.MakeMonorailRequest(
+ path='/u/spammer@domain.com/banSpammer.do',
+ perms=permissions.GetPermissions(None, {}, None))
+ mr.viewed_user_auth.user_view = framework_views.MakeUserView(mr.cnxn,
+ self.servlet.services.user, 111)
+ mr.auth.user_id = 222
+ self.assertRaises(permissions.PermissionException,
+ self.servlet.AssertBasePermission, mr)
+ try:
+ self.servlet.ProcessFormData(mr, {})
+ except permissions.PermissionException:
+ pass
+ self.assertEqual(get_client_mock().queue_path.call_count, 0)
+ self.assertEqual(get_client_mock().create_task.call_count, 0)
+
+ @mock.patch('framework.cloud_tasks_helpers._get_client')
+ def testProcessFormData_ok(self, get_client_mock):
+ self.servlet.services.user.TestAddUser('owner', 222)
+ self.servlet.services.user.TestAddUser('spammer@domain.com', 111)
+ mr = testing_helpers.MakeMonorailRequest(
+ path='/u/spammer@domain.com/banSpammer.do',
+ perms=permissions.ADMIN_PERMISSIONSET)
+ mr.viewed_user_auth.user_view = framework_views.MakeUserView(mr.cnxn,
+ self.servlet.services.user, 111)
+ mr.viewed_user_auth.user_pb.user_id = 111
+ mr.auth.user_id = 222
+ self.servlet.ProcessFormData(mr, {'banned': 'non-empty'})
+
+ params = {'spammer_id': 111, 'reporter_id': 222, 'is_spammer': True}
+ task = {
+ 'app_engine_http_request':
+ {
+ 'relative_uri': urls.BAN_SPAMMER_TASK + '.do',
+ 'body': urllib.urlencode(params),
+ 'headers': {
+ 'Content-type': 'application/x-www-form-urlencoded'
+ }
+ }
+ }
+ get_client_mock().queue_path.assert_called_with(
+ settings.app_id, settings.CLOUD_TASKS_REGION, 'default')
+ get_client_mock().create_task.assert_called_once()
+ ((_parent, called_task), _kwargs) = get_client_mock().create_task.call_args
+ self.assertEqual(called_task, task)
+
+
+class BanSpammerTaskTest(unittest.TestCase):
+ def setUp(self):
+ self.services = service_manager.Services(
+ issue=fake.IssueService(),
+ spam=fake.SpamService())
+ self.res = webapp2.Response()
+ self.servlet = banspammer.BanSpammerTask('req', self.res,
+ services=self.services)
+
+ def testProcessFormData_okNoIssues(self):
+ mr = testing_helpers.MakeMonorailRequest(
+ path=urls.BAN_SPAMMER_TASK + '.do', method='POST',
+ params={'spammer_id': 111, 'reporter_id': 222})
+
+ self.servlet.HandleRequest(mr)
+ self.assertEqual(self.res.body, json.dumps({'comments': 0, 'issues': 0}))
+
+ def testProcessFormData_okSomeIssues(self):
+ mr = testing_helpers.MakeMonorailRequest(
+ path=urls.BAN_SPAMMER_TASK + '.do', method='POST',
+ params={'spammer_id': 111, 'reporter_id': 222})
+
+ for i in range(0, 10):
+ issue = fake.MakeTestIssue(
+ 1, i, 'issue_summary', 'New', 111, project_name='project-name')
+ self.servlet.services.issue.TestAddIssue(issue)
+
+ self.servlet.HandleRequest(mr)
+ self.assertEqual(self.res.body, json.dumps({'comments': 0, 'issues': 10}))
+
+ def testProcessFormData_okSomeCommentsAndIssues(self):
+ mr = testing_helpers.MakeMonorailRequest(
+ path=urls.BAN_SPAMMER_TASK + '.do', method='POST',
+ params={'spammer_id': 111, 'reporter_id': 222})
+
+ for i in range(0, 12):
+ issue = fake.MakeTestIssue(
+ 1, i, 'issue_summary', 'New', 111, project_name='project-name')
+ self.servlet.services.issue.TestAddIssue(issue)
+
+ for i in range(10, 20):
+ issue = fake.MakeTestIssue(
+ 1, i, 'issue_summary', 'New', 222, project_name='project-name')
+ self.servlet.services.issue.TestAddIssue(issue)
+ for _ in range(0, 5):
+ comment = tracker_pb2.IssueComment()
+ comment.project_id = 1
+ comment.user_id = 111
+ comment.issue_id = issue.issue_id
+ self.servlet.services.issue.TestAddComment(comment, issue.local_id)
+ self.servlet.HandleRequest(mr)
+ self.assertEqual(self.res.body, json.dumps({'comments': 50, 'issues': 10}))
diff --git a/features/test/commands_test.py b/features/test/commands_test.py
new file mode 100644
index 0000000..e8bc47b
--- /dev/null
+++ b/features/test/commands_test.py
@@ -0,0 +1,230 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Classes and functions that implement command-line-like issue updates."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import unittest
+
+from features import commands
+from framework import framework_constants
+from proto import tracker_pb2
+from services import service_manager
+from testing import fake
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+
+
+class CommandsTest(unittest.TestCase):
+
+ def VerifyParseQuickEditCommmand(
+ self, cmd, exp_summary='sum', exp_status='New', exp_owner_id=111,
+ exp_cc_ids=None, exp_labels=None):
+
+ issue = tracker_pb2.Issue()
+ issue.project_name = 'proj'
+ issue.local_id = 1
+ issue.summary = 'sum'
+ issue.status = 'New'
+ issue.owner_id = 111
+ issue.cc_ids.extend([222, 333])
+ issue.labels.extend(['Type-Defect', 'Priority-Medium', 'Hot'])
+
+ if exp_cc_ids is None:
+ exp_cc_ids = [222, 333]
+ if exp_labels is None:
+ exp_labels = ['Type-Defect', 'Priority-Medium', 'Hot']
+
+ config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+ logged_in_user_id = 999
+ services = service_manager.Services(
+ config=fake.ConfigService(),
+ issue=fake.IssueService(),
+ user=fake.UserService())
+ services.user.TestAddUser('jrobbins', 333)
+ services.user.TestAddUser('jrobbins@jrobbins.org', 888)
+
+ cnxn = 'fake cnxn'
+ (summary, status, owner_id, cc_ids,
+ labels) = commands.ParseQuickEditCommand(
+ cnxn, cmd, issue, config, logged_in_user_id, services)
+ self.assertEqual(exp_summary, summary)
+ self.assertEqual(exp_status, status)
+ self.assertEqual(exp_owner_id, owner_id)
+ self.assertListEqual(exp_cc_ids, cc_ids)
+ self.assertListEqual(exp_labels, labels)
+
+ def testParseQuickEditCommmand_Empty(self):
+ self.VerifyParseQuickEditCommmand('') # Nothing should change.
+
+ def testParseQuickEditCommmand_BuiltInFields(self):
+ self.VerifyParseQuickEditCommmand(
+ 'status=Fixed', exp_status='Fixed')
+ self.VerifyParseQuickEditCommmand( # Normalized capitalization.
+ 'status=fixed', exp_status='Fixed')
+ self.VerifyParseQuickEditCommmand(
+ 'status=limbo', exp_status='limbo')
+
+ self.VerifyParseQuickEditCommmand(
+ 'owner=me', exp_owner_id=999)
+ self.VerifyParseQuickEditCommmand(
+ 'owner=jrobbins@jrobbins.org', exp_owner_id=888)
+ self.VerifyParseQuickEditCommmand(
+ 'owner=----', exp_owner_id=framework_constants.NO_USER_SPECIFIED)
+
+ self.VerifyParseQuickEditCommmand(
+ 'summary=JustOneWord', exp_summary='JustOneWord')
+ self.VerifyParseQuickEditCommmand(
+ 'summary="quoted sentence"', exp_summary='quoted sentence')
+ self.VerifyParseQuickEditCommmand(
+ "summary='quoted sentence'", exp_summary='quoted sentence')
+
+ self.VerifyParseQuickEditCommmand(
+ 'cc=me', exp_cc_ids=[222, 333, 999])
+ self.VerifyParseQuickEditCommmand(
+ 'cc=jrobbins@jrobbins.org', exp_cc_ids=[222, 333, 888])
+ self.VerifyParseQuickEditCommmand(
+ 'cc=me,jrobbins@jrobbins.org',
+ exp_cc_ids=[222, 333, 999, 888])
+ self.VerifyParseQuickEditCommmand(
+ 'cc=-jrobbins,jrobbins@jrobbins.org',
+ exp_cc_ids=[222, 888])
+
+ def testParseQuickEditCommmand_Labels(self):
+ self.VerifyParseQuickEditCommmand(
+ 'Priority=Low', exp_labels=['Type-Defect', 'Hot', 'Priority-Low'])
+ self.VerifyParseQuickEditCommmand(
+ 'priority=low', exp_labels=['Type-Defect', 'Hot', 'Priority-Low'])
+ self.VerifyParseQuickEditCommmand(
+ 'priority-low', exp_labels=['Type-Defect', 'Hot', 'Priority-Low'])
+ self.VerifyParseQuickEditCommmand(
+ '-priority-low', exp_labels=['Type-Defect', 'Priority-Medium', 'Hot'])
+ self.VerifyParseQuickEditCommmand(
+ '-priority-medium', exp_labels=['Type-Defect', 'Hot'])
+
+ self.VerifyParseQuickEditCommmand(
+ 'Cold', exp_labels=['Type-Defect', 'Priority-Medium', 'Hot', 'Cold'])
+ self.VerifyParseQuickEditCommmand(
+ '+Cold', exp_labels=['Type-Defect', 'Priority-Medium', 'Hot', 'Cold'])
+ self.VerifyParseQuickEditCommmand(
+ '-Hot Cold', exp_labels=['Type-Defect', 'Priority-Medium', 'Cold'])
+ self.VerifyParseQuickEditCommmand(
+ '-Hot', exp_labels=['Type-Defect', 'Priority-Medium'])
+
+ def testParseQuickEditCommmand_Multiple(self):
+ self.VerifyParseQuickEditCommmand(
+ 'Priority=Low -hot owner:me cc:-jrobbins summary="other summary"',
+ exp_summary='other summary', exp_owner_id=999,
+ exp_cc_ids=[222], exp_labels=['Type-Defect', 'Priority-Low'])
+
+ def testBreakCommandIntoParts_Empty(self):
+ self.assertListEqual(
+ [],
+ commands._BreakCommandIntoParts(''))
+
+ def testBreakCommandIntoParts_Single(self):
+ self.assertListEqual(
+ [('summary', 'new summary')],
+ commands._BreakCommandIntoParts('summary="new summary"'))
+ self.assertListEqual(
+ [('summary', 'OneWordSummary')],
+ commands._BreakCommandIntoParts('summary=OneWordSummary'))
+ self.assertListEqual(
+ [('key', 'value')],
+ commands._BreakCommandIntoParts('key=value'))
+ self.assertListEqual(
+ [('key', 'value-with-dashes')],
+ commands._BreakCommandIntoParts('key=value-with-dashes'))
+ self.assertListEqual(
+ [('key', 'value')],
+ commands._BreakCommandIntoParts('key:value'))
+ self.assertListEqual(
+ [('key', 'value')],
+ commands._BreakCommandIntoParts(' key:value '))
+ self.assertListEqual(
+ [('key', 'value')],
+ commands._BreakCommandIntoParts('key:"value"'))
+ self.assertListEqual(
+ [('key', 'user@dom.com')],
+ commands._BreakCommandIntoParts('key:user@dom.com'))
+ self.assertListEqual(
+ [('key', 'a@dom.com,-b@dom.com')],
+ commands._BreakCommandIntoParts('key:a@dom.com,-b@dom.com'))
+ self.assertListEqual(
+ [(None, 'label')],
+ commands._BreakCommandIntoParts('label'))
+ self.assertListEqual(
+ [(None, '-label')],
+ commands._BreakCommandIntoParts('-label'))
+ self.assertListEqual(
+ [(None, '+label')],
+ commands._BreakCommandIntoParts('+label'))
+
+ def testBreakCommandIntoParts_Multiple(self):
+ self.assertListEqual(
+ [('summary', 'new summary'), (None, 'Hot'), (None, '-Cold'),
+ ('owner', 'me'), ('cc', '+a,-b')],
+ commands._BreakCommandIntoParts(
+ 'summary="new summary" Hot -Cold owner:me cc:+a,-b'))
+
+
+class CommandSyntaxParsingTest(unittest.TestCase):
+
+ def setUp(self):
+ self.services = service_manager.Services(
+ project=fake.ProjectService(),
+ config=fake.ConfigService(),
+ user=fake.UserService())
+
+ self.services.project.TestAddProject('proj', owner_ids=[111])
+ self.services.user.TestAddUser('a@example.com', 222)
+
+ cnxn = 'fake connection'
+ config = self.services.config.GetProjectConfig(cnxn, 789)
+
+ for status in ['New', 'ReadyForReview']:
+ config.well_known_statuses.append(tracker_pb2.StatusDef(
+ status=status))
+
+ for label in ['Prioity-Low', 'Priority-High']:
+ config.well_known_labels.append(tracker_pb2.LabelDef(
+ label=label))
+
+ config.exclusive_label_prefixes.extend(
+ tracker_constants.DEFAULT_EXCL_LABEL_PREFIXES)
+
+ self.services.config.StoreConfig(cnxn, config)
+
+ def testStandardizeStatus(self):
+ config = self.services.config.GetProjectConfig('fake cnxn', 789)
+ self.assertEqual('New',
+ commands._StandardizeStatus('NEW', config))
+ self.assertEqual('New',
+ commands._StandardizeStatus('n$Ew ', config))
+ self.assertEqual(
+ 'custom-label',
+ commands._StandardizeLabel('custom=label ', config))
+
+ def testStandardizeLabel(self):
+ config = self.services.config.GetProjectConfig('fake cnxn', 789)
+ self.assertEqual(
+ 'Priority-High',
+ commands._StandardizeLabel('priority-high', config))
+ self.assertEqual(
+ 'Priority-High',
+ commands._StandardizeLabel('PRIORITY=HIGH', config))
+
+ def testLookupMeOrUsername(self):
+ self.assertEqual(
+ 123,
+ commands._LookupMeOrUsername('fake cnxn', 'me', self.services, 123))
+
+ self.assertEqual(
+ 222,
+ commands._LookupMeOrUsername(
+ 'fake cnxn', 'a@example.com', self.services, 0))
diff --git a/features/test/commitlogcommands_test.py b/features/test/commitlogcommands_test.py
new file mode 100644
index 0000000..7e5d566
--- /dev/null
+++ b/features/test/commitlogcommands_test.py
@@ -0,0 +1,111 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unittests for monorail.features.commitlogcommands."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import mock
+import unittest
+
+from features import commitlogcommands
+from features import send_notifications
+from framework import monorailcontext
+from proto import tracker_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+
+
+class InboundEmailTest(unittest.TestCase):
+
+ def setUp(self):
+ self.cnxn = 'fake cnxn'
+ self.services = service_manager.Services(
+ issue=fake.IssueService(),
+ user=fake.UserService(),
+ usergroup=fake.UserGroupService(),
+ project=fake.ProjectService(),
+ config=fake.ConfigService())
+
+ self.member = self.services.user.TestAddUser('member@example.com', 111)
+ self.outsider = self.services.user.TestAddUser('outsider@example.com', 222)
+ self.project = self.services.project.TestAddProject(
+ 'proj', project_id=987, process_inbound_email=True,
+ committer_ids=[self.member.user_id])
+ self.issue = tracker_pb2.Issue()
+ self.issue.issue_id = 98701
+ self.issue.project_id = 987
+ self.issue.local_id = 1
+ self.issue.owner_id = 0
+ self.issue.summary = 'summary'
+ self.issue.status = 'Assigned'
+ self.services.issue.TestAddIssue(self.issue)
+
+ self.uia = commitlogcommands.UpdateIssueAction(self.issue.local_id)
+
+ def testParse_NoCommandLines(self):
+ commands_found = self.uia.Parse(self.cnxn, self.project.project_name, 111,
+ ['line 1'], self.services,
+ hostport='testing-app.appspot.com', strip_quoted_lines=True)
+ self.assertEqual(False, commands_found)
+ self.assertEqual('line 1', self.uia.description)
+ self.assertEqual('line 1', self.uia.inbound_message)
+
+ def testParse_StripQuotedLines(self):
+ commands_found = self.uia.Parse(self.cnxn, self.project.project_name, 111,
+ ['summary:something', '> line 1', 'line 2'], self.services,
+ hostport='testing-app.appspot.com', strip_quoted_lines=True)
+ self.assertEqual(True, commands_found)
+ self.assertEqual('line 2', self.uia.description)
+ self.assertEqual(
+ 'summary:something\n> line 1\nline 2', self.uia.inbound_message)
+
+ def testParse_NoStripQuotedLines(self):
+ commands_found = self.uia.Parse(self.cnxn, self.project.project_name, 111,
+ ['summary:something', '> line 1', 'line 2'], self.services,
+ hostport='testing-app.appspot.com')
+ self.assertEqual(True, commands_found)
+ self.assertEqual('> line 1\nline 2', self.uia.description)
+ self.assertIsNone(self.uia.inbound_message)
+
+ def setupAndCallRun(self, mc, commenter_id, mock_pasicn):
+ self.uia.Parse(self.cnxn, self.project.project_name, 111,
+ ['summary:something', 'status:New', '> line 1', '> line 2'],
+ self.services, hostport='testing-app.appspot.com')
+ self.uia.Run(mc, self.services)
+
+ mock_pasicn.assert_called_once_with(
+ self.issue.issue_id, 'testing-app.appspot.com', commenter_id,
+ old_owner_id=self.issue.owner_id, comment_id=1, send_email=True)
+
+ @mock.patch(
+ 'features.send_notifications.PrepareAndSendIssueChangeNotification')
+ def testRun_AllowEdit(self, mock_pasicn):
+ mc = monorailcontext.MonorailContext(
+ self.services, cnxn=self.cnxn, requester='member@example.com')
+ mc.LookupLoggedInUserPerms(self.project)
+
+ self.setupAndCallRun(mc, 111, mock_pasicn)
+
+ self.assertEqual('> line 1\n> line 2', self.uia.description)
+ # Assert that amendments were made to the issue.
+ self.assertEqual('something', self.issue.summary)
+ self.assertEqual('New', self.issue.status)
+
+ @mock.patch(
+ 'features.send_notifications.PrepareAndSendIssueChangeNotification')
+ def testRun_NoAllowEdit(self, mock_pasicn):
+ mc = monorailcontext.MonorailContext(
+ self.services, cnxn=self.cnxn, requester='outsider@example.com')
+ mc.LookupLoggedInUserPerms(self.project)
+
+ self.setupAndCallRun(mc, 222, mock_pasicn)
+
+ self.assertEqual('> line 1\n> line 2', self.uia.description)
+ # Assert that amendments were *not* made to the issue.
+ self.assertEqual('summary', self.issue.summary)
+ self.assertEqual('Assigned', self.issue.status)
diff --git a/features/test/component_helpers_test.py b/features/test/component_helpers_test.py
new file mode 100644
index 0000000..aa6c761
--- /dev/null
+++ b/features/test/component_helpers_test.py
@@ -0,0 +1,145 @@
+# Copyright 2018 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unit tests for component prediction endpoints."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import json
+import mock
+import sys
+import unittest
+
+from services import service_manager
+from testing import fake
+
+# Mock cloudstorage before it's imported by component_helpers
+sys.modules['cloudstorage'] = mock.Mock()
+from features import component_helpers
+
+
+class FakeMLEngine(object):
+ def __init__(self, test):
+ self.test = test
+ self.expected_features = None
+ self.scores = None
+ self._execute_response = None
+
+ def projects(self):
+ return self
+
+ def models(self):
+ return self
+
+ def predict(self, name, body):
+ self.test.assertEqual(component_helpers.MODEL_NAME, name)
+ self.test.assertEqual(
+ {'instances': [{'inputs': self.expected_features}]}, body)
+ self._execute_response = {'predictions': [{'scores': self.scores}]}
+ return self
+
+ def get(self, name):
+ self.test.assertEqual(component_helpers.MODEL_NAME, name)
+ self._execute_response = {'defaultVersion': {'name': 'v_1234'}}
+ return self
+
+ def execute(self):
+ response = self._execute_response
+ self._execute_response = None
+ return response
+
+
+class ComponentHelpersTest(unittest.TestCase):
+
+ def setUp(self):
+ self.services = service_manager.Services(
+ config=fake.ConfigService(),
+ user=fake.UserService())
+ self.project = fake.Project(project_name='proj')
+
+ self._ml_engine = FakeMLEngine(self)
+ self._top_words = None
+ self._components_by_index = None
+
+ mock.patch(
+ 'services.ml_helpers.setup_ml_engine', lambda: self._ml_engine).start()
+ mock.patch(
+ 'features.component_helpers._GetTopWords',
+ lambda _: self._top_words).start()
+ mock.patch('cloudstorage.open', self.cloudstorageOpen).start()
+ mock.patch('settings.component_features', 5).start()
+
+ self.addCleanup(mock.patch.stopall)
+
+ def cloudstorageOpen(self, name, mode):
+ """Create a file mock that returns self._components_by_index when read."""
+ open_fn = mock.mock_open(read_data=json.dumps(self._components_by_index))
+ return open_fn(name, mode)
+
+ def testPredict_Normal(self):
+ """Test normal case when predicted component exists."""
+ component_id = self.services.config.CreateComponentDef(
+ cnxn=None, project_id=self.project.project_id, path='Ruta>Baga',
+ docstring='', deprecated=False, admin_ids=[], cc_ids=[], created=None,
+ creator_id=None, label_ids=[])
+ config = self.services.config.GetProjectConfig(
+ None, self.project.project_id)
+
+ self._top_words = {
+ 'foo': 0,
+ 'bar': 1,
+ 'baz': 2}
+ self._components_by_index = {
+ '0': '123',
+ '1': str(component_id),
+ '2': '789'}
+ self._ml_engine.expected_features = [3, 0, 1, 0, 0]
+ self._ml_engine.scores = [5, 10, 3]
+
+ text = 'foo baz foo foo'
+
+ self.assertEqual(
+ component_id, component_helpers.PredictComponent(text, config))
+
+ def testPredict_UnknownComponentIndex(self):
+ """Test case where the prediction is not in components_by_index."""
+ config = self.services.config.GetProjectConfig(
+ None, self.project.project_id)
+
+ self._top_words = {
+ 'foo': 0,
+ 'bar': 1,
+ 'baz': 2}
+ self._components_by_index = {
+ '0': '123',
+ '1': '456',
+ '2': '789'}
+ self._ml_engine.expected_features = [3, 0, 1, 0, 0]
+ self._ml_engine.scores = [5, 10, 3, 1000]
+
+ text = 'foo baz foo foo'
+
+ self.assertIsNone(component_helpers.PredictComponent(text, config))
+
+ def testPredict_InvalidComponentIndex(self):
+ """Test case where the prediction is not a valid component id."""
+ config = self.services.config.GetProjectConfig(
+ None, self.project.project_id)
+
+ self._top_words = {
+ 'foo': 0,
+ 'bar': 1,
+ 'baz': 2}
+ self._components_by_index = {
+ '0': '123',
+ '1': '456',
+ '2': '789'}
+ self._ml_engine.expected_features = [3, 0, 1, 0, 0]
+ self._ml_engine.scores = [5, 10, 3]
+
+ text = 'foo baz foo foo'
+
+ self.assertIsNone(component_helpers.PredictComponent(text, config))
diff --git a/features/test/componentexport_test.py b/features/test/componentexport_test.py
new file mode 100644
index 0000000..0e5fbf8
--- /dev/null
+++ b/features/test/componentexport_test.py
@@ -0,0 +1,42 @@
+# Copyright 2020 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+"""Tests for the componentexport module."""
+
+from __future__ import absolute_import
+from __future__ import division
+from __future__ import print_function
+
+import mock
+import unittest
+import webapp2
+
+import settings
+from features import componentexport
+from framework import urls
+
+
+class ComponentTrainingDataExportTest(unittest.TestCase):
+
+ def test_handler_definition(self):
+ instance = componentexport.ComponentTrainingDataExport()
+ self.assertIsInstance(instance, webapp2.RequestHandler)
+
+ @mock.patch('framework.cloud_tasks_helpers._get_client')
+ def test_enqueues_task(self, get_client_mock):
+ componentexport.ComponentTrainingDataExport().get()
+
+ queue = 'componentexport'
+ task = {
+ 'app_engine_http_request':
+ {
+ 'http_method': 'GET',
+ 'relative_uri': urls.COMPONENT_DATA_EXPORT_TASK
+ }
+ }
+
+ get_client_mock().queue_path.assert_called_with(
+ settings.app_id, settings.CLOUD_TASKS_REGION, queue)
+ get_client_mock().create_task.assert_called_once()
+ ((_parent, called_task), _kwargs) = get_client_mock().create_task.call_args
+ self.assertEqual(called_task, task)
diff --git a/features/test/dateaction_test.py b/features/test/dateaction_test.py
new file mode 100644
index 0000000..09e5c5c
--- /dev/null
+++ b/features/test/dateaction_test.py
@@ -0,0 +1,323 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unittest for the dateaction module."""
+
+from __future__ import division
+from __future__ import print_function
+from __future__ import absolute_import
+
+import logging
+import mock
+import time
+import unittest
+
+from features import dateaction
+from framework import cloud_tasks_helpers
+from framework import framework_constants
+from framework import framework_views
+from framework import timestr
+from framework import urls
+from proto import tracker_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+from tracker import tracker_bizobj
+
+
+NOW = 1492120863
+
+
+class DateActionCronTest(unittest.TestCase):
+
+ def setUp(self):
+ self.services = service_manager.Services(
+ user=fake.UserService(),
+ issue=fake.IssueService())
+ self.servlet = dateaction.DateActionCron(
+ 'req', 'res', services=self.services)
+ self.TIMESTAMP_MIN = (
+ NOW // framework_constants.SECS_PER_DAY *
+ framework_constants.SECS_PER_DAY)
+ self.TIMESTAMP_MAX = self.TIMESTAMP_MIN + framework_constants.SECS_PER_DAY
+ self.left_joins = [
+ ('Issue2FieldValue ON Issue.id = Issue2FieldValue.issue_id', []),
+ ('FieldDef ON Issue2FieldValue.field_id = FieldDef.id', []),
+ ]
+ self.where = [
+ ('FieldDef.field_type = %s', ['date_type']),
+ (
+ 'FieldDef.date_action IN (%s,%s)',
+ ['ping_owner_only', 'ping_participants']),
+ ('Issue2FieldValue.date_value >= %s', [self.TIMESTAMP_MIN]),
+ ('Issue2FieldValue.date_value < %s', [self.TIMESTAMP_MAX]),
+ ]
+ self.order_by = [
+ ('Issue.id', []),
+ ]
+
+ @mock.patch('time.time', return_value=NOW)
+ def testHandleRequest_NoMatches(self, _mock_time):
+ _request, mr = testing_helpers.GetRequestObjects(
+ path=urls.DATE_ACTION_CRON)
+ self.services.issue.RunIssueQuery = mock.MagicMock(return_value=([], False))
+
+ self.servlet.HandleRequest(mr)
+
+ self.services.issue.RunIssueQuery.assert_called_with(
+ mr.cnxn, self.left_joins, self.where + [('Issue.id > %s', [0])],
+ self.order_by)
+
+ @mock.patch('framework.cloud_tasks_helpers._get_client')
+ @mock.patch('time.time', return_value=NOW)
+ def testHandleRequest_OneMatche(self, _mock_time, get_client_mock):
+ _request, mr = testing_helpers.GetRequestObjects(
+ path=urls.DATE_ACTION_CRON)
+ self.services.issue.RunIssueQuery = mock.MagicMock(
+ return_value=([78901], False))
+
+ self.servlet.HandleRequest(mr)
+
+ self.services.issue.RunIssueQuery.assert_called_with(
+ mr.cnxn, self.left_joins, self.where + [('Issue.id > %s', [0])],
+ self.order_by)
+ expected_task = {
+ 'app_engine_http_request':
+ {
+ 'relative_uri': urls.ISSUE_DATE_ACTION_TASK + '.do',
+ 'body': 'issue_id=78901',
+ 'headers': {
+ 'Content-type': 'application/x-www-form-urlencoded'
+ }
+ }
+ }
+ get_client_mock().create_task.assert_any_call(
+ get_client_mock().queue_path(),
+ expected_task,
+ retry=cloud_tasks_helpers._DEFAULT_RETRY)
+
+ @mock.patch('framework.cloud_tasks_helpers._get_client')
+ def testEnqueueDateAction(self, get_client_mock):
+ self.servlet.EnqueueDateAction(78901)
+ expected_task = {
+ 'app_engine_http_request':
+ {
+ 'relative_uri': urls.ISSUE_DATE_ACTION_TASK + '.do',
+ 'body': 'issue_id=78901',
+ 'headers': {
+ 'Content-type': 'application/x-www-form-urlencoded'
+ }
+ }
+ }
+ get_client_mock().create_task.assert_any_call(
+ get_client_mock().queue_path(),
+ expected_task,
+ retry=cloud_tasks_helpers._DEFAULT_RETRY)
+
+
+class IssueDateActionTaskTest(unittest.TestCase):
+
+ def setUp(self):
+ self.services = service_manager.Services(
+ user=fake.UserService(),
+ usergroup=fake.UserGroupService(),
+ features=fake.FeaturesService(),
+ issue=fake.IssueService(),
+ project=fake.ProjectService(),
+ config=fake.ConfigService(),
+ issue_star=fake.IssueStarService())
+ self.servlet = dateaction.IssueDateActionTask(
+ 'req', 'res', services=self.services)
+
+ self.config = self.services.config.GetProjectConfig('cnxn', 789)
+ self.config.field_defs = [
+ tracker_bizobj.MakeFieldDef(
+ 123, 789, 'NextAction', tracker_pb2.FieldTypes.DATE_TYPE,
+ '', '', False, False, False, None, None, None, False, '',
+ None, None, tracker_pb2.DateAction.PING_OWNER_ONLY,
+ 'Date of next expected progress update', False),
+ tracker_bizobj.MakeFieldDef(
+ 124, 789, 'EoL', tracker_pb2.FieldTypes.DATE_TYPE,
+ '', '', False, False, False, None, None, None, False, '',
+ None, None, tracker_pb2.DateAction.PING_OWNER_ONLY, 'doc', False),
+ tracker_bizobj.MakeFieldDef(
+ 125, 789, 'TLsBirthday', tracker_pb2.FieldTypes.DATE_TYPE,
+ '', '', False, False, False, None, None, None, False, '',
+ None, None, tracker_pb2.DateAction.NO_ACTION, 'doc', False),
+ ]
+ self.services.config.StoreConfig('cnxn', self.config)
+ self.project = self.services.project.TestAddProject('proj', project_id=789)
+ self.owner = self.services.user.TestAddUser('owner@example.com', 111)
+ self.date_action_user = self.services.user.TestAddUser(
+ 'date-action-user@example.com', 555)
+
+ def testHandleRequest_IssueHasNoArrivedDates(self):
+ _request, mr = testing_helpers.GetRequestObjects(
+ path=urls.ISSUE_DATE_ACTION_TASK + '.do?issue_id=78901')
+ self.services.issue.TestAddIssue(fake.MakeTestIssue(
+ 789, 1, 'summary', 'New', 111, issue_id=78901))
+ self.assertEqual(1, len(self.services.issue.GetCommentsForIssue(
+ mr.cnxn, 78901)))
+
+ self.servlet.HandleRequest(mr)
+ self.assertEqual(1, len(self.services.issue.GetCommentsForIssue(
+ mr.cnxn, 78901)))
+
+ @mock.patch('framework.cloud_tasks_helpers.create_task')
+ def testHandleRequest_IssueHasOneArriveDate(self, create_task_mock):
+ _request, mr = testing_helpers.GetRequestObjects(
+ path=urls.ISSUE_DATE_ACTION_TASK + '.do?issue_id=78901')
+
+ now = int(time.time())
+ date_str = timestr.TimestampToDateWidgetStr(now)
+ issue = fake.MakeTestIssue(789, 1, 'summary', 'New', 111, issue_id=78901)
+ self.services.issue.TestAddIssue(issue)
+ issue.field_values = [
+ tracker_bizobj.MakeFieldValue(123, None, None, None, now, None, False)]
+ self.assertEqual(1, len(self.services.issue.GetCommentsForIssue(
+ mr.cnxn, 78901)))
+
+ self.servlet.HandleRequest(mr)
+ comments = self.services.issue.GetCommentsForIssue(mr.cnxn, 78901)
+ self.assertEqual(2, len(comments))
+ self.assertEqual(
+ 'The NextAction date has arrived: %s' % date_str,
+ comments[1].content)
+
+ self.assertEqual(create_task_mock.call_count, 1)
+
+ (args, kwargs) = create_task_mock.call_args
+ self.assertEqual(
+ args[0]['app_engine_http_request']['relative_uri'],
+ urls.OUTBOUND_EMAIL_TASK + '.do')
+ self.assertEqual(kwargs['queue'], 'outboundemail')
+
+ def SetUpFieldValues(self, issue, now):
+ issue.field_values = [
+ tracker_bizobj.MakeFieldValue(123, None, None, None, now, None, False),
+ tracker_bizobj.MakeFieldValue(124, None, None, None, now, None, False),
+ tracker_bizobj.MakeFieldValue(125, None, None, None, now, None, False),
+ ]
+
+ @mock.patch('framework.cloud_tasks_helpers.create_task')
+ def testHandleRequest_IssueHasTwoArriveDates(self, create_task_mock):
+ _request, mr = testing_helpers.GetRequestObjects(
+ path=urls.ISSUE_DATE_ACTION_TASK + '.do?issue_id=78901')
+
+ now = int(time.time())
+ date_str = timestr.TimestampToDateWidgetStr(now)
+ issue = fake.MakeTestIssue(789, 1, 'summary', 'New', 111, issue_id=78901)
+ self.services.issue.TestAddIssue(issue)
+ self.SetUpFieldValues(issue, now)
+ self.assertEqual(1, len(self.services.issue.GetCommentsForIssue(
+ mr.cnxn, 78901)))
+
+ self.servlet.HandleRequest(mr)
+ comments = self.services.issue.GetCommentsForIssue(mr.cnxn, 78901)
+ self.assertEqual(2, len(comments))
+ self.assertEqual(
+ 'The EoL date has arrived: %s\n'
+ 'The NextAction date has arrived: %s' % (date_str, date_str),
+ comments[1].content)
+
+ self.assertEqual(create_task_mock.call_count, 1)
+
+ (args, kwargs) = create_task_mock.call_args
+ self.assertEqual(
+ args[0]['app_engine_http_request']['relative_uri'],
+ urls.OUTBOUND_EMAIL_TASK + '.do')
+ self.assertEqual(kwargs['queue'], 'outboundemail')
+
+ def MakePingComment(self):
+ comment = tracker_pb2.IssueComment()
+ comment.project_id = self.project.project_id
+ comment.user_id = self.date_action_user.user_id
+ comment.content = 'Some date(s) arrived...'
+ return comment
+
+ def testMakeEmailTasks_Owner(self):
+ """The issue owner gets pinged and the email has expected content."""
+ issue = fake.MakeTestIssue(
+ 789, 1, 'summary', 'New', self.owner.user_id, issue_id=78901)
+ self.services.issue.TestAddIssue(issue)
+ now = int(time.time())
+ self.SetUpFieldValues(issue, now)
+ issue.project_name = 'proj'
+ comment = self.MakePingComment()
+ next_action_field_def = self.config.field_defs[0]
+ pings = [(next_action_field_def, now)]
+ users_by_id = framework_views.MakeAllUserViews(
+ 'fake cnxn', self.services.user,
+ [self.owner.user_id, self.date_action_user.user_id])
+
+ tasks = self.servlet._MakeEmailTasks(
+ 'fake cnxn', issue, self.project, self.config, comment,
+ [], 'example-app.appspot.com', users_by_id, pings)
+ self.assertEqual(1, len(tasks))
+ notify_owner_task = tasks[0]
+ self.assertEqual('owner@example.com', notify_owner_task['to'])
+ self.assertEqual(
+ 'Follow up on issue 1 in proj: summary',
+ notify_owner_task['subject'])
+ body = notify_owner_task['body']
+ self.assertIn(comment.content, body)
+ self.assertIn(next_action_field_def.docstring, body)
+
+ def testMakeEmailTasks_Starrer(self):
+ """Users who starred the issue are notified iff they opt in."""
+ issue = fake.MakeTestIssue(
+ 789, 1, 'summary', 'New', 0, issue_id=78901)
+ self.services.issue.TestAddIssue(issue)
+ now = int(time.time())
+ self.SetUpFieldValues(issue, now)
+ issue.project_name = 'proj'
+ comment = self.MakePingComment()
+ next_action_field_def = self.config.field_defs[0]
+ pings = [(next_action_field_def, now)]
+
+ starrer_333 = self.services.user.TestAddUser('starrer333@example.com', 333)
+ starrer_333.notify_starred_ping = True
+ self.services.user.TestAddUser('starrer444@example.com', 444)
+ starrer_ids = [333, 444]
+ users_by_id = framework_views.MakeAllUserViews(
+ 'fake cnxn', self.services.user,
+ [self.owner.user_id, self.date_action_user.user_id],
+ starrer_ids)
+
+ tasks = self.servlet._MakeEmailTasks(
+ 'fake cnxn', issue, self.project, self.config, comment,
+ starrer_ids, 'example-app.appspot.com', users_by_id, pings)
+ self.assertEqual(1, len(tasks))
+ notify_owner_task = tasks[0]
+ self.assertEqual('starrer333@example.com', notify_owner_task['to'])
+
+ def testCalculateIssuePings_Normal(self):
+ """Return a ping for an issue that has a date that happened today."""
+ issue = fake.MakeTestIssue(
+ 789, 1, 'summary', 'New', 0, issue_id=78901)
+ self.services.issue.TestAddIssue(issue)
+ now = int(time.time())
+ self.SetUpFieldValues(issue, now)
+ issue.project_name = 'proj'
+
+ pings = self.servlet._CalculateIssuePings(issue, self.config)
+
+ self.assertEqual(
+ [(self.config.field_defs[1], now),
+ (self.config.field_defs[0], now)],
+ pings)
+
+ def testCalculateIssuePings_Closed(self):
+ """Don't ping for a closed issue."""
+ issue = fake.MakeTestIssue(
+ 789, 1, 'summary', 'Fixed', 0, issue_id=78901)
+ self.services.issue.TestAddIssue(issue)
+ now = int(time.time())
+ self.SetUpFieldValues(issue, now)
+ issue.project_name = 'proj'
+
+ pings = self.servlet._CalculateIssuePings(issue, self.config)
+
+ self.assertEqual([], pings)
diff --git a/features/test/features_bizobj_test.py b/features/test/features_bizobj_test.py
new file mode 100644
index 0000000..1814ae2
--- /dev/null
+++ b/features/test/features_bizobj_test.py
@@ -0,0 +1,134 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Tests for features bizobj functions."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from proto import features_pb2
+from features import features_bizobj
+from testing import fake
+
+class FeaturesBizobjTest(unittest.TestCase):
+
+ def setUp(self):
+ self.local_ids = [1, 2, 3, 4, 5]
+ self.issues = [fake.MakeTestIssue(1000, local_id, '', 'New', 111)
+ for local_id in self.local_ids]
+ self.hotlistitems = [features_pb2.MakeHotlistItem(
+ issue.issue_id, rank=rank*10, adder_id=111, date_added=3) for
+ rank, issue in enumerate(self.issues)]
+ self.iids = [item.issue_id for item in self.hotlistitems]
+
+ def testIssueIsInHotlist(self):
+ hotlist = features_pb2.Hotlist(items=self.hotlistitems)
+ for issue in self.issues:
+ self.assertTrue(features_bizobj.IssueIsInHotlist(hotlist, issue.issue_id))
+
+ self.assertFalse(features_bizobj.IssueIsInHotlist(
+ hotlist, fake.MakeTestIssue(1000, 9, '', 'New', 111)))
+
+ def testSplitHotlistIssueRanks(self):
+ iid_rank_tuples = [(issue.issue_id, issue.rank)
+ for issue in self.hotlistitems]
+ iid_rank_tuples.reverse()
+ ret = features_bizobj.SplitHotlistIssueRanks(
+ 100003, True, iid_rank_tuples)
+ self.assertEqual(ret, (iid_rank_tuples[:2], iid_rank_tuples[2:]))
+
+ iid_rank_tuples.reverse()
+ ret = features_bizobj.SplitHotlistIssueRanks(
+ 100003, False, iid_rank_tuples)
+ self.assertEqual(ret, (iid_rank_tuples[:3], iid_rank_tuples[3:]))
+
+ # target issue not found
+ first_pairs, second_pairs = features_bizobj.SplitHotlistIssueRanks(
+ 100009, True, iid_rank_tuples)
+ self.assertEqual(iid_rank_tuples, first_pairs)
+ self.assertEqual(second_pairs, [])
+
+ def testGetOwnerIds(self):
+ hotlist = features_pb2.Hotlist(owner_ids=[111])
+ self.assertEqual(features_bizobj.GetOwnerIds(hotlist), [111])
+
+ def testUsersInvolvedInHotlists_Empty(self):
+ self.assertEqual(set(), features_bizobj.UsersInvolvedInHotlists([]))
+
+ def testUsersInvolvedInHotlists_Normal(self):
+ hotlist1 = features_pb2.Hotlist(
+ owner_ids=[111, 222], editor_ids=[333, 444, 555],
+ follower_ids=[123])
+ hotlist2 = features_pb2.Hotlist(
+ owner_ids=[111], editor_ids=[222, 123])
+ self.assertEqual(set([111, 222, 333, 444, 555, 123]),
+ features_bizobj.UsersInvolvedInHotlists([hotlist1,
+ hotlist2]))
+
+ def testUserIsInHotlist(self):
+ h = features_pb2.Hotlist()
+ self.assertFalse(features_bizobj.UserIsInHotlist(h, {9}))
+ self.assertFalse(features_bizobj.UserIsInHotlist(h, set()))
+
+ h.owner_ids.extend([1, 2, 3])
+ h.editor_ids.extend([4, 5, 6])
+ h.follower_ids.extend([7, 8, 9])
+ self.assertTrue(features_bizobj.UserIsInHotlist(h, {1}))
+ self.assertTrue(features_bizobj.UserIsInHotlist(h, {4}))
+ self.assertTrue(features_bizobj.UserIsInHotlist(h, {7}))
+ self.assertFalse(features_bizobj.UserIsInHotlist(h, {10}))
+
+ # Membership via group membership
+ self.assertTrue(features_bizobj.UserIsInHotlist(h, {10, 4}))
+
+ # Membership via several group memberships
+ self.assertTrue(features_bizobj.UserIsInHotlist(h, {1, 4}))
+
+ # Several irrelevant group memberships
+ self.assertFalse(features_bizobj.UserIsInHotlist(h, {10, 11, 12}))
+
+ def testDetermineHotlistIssuePosition(self):
+ # normal
+ prev_iid, index, next_iid = features_bizobj.DetermineHotlistIssuePosition(
+ self.issues[2], self.iids)
+ self.assertEqual(prev_iid, self.hotlistitems[1].issue_id)
+ self.assertEqual(index, 2)
+ self.assertEqual(next_iid, self.hotlistitems[3].issue_id)
+
+ # end of list
+ prev_iid, index, next_iid = features_bizobj.DetermineHotlistIssuePosition(
+ self.issues[4], self.iids)
+ self.assertEqual(prev_iid, self.hotlistitems[3].issue_id)
+ self.assertEqual(index, 4)
+ self.assertEqual(next_iid, None)
+
+ # beginning of list
+ prev_iid, index, next_iid = features_bizobj.DetermineHotlistIssuePosition(
+ self.issues[0], self.iids)
+ self.assertEqual(prev_iid, None)
+ self.assertEqual(index, 0)
+ self.assertEqual(next_iid, self.hotlistitems[1].issue_id)
+
+ # one item in list
+ prev_iid, index, next_iid = features_bizobj.DetermineHotlistIssuePosition(
+ self.issues[2], [self.iids[2]])
+ self.assertEqual(prev_iid, None)
+ self.assertEqual(index, 0)
+ self.assertEqual(next_iid, None)
+
+ prev_iid, index, next_iid = features_bizobj.DetermineHotlistIssuePosition(
+ self.issues[2], [self.iids[3]])
+ self.assertEqual(prev_iid, None)
+ self.assertEqual(index, None)
+ self.assertEqual(next_iid, None)
+
+ #none
+ prev_iid, index, next_iid = features_bizobj.DetermineHotlistIssuePosition(
+ self.issues[2], [])
+ self.assertEqual(prev_iid, None)
+ self.assertEqual(index, None)
+ self.assertEqual(next_iid, None)
diff --git a/features/test/federated_test.py b/features/test/federated_test.py
new file mode 100644
index 0000000..1ba088a
--- /dev/null
+++ b/features/test/federated_test.py
@@ -0,0 +1,114 @@
+# Copyright 2019 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unit tests for monorail.feature.federated."""
+
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from features import federated
+from framework.exceptions import InvalidExternalIssueReference
+
+
+# Schema: tracker, shortlink.
+VALID_SHORTLINKS = [
+ ('google', 'b/1'),
+ ('google', 'b/123456'),
+ ('google', 'b/1234567890123')]
+
+
+# Schema: tracker, shortlink.
+INVALID_SHORTLINKS = [
+ ('google', 'b'),
+ ('google', 'b/'),
+ ('google', 'b//123'),
+ ('google', 'b/123/123')]
+
+
+class FederatedTest(unittest.TestCase):
+ """Test public module methods."""
+
+ def testIsShortlinkValid_Valid(self):
+ for _, shortlink in VALID_SHORTLINKS:
+ self.assertTrue(federated.IsShortlinkValid(shortlink),
+ 'Expected %s to be a valid shortlink for any tracker.'
+ % shortlink)
+
+ def testIsShortlinkValid_Invalid(self):
+ for _, shortlink in INVALID_SHORTLINKS:
+ self.assertFalse(federated.IsShortlinkValid(shortlink),
+ 'Expected %s to be an invalid shortlink for any tracker.'
+ % shortlink)
+
+ def testFromShortlink_Valid(self):
+ for _, shortlink in VALID_SHORTLINKS:
+ issue = federated.FromShortlink(shortlink)
+ self.assertEqual(shortlink, issue.shortlink, (
+ 'Expected %s to be converted into a valid tracker object '
+ 'with shortlink %s' % (shortlink, issue.shortlink)))
+
+ def testFromShortlink_Invalid(self):
+ for _, shortlink in INVALID_SHORTLINKS:
+ self.assertIsNone(federated.FromShortlink(shortlink))
+
+
+class FederatedIssueTest(unittest.TestCase):
+
+ def testInit_NotImplemented(self):
+ """By default, __init__ raises NotImplementedError.
+
+ Because __init__ calls IsShortlinkValid. See test below.
+ """
+ with self.assertRaises(NotImplementedError):
+ federated.FederatedIssue('a')
+
+ def testIsShortlinkValid_NotImplemented(self):
+ """By default, IsShortlinkValid raises NotImplementedError."""
+ with self.assertRaises(NotImplementedError):
+ federated.FederatedIssue('a').IsShortlinkValid('rutabaga')
+
+
+class GoogleIssueTrackerIssueTest(unittest.TestCase):
+
+ def setUp(self):
+ self.valid_shortlinks = [s for tracker, s in VALID_SHORTLINKS
+ if tracker == 'google']
+ self.invalid_shortlinks = [s for tracker, s in INVALID_SHORTLINKS
+ if tracker == 'google']
+
+ def testInit_ValidatesValidShortlink(self):
+ for shortlink in self.valid_shortlinks:
+ issue = federated.GoogleIssueTrackerIssue(shortlink)
+ self.assertEqual(issue.shortlink, shortlink)
+
+ def testInit_ValidatesInvalidShortlink(self):
+ for shortlink in self.invalid_shortlinks:
+ with self.assertRaises(InvalidExternalIssueReference):
+ federated.GoogleIssueTrackerIssue(shortlink)
+
+ def testIsShortlinkValid_Valid(self):
+ for shortlink in self.valid_shortlinks:
+ self.assertTrue(
+ federated.GoogleIssueTrackerIssue.IsShortlinkValid(shortlink),
+ 'Expected %s to be a valid shortlink for Google.'
+ % shortlink)
+
+ def testIsShortlinkValid_Invalid(self):
+ for shortlink in self.invalid_shortlinks:
+ self.assertFalse(
+ federated.GoogleIssueTrackerIssue.IsShortlinkValid(shortlink),
+ 'Expected %s to be an invalid shortlink for Google.'
+ % shortlink)
+
+ def testToURL(self):
+ self.assertEqual('https://issuetracker.google.com/issues/123456',
+ federated.GoogleIssueTrackerIssue('b/123456').ToURL())
+
+ def testSummary(self):
+ self.assertEqual('Google Issue Tracker issue 123456.',
+ federated.GoogleIssueTrackerIssue('b/123456').Summary())
diff --git a/features/test/filterrules_helpers_test.py b/features/test/filterrules_helpers_test.py
new file mode 100644
index 0000000..99d22b7
--- /dev/null
+++ b/features/test/filterrules_helpers_test.py
@@ -0,0 +1,927 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unit tests for filterrules_helpers feature."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import mock
+import unittest
+import urllib
+import urlparse
+
+import settings
+from features import filterrules_helpers
+from framework import cloud_tasks_helpers
+from framework import framework_constants
+from framework import template_helpers
+from framework import urls
+from proto import ast_pb2
+from proto import tracker_pb2
+from search import query2ast
+from services import service_manager
+from testing import fake
+from tracker import tracker_bizobj
+
+
+ORIG_SUMMARY = 'this is the orginal summary'
+ORIG_LABELS = ['one', 'two']
+
+# Fake user id mapping
+TEST_ID_MAP = {
+ 'mike.j.parent': 1,
+ 'jrobbins': 2,
+ 'ningerso': 3,
+ 'ui@example.com': 4,
+ 'db@example.com': 5,
+ 'ui-db@example.com': 6,
+ }
+
+TEST_LABEL_IDS = {
+ 'i18n': 1,
+ 'l10n': 2,
+ 'Priority-High': 3,
+ 'Priority-Medium': 4,
+ }
+
+
+class RecomputeAllDerivedFieldsTest(unittest.TestCase):
+
+ BLOCK = filterrules_helpers.BLOCK
+
+ def setUp(self):
+ self.features = fake.FeaturesService()
+ self.user = fake.UserService()
+ self.services = service_manager.Services(
+ features=self.features,
+ user=self.user,
+ issue=fake.IssueService())
+ self.project = fake.Project(project_name='proj')
+ self.config = 'fake config'
+ self.cnxn = 'fake cnxn'
+
+
+ def testRecomputeDerivedFields_Disabled(self):
+ """Servlet should just call RecomputeAllDerivedFieldsNow with no bounds."""
+ saved_flag = settings.recompute_derived_fields_in_worker
+ settings.recompute_derived_fields_in_worker = False
+
+ filterrules_helpers.RecomputeAllDerivedFields(
+ self.cnxn, self.services, self.project, self.config)
+ self.assertTrue(self.services.issue.get_all_issues_in_project_called)
+ self.assertTrue(self.services.issue.update_issues_called)
+ self.assertTrue(self.services.issue.enqueue_issues_called)
+
+ settings.recompute_derived_fields_in_worker = saved_flag
+
+ def testRecomputeDerivedFields_DisabledNextIDSet(self):
+ """Servlet should just call RecomputeAllDerivedFields with no bounds."""
+ saved_flag = settings.recompute_derived_fields_in_worker
+ settings.recompute_derived_fields_in_worker = False
+ self.services.issue.next_id = 1234
+
+ filterrules_helpers.RecomputeAllDerivedFields(
+ self.cnxn, self.services, self.project, self.config)
+ self.assertTrue(self.services.issue.get_all_issues_in_project_called)
+ self.assertTrue(self.services.issue.enqueue_issues_called)
+
+ settings.recompute_derived_fields_in_worker = saved_flag
+
+ def testRecomputeDerivedFields_NoIssues(self):
+ """Servlet should not call because there is no work to do."""
+ saved_flag = settings.recompute_derived_fields_in_worker
+ settings.recompute_derived_fields_in_worker = True
+
+ filterrules_helpers.RecomputeAllDerivedFields(
+ self.cnxn, self.services, self.project, self.config)
+ self.assertFalse(self.services.issue.get_all_issues_in_project_called)
+ self.assertFalse(self.services.issue.update_issues_called)
+ self.assertFalse(self.services.issue.enqueue_issues_called)
+
+ settings.recompute_derived_fields_in_worker = saved_flag
+
+ @mock.patch('framework.cloud_tasks_helpers._get_client')
+ def testRecomputeDerivedFields_SomeIssues(self, get_client_mock):
+ """Servlet should enqueue one work item rather than call directly."""
+ saved_flag = settings.recompute_derived_fields_in_worker
+ settings.recompute_derived_fields_in_worker = True
+ self.services.issue.next_id = 1234
+ num_calls = (self.services.issue.next_id // self.BLOCK + 1)
+
+ filterrules_helpers.RecomputeAllDerivedFields(
+ self.cnxn, self.services, self.project, self.config)
+ self.assertFalse(self.services.issue.get_all_issues_in_project_called)
+ self.assertFalse(self.services.issue.update_issues_called)
+ self.assertFalse(self.services.issue.enqueue_issues_called)
+
+ get_client_mock().queue_path.assert_any_call(
+ settings.app_id, settings.CLOUD_TASKS_REGION, 'recomputederivedfields')
+ self.assertEqual(get_client_mock().queue_path.call_count, num_calls)
+ self.assertEqual(get_client_mock().create_task.call_count, num_calls)
+
+ parent = get_client_mock().queue_path()
+ highest_id = self.services.issue.GetHighestLocalID(
+ self.cnxn, self.project.project_id)
+ steps = list(range(1, highest_id + 1, self.BLOCK))
+ steps.reverse()
+ shard_id = 0
+ for step in steps:
+ params = {
+ 'project_id': self.project.project_id,
+ 'lower_bound': step,
+ 'upper_bound': min(step + self.BLOCK, highest_id + 1),
+ 'shard_id': shard_id,
+ }
+ task = {
+ 'app_engine_http_request':
+ {
+ 'relative_uri': urls.RECOMPUTE_DERIVED_FIELDS_TASK + '.do',
+ 'body': urllib.urlencode(params),
+ 'headers':
+ {
+ 'Content-type': 'application/x-www-form-urlencoded'
+ }
+ }
+ }
+ get_client_mock().create_task.assert_any_call(
+ parent, task, retry=cloud_tasks_helpers._DEFAULT_RETRY)
+ shard_id = (shard_id + 1) % settings.num_logical_shards
+
+ settings.recompute_derived_fields_in_worker = saved_flag
+
+ @mock.patch('framework.cloud_tasks_helpers._get_client')
+ def testRecomputeDerivedFields_LotsOfIssues(self, get_client_mock):
+ """Servlet should enqueue multiple work items."""
+ saved_flag = settings.recompute_derived_fields_in_worker
+ settings.recompute_derived_fields_in_worker = True
+ self.services.issue.next_id = 12345
+
+ filterrules_helpers.RecomputeAllDerivedFields(
+ self.cnxn, self.services, self.project, self.config)
+
+ self.assertFalse(self.services.issue.get_all_issues_in_project_called)
+ self.assertFalse(self.services.issue.update_issues_called)
+ self.assertFalse(self.services.issue.enqueue_issues_called)
+ num_calls = (self.services.issue.next_id // self.BLOCK + 1)
+ get_client_mock().queue_path.assert_any_call(
+ settings.app_id, settings.CLOUD_TASKS_REGION, 'recomputederivedfields')
+ self.assertEqual(get_client_mock().queue_path.call_count, num_calls)
+ self.assertEqual(get_client_mock().create_task.call_count, num_calls)
+
+ ((_parent, called_task),
+ _kwargs) = get_client_mock().create_task.call_args_list[0]
+ relative_uri = called_task.get('app_engine_http_request').get(
+ 'relative_uri')
+ self.assertEqual(relative_uri, urls.RECOMPUTE_DERIVED_FIELDS_TASK + '.do')
+ encoded_params = called_task.get('app_engine_http_request').get('body')
+ params = {k: v[0] for k, v in urlparse.parse_qs(encoded_params).items()}
+ self.assertEqual(params['project_id'], str(self.project.project_id))
+ self.assertEqual(
+ params['lower_bound'], str(12345 // self.BLOCK * self.BLOCK + 1))
+ self.assertEqual(params['upper_bound'], str(12345))
+
+ ((_parent, called_task), _kwargs) = get_client_mock().create_task.call_args
+ relative_uri = called_task.get('app_engine_http_request').get(
+ 'relative_uri')
+ self.assertEqual(relative_uri, urls.RECOMPUTE_DERIVED_FIELDS_TASK + '.do')
+ encoded_params = called_task.get('app_engine_http_request').get('body')
+ params = {k: v[0] for k, v in urlparse.parse_qs(encoded_params).items()}
+ self.assertEqual(params['project_id'], str(self.project.project_id))
+ self.assertEqual(params['lower_bound'], str(1))
+ self.assertEqual(params['upper_bound'], str(self.BLOCK + 1))
+
+ settings.recompute_derived_fields_in_worker = saved_flag
+
+ @mock.patch(
+ 'features.filterrules_helpers.ApplyGivenRules', return_value=(True, {}))
+ def testRecomputeAllDerivedFieldsNow(self, apply_mock):
+ """Servlet should reapply all filter rules to project's issues."""
+ self.services.issue.next_id = 12345
+ test_issue_1 = fake.MakeTestIssue(
+ project_id=self.project.project_id, local_id=1, issue_id=1001,
+ summary='sum1', owner_id=100, status='New')
+ test_issue_1.assume_stale = False # We will store this issue.
+ test_issue_2 = fake.MakeTestIssue(
+ project_id=self.project.project_id, local_id=2, issue_id=1002,
+ summary='sum2', owner_id=100, status='New')
+ test_issue_2.assume_stale = False # We will store this issue.
+ test_issues = [test_issue_1, test_issue_2]
+ self.services.issue.TestAddIssue(test_issue_1)
+ self.services.issue.TestAddIssue(test_issue_2)
+
+ filterrules_helpers.RecomputeAllDerivedFieldsNow(
+ self.cnxn, self.services, self.project, self.config)
+
+ self.assertTrue(self.services.issue.get_all_issues_in_project_called)
+ self.assertTrue(self.services.issue.update_issues_called)
+ self.assertTrue(self.services.issue.enqueue_issues_called)
+ self.assertEqual(test_issues, self.services.issue.updated_issues)
+ self.assertEqual([issue.issue_id for issue in test_issues],
+ self.services.issue.enqueued_issues)
+ self.assertEqual(apply_mock.call_count, 2)
+ for test_issue in test_issues:
+ apply_mock.assert_any_call(
+ self.cnxn, self.services, test_issue, self.config, [], [])
+
+
+class FilterRulesHelpersTest(unittest.TestCase):
+
+ def setUp(self):
+ self.cnxn = 'fake cnxn'
+ self.services = service_manager.Services(
+ user=fake.UserService(),
+ project=fake.ProjectService(),
+ issue=fake.IssueService(),
+ config=fake.ConfigService())
+ self.project = self.services.project.TestAddProject('proj', project_id=789)
+ self.other_project = self.services.project.TestAddProject(
+ 'otherproj', project_id=890)
+ for email, user_id in TEST_ID_MAP.items():
+ self.services.user.TestAddUser(email, user_id)
+ self.services.config.TestAddLabelsDict(TEST_LABEL_IDS)
+
+ def testApplyRule(self):
+ cnxn = 'fake sql connection'
+ issue = fake.MakeTestIssue(
+ 789, 1, ORIG_SUMMARY, 'New', 111, labels=ORIG_LABELS)
+ config = tracker_pb2.ProjectIssueConfig(project_id=self.project.project_id)
+ # Empty label set cannot satisfy rule looking for labels.
+ pred = 'label:a label:b'
+ rule = filterrules_helpers.MakeRule(
+ pred, default_owner_id=1, default_status='S')
+ predicate_ast = query2ast.ParseUserQuery(
+ pred, '', query2ast.BUILTIN_ISSUE_FIELDS, config)
+ self.assertEqual(
+ (None, None, [], [], [], None, None),
+ filterrules_helpers._ApplyRule(
+ cnxn, self.services, rule, predicate_ast, issue, set(), config))
+
+ pred = 'label:a -label:b'
+ rule = filterrules_helpers.MakeRule(
+ pred, default_owner_id=1, default_status='S')
+ predicate_ast = query2ast.ParseUserQuery(
+ pred, '', query2ast.BUILTIN_ISSUE_FIELDS, config)
+ self.assertEqual(
+ (None, None, [], [], [], None, None),
+ filterrules_helpers._ApplyRule(
+ cnxn, self.services, rule, predicate_ast, issue, set(), config))
+
+ # Empty label set will satisfy rule looking for missing labels.
+ pred = '-label:a -label:b'
+ rule = filterrules_helpers.MakeRule(
+ pred, default_owner_id=1, default_status='S')
+ predicate_ast = query2ast.ParseUserQuery(
+ pred, '', query2ast.BUILTIN_ISSUE_FIELDS, config)
+ self.assertEqual(
+ (1, 'S', [], [], [], None, None),
+ filterrules_helpers._ApplyRule(
+ cnxn, self.services, rule, predicate_ast, issue, set(), config))
+
+ # Label set has the needed labels.
+ pred = 'label:a label:b'
+ rule = filterrules_helpers.MakeRule(
+ pred, default_owner_id=1, default_status='S')
+ predicate_ast = query2ast.ParseUserQuery(
+ pred, '', query2ast.BUILTIN_ISSUE_FIELDS, config)
+ self.assertEqual(
+ (1, 'S', [], [], [], None, None),
+ filterrules_helpers._ApplyRule(
+ cnxn, self.services, rule, predicate_ast, issue, {'a', 'b'},
+ config))
+
+ # Label set has the needed labels with test for unicode.
+ pred = 'label:a label:b'
+ rule = filterrules_helpers.MakeRule(
+ pred, default_owner_id=1, default_status='S')
+ predicate_ast = query2ast.ParseUserQuery(
+ pred, '', query2ast.BUILTIN_ISSUE_FIELDS, config)
+ self.assertEqual(
+ (1, 'S', [], [], [], None, None),
+ filterrules_helpers._ApplyRule(
+ cnxn, self.services, rule, predicate_ast, issue, {u'a', u'b'},
+ config))
+
+ # Label set has the needed labels, capitalization irrelevant.
+ pred = 'label:A label:B'
+ rule = filterrules_helpers.MakeRule(
+ pred, default_owner_id=1, default_status='S')
+ predicate_ast = query2ast.ParseUserQuery(
+ pred, '', query2ast.BUILTIN_ISSUE_FIELDS, config)
+ self.assertEqual(
+ (1, 'S', [], [], [], None, None),
+ filterrules_helpers._ApplyRule(
+ cnxn, self.services, rule, predicate_ast, issue, {'a', 'b'},
+ config))
+
+ # Label set has a label, the rule negates.
+ pred = 'label:a -label:b'
+ rule = filterrules_helpers.MakeRule(
+ pred, default_owner_id=1, default_status='S')
+ predicate_ast = query2ast.ParseUserQuery(
+ pred, '', query2ast.BUILTIN_ISSUE_FIELDS, config)
+ self.assertEqual(
+ (None, None, [], [], [], None, None),
+ filterrules_helpers._ApplyRule(
+ cnxn, self.services, rule, predicate_ast, issue, {'a', 'b'},
+ config))
+
+ # Consequence is to add a warning.
+ pred = 'label:a'
+ rule = filterrules_helpers.MakeRule(
+ pred, warning='Hey look out')
+ predicate_ast = query2ast.ParseUserQuery(
+ pred, '', query2ast.BUILTIN_ISSUE_FIELDS, config)
+ self.assertEqual(
+ (None, None, [], [], [], 'Hey look out', None),
+ filterrules_helpers._ApplyRule(
+ cnxn, self.services, rule, predicate_ast, issue, {'a', 'b'},
+ config))
+
+ # Consequence is to add an error.
+ pred = 'label:a'
+ rule = filterrules_helpers.MakeRule(
+ pred, error='We cannot allow that')
+ predicate_ast = query2ast.ParseUserQuery(
+ pred, '', query2ast.BUILTIN_ISSUE_FIELDS, config)
+ self.assertEqual(
+ (None, None, [], [], [], None, 'We cannot allow that'),
+ filterrules_helpers._ApplyRule(
+ cnxn, self.services, rule, predicate_ast, issue, {'a', 'b'},
+ config))
+
+ def testComputeDerivedFields_Components(self):
+ cnxn = 'fake sql connection'
+ rules = []
+ component_defs = [
+ tracker_bizobj.MakeComponentDef(
+ 10, 789, 'DB', 'database', False, [],
+ [TEST_ID_MAP['db@example.com'],
+ TEST_ID_MAP['ui-db@example.com']],
+ 0, 0,
+ label_ids=[TEST_LABEL_IDS['i18n'],
+ TEST_LABEL_IDS['Priority-High']]),
+ tracker_bizobj.MakeComponentDef(
+ 20, 789, 'Install', 'installer', False, [],
+ [], 0, 0),
+ tracker_bizobj.MakeComponentDef(
+ 30, 789, 'UI', 'doc', False, [],
+ [TEST_ID_MAP['ui@example.com'],
+ TEST_ID_MAP['ui-db@example.com']],
+ 0, 0,
+ label_ids=[TEST_LABEL_IDS['i18n'],
+ TEST_LABEL_IDS['l10n'],
+ TEST_LABEL_IDS['Priority-Medium']]),
+ ]
+ excl_prefixes = ['Priority', 'type', 'milestone']
+ config = tracker_pb2.ProjectIssueConfig(
+ exclusive_label_prefixes=excl_prefixes,
+ component_defs=component_defs)
+ predicate_asts = filterrules_helpers.ParsePredicateASTs(rules, config, [])
+
+ # No components.
+ issue = fake.MakeTestIssue(
+ 789, 1, ORIG_SUMMARY, 'New', 0, labels=ORIG_LABELS)
+ self.assertEqual(
+ (0, '', [], [], [], {}, [], []),
+ filterrules_helpers._ComputeDerivedFields(
+ cnxn, self.services, issue, config, rules, predicate_asts))
+
+ # One component, no CCs or labels added
+ issue.component_ids = [20]
+ issue = fake.MakeTestIssue(
+ 789, 1, ORIG_SUMMARY, 'New', 0, labels=ORIG_LABELS)
+ self.assertEqual(
+ (0, '', [], [], [], {}, [], []),
+ filterrules_helpers._ComputeDerivedFields(
+ cnxn, self.services, issue, config, rules, predicate_asts))
+
+ # One component, some CCs and labels added
+ issue = fake.MakeTestIssue(
+ 789, 1, ORIG_SUMMARY, 'New', 0, labels=ORIG_LABELS,
+ component_ids=[10])
+ traces = {
+ (tracker_pb2.FieldID.CC, TEST_ID_MAP['db@example.com']):
+ 'Added by component DB',
+ (tracker_pb2.FieldID.CC, TEST_ID_MAP['ui-db@example.com']):
+ 'Added by component DB',
+ (tracker_pb2.FieldID.LABELS, 'i18n'):
+ 'Added by component DB',
+ (tracker_pb2.FieldID.LABELS, 'Priority-High'):
+ 'Added by component DB',
+ }
+ self.assertEqual(
+ (
+ 0, '', [
+ TEST_ID_MAP['db@example.com'], TEST_ID_MAP['ui-db@example.com']
+ ], ['i18n', 'Priority-High'], [], traces, [], []),
+ filterrules_helpers._ComputeDerivedFields(
+ cnxn, self.services, issue, config, rules, predicate_asts))
+
+ # One component, CCs and labels not added because of labels on the issue.
+ issue = fake.MakeTestIssue(
+ 789, 1, ORIG_SUMMARY, 'New', 0, labels=['Priority-Low', 'i18n'],
+ component_ids=[10])
+ issue.cc_ids = [TEST_ID_MAP['db@example.com']]
+ traces = {
+ (tracker_pb2.FieldID.CC, TEST_ID_MAP['ui-db@example.com']):
+ 'Added by component DB',
+ }
+ self.assertEqual(
+ (0, '', [TEST_ID_MAP['ui-db@example.com']], [], [], traces, [], []),
+ filterrules_helpers._ComputeDerivedFields(
+ cnxn, self.services, issue, config, rules, predicate_asts))
+
+ # Multiple components, added CCs treated as a set, exclusive labels in later
+ # components take priority over earlier ones.
+ issue = fake.MakeTestIssue(
+ 789, 1, ORIG_SUMMARY, 'New', 0, labels=ORIG_LABELS,
+ component_ids=[10, 30])
+ traces = {
+ (tracker_pb2.FieldID.CC, TEST_ID_MAP['db@example.com']):
+ 'Added by component DB',
+ (tracker_pb2.FieldID.CC, TEST_ID_MAP['ui-db@example.com']):
+ 'Added by component DB',
+ (tracker_pb2.FieldID.LABELS, 'i18n'):
+ 'Added by component DB',
+ (tracker_pb2.FieldID.LABELS, 'Priority-High'):
+ 'Added by component DB',
+ (tracker_pb2.FieldID.CC, TEST_ID_MAP['ui@example.com']):
+ 'Added by component UI',
+ (tracker_pb2.FieldID.LABELS, 'Priority-Medium'):
+ 'Added by component UI',
+ (tracker_pb2.FieldID.LABELS, 'l10n'):
+ 'Added by component UI',
+ }
+ self.assertEqual(
+ (
+ 0, '', [
+ TEST_ID_MAP['db@example.com'], TEST_ID_MAP['ui-db@example.com'],
+ TEST_ID_MAP['ui@example.com']
+ ], ['i18n', 'l10n', 'Priority-Medium'], [], traces, [], []),
+ filterrules_helpers._ComputeDerivedFields(
+ cnxn, self.services, issue, config, rules, predicate_asts))
+
+ def testComputeDerivedFields_Rules(self):
+ cnxn = 'fake sql connection'
+ rules = [
+ filterrules_helpers.MakeRule(
+ 'label:HasWorkaround', add_labels=['Priority-Low']),
+ filterrules_helpers.MakeRule(
+ 'label:Security', add_labels=['Private']),
+ filterrules_helpers.MakeRule(
+ 'label:Security', add_labels=['Priority-High'],
+ add_notify=['jrobbins@chromium.org']),
+ filterrules_helpers.MakeRule(
+ 'Priority=High label:Regression', add_labels=['Urgent']),
+ filterrules_helpers.MakeRule(
+ 'Size=L', default_owner_id=444),
+ filterrules_helpers.MakeRule(
+ 'Size=XL', warning='It will take too long'),
+ filterrules_helpers.MakeRule(
+ 'Size=XL', warning='It will cost too much'),
+ ]
+ excl_prefixes = ['Priority', 'type', 'milestone']
+ config = tracker_pb2.ProjectIssueConfig(
+ exclusive_label_prefixes=excl_prefixes,
+ project_id=self.project.project_id)
+ predicate_asts = filterrules_helpers.ParsePredicateASTs(rules, config, [])
+
+ # No rules fire.
+ issue = fake.MakeTestIssue(
+ 789, 1, ORIG_SUMMARY, 'New', 0, labels=ORIG_LABELS)
+ self.assertEqual(
+ (0, '', [], [], [], {}, [], []),
+ filterrules_helpers._ComputeDerivedFields(
+ cnxn, self.services, issue, config, rules, predicate_asts))
+
+ issue = fake.MakeTestIssue(
+ 789, 1, ORIG_SUMMARY, 'New', 0, labels=['foo', 'bar'])
+ self.assertEqual(
+ (0, '', [], [], [], {}, [], []),
+ filterrules_helpers._ComputeDerivedFields(
+ cnxn, self.services, issue, config, rules, predicate_asts))
+
+ # One rule fires.
+ issue = fake.MakeTestIssue(
+ 789, 1, ORIG_SUMMARY, 'New', 0, labels=['Size-L'])
+ traces = {
+ (tracker_pb2.FieldID.OWNER, 444):
+ 'Added by rule: IF Size=L THEN SET DEFAULT OWNER',
+ }
+ self.assertEqual(
+ (444, '', [], [], [], traces, [], []),
+ filterrules_helpers._ComputeDerivedFields(
+ cnxn, self.services, issue, config, rules, predicate_asts))
+
+ # One rule fires, but no effect because of explicit fields.
+ issue = fake.MakeTestIssue(
+ 789, 1, ORIG_SUMMARY, 'New', 0,
+ labels=['HasWorkaround', 'Priority-Critical'])
+ traces = {}
+ self.assertEqual(
+ (0, '', [], [], [], traces, [], []),
+ filterrules_helpers._ComputeDerivedFields(
+ cnxn, self.services, issue, config, rules, predicate_asts))
+
+ # One rule fires, another has no effect because of explicit exclusive label.
+ issue = fake.MakeTestIssue(
+ 789, 1, ORIG_SUMMARY, 'New', 0,
+ labels=['Security', 'Priority-Critical'])
+ traces = {
+ (tracker_pb2.FieldID.LABELS, 'Private'):
+ 'Added by rule: IF label:Security THEN ADD LABEL',
+ }
+ self.assertEqual(
+ (0, '', [], ['Private'], ['jrobbins@chromium.org'], traces, [], []),
+ filterrules_helpers._ComputeDerivedFields(
+ cnxn, self.services, issue, config, rules, predicate_asts))
+
+ # Multiple rules have cumulative effect.
+ issue = fake.MakeTestIssue(
+ 789, 1, ORIG_SUMMARY, 'New', 0, labels=['HasWorkaround', 'Size-L'])
+ traces = {
+ (tracker_pb2.FieldID.LABELS, 'Priority-Low'):
+ 'Added by rule: IF label:HasWorkaround THEN ADD LABEL',
+ (tracker_pb2.FieldID.OWNER, 444):
+ 'Added by rule: IF Size=L THEN SET DEFAULT OWNER',
+ }
+ self.assertEqual(
+ (444, '', [], ['Priority-Low'], [], traces, [], []),
+ filterrules_helpers._ComputeDerivedFields(
+ cnxn, self.services, issue, config, rules, predicate_asts))
+
+ # Multiple rules have cumulative warnings.
+ issue = fake.MakeTestIssue(
+ 789, 1, ORIG_SUMMARY, 'New', 0, labels=['Size-XL'])
+ traces = {
+ (tracker_pb2.FieldID.WARNING, 'It will take too long'):
+ 'Added by rule: IF Size=XL THEN ADD WARNING',
+ (tracker_pb2.FieldID.WARNING, 'It will cost too much'):
+ 'Added by rule: IF Size=XL THEN ADD WARNING',
+ }
+ self.assertEqual(
+ (
+ 0, '', [], [], [], traces,
+ ['It will take too long', 'It will cost too much'], []),
+ filterrules_helpers._ComputeDerivedFields(
+ cnxn, self.services, issue, config, rules, predicate_asts))
+
+ # Two rules fire, second overwrites the first.
+ issue = fake.MakeTestIssue(
+ 789, 1, ORIG_SUMMARY, 'New', 0, labels=['HasWorkaround', 'Security'])
+ traces = {
+ (tracker_pb2.FieldID.LABELS, 'Priority-Low'):
+ 'Added by rule: IF label:HasWorkaround THEN ADD LABEL',
+ (tracker_pb2.FieldID.LABELS, 'Priority-High'):
+ 'Added by rule: IF label:Security THEN ADD LABEL',
+ (tracker_pb2.FieldID.LABELS, 'Private'):
+ 'Added by rule: IF label:Security THEN ADD LABEL',
+ }
+ self.assertEqual(
+ (
+ 0, '', [], ['Private', 'Priority-High'], ['jrobbins@chromium.org'],
+ traces, [], []),
+ filterrules_helpers._ComputeDerivedFields(
+ cnxn, self.services, issue, config, rules, predicate_asts))
+
+ # Two rules fire, second triggered by the first.
+ issue = fake.MakeTestIssue(
+ 789, 1, ORIG_SUMMARY, 'New', 0, labels=['Security', 'Regression'])
+ traces = {
+ (tracker_pb2.FieldID.LABELS, 'Priority-High'):
+ 'Added by rule: IF label:Security THEN ADD LABEL',
+ (tracker_pb2.FieldID.LABELS, 'Urgent'):
+ 'Added by rule: IF Priority=High label:Regression THEN ADD LABEL',
+ (tracker_pb2.FieldID.LABELS, 'Private'):
+ 'Added by rule: IF label:Security THEN ADD LABEL',
+ }
+ self.assertEqual(
+ (
+ 0, '', [], ['Private', 'Priority-High', 'Urgent'],
+ ['jrobbins@chromium.org'], traces, [], []),
+ filterrules_helpers._ComputeDerivedFields(
+ cnxn, self.services, issue, config, rules, predicate_asts))
+
+ # Two rules fire, each one wants to add the same CC: only add once.
+ rules.append(filterrules_helpers.MakeRule('Watch', add_cc_ids=[111]))
+ rules.append(filterrules_helpers.MakeRule('Monitor', add_cc_ids=[111]))
+ config = tracker_pb2.ProjectIssueConfig(
+ exclusive_label_prefixes=excl_prefixes,
+ project_id=self.project.project_id)
+ predicate_asts = filterrules_helpers.ParsePredicateASTs(rules, config, [])
+ traces = {
+ (tracker_pb2.FieldID.CC, 111):
+ 'Added by rule: IF Watch THEN ADD CC',
+ }
+ issue = fake.MakeTestIssue(
+ 789, 1, ORIG_SUMMARY, 'New', 111, labels=['Watch', 'Monitor'])
+ self.assertEqual(
+ (0, '', [111], [], [], traces, [], []),
+ filterrules_helpers._ComputeDerivedFields(
+ cnxn, self.services, issue, config, rules, predicate_asts))
+
+ def testCompareComponents_Trivial(self):
+ config = tracker_pb2.ProjectIssueConfig()
+ self.assertTrue(filterrules_helpers._CompareComponents(
+ config, ast_pb2.QueryOp.IS_DEFINED, [], [123]))
+ self.assertFalse(filterrules_helpers._CompareComponents(
+ config, ast_pb2.QueryOp.IS_NOT_DEFINED, [], [123]))
+ self.assertFalse(filterrules_helpers._CompareComponents(
+ config, ast_pb2.QueryOp.IS_DEFINED, [], []))
+ self.assertTrue(filterrules_helpers._CompareComponents(
+ config, ast_pb2.QueryOp.IS_NOT_DEFINED, [], []))
+ self.assertFalse(filterrules_helpers._CompareComponents(
+ config, ast_pb2.QueryOp.EQ, [123], []))
+
+ def testCompareComponents_Normal(self):
+ config = tracker_pb2.ProjectIssueConfig()
+ config.component_defs.append(tracker_bizobj.MakeComponentDef(
+ 100, 789, 'UI', 'doc', False, [], [], 0, 0))
+ config.component_defs.append(tracker_bizobj.MakeComponentDef(
+ 110, 789, 'UI>Help', 'doc', False, [], [], 0, 0))
+ config.component_defs.append(tracker_bizobj.MakeComponentDef(
+ 200, 789, 'Networking', 'doc', False, [], [], 0, 0))
+
+ # Check if the issue is in a specified component or subcomponent.
+ self.assertTrue(filterrules_helpers._CompareComponents(
+ config, ast_pb2.QueryOp.EQ, ['UI'], [100]))
+ self.assertTrue(filterrules_helpers._CompareComponents(
+ config, ast_pb2.QueryOp.EQ, ['UI>Help'], [110]))
+ self.assertTrue(filterrules_helpers._CompareComponents(
+ config, ast_pb2.QueryOp.EQ, ['UI'], [100, 110]))
+ self.assertFalse(filterrules_helpers._CompareComponents(
+ config, ast_pb2.QueryOp.EQ, ['UI'], []))
+ self.assertFalse(filterrules_helpers._CompareComponents(
+ config, ast_pb2.QueryOp.EQ, ['UI'], [110]))
+ self.assertFalse(filterrules_helpers._CompareComponents(
+ config, ast_pb2.QueryOp.EQ, ['UI'], [200]))
+ self.assertFalse(filterrules_helpers._CompareComponents(
+ config, ast_pb2.QueryOp.EQ, ['UI>Help'], [100]))
+ self.assertFalse(filterrules_helpers._CompareComponents(
+ config, ast_pb2.QueryOp.EQ, ['Networking'], [100]))
+
+ self.assertTrue(filterrules_helpers._CompareComponents(
+ config, ast_pb2.QueryOp.NE, ['UI'], []))
+ self.assertFalse(filterrules_helpers._CompareComponents(
+ config, ast_pb2.QueryOp.NE, ['UI'], [100]))
+ self.assertTrue(filterrules_helpers._CompareComponents(
+ config, ast_pb2.QueryOp.NE, ['Networking'], [100]))
+
+ # Exact vs non-exact.
+ self.assertFalse(filterrules_helpers._CompareComponents(
+ config, ast_pb2.QueryOp.EQ, ['Help'], [110]))
+ self.assertTrue(filterrules_helpers._CompareComponents(
+ config, ast_pb2.QueryOp.TEXT_HAS, ['UI'], [110]))
+ self.assertFalse(filterrules_helpers._CompareComponents(
+ config, ast_pb2.QueryOp.TEXT_HAS, ['Help'], [110]))
+ self.assertFalse(filterrules_helpers._CompareComponents(
+ config, ast_pb2.QueryOp.NOT_TEXT_HAS, ['UI'], [110]))
+ self.assertTrue(filterrules_helpers._CompareComponents(
+ config, ast_pb2.QueryOp.NOT_TEXT_HAS, ['Help'], [110]))
+
+ # Multivalued issues and Quick-OR notation
+ self.assertTrue(filterrules_helpers._CompareComponents(
+ config, ast_pb2.QueryOp.EQ, ['Networking'], [200]))
+ self.assertFalse(filterrules_helpers._CompareComponents(
+ config, ast_pb2.QueryOp.EQ, ['Networking'], [100, 110]))
+ self.assertTrue(filterrules_helpers._CompareComponents(
+ config, ast_pb2.QueryOp.EQ, ['UI', 'Networking'], [100]))
+ self.assertFalse(filterrules_helpers._CompareComponents(
+ config, ast_pb2.QueryOp.EQ, ['UI', 'Networking'], [110]))
+ self.assertTrue(filterrules_helpers._CompareComponents(
+ config, ast_pb2.QueryOp.EQ, ['UI', 'Networking'], [200]))
+ self.assertTrue(filterrules_helpers._CompareComponents(
+ config, ast_pb2.QueryOp.EQ, ['UI', 'Networking'], [110, 200]))
+ self.assertTrue(filterrules_helpers._CompareComponents(
+ config, ast_pb2.QueryOp.TEXT_HAS, ['UI', 'Networking'], [110, 200]))
+ self.assertTrue(filterrules_helpers._CompareComponents(
+ config, ast_pb2.QueryOp.EQ, ['UI>Help', 'Networking'], [110, 200]))
+
+ def testCompareIssueRefs_Trivial(self):
+ self.assertTrue(filterrules_helpers._CompareIssueRefs(
+ self.cnxn, self.services, self.project,
+ ast_pb2.QueryOp.IS_DEFINED, [], [123]))
+ self.assertFalse(filterrules_helpers._CompareIssueRefs(
+ self.cnxn, self.services, self.project,
+ ast_pb2.QueryOp.IS_NOT_DEFINED, [], [123]))
+ self.assertFalse(filterrules_helpers._CompareIssueRefs(
+ self.cnxn, self.services, self.project,
+ ast_pb2.QueryOp.IS_DEFINED, [], []))
+ self.assertTrue(filterrules_helpers._CompareIssueRefs(
+ self.cnxn, self.services, self.project,
+ ast_pb2.QueryOp.IS_NOT_DEFINED, [], []))
+ self.assertFalse(filterrules_helpers._CompareIssueRefs(
+ self.cnxn, self.services, self.project,
+ ast_pb2.QueryOp.EQ, ['1'], []))
+
+ def testCompareIssueRefs_Normal(self):
+ self.services.issue.TestAddIssue(fake.MakeTestIssue(
+ 789, 1, 'summary', 'New', 0, issue_id=123))
+ self.services.issue.TestAddIssue(fake.MakeTestIssue(
+ 789, 2, 'summary', 'New', 0, issue_id=124))
+ self.services.issue.TestAddIssue(fake.MakeTestIssue(
+ 890, 1, 'other summary', 'New', 0, issue_id=125))
+
+ # EQ and NE, implict references to the current project.
+ self.assertTrue(filterrules_helpers._CompareIssueRefs(
+ self.cnxn, self.services, self.project,
+ ast_pb2.QueryOp.EQ, ['1'], [123]))
+ self.assertFalse(filterrules_helpers._CompareIssueRefs(
+ self.cnxn, self.services, self.project,
+ ast_pb2.QueryOp.NE, ['1'], [123]))
+
+ # EQ and NE, explicit project references.
+ self.assertTrue(filterrules_helpers._CompareIssueRefs(
+ self.cnxn, self.services, self.project,
+ ast_pb2.QueryOp.EQ, ['proj:1'], [123]))
+ self.assertTrue(filterrules_helpers._CompareIssueRefs(
+ self.cnxn, self.services, self.project,
+ ast_pb2.QueryOp.EQ, ['otherproj:1'], [125]))
+
+ # Inequalities
+ self.assertTrue(filterrules_helpers._CompareIssueRefs(
+ self.cnxn, self.services, self.project,
+ ast_pb2.QueryOp.GE, ['1'], [123]))
+ self.assertTrue(filterrules_helpers._CompareIssueRefs(
+ self.cnxn, self.services, self.project,
+ ast_pb2.QueryOp.GE, ['1'], [124]))
+ self.assertTrue(filterrules_helpers._CompareIssueRefs(
+ self.cnxn, self.services, self.project,
+ ast_pb2.QueryOp.GE, ['2'], [124]))
+ self.assertFalse(filterrules_helpers._CompareIssueRefs(
+ self.cnxn, self.services, self.project,
+ ast_pb2.QueryOp.GT, ['2'], [124]))
+
+ def testCompareUsers(self):
+ pass # TODO(jrobbins): Add this test.
+
+ def testCompareUserIDs(self):
+ pass # TODO(jrobbins): Add this test.
+
+ def testCompareEmails(self):
+ pass # TODO(jrobbins): Add this test.
+
+ def testCompare(self):
+ pass # TODO(jrobbins): Add this test.
+
+ def testParseOneRuleAddLabels(self):
+ cnxn = 'fake SQL connection'
+ error_list = []
+ rule_pb = filterrules_helpers._ParseOneRule(
+ cnxn, 'label:lab1 label:lab2', 'add_labels', 'hot cOld, ', None, 1,
+ error_list)
+ self.assertEqual('label:lab1 label:lab2', rule_pb.predicate)
+ self.assertEqual(error_list, [])
+ self.assertEqual(len(rule_pb.add_labels), 2)
+ self.assertEqual(rule_pb.add_labels[0], 'hot')
+ self.assertEqual(rule_pb.add_labels[1], 'cOld')
+
+ rule_pb = filterrules_helpers._ParseOneRule(
+ cnxn, '', 'default_status', 'hot cold', None, 1, error_list)
+ self.assertEqual(len(rule_pb.predicate), 0)
+ self.assertEqual(error_list, [])
+
+ def testParseOneRuleDefaultOwner(self):
+ cnxn = 'fake SQL connection'
+ error_list = []
+ rule_pb = filterrules_helpers._ParseOneRule(
+ cnxn, 'label:lab1, label:lab2 ', 'default_owner', 'jrobbins',
+ self.services.user, 1, error_list)
+ self.assertEqual(error_list, [])
+ self.assertEqual(rule_pb.default_owner_id, TEST_ID_MAP['jrobbins'])
+
+ def testParseOneRuleDefaultStatus(self):
+ cnxn = 'fake SQL connection'
+ error_list = []
+ rule_pb = filterrules_helpers._ParseOneRule(
+ cnxn, 'label:lab1', 'default_status', 'InReview',
+ None, 1, error_list)
+ self.assertEqual(error_list, [])
+ self.assertEqual(rule_pb.default_status, 'InReview')
+
+ def testParseOneRuleAddCcs(self):
+ cnxn = 'fake SQL connection'
+ error_list = []
+ rule_pb = filterrules_helpers._ParseOneRule(
+ cnxn, 'label:lab1', 'add_ccs', 'jrobbins, mike.j.parent',
+ self.services.user, 1, error_list)
+ self.assertEqual(error_list, [])
+ self.assertEqual(rule_pb.add_cc_ids[0], TEST_ID_MAP['jrobbins'])
+ self.assertEqual(rule_pb.add_cc_ids[1], TEST_ID_MAP['mike.j.parent'])
+ self.assertEqual(len(rule_pb.add_cc_ids), 2)
+
+ def testParseRulesNone(self):
+ cnxn = 'fake SQL connection'
+ post_data = {}
+ rules = filterrules_helpers.ParseRules(
+ cnxn, post_data, None, template_helpers.EZTError())
+ self.assertEqual(rules, [])
+
+ def testParseRules(self):
+ cnxn = 'fake SQL connection'
+ post_data = {
+ 'predicate1': 'a, b c',
+ 'action_type1': 'default_status',
+ 'action_value1': 'Reviewed',
+ 'predicate2': 'a, b c',
+ 'action_type2': 'default_owner',
+ 'action_value2': 'jrobbins',
+ 'predicate3': 'a, b c',
+ 'action_type3': 'add_ccs',
+ 'action_value3': 'jrobbins, mike.j.parent',
+ 'predicate4': 'a, b c',
+ 'action_type4': 'add_labels',
+ 'action_value4': 'hot, cold',
+ }
+ errors = template_helpers.EZTError()
+ rules = filterrules_helpers.ParseRules(
+ cnxn, post_data, self.services.user, errors)
+ self.assertEqual(rules[0].predicate, 'a, b c')
+ self.assertEqual(rules[0].default_status, 'Reviewed')
+ self.assertEqual(rules[1].default_owner_id, TEST_ID_MAP['jrobbins'])
+ self.assertEqual(rules[2].add_cc_ids[0], TEST_ID_MAP['jrobbins'])
+ self.assertEqual(rules[2].add_cc_ids[1], TEST_ID_MAP['mike.j.parent'])
+ self.assertEqual(rules[3].add_labels[0], 'hot')
+ self.assertEqual(rules[3].add_labels[1], 'cold')
+ self.assertEqual(len(rules), 4)
+ self.assertFalse(errors.AnyErrors())
+
+ def testOwnerCcsInvolvedInFilterRules(self):
+ rules = [
+ tracker_pb2.FilterRule(add_cc_ids=[111, 333], default_owner_id=999),
+ tracker_pb2.FilterRule(default_owner_id=888),
+ tracker_pb2.FilterRule(add_cc_ids=[999, 777]),
+ tracker_pb2.FilterRule(),
+ ]
+ actual_user_ids = filterrules_helpers.OwnerCcsInvolvedInFilterRules(rules)
+ self.assertItemsEqual([111, 333, 777, 888, 999], actual_user_ids)
+
+ def testBuildFilterRuleStrings(self):
+ rules = [
+ tracker_pb2.FilterRule(
+ predicate='label:machu', add_cc_ids=[111, 333, 999]),
+ tracker_pb2.FilterRule(predicate='label:pichu', default_owner_id=222),
+ tracker_pb2.FilterRule(
+ predicate='owner:farmer@test.com',
+ add_labels=['cows-farting', 'chicken', 'machu-pichu']),
+ tracker_pb2.FilterRule(predicate='label:beach', default_status='New'),
+ tracker_pb2.FilterRule(
+ predicate='label:rainforest',
+ add_notify_addrs=['cake@test.com', 'pie@test.com']),
+ ]
+ emails_by_id = {
+ 111: 'cow@test.com', 222: 'fox@test.com', 333: 'llama@test.com'}
+ rule_strs = filterrules_helpers.BuildFilterRuleStrings(rules, emails_by_id)
+
+ self.assertItemsEqual(
+ rule_strs, [
+ 'if label:machu '
+ 'then add cc(s): cow@test.com, llama@test.com, user not found',
+ 'if label:pichu then set default owner: fox@test.com',
+ 'if owner:farmer@test.com '
+ 'then add label(s): cows-farting, chicken, machu-pichu',
+ 'if label:beach then set default status: New',
+ 'if label:rainforest then notify: cake@test.com, pie@test.com',
+ ])
+
+ def testBuildRedactedFilterRuleStrings(self):
+ rules_by_project = {
+ 16: [
+ tracker_pb2.FilterRule(
+ predicate='label:machu', add_cc_ids=[111, 333, 999]),
+ tracker_pb2.FilterRule(
+ predicate='label:pichu', default_owner_id=222)],
+ 19: [
+ tracker_pb2.FilterRule(
+ predicate='owner:farmer@test.com',
+ add_labels=['cows-farting', 'chicken', 'machu-pichu']),
+ tracker_pb2.FilterRule(
+ predicate='label:rainforest',
+ add_notify_addrs=['cake@test.com', 'pie@test.com'])],
+ }
+ deleted_emails = ['farmer@test.com', 'pie@test.com', 'fox@test.com']
+ self.services.user.TestAddUser('cow@test.com', 111)
+ self.services.user.TestAddUser('fox@test.com', 222)
+ self.services.user.TestAddUser('llama@test.com', 333)
+ actual = filterrules_helpers.BuildRedactedFilterRuleStrings(
+ self.cnxn, rules_by_project, self.services.user, deleted_emails)
+
+ self.assertItemsEqual(
+ actual,
+ {16: [
+ 'if label:machu '
+ 'then add cc(s): cow@test.com, llama@test.com, user not found',
+ 'if label:pichu '
+ 'then set default owner: %s' %
+ framework_constants.DELETED_USER_NAME],
+ 19: [
+ 'if owner:%s '
+ 'then add label(s): cows-farting, chicken, machu-pichu' %
+ framework_constants.DELETED_USER_NAME,
+ 'if label:rainforest '
+ 'then notify: cake@test.com, %s' %
+ framework_constants.DELETED_USER_NAME],
+ })
diff --git a/features/test/filterrules_views_test.py b/features/test/filterrules_views_test.py
new file mode 100644
index 0000000..323b6c2
--- /dev/null
+++ b/features/test/filterrules_views_test.py
@@ -0,0 +1,75 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unittest for issue tracker views."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from features import filterrules_views
+from proto import tracker_pb2
+from testing import testing_helpers
+
+
+class RuleViewTest(unittest.TestCase):
+
+ def setUp(self):
+ self.rule = tracker_pb2.FilterRule()
+ self.rule.predicate = 'label:a label:b'
+
+ def testNone(self):
+ view = filterrules_views.RuleView(None, {})
+ self.assertEqual('', view.action_type)
+ self.assertEqual('', view.action_value)
+
+ def testEmpty(self):
+ view = filterrules_views.RuleView(self.rule, {})
+ self.rule.predicate = ''
+ self.assertEqual('', view.predicate)
+ self.assertEqual('', view.action_type)
+ self.assertEqual('', view.action_value)
+
+ def testDefaultStatus(self):
+ self.rule.default_status = 'Unknown'
+ view = filterrules_views.RuleView(self.rule, {})
+ self.assertEqual('label:a label:b', view.predicate)
+ self.assertEqual('default_status', view.action_type)
+ self.assertEqual('Unknown', view.action_value)
+
+ def testDefaultOwner(self):
+ self.rule.default_owner_id = 111
+ view = filterrules_views.RuleView(
+ self.rule, {
+ 111: testing_helpers.Blank(email='jrobbins@chromium.org')})
+ self.assertEqual('label:a label:b', view.predicate)
+ self.assertEqual('default_owner', view.action_type)
+ self.assertEqual('jrobbins@chromium.org', view.action_value)
+
+ def testAddCCs(self):
+ self.rule.add_cc_ids.extend([111, 222])
+ view = filterrules_views.RuleView(
+ self.rule, {
+ 111: testing_helpers.Blank(email='jrobbins@chromium.org'),
+ 222: testing_helpers.Blank(email='jrobbins@gmail.com')})
+ self.assertEqual('label:a label:b', view.predicate)
+ self.assertEqual('add_ccs', view.action_type)
+ self.assertEqual(
+ 'jrobbins@chromium.org, jrobbins@gmail.com', view.action_value)
+
+ def testAddLabels(self):
+ self.rule.add_labels.extend(['Hot', 'Cool'])
+ view = filterrules_views.RuleView(self.rule, {})
+ self.assertEqual('label:a label:b', view.predicate)
+ self.assertEqual('add_labels', view.action_type)
+ self.assertEqual('Hot, Cool', view.action_value)
+
+ def testAlsoNotify(self):
+ self.rule.add_notify_addrs.extend(['a@dom.com', 'b@dom.com'])
+ view = filterrules_views.RuleView(self.rule, {})
+ self.assertEqual('label:a label:b', view.predicate)
+ self.assertEqual('also_notify', view.action_type)
+ self.assertEqual('a@dom.com, b@dom.com', view.action_value)
diff --git a/features/test/generate_features_test.py b/features/test/generate_features_test.py
new file mode 100644
index 0000000..8b1664e
--- /dev/null
+++ b/features/test/generate_features_test.py
@@ -0,0 +1,25 @@
+# Copyright 2018 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unit test for generate_features."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from features import generate_dataset
+
+
+class GenerateFeaturesTest(unittest.TestCase):
+
+ def testCleanText(self):
+ sampleText = """Here's some sample text...$*IT should l00k much\n\n\t,
+ _much_MUCH better \"cleaned\"!"""
+ self.assertEqual(generate_dataset.CleanText(sampleText),
+ ("heres some sample text it should l00k much much much "
+ "better cleaned"))
+ emptyText = ""
+ self.assertEqual(generate_dataset.CleanText(emptyText), "")
diff --git a/features/test/hotlist_helpers_test.py b/features/test/hotlist_helpers_test.py
new file mode 100644
index 0000000..800a913
--- /dev/null
+++ b/features/test/hotlist_helpers_test.py
@@ -0,0 +1,285 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unit tests for helpers module."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from features import hotlist_helpers
+from features import features_constants
+from framework import profiler
+from framework import table_view_helpers
+from framework import sorting
+from services import service_manager
+from testing import testing_helpers
+from testing import fake
+from tracker import tablecell
+from tracker import tracker_bizobj
+from proto import features_pb2
+from proto import tracker_pb2
+
+
+class HotlistTableDataTest(unittest.TestCase):
+
+ def setUp(self):
+ self.services = service_manager.Services(
+ issue=fake.IssueService(),
+ features=fake.FeaturesService(),
+ issue_star=fake.AbstractStarService(),
+ config=fake.ConfigService(),
+ project=fake.ProjectService(),
+ user=fake.UserService(),
+ cache_manager=fake.CacheManager())
+ self.services.project.TestAddProject('ProjectName', project_id=1)
+
+ self.services.user.TestAddUser('annajowang@email.com', 111)
+ self.services.user.TestAddUser('claremont@email.com', 222)
+ issue1 = fake.MakeTestIssue(
+ 1, 1, 'issue_summary', 'New', 111, project_name='ProjectName')
+ self.services.issue.TestAddIssue(issue1)
+ issue2 = fake.MakeTestIssue(
+ 1, 2, 'issue_summary2', 'New', 111, project_name='ProjectName')
+ self.services.issue.TestAddIssue(issue2)
+ issue3 = fake.MakeTestIssue(
+ 1, 3, 'issue_summary3', 'New', 222, project_name='ProjectName')
+ self.services.issue.TestAddIssue(issue3)
+ issues = [issue1, issue2, issue3]
+ hotlist_items = [
+ (issue.issue_id, rank, 222, None, '') for
+ rank, issue in enumerate(issues)]
+
+ self.hotlist_items_list = [
+ features_pb2.MakeHotlistItem(
+ issue_id, rank=rank, adder_id=adder_id,
+ date_added=date, note=note) for (
+ issue_id, rank, adder_id, date, note) in hotlist_items]
+ self.test_hotlist = self.services.features.TestAddHotlist(
+ 'hotlist', hotlist_id=123, owner_ids=[111],
+ hotlist_item_fields=hotlist_items)
+ sorting.InitializeArtValues(self.services)
+ self.mr = None
+
+ def setUpCreateHotlistTableDataTestMR(self, **kwargs):
+ self.mr = testing_helpers.MakeMonorailRequest(**kwargs)
+ self.services.user.TestAddUser('annajo@email.com', 148)
+ self.mr.auth.effective_ids = {148}
+ self.mr.col_spec = 'ID Summary Modified'
+
+ def testCreateHotlistTableData(self):
+ self.setUpCreateHotlistTableDataTestMR(hotlist=self.test_hotlist)
+ table_data, table_related_dict = hotlist_helpers.CreateHotlistTableData(
+ self.mr, self.hotlist_items_list, self.services)
+ self.assertEqual(len(table_data), 3)
+ start_index = 100001
+ for row in table_data:
+ self.assertEqual(row.project_name, 'ProjectName')
+ self.assertEqual(row.issue_id, start_index)
+ start_index += 1
+ self.assertEqual(len(table_related_dict['column_values']), 3)
+
+ # test none of the shown columns show up in unshown_columns
+ self.assertTrue(
+ set(self.mr.col_spec.split()).isdisjoint(
+ table_related_dict['unshown_columns']))
+ self.assertEqual(table_related_dict['is_cross_project'], False)
+ self.assertEqual(len(table_related_dict['pagination'].visible_results), 3)
+
+ def testCreateHotlistTableData_Pagination(self):
+ self.setUpCreateHotlistTableDataTestMR(
+ hotlist=self.test_hotlist, path='/123?num=2')
+ table_data, _ = hotlist_helpers.CreateHotlistTableData(
+ self.mr, self.hotlist_items_list, self.services)
+ self.assertEqual(len(table_data), 2)
+
+ def testCreateHotlistTableData_EndPagination(self):
+ self.setUpCreateHotlistTableDataTestMR(
+ hotlist=self.test_hotlist, path='/123?num=2&start=2')
+ table_data, _ = hotlist_helpers.CreateHotlistTableData(
+ self.mr, self.hotlist_items_list, self.services)
+ self.assertEqual(len(table_data), 1)
+
+
+class MakeTableDataTest(unittest.TestCase):
+
+ def test_MakeTableData(self):
+ issues = [fake.MakeTestIssue(
+ 789, 1, 'issue_summary', 'New', 111, project_name='ProjectName',
+ issue_id=1001)]
+ config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+ cell_factories = {
+ 'summary': table_view_helpers.TableCellSummary}
+ table_data = hotlist_helpers._MakeTableData(
+ issues, [], ['summary'], [], {} , cell_factories,
+ {}, set(), config, None, 29, 'stars')
+ self.assertEqual(len(table_data), 1)
+ row = table_data[0]
+ self.assertEqual(row.issue_id, 1001)
+ self.assertEqual(row.local_id, 1)
+ self.assertEqual(row.project_name, 'ProjectName')
+ self.assertEqual(row.issue_ref, 'ProjectName:1')
+ self.assertTrue('hotlist_id=29' in row.issue_ctx_url)
+ self.assertTrue('sort=stars' in row.issue_ctx_url)
+
+
+class GetAllProjectsOfIssuesTest(unittest.TestCase):
+
+ issue_x_1 = tracker_pb2.Issue()
+ issue_x_1.project_id = 789
+
+ issue_x_2 = tracker_pb2.Issue()
+ issue_x_2.project_id = 789
+
+ issue_y_1 = tracker_pb2.Issue()
+ issue_y_1.project_id = 678
+
+ def testGetAllProjectsOfIssues_Normal(self):
+ issues = [self.issue_x_1, self.issue_x_2]
+ self.assertEqual(
+ hotlist_helpers.GetAllProjectsOfIssues(issues),
+ set([789]))
+ issues = [self.issue_x_1, self.issue_x_2, self.issue_y_1]
+ self.assertEqual(
+ hotlist_helpers.GetAllProjectsOfIssues(issues),
+ set([678, 789]))
+
+ def testGetAllProjectsOfIssues_Empty(self):
+ self.assertEqual(
+ hotlist_helpers.GetAllProjectsOfIssues([]),
+ set())
+
+
+class HelpersUnitTest(unittest.TestCase):
+
+ # TODO(jojwang): Write Tests for GetAllConfigsOfProjects
+ def setUp(self):
+ self.services = service_manager.Services(issue=fake.IssueService(),
+ config=fake.ConfigService(),
+ project=fake.ProjectService(),
+ features=fake.FeaturesService(),
+ user=fake.UserService())
+ self.project = self.services.project.TestAddProject(
+ 'ProjectName', project_id=1, owner_ids=[111])
+
+ self.services.user.TestAddUser('annajowang@email.com', 111)
+ self.services.user.TestAddUser('claremont@email.com', 222)
+ self.issue1 = fake.MakeTestIssue(
+ 1, 1, 'issue_summary', 'New', 111,
+ project_name='ProjectName', labels='restrict-view-Googler')
+ self.services.issue.TestAddIssue(self.issue1)
+ self.issue3 = fake.MakeTestIssue(
+ 1, 3, 'issue_summary3', 'New', 222, project_name='ProjectName')
+ self.services.issue.TestAddIssue(self.issue3)
+ self.issue4 = fake.MakeTestIssue(
+ 1, 4, 'issue_summary4', 'Fixed', 222, closed_timestamp=232423,
+ project_name='ProjectName')
+ self.services.issue.TestAddIssue(self.issue4)
+ self.issues = [self.issue1, self.issue3, self.issue4]
+ self.mr = testing_helpers.MakeMonorailRequest()
+
+ def testFilterIssues(self):
+ test_allowed_issues = hotlist_helpers.FilterIssues(
+ self.mr.cnxn, self.mr.auth, 2, self.issues, self.services)
+ self.assertEqual(len(test_allowed_issues), 1)
+ self.assertEqual(test_allowed_issues[0].local_id, 3)
+
+ def testFilterIssues_ShowClosed(self):
+ test_allowed_issues = hotlist_helpers.FilterIssues(
+ self.mr.cnxn, self.mr.auth, 1, self.issues, self.services)
+ self.assertEqual(len(test_allowed_issues), 2)
+ self.assertEqual(test_allowed_issues[0].local_id, 3)
+ self.assertEqual(test_allowed_issues[1].local_id, 4)
+
+ def testMembersWithoutGivenIDs(self):
+ h = features_pb2.Hotlist()
+ owners, editors, followers = hotlist_helpers.MembersWithoutGivenIDs(
+ h, set())
+ # Check lists are empty
+ self.assertFalse(owners)
+ self.assertFalse(editors)
+ self.assertFalse(followers)
+
+ h.owner_ids.extend([1, 2, 3])
+ h.editor_ids.extend([4, 5, 6])
+ h.follower_ids.extend([7, 8, 9])
+ owners, editors, followers = hotlist_helpers.MembersWithoutGivenIDs(
+ h, {10, 11, 12})
+ self.assertEqual(h.owner_ids, owners)
+ self.assertEqual(h.editor_ids, editors)
+ self.assertEqual(h.follower_ids, followers)
+
+ owners, editors, followers = hotlist_helpers.MembersWithoutGivenIDs(
+ h, set())
+ self.assertEqual(h.owner_ids, owners)
+ self.assertEqual(h.editor_ids, editors)
+ self.assertEqual(h.follower_ids, followers)
+
+ owners, editors, followers = hotlist_helpers.MembersWithoutGivenIDs(
+ h, {1, 4, 7})
+ self.assertEqual([2, 3], owners)
+ self.assertEqual([5, 6], editors)
+ self.assertEqual([8, 9], followers)
+
+ def testMembersWithGivenIDs(self):
+ h = features_pb2.Hotlist()
+
+ # empty GivenIDs give empty member lists from originally empty member lists
+ owners, editors, followers = hotlist_helpers.MembersWithGivenIDs(
+ h, set(), 'follower')
+ self.assertFalse(owners)
+ self.assertFalse(editors)
+ self.assertFalse(followers)
+
+ # empty GivenIDs return original non-empty member lists
+ h.owner_ids.extend([1, 2, 3])
+ h.editor_ids.extend([4, 5, 6])
+ h.follower_ids.extend([7, 8, 9])
+ owners, editors, followers = hotlist_helpers.MembersWithGivenIDs(
+ h, set(), 'editor')
+ self.assertEqual(owners, h.owner_ids)
+ self.assertEqual(editors, h.editor_ids)
+ self.assertEqual(followers, h.follower_ids)
+
+ # non-member GivenIDs return updated member lists
+ owners, editors, followers = hotlist_helpers.MembersWithGivenIDs(
+ h, {10, 11, 12}, 'owner')
+ self.assertEqual(owners, [1, 2, 3, 10, 11, 12])
+ self.assertEqual(editors, [4, 5, 6])
+ self.assertEqual(followers, [7, 8, 9])
+
+ # member GivenIDs return updated member lists
+ owners, editors, followers = hotlist_helpers.MembersWithGivenIDs(
+ h, {1, 4, 7}, 'editor')
+ self.assertEqual(owners, [2, 3])
+ self.assertEqual(editors, [5, 6, 1, 4, 7])
+ self.assertEqual(followers, [8, 9])
+
+ def testGetURLOfHotlist(self):
+ cnxn = 'fake cnxn'
+ user = self.services.user.TestAddUser('claremont@email.com', 432)
+ user.obscure_email = False
+ hotlist1 = self.services.features.TestAddHotlist(
+ 'hotlist1', hotlist_id=123, owner_ids=[432])
+ url = hotlist_helpers.GetURLOfHotlist(
+ cnxn, hotlist1, self.services.user)
+ self.assertEqual('/u/claremont@email.com/hotlists/hotlist1', url)
+
+ url = hotlist_helpers.GetURLOfHotlist(
+ cnxn, hotlist1, self.services.user, url_for_token=True)
+ self.assertEqual('/u/432/hotlists/hotlist1', url)
+
+ user.obscure_email = True
+ url = hotlist_helpers.GetURLOfHotlist(
+ cnxn, hotlist1, self.services.user)
+ self.assertEqual('/u/432/hotlists/hotlist1', url)
+
+ # Test that a Hotlist without an owner has an empty URL.
+ hotlist_unowned = self.services.features.TestAddHotlist('hotlist2',
+ hotlist_id=234, owner_ids=[])
+ url = hotlist_helpers.GetURLOfHotlist(cnxn, hotlist_unowned,
+ self.services.user)
+ self.assertFalse(url)
diff --git a/features/test/hotlist_views_test.py b/features/test/hotlist_views_test.py
new file mode 100644
index 0000000..92369ba
--- /dev/null
+++ b/features/test/hotlist_views_test.py
@@ -0,0 +1,125 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unit tests for hotlist_views classes."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from features import hotlist_views
+from framework import authdata
+from framework import framework_views
+from framework import permissions
+from services import service_manager
+from testing import fake
+from proto import user_pb2
+
+
+class MemberViewTest(unittest.TestCase):
+
+ def setUp(self):
+ self.hotlist = fake.Hotlist('hotlistName', 123,
+ hotlist_item_fields=[
+ (2, 0, None, None, ''),
+ (1, 0, None, None, ''),
+ (5, 0, None, None, '')],
+ is_private=False, owner_ids=[111])
+ self.user1 = user_pb2.User(user_id=111)
+ self.user1_view = framework_views.UserView(self.user1)
+
+ def testMemberViewCorrect(self):
+ member_view = hotlist_views.MemberView(111, 111, self.user1_view,
+ self.hotlist)
+ self.assertEqual(member_view.user, self.user1_view)
+ self.assertEqual(member_view.detail_url, '/u/111/')
+ self.assertEqual(member_view.role, 'Owner')
+ self.assertTrue(member_view.viewing_self)
+
+
+class HotlistViewTest(unittest.TestCase):
+
+ def setUp(self):
+ self.services = service_manager.Services(user=fake.UserService(),
+ usergroup=fake.UserGroupService())
+ self.user1 = self.services.user.TestAddUser('user1', 111)
+ self.user1.obscure_email = True
+ self.user1_view = framework_views.UserView(self.user1)
+ self.user2 = self.services.user.TestAddUser('user2', 222)
+ self.user2.obscure_email = False
+ self.user2_view = framework_views.UserView(self.user2)
+ self.user3 = self.services.user.TestAddUser('user3', 333)
+ self.user3_view = framework_views.UserView(self.user3)
+ self.user4 = self.services.user.TestAddUser('user4', 444, banned=True)
+ self.user4_view = framework_views.UserView(self.user4)
+
+ self.user_auth = authdata.AuthData.FromEmail(
+ None, 'user3', self.services)
+ self.user_auth.effective_ids = {3}
+ self.user_auth.user_id = 3
+ self.users_by_id = {1: self.user1_view, 2: self.user2_view,
+ 3: self.user3_view, 4: self.user4_view}
+ self.perms = permissions.EMPTY_PERMISSIONSET
+
+ def testNoOwner(self):
+ hotlist = fake.Hotlist('unowned', 500, owner_ids=[])
+ view = hotlist_views.HotlistView(hotlist, self.perms,
+ self.user_auth, 1, self.users_by_id)
+ self.assertFalse(view.url)
+
+ def testBanned(self):
+ # With a banned user
+ hotlist = fake.Hotlist('userBanned', 423, owner_ids=[4])
+ hotlist_view = hotlist_views.HotlistView(
+ hotlist, self.perms, self.user_auth, 1, self.users_by_id)
+ self.assertFalse(hotlist_view.visible)
+
+ # With a user not banned
+ hotlist = fake.Hotlist('userNotBanned', 453, owner_ids=[1])
+ hotlist_view = hotlist_views.HotlistView(
+ hotlist, self.perms, self.user_auth, 1, self.users_by_id)
+ self.assertTrue(hotlist_view.visible)
+
+ def testNoPermissions(self):
+ hotlist = fake.Hotlist(
+ 'private', 333, is_private=True, owner_ids=[1], editor_ids=[2])
+ hotlist_view = hotlist_views.HotlistView(
+ hotlist, self.perms, self.user_auth, 1, self.users_by_id)
+ self.assertFalse(hotlist_view.visible)
+ self.assertEqual(hotlist_view.url, '/u/1/hotlists/private')
+
+ def testFriendlyURL(self):
+ # owner with obscure_email:false
+ hotlist = fake.Hotlist(
+ 'noObscureHotlist', 133, owner_ids=[2], editor_ids=[3])
+ hotlist_view = hotlist_views.HotlistView(
+ hotlist, self.perms, self.user_auth,
+ viewed_user_id=3, users_by_id=self.users_by_id)
+ self.assertEqual(hotlist_view.url, '/u/user2/hotlists/noObscureHotlist')
+
+ #owner with obscure_email:true
+ hotlist = fake.Hotlist('ObscureHotlist', 133, owner_ids=[1], editor_ids=[3])
+ hotlist_view = hotlist_views.HotlistView(
+ hotlist, self.perms, self.user_auth, viewed_user_id=1,
+ users_by_id=self.users_by_id)
+ self.assertEqual(hotlist_view.url, '/u/1/hotlists/ObscureHotlist')
+
+ def testOtherAttributes(self):
+ hotlist = fake.Hotlist(
+ 'hotlistName', 123, hotlist_item_fields=[(2, 0, None, None, ''),
+ (1, 0, None, None, ''),
+ (5, 0, None, None, '')],
+ is_private=False, owner_ids=[1],
+ editor_ids=[2, 3])
+ hotlist_view = hotlist_views.HotlistView(
+ hotlist, self.perms, self.user_auth, viewed_user_id=2,
+ users_by_id=self.users_by_id, is_starred=True)
+ self.assertTrue(hotlist_view.visible, True)
+ self.assertEqual(hotlist_view.role_name, 'editor')
+ self.assertEqual(hotlist_view.owners, [self.user1_view])
+ self.assertEqual(hotlist_view.editors, [self.user2_view, self.user3_view])
+ self.assertEqual(hotlist_view.num_issues, 3)
+ self.assertTrue(hotlist_view.is_starred)
diff --git a/features/test/hotlistcreate_test.py b/features/test/hotlistcreate_test.py
new file mode 100644
index 0000000..8cf0012
--- /dev/null
+++ b/features/test/hotlistcreate_test.py
@@ -0,0 +1,148 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unit test for Hotlist creation servlet."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import mox
+import unittest
+
+import settings
+from framework import permissions
+from features import hotlistcreate
+from proto import site_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+
+
+class HotlistCreateTest(unittest.TestCase):
+ """Tests for the HotlistCreate servlet."""
+
+ def setUp(self):
+ self.cnxn = 'fake cnxn'
+ self.mr = testing_helpers.MakeMonorailRequest()
+ self.services = service_manager.Services(project=fake.ProjectService(),
+ user=fake.UserService(),
+ issue=fake.IssueService(),
+ features=fake.FeaturesService())
+ self.servlet = hotlistcreate.HotlistCreate('req', 'res',
+ services=self.services)
+ self.mox = mox.Mox()
+
+ def tearDown(self):
+ self.mox.UnsetStubs()
+ self.mox.ResetAll()
+
+ def CheckAssertBasePermissions(
+ self, restriction, expect_admin_ok, expect_nonadmin_ok):
+ old_hotlist_creation_restriction = settings.hotlist_creation_restriction
+ settings.hotlist_creation_restriction = restriction
+
+ mr = testing_helpers.MakeMonorailRequest(
+ perms=permissions.GetPermissions(None, {}, None))
+ self.assertRaises(
+ permissions.PermissionException,
+ self.servlet.AssertBasePermission, mr)
+
+ mr = testing_helpers.MakeMonorailRequest()
+ if expect_admin_ok:
+ self.servlet.AssertBasePermission(mr)
+ else:
+ self.assertRaises(
+ permissions.PermissionException,
+ self.servlet.AssertBasePermission, mr)
+
+ mr = testing_helpers.MakeMonorailRequest(
+ perms=permissions.GetPermissions(mr.auth.user_pb, {111}, None))
+ if expect_nonadmin_ok:
+ self.servlet.AssertBasePermission(mr)
+ else:
+ self.assertRaises(
+ permissions.PermissionException,
+ self.servlet.AssertBasePermission, mr)
+
+ settings.hotlist_creation_restriction = old_hotlist_creation_restriction
+
+ def testAssertBasePermission(self):
+ self.CheckAssertBasePermissions(
+ site_pb2.UserTypeRestriction.ANYONE, True, True)
+ self.CheckAssertBasePermissions(
+ site_pb2.UserTypeRestriction.ADMIN_ONLY, True, False)
+ self.CheckAssertBasePermissions(
+ site_pb2.UserTypeRestriction.NO_ONE, False, False)
+
+ def testGatherPageData(self):
+ page_data = self.servlet.GatherPageData(self.mr)
+ self.assertEqual('st6', page_data['user_tab_mode'])
+ self.assertEqual('', page_data['initial_name'])
+ self.assertEqual('', page_data['initial_summary'])
+ self.assertEqual('', page_data['initial_description'])
+ self.assertEqual('', page_data['initial_editors'])
+ self.assertEqual('no', page_data['initial_privacy'])
+
+ def testProcessFormData(self):
+ self.servlet.services.user.TestAddUser('owner', 111)
+ self.mr.auth.user_id = 111
+ post_data = fake.PostData(hotlistname=['Hotlist'], summary=['summ'],
+ description=['hey'],
+ editors=[''], is_private=['yes'])
+ url = self.servlet.ProcessFormData(self.mr, post_data)
+ self.assertTrue('/u/111/hotlists/Hotlist' in url)
+
+ def testProcessFormData_OwnerInEditors(self):
+ self.servlet.services.user.TestAddUser('owner_editor', 222)
+ self.mr.auth.user_id = 222
+ self.mr.cnxn = 'fake cnxn'
+ post_data = fake.PostData(hotlistname=['Hotlist-owner-editor'],
+ summary=['summ'],
+ description=['hi'],
+ editors=['owner_editor'], is_private=['yes'])
+ url = self.servlet.ProcessFormData(self.mr, post_data)
+ self.assertTrue('/u/222/hotlists/Hotlist-owner-editor' in url)
+ hotlists_by_id = self.servlet.services.features.LookupHotlistIDs(
+ self.mr.cnxn, ['Hotlist-owner-editor'], [222])
+ self.assertTrue(('hotlist-owner-editor', 222) in hotlists_by_id)
+ hotlist_id = hotlists_by_id[('hotlist-owner-editor', 222)]
+ hotlist = self.servlet.services.features.GetHotlist(
+ self.mr.cnxn, hotlist_id, use_cache=False)
+ self.assertEqual(hotlist.owner_ids, [222])
+ self.assertEqual(hotlist.editor_ids, [])
+
+ def testProcessFormData_RejectTemplateInvalid(self):
+ mr = testing_helpers.MakeMonorailRequest()
+ # invalid hotlist name and nonexistent editor
+ post_data = fake.PostData(hotlistname=['123BadName'], summary=['summ'],
+ description=['hey'],
+ editors=['test@email.com'], is_private=['yes'])
+ self.mox.StubOutWithMock(self.servlet, 'PleaseCorrect')
+ self.servlet.PleaseCorrect(
+ mr, initial_name = '123BadName', initial_summary='summ',
+ initial_description='hey',
+ initial_editors='test@email.com', initial_privacy='yes')
+ self.mox.ReplayAll()
+ url = self.servlet.ProcessFormData(mr, post_data)
+ self.mox.VerifyAll()
+ self.assertEqual(mr.errors.hotlistname, 'Invalid hotlist name')
+ self.assertEqual(mr.errors.editors,
+ 'One or more editor emails is not valid.')
+ self.assertIsNone(url)
+
+ def testProcessFormData_RejectTemplateMissing(self):
+ mr = testing_helpers.MakeMonorailRequest()
+ # missing name and summary
+ post_data = fake.PostData()
+ self.mox.StubOutWithMock(self.servlet, 'PleaseCorrect')
+ self.servlet.PleaseCorrect(mr, initial_name = None, initial_summary=None,
+ initial_description='',
+ initial_editors='', initial_privacy=None)
+ self.mox.ReplayAll()
+ url = self.servlet.ProcessFormData(mr, post_data)
+ self.mox.VerifyAll()
+ self.assertEqual(mr.errors.hotlistname, 'Missing hotlist name')
+ self.assertEqual(mr.errors.summary,'Missing hotlist summary')
+ self.assertIsNone(url)
diff --git a/features/test/hotlistdetails_test.py b/features/test/hotlistdetails_test.py
new file mode 100644
index 0000000..9a9e53f
--- /dev/null
+++ b/features/test/hotlistdetails_test.py
@@ -0,0 +1,226 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unit tests for hotlistdetails page."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import mox
+import unittest
+import mock
+
+import ezt
+
+from framework import permissions
+from features import features_constants
+from services import service_manager
+from features import hotlistdetails
+from proto import features_pb2
+from testing import fake
+from testing import testing_helpers
+
+class HotlistDetailsTest(unittest.TestCase):
+ """Unit tests for the HotlistDetails servlet class."""
+
+ def setUp(self):
+ self.user_service = fake.UserService()
+ self.user_1 = self.user_service.TestAddUser('111@test.com', 111)
+ self.user_2 = self.user_service.TestAddUser('user2@test.com', 222)
+ services = service_manager.Services(
+ features=fake.FeaturesService(), user=self.user_service)
+ self.servlet = hotlistdetails.HotlistDetails(
+ 'req', 'res', services=services)
+ self.hotlist = self.servlet.services.features.TestAddHotlist(
+ 'hotlist', summary='hotlist summary', description='hotlist description',
+ owner_ids=[111], editor_ids=[222])
+ self.request, self.mr = testing_helpers.GetRequestObjects(
+ hotlist=self.hotlist)
+ self.mr.auth.user_id = 111
+ self.private_hotlist = services.features.TestAddHotlist(
+ 'private_hotlist', owner_ids=[111], editor_ids=[222], is_private=True)
+ self.mox = mox.Mox()
+
+ def tearDown(self):
+ self.mox.UnsetStubs()
+ self.mox.ResetAll()
+
+ def testAssertBasePermission(self):
+ # non-members cannot view private hotlists
+ mr = testing_helpers.MakeMonorailRequest(
+ hotlist=self.private_hotlist, perms=permissions.EMPTY_PERMISSIONSET)
+ mr.auth.effective_ids = {333}
+ self.assertRaises(permissions.PermissionException,
+ self.servlet.AssertBasePermission, mr)
+
+ # members can view private hotlists
+ mr = testing_helpers.MakeMonorailRequest(
+ hotlist=self.private_hotlist)
+ mr.auth.effective_ids = {222, 444}
+ self.servlet.AssertBasePermission(mr)
+
+ # non-members can view public hotlists
+ mr = testing_helpers.MakeMonorailRequest(
+ hotlist=self.hotlist)
+ mr.auth.effective_ids = {333, 444}
+ self.servlet.AssertBasePermission(mr)
+
+ # members can view public hotlists
+ mr = testing_helpers.MakeMonorailRequest(
+ hotlist=self.hotlist)
+ mr.auth.effective_ids = {111, 333}
+ self.servlet.AssertBasePermission(mr)
+
+ def testGatherPageData(self):
+ self.mr.auth.effective_ids = [222]
+ self.mr.perms = permissions.EMPTY_PERMISSIONSET
+ page_data = self.servlet.GatherPageData(self.mr)
+ self.assertEqual('hotlist summary', page_data['initial_summary'])
+ self.assertEqual('hotlist description', page_data['initial_description'])
+ self.assertEqual('hotlist', page_data['initial_name'])
+ self.assertEqual(features_constants.DEFAULT_COL_SPEC,
+ page_data['initial_default_col_spec'])
+ self.assertEqual(ezt.boolean(False), page_data['initial_is_private'])
+
+ # editor is viewing, so cant_administer_hotlist is True
+ self.assertEqual(ezt.boolean(True), page_data['cant_administer_hotlist'])
+
+ # owner is veiwing, so cant_administer_hotlist is False
+ self.mr.auth.effective_ids = [111]
+ page_data = self.servlet.GatherPageData(self.mr)
+ self.assertEqual(ezt.boolean(False), page_data['cant_administer_hotlist'])
+
+ def testProcessFormData(self):
+ mr = testing_helpers.MakeMonorailRequest(
+ hotlist=self.hotlist,
+ path='/u/111/hotlists/%s/details' % self.hotlist.hotlist_id,
+ services=service_manager.Services(user=self.user_service),
+ perms=permissions.EMPTY_PERMISSIONSET)
+ mr.auth.effective_ids = {111}
+ mr.auth.user_id = 111
+ post_data = fake.PostData(
+ name=['hotlist'],
+ summary = ['hotlist summary'],
+ description = ['hotlist description'],
+ default_col_spec = ['test default col spec'])
+ url = self.servlet.ProcessFormData(mr, post_data)
+ self.assertTrue((
+ '/u/111/hotlists/%d/details?saved=' % self.hotlist.hotlist_id) in url)
+
+ @mock.patch('features.hotlist_helpers.RemoveHotlist')
+ def testProcessFormData_DeleteHotlist(self, fake_rh):
+ mr = testing_helpers.MakeMonorailRequest(
+ hotlist=self.hotlist,
+ path='/u/111/hotlists/%s/details' % self.hotlist.hotlist_id,
+ services=service_manager.Services(user=self.user_service),
+ perms=permissions.EMPTY_PERMISSIONSET)
+ mr.auth.effective_ids = {self.user_1.user_id}
+ mr.auth.user_id = self.user_1.user_id
+ mr.auth.email = self.user_1.email
+
+ post_data = fake.PostData(deletestate=['true'])
+ url = self.servlet.ProcessFormData(mr, post_data)
+ fake_rh.assert_called_once_with(
+ mr.cnxn, mr.hotlist_id, self.servlet.services)
+ self.assertTrue(('/u/%s/hotlists?saved=' % self.user_1.email) in url)
+
+ def testProcessFormData_RejectTemplate(self):
+ mr = testing_helpers.MakeMonorailRequest(
+ hotlist=self.hotlist,
+ path='/u/111/hotlists/%s/details' % self.hotlist.hotlist_id,
+ services=service_manager.Services(user=self.user_service),
+ perms=permissions.EMPTY_PERMISSIONSET)
+ mr.auth.user_id = 111
+ mr.auth.effective_ids = {111}
+ post_data = fake.PostData(
+ summary = [''],
+ name = [''],
+ description = ['fake description'],
+ default_col_spec = ['test default col spec'])
+ self.mox.StubOutWithMock(self.servlet, 'PleaseCorrect')
+ self.servlet.PleaseCorrect(
+ mr, initial_summary='',
+ initial_description='fake description', initial_name = '',
+ initial_default_col_spec = 'test default col spec')
+ self.mox.ReplayAll()
+
+ url = self.servlet.ProcessFormData(mr, post_data)
+ self.mox.VerifyAll()
+ self.assertEqual(hotlistdetails._MSG_NAME_MISSING, mr.errors.name)
+ self.assertEqual(hotlistdetails._MSG_SUMMARY_MISSING,
+ mr.errors.summary)
+ self.assertIsNone(url)
+
+ def testProcessFormData_DuplicateName(self):
+ self.servlet.services.features.TestAddHotlist(
+ 'FirstHotlist', summary='hotlist summary', description='description',
+ owner_ids=[111], editor_ids=[])
+ mr = testing_helpers.MakeMonorailRequest(
+ hotlist=self.hotlist,
+ path='/u/111/hotlists/%s/details' % (self.hotlist.hotlist_id),
+ services=service_manager.Services(user=self.user_service),
+ perms=permissions.EMPTY_PERMISSIONSET)
+ mr.auth.user_id = 111
+ mr.auth.effective_ids = {111}
+ post_data = fake.PostData(
+ summary = ['hotlist summary'],
+ name = ['FirstHotlist'],
+ description = ['description'],
+ default_col_spec = ['test default col spec'])
+ self.mox.StubOutWithMock(self.servlet, 'PleaseCorrect')
+ self.servlet.PleaseCorrect(
+ mr, initial_summary='hotlist summary',
+ initial_description='description', initial_name = 'FirstHotlist',
+ initial_default_col_spec = 'test default col spec')
+ self.mox.ReplayAll()
+
+ url = self.servlet.ProcessFormData(mr, post_data)
+ self.mox.VerifyAll()
+ self.assertEqual(hotlistdetails._MSG_HOTLIST_NAME_NOT_AVAIL,
+ mr.errors.name)
+ self.assertIsNone(url)
+
+ def testProcessFormData_Bad(self):
+ mr = testing_helpers.MakeMonorailRequest(
+ hotlist=self.hotlist,
+ path='/u/111/hotlists/%s/details' % (self.hotlist.hotlist_id),
+ services=service_manager.Services(user=self.user_service),
+ perms=permissions.EMPTY_PERMISSIONSET)
+ mr.auth.user_id = 111
+ mr.auth.effective_ids = {111}
+ post_data = fake.PostData(
+ summary = ['hotlist summary'],
+ name = ['2badName'],
+ description = ['fake description'],
+ default_col_spec = ['test default col spec'])
+ self.mox.StubOutWithMock(self.servlet, 'PleaseCorrect')
+ self.servlet.PleaseCorrect(
+ mr, initial_summary='hotlist summary',
+ initial_description='fake description', initial_name = '2badName',
+ initial_default_col_spec = 'test default col spec')
+ self.mox.ReplayAll()
+
+ url = self.servlet.ProcessFormData(mr, post_data)
+ self.mox.VerifyAll()
+ self.assertEqual(hotlistdetails._MSG_INVALID_HOTLIST_NAME,
+ mr.errors.name)
+ self.assertIsNone(url)
+
+ def testProcessFormData_NoPermissions(self):
+ mr = testing_helpers.MakeMonorailRequest(
+ hotlist=self.hotlist,
+ path='/u/111/hotlists/%s/details' % (self.hotlist.hotlist_id),
+ services=service_manager.Services(user=self.user_service),
+ perms=permissions.EMPTY_PERMISSIONSET)
+ mr.auth.user_id = self.user_2.user_id
+ mr.auth.effective_ids = {self.user_2.user_id}
+ post_data = fake.PostData(
+ summary = ['hotlist summary'],
+ name = ['hotlist'],
+ description = ['fake description'],
+ default_col_spec = ['test default col spec'])
+ with self.assertRaises(permissions.PermissionException):
+ self.servlet.ProcessFormData(mr, post_data)
diff --git a/features/test/hotlistissues_test.py b/features/test/hotlistissues_test.py
new file mode 100644
index 0000000..49c3270
--- /dev/null
+++ b/features/test/hotlistissues_test.py
@@ -0,0 +1,211 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unit tests for issuelist module."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import mox
+import mock
+import unittest
+import time
+
+from google.appengine.ext import testbed
+import ezt
+
+from features import hotlistissues
+from features import hotlist_helpers
+from framework import framework_views
+from framework import permissions
+from framework import sorting
+from framework import template_helpers
+from framework import xsrf
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+
+
+class HotlistIssuesUnitTest(unittest.TestCase):
+
+ def setUp(self):
+ self.testbed = testbed.Testbed()
+ self.testbed.activate()
+ self.testbed.init_memcache_stub()
+ self.testbed.init_datastore_v3_stub()
+ self.services = service_manager.Services(
+ issue_star=fake.IssueStarService(),
+ config=fake.ConfigService(),
+ user=fake.UserService(),
+ issue=fake.IssueService(),
+ project=fake.ProjectService(),
+ features=fake.FeaturesService(),
+ cache_manager=fake.CacheManager(),
+ hotlist_star=fake.HotlistStarService())
+ self.servlet = hotlistissues.HotlistIssues(
+ 'req', 'res', services=self.services)
+ self.user1 = self.services.user.TestAddUser('testuser@gmail.com', 111)
+ self.user2 = self.services.user.TestAddUser('testuser2@gmail.com', 222, )
+ self.services.project.TestAddProject('project-name', project_id=1)
+ self.issue1 = fake.MakeTestIssue(
+ 1, 1, 'issue_summary', 'New', 111, project_name='project-name')
+ self.services.issue.TestAddIssue(self.issue1)
+ self.issue2 = fake.MakeTestIssue(
+ 1, 2, 'issue_summary2', 'New', 111, project_name='project-name')
+ self.services.issue.TestAddIssue(self.issue2)
+ self.issue3 = fake.MakeTestIssue(
+ 1, 3, 'issue_summary3', 'New', 222, project_name='project-name')
+ self.services.issue.TestAddIssue(self.issue3)
+ self.issues = [self.issue1, self.issue2, self.issue3]
+ self.hotlist_item_fields = [
+ (issue.issue_id, rank, 111, 1205079300, '') for
+ rank, issue in enumerate(self.issues)]
+ self.test_hotlist = self.services.features.TestAddHotlist(
+ 'hotlist', hotlist_id=123, owner_ids=[222], editor_ids=[111],
+ hotlist_item_fields=self.hotlist_item_fields)
+ self.hotlistissues = self.test_hotlist.items
+ # Unless perms is specified,
+ # MakeMonorailRequest will return an mr with admin permissions.
+ self.mr = testing_helpers.MakeMonorailRequest(
+ hotlist=self.test_hotlist, path='/u/222/hotlists/123',
+ services=self.services, perms=permissions.EMPTY_PERMISSIONSET)
+ self.mr.hotlist_id = self.test_hotlist.hotlist_id
+ self.mr.auth.user_id = 111
+ self.mr.auth.effective_ids = {111}
+ self.mr.viewed_user_auth.user_id = 111
+ sorting.InitializeArtValues(self.services)
+
+ self.mox = mox.Mox()
+
+ def tearDown(self):
+ self.mox.UnsetStubs()
+ self.testbed.deactivate()
+
+ def testAssertBasePermissions(self):
+ private_hotlist = self.services.features.TestAddHotlist(
+ 'privateHotlist', hotlist_id=321, owner_ids=[222],
+ hotlist_item_fields=self.hotlist_item_fields, is_private=True)
+ # non-members cannot view private hotlists
+ mr = testing_helpers.MakeMonorailRequest(
+ hotlist=private_hotlist, perms=permissions.EMPTY_PERMISSIONSET)
+ mr.auth.effective_ids = {333}
+ mr.hotlist_id = private_hotlist.hotlist_id
+ self.assertRaises(permissions.PermissionException,
+ self.servlet.AssertBasePermission, mr)
+
+ # members can view private hotlists
+ mr = testing_helpers.MakeMonorailRequest(
+ hotlist=private_hotlist, perms=permissions.EMPTY_PERMISSIONSET)
+ mr.auth.effective_ids = {222, 444}
+ mr.hotlist_id = private_hotlist.hotlist_id
+ self.servlet.AssertBasePermission(mr)
+
+ # non-members can view public hotlists
+ mr = testing_helpers.MakeMonorailRequest(
+ hotlist=self.test_hotlist, perms=permissions.EMPTY_PERMISSIONSET)
+ mr.auth.effective_ids = {333, 444}
+ mr.hotlist_id = self.test_hotlist.hotlist_id
+ self.servlet.AssertBasePermission(mr)
+
+ # members can view public hotlists
+ mr = testing_helpers.MakeMonorailRequest(
+ hotlist=self.test_hotlist, perms=permissions.EMPTY_PERMISSIONSET)
+ mr.auth.effective_ids = {111, 333}
+ mr.hotlist_id = self.test_hotlist.hotlist_id
+ self.servlet.AssertBasePermission(mr)
+
+ def testGatherPageData(self):
+ self.mr.mode = 'list'
+ self.mr.auth.effective_ids = {111}
+ self.mr.auth.user_id = 111
+ self.mr.sort_spec = 'rank stars'
+ page_data = self.servlet.GatherPageData(self.mr)
+ self.assertEqual(ezt.boolean(False), page_data['owner_permissions'])
+ self.assertEqual(ezt.boolean(True), page_data['editor_permissions'])
+ self.assertEqual(ezt.boolean(False), page_data['grid_mode'])
+ self.assertEqual(ezt.boolean(True), page_data['allow_rerank'])
+
+ self.mr.sort_spec = 'stars ranks'
+ page_data = self.servlet.GatherPageData(self.mr)
+ self.assertEqual(ezt.boolean(False), page_data['allow_rerank'])
+
+ def testGetTableViewData(self):
+ now = time.time()
+ self.mox.StubOutWithMock(time, 'time')
+ time.time().MultipleTimes().AndReturn(now)
+ self.mox.ReplayAll()
+
+ self.mr.auth.user_id = 222
+ self.mr.col_spec = 'Stars Projects Rank'
+ table_view_data = self.servlet.GetTableViewData(self.mr)
+ self.assertEqual(table_view_data['edit_hotlist_token'], xsrf.GenerateToken(
+ self.mr.auth.user_id, '/u/222/hotlists/hotlist.do'))
+ self.assertEqual(table_view_data['add_issues_selected'], ezt.boolean(False))
+
+ self.user2.obscure_email = False
+ table_view_data = self.servlet.GetTableViewData(self.mr)
+ self.assertEqual(table_view_data['edit_hotlist_token'], xsrf.GenerateToken(
+ self.mr.auth.user_id, '/u/222/hotlists/hotlist.do'))
+ self.mox.VerifyAll()
+
+ def testGetGridViewData(self):
+ # TODO(jojwang): Write this test
+ pass
+
+ def testProcessFormData_NoNewIssues(self):
+ post_data = fake.PostData(remove=['false'], add_local_ids=[''])
+ url = self.servlet.ProcessFormData(self.mr, post_data)
+ self.assertTrue(url.endswith('u/222/hotlists/hotlist'))
+ self.assertEqual(self.test_hotlist.items, self.hotlistissues)
+
+ def testProcessFormData_AddBadIssueRef(self):
+ self.servlet.PleaseCorrect = mock.Mock()
+ post_data = fake.PostData(
+ remove=['false'], add_local_ids=['no-such-project:999'])
+ url = self.servlet.ProcessFormData(self.mr, post_data)
+ self.assertIsNone(url)
+ self.servlet.PleaseCorrect.assert_called_once()
+
+ def testProcessFormData_RemoveBadIssueRef(self):
+ post_data = fake.PostData(
+ remove=['true'], add_local_ids=['no-such-project:999'])
+ url = self.servlet.ProcessFormData(self.mr, post_data)
+ self.assertIn('u/222/hotlists/hotlist', url)
+ self.assertEqual(self.test_hotlist.items, self.hotlistissues)
+
+ def testProcessFormData_NormalEditIssues(self):
+ issue4 = fake.MakeTestIssue(
+ 1, 4, 'issue_summary4', 'New', 222, project_name='project-name')
+ self.services.issue.TestAddIssue(issue4)
+ issue5 = fake.MakeTestIssue(
+ 1, 5, 'issue_summary5', 'New', 222, project_name='project-name')
+ self.services.issue.TestAddIssue(issue5)
+
+ post_data = fake.PostData(remove=['false'],
+ add_local_ids=['project-name:4, project-name:5'])
+ url = self.servlet.ProcessFormData(self.mr, post_data)
+ self.assertTrue('u/222/hotlists/hotlist' in url)
+ self.assertEqual(len(self.test_hotlist.items), 5)
+ self.assertEqual(
+ self.test_hotlist.items[3].issue_id, issue4.issue_id)
+ self.assertEqual(
+ self.test_hotlist.items[4].issue_id, issue5.issue_id)
+
+ post_data = fake.PostData(remove=['true'], remove_local_ids=[
+ 'project-name:4, project-name:1, project-name:2'])
+ url = self.servlet.ProcessFormData(self.mr, post_data)
+ self.assertTrue('u/222/hotlists/hotlist' in url)
+ self.assertTrue(len(self.test_hotlist.items), 2)
+ issue_ids = [issue.issue_id for issue in self.test_hotlist.items]
+ self.assertTrue(issue5.issue_id in issue_ids)
+ self.assertTrue(self.issue3.issue_id in issue_ids)
+
+ def testProcessFormData_NoPermissions(self):
+ post_data = fake.PostData(remove=['false'],
+ add_local_ids=['project-name:4, project-name:5'])
+ self.mr.auth.effective_ids = {333}
+ self.mr.auth.user_id = 333
+ with self.assertRaises(permissions.PermissionException):
+ self.servlet.ProcessFormData(self.mr, post_data)
diff --git a/features/test/hotlistissuescsv_test.py b/features/test/hotlistissuescsv_test.py
new file mode 100644
index 0000000..afa53d5
--- /dev/null
+++ b/features/test/hotlistissuescsv_test.py
@@ -0,0 +1,97 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unit tests for issuelistcsv module."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from google.appengine.ext import testbed
+
+import webapp2
+
+from framework import permissions
+from framework import sorting
+from framework import xsrf
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+from features import hotlistissuescsv
+
+
+class HotlistIssuesCsvTest(unittest.TestCase):
+
+ def setUp(self):
+ self.testbed = testbed.Testbed()
+ self.testbed.activate()
+ self.testbed.init_memcache_stub()
+ self.testbed.init_datastore_v3_stub()
+ self.services = service_manager.Services(
+ issue_star=fake.IssueStarService(),
+ config=fake.ConfigService(),
+ user=fake.UserService(),
+ issue=fake.IssueService(),
+ project=fake.ProjectService(),
+ cache_manager=fake.CacheManager(),
+ features=fake.FeaturesService())
+ self.servlet = hotlistissuescsv.HotlistIssuesCsv(
+ 'req', webapp2.Response(), services=self.services)
+ self.user1 = self.services.user.TestAddUser('testuser@gmail.com', 111)
+ self.user2 = self.services.user.TestAddUser('testuser2@gmail.com', 222)
+ self.services.project.TestAddProject('project-name', project_id=1)
+ self.issue1 = fake.MakeTestIssue(
+ 1, 1, 'issue_summary', 'New', 111, project_name='project-name')
+ self.services.issue.TestAddIssue(self.issue1)
+ self.issues = [self.issue1]
+ self.hotlist_item_fields = [
+ (issue.issue_id, rank, 111, 1205079300, '') for
+ rank, issue in enumerate(self.issues)]
+ self.hotlist = self.services.features.TestAddHotlist(
+ 'MyHotlist', hotlist_id=123, owner_ids=[222], editor_ids=[111],
+ hotlist_item_fields=self.hotlist_item_fields)
+ self._MakeMR('/u/222/hotlists/MyHotlist')
+ sorting.InitializeArtValues(self.services)
+
+ def _MakeMR(self, path):
+ self.mr = testing_helpers.MakeMonorailRequest(
+ hotlist=self.hotlist, path=path, services=self.services)
+ self.mr.hotlist_id = self.hotlist.hotlist_id
+ self.mr.hotlist = self.hotlist
+
+ def testGatherPageData_AnonUsers(self):
+ """Anonymous users cannot download the issue list."""
+ self.mr.auth.user_id = 0
+ self.assertRaises(permissions.PermissionException,
+ self.servlet.GatherPageData, self.mr)
+
+ def testGatherPageData_NoXSRF(self):
+ """Users need a valid XSRF token to download the issue list."""
+ # Note no token query-string parameter is set.
+ self.mr.auth.user_id = self.user2.user_id
+ self.assertRaises(xsrf.TokenIncorrect,
+ self.servlet.GatherPageData, self.mr)
+
+ def testGatherPageData_BadXSRF(self):
+ """Users need a valid XSRF token to download the issue list."""
+ for path in ('/u/222/hotlists/MyHotlist',
+ '/u/testuser2@gmail.com/hotlists/MyHotlist'):
+ token = 'bad'
+ self._MakeMR(path + '?token=%s' % token)
+ self.mr.auth.user_id = self.user2.user_id
+ self.assertRaises(xsrf.TokenIncorrect,
+ self.servlet.GatherPageData, self.mr)
+
+ def testGatherPageData_Normal(self):
+ """Users can get the hotlist issue list."""
+ for path in ('/u/222/hotlists/MyHotlist',
+ '/u/testuser2@gmail.com/hotlists/MyHotlist'):
+ form_token_path = self.servlet._FormHandlerURL(path)
+ token = xsrf.GenerateToken(self.user1.user_id, form_token_path)
+ self._MakeMR(path + '?token=%s' % token)
+ self.mr.auth.email = self.user1.email
+ self.mr.auth.user_id = self.user1.user_id
+ self.servlet.GatherPageData(self.mr)
diff --git a/features/test/hotlistpeople_test.py b/features/test/hotlistpeople_test.py
new file mode 100644
index 0000000..74beec3
--- /dev/null
+++ b/features/test/hotlistpeople_test.py
@@ -0,0 +1,253 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unittest for Hotlist People servlet."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import mox
+import unittest
+import logging
+
+import ezt
+
+from testing import fake
+from features import hotlistpeople
+from framework import permissions
+from services import service_manager
+from testing import testing_helpers
+
+class HotlistPeopleListTest(unittest.TestCase):
+
+ def setUp(self):
+ self.services = service_manager.Services(
+ features=fake.FeaturesService(),
+ project=fake.ProjectService(),
+ user=fake.UserService(),
+ usergroup=fake.UserGroupService())
+ self.owner_user = self.services.user.TestAddUser('buzbuz@gmail.com', 111)
+ self.editor_user = self.services.user.TestAddUser('monica@gmail.com', 222)
+ self.non_member_user = self.services.user.TestAddUser(
+ 'who-dis@gmail.com', 333)
+ self.private_hotlist = self.services.features.TestAddHotlist(
+ 'PrivateHotlist', 'owner only', [111], [222], is_private=True)
+ self.public_hotlist = self.services.features.TestAddHotlist(
+ 'PublicHotlist', 'everyone', [111], [222], is_private=False)
+ self.servlet = hotlistpeople.HotlistPeopleList(
+ 'req', 'res', services=self.services)
+ self.mox = mox.Mox()
+
+ def tearDown(self):
+ self.mox.UnsetStubs()
+ self.mox.ResetAll()
+
+ def testAssertBasePermission(self):
+ # owner can view people in private hotlist
+ mr = testing_helpers.MakeMonorailRequest(
+ hotlist=self.private_hotlist, perms=permissions.EMPTY_PERMISSIONSET)
+ mr.auth.effective_ids = {111, 444}
+ self.servlet.AssertBasePermission(mr)
+
+ # editor can view people in private hotlist
+ mr.auth.effective_ids = {222, 333}
+ self.servlet.AssertBasePermission(mr)
+
+ # non-members cannot view people in private hotlist
+ mr.auth.effective_ids = {444, 333}
+ self.assertRaises(permissions.PermissionException,
+ self.servlet.AssertBasePermission, mr)
+
+ # owner can view people in public hotlist
+ mr = testing_helpers.MakeMonorailRequest(hotlist=self.public_hotlist)
+ mr.auth.effective_ids = {111, 444}
+ self.servlet.AssertBasePermission(mr)
+
+ # editor can view people in public hotlist
+ mr.auth.effective_ids = {222, 333}
+ self.servlet.AssertBasePermission(mr)
+
+ # non-members cannot view people in public hotlist
+ mr.auth.effective_ids = {444, 333}
+ self.servlet.AssertBasePermission(mr)
+
+ def testGatherPageData(self):
+ mr = testing_helpers.MakeMonorailRequest(
+ hotlist=self.public_hotlist, perms=permissions.EMPTY_PERMISSIONSET)
+ mr.auth.user_id = 111
+ mr.auth.effective_ids = {111}
+ mr.cnxn = 'fake cnxn'
+ page_data = self.servlet.GatherPageData(mr)
+ self.assertEqual(ezt.boolean(True), page_data['offer_membership_editing'])
+ self.assertEqual(ezt.boolean(False), page_data['offer_remove_self'])
+ self.assertEqual(page_data['total_num_owners'], 1)
+ self.assertEqual(page_data['newly_added_views'], [])
+ self.assertEqual(len(page_data['pagination'].visible_results), 2)
+
+ # non-owners cannot edit people list
+ mr.auth.user_id = 222
+ mr.auth.effective_ids = {222}
+ page_data = self.servlet.GatherPageData(mr)
+ self.assertEqual(ezt.boolean(False), page_data['offer_membership_editing'])
+ self.assertEqual(ezt.boolean(True), page_data['offer_remove_self'])
+
+ mr.auth.user_id = 333
+ mr.auth.effective_ids = {333}
+ page_data = self.servlet.GatherPageData(mr)
+ self.assertEqual(ezt.boolean(False), page_data['offer_membership_editing'])
+ self.assertEqual(ezt.boolean(False), page_data['offer_remove_self'])
+
+ def testProcessFormData_Permission(self):
+ """Only owner can change member of hotlist."""
+ mr = testing_helpers.MakeMonorailRequest(
+ path='/u/buzbuz@gmail.com/hotlists/PrivateHotlist/people',
+ hotlist=self.private_hotlist, perms=permissions.EMPTY_PERMISSIONSET)
+ mr.auth.effective_ids = {111, 444}
+ self.servlet.ProcessFormData(mr, {})
+
+ mr.auth.effective_ids = {222, 444}
+ self.assertRaises(permissions.PermissionException,
+ self.servlet.ProcessFormData, mr, {})
+
+ def testProcessRemoveMembers(self):
+ hotlist = self.servlet.services.features.TestAddHotlist(
+ 'HotlistName', 'removing 222, monica', [111], [222])
+ mr = testing_helpers.MakeMonorailRequest(
+ path='/u/buzbuz@gmail.com/hotlists/HotlistName/people',
+ hotlist=hotlist)
+ mr.hotlist_id = hotlist.hotlist_id
+ post_data = fake.PostData(
+ remove = ['monica@gmail.com'])
+ url = self.servlet.ProcessRemoveMembers(
+ mr, post_data, '/u/111/hotlists/HotlistName')
+ self.assertTrue('/u/111/hotlists/HotlistName/people' in url)
+ self.assertEqual(hotlist.editor_ids, [])
+
+ def testProcessRemoveSelf(self):
+ hotlist = self.servlet.services.features.TestAddHotlist(
+ 'HotlistName', 'self removing 222, monica', [111], [222])
+ mr = testing_helpers.MakeMonorailRequest(
+ path='/u/buzbuz@gmail.com/hotlists/HotlistName/people',
+ hotlist=hotlist)
+ mr.hotlist_id = hotlist.hotlist_id
+ mr.cnxn = 'fake cnxn'
+ # The owner cannot be removed using ProcessRemoveSelf(); this is enforced
+ # by permission in ProcessFormData, not in the function itself;
+ # nor may a random user...
+ mr.auth.user_id = 333
+ mr.auth.effective_ids = {333}
+ url = self.servlet.ProcessRemoveSelf(mr, '/u/111/hotlists/HotlistName')
+ self.assertTrue('/u/111/hotlists/HotlistName/people' in url)
+ self.assertEqual(hotlist.owner_ids, [111])
+ self.assertEqual(hotlist.editor_ids, [222])
+ # ...but an editor can.
+ mr.auth.user_id = 222
+ mr.auth.effective_ids = {222}
+ url = self.servlet.ProcessRemoveSelf(mr, '/u/111/hotlists/HotlistName')
+ self.assertTrue('/u/111/hotlists/HotlistName/people' in url)
+ self.assertEqual(hotlist.owner_ids, [111])
+ self.assertEqual(hotlist.editor_ids, [])
+
+ def testProcessAddMembers(self):
+ hotlist = self.servlet.services.features.TestAddHotlist(
+ 'HotlistName', 'adding 333, who-dis', [111], [222])
+ mr = testing_helpers.MakeMonorailRequest(
+ path='/u/buzbuz@gmail.com/hotlists/HotlistName/people',
+ hotlist=hotlist)
+ mr.hotlist_id = hotlist.hotlist_id
+ post_data = fake.PostData(
+ addmembers = ['who-dis@gmail.com'],
+ role = ['editor'])
+ url = self.servlet.ProcessAddMembers(
+ mr, post_data, '/u/111/hotlists/HotlistName')
+ self.assertTrue('/u/111/hotlists/HotlistName/people' in url)
+ self.assertEqual(hotlist.editor_ids, [222, 333])
+
+ def testProcessAddMembers_OwnerToEditor(self):
+ hotlist = self.servlet.services.features.TestAddHotlist(
+ 'HotlistName', 'adding owner 111, buzbuz as editor', [111], [222])
+ mr = testing_helpers.MakeMonorailRequest(
+ path='/u/buzbuz@gmail.com/hotlists/HotlistName/people',
+ hotlist=hotlist)
+ mr.hotlist_id = hotlist.hotlist_id
+ addmembers_input = 'buzbuz@gmail.com'
+ post_data = fake.PostData(
+ addmembers = [addmembers_input],
+ role = ['editor'])
+ self.mox.StubOutWithMock(self.servlet, 'PleaseCorrect')
+ self.servlet.PleaseCorrect(
+ mr, initial_add_members=addmembers_input, initially_expand_form=True)
+ self.mox.ReplayAll()
+ url = self.servlet.ProcessAddMembers(
+ mr, post_data, '/u/111/hotlists/HotlistName')
+ self.mox.VerifyAll()
+ self.assertEqual(
+ 'Cannot have a hotlist without an owner; please leave at least one.',
+ mr.errors.addmembers)
+ self.assertIsNone(url)
+ # Verify that no changes have actually occurred.
+ self.assertEqual(hotlist.owner_ids, [111])
+ self.assertEqual(hotlist.editor_ids, [222])
+
+ def testProcessChangeOwnership(self):
+ hotlist = self.servlet.services.features.TestAddHotlist(
+ 'HotlistName', 'new owner 333, who-dis', [111], [222])
+ mr = testing_helpers.MakeMonorailRequest(
+ path='/u/buzbuz@gmail.com/hotlists/HotlistName/people',
+ hotlist=hotlist)
+ mr.hotlist_id = hotlist.hotlist_id
+ post_data = fake.PostData(
+ changeowners = ['who-dis@gmail.com'],
+ becomeeditor = ['on'])
+ url = self.servlet.ProcessChangeOwnership(mr, post_data)
+ self.assertTrue('/u/333/hotlists/HotlistName/people' in url)
+ self.assertEqual(hotlist.owner_ids, [333])
+ self.assertEqual(hotlist.editor_ids, [222, 111])
+
+ def testProcessChangeOwnership_UnownedHotlist(self):
+ hotlist = self.services.features.TestAddHotlist(
+ 'unowned', 'new owner 333, who-dis', [], [222])
+ mr = testing_helpers.MakeMonorailRequest(
+ path='/whatever',
+ hotlist=hotlist)
+ mr.hotlist_id = hotlist.hotlist_id
+ post_data = fake.PostData(
+ changeowners = ['who-dis@gmail.com'],
+ becomeeditor = ['on'])
+ self.servlet.ProcessChangeOwnership(mr, post_data)
+ self.assertEqual([333], mr.hotlist.owner_ids)
+
+ def testProcessChangeOwnership_BadEmail(self):
+ hotlist = self.servlet.services.features.TestAddHotlist(
+ 'HotlistName', 'new owner 333, who-dis', [111], [222])
+ mr = testing_helpers.MakeMonorailRequest(
+ path='/u/buzbuz@gmail.com/hotlists/HotlistName/people',
+ hotlist=hotlist)
+ mr.hotlist_id = hotlist.hotlist_id
+ changeowners_input = 'who-dis@gmail.com, extra-email@gmail.com'
+ post_data = fake.PostData(
+ changeowners = [changeowners_input],
+ becomeeditor = ['on'])
+ self.mox.StubOutWithMock(self.servlet, 'PleaseCorrect')
+ self.servlet.PleaseCorrect(
+ mr, initial_new_owner_username=changeowners_input, open_dialog='yes')
+ self.mox.ReplayAll()
+ url = self.servlet.ProcessChangeOwnership(mr, post_data)
+ self.mox.VerifyAll()
+ self.assertEqual(
+ 'Please add one valid user email.', mr.errors.transfer_ownership)
+ self.assertIsNone(url)
+
+ def testProcessChangeOwnership_DuplicateName(self):
+ # other_hotlist = self.servlet.services.features.TestAddHotlist(
+ # 'HotlistName', 'hotlist with same name', [333], [])
+ # hotlist = self.servlet.services.features.TestAddHotlist(
+ # 'HotlistName', 'new owner 333, who-dis', [111], [222])
+
+ # in the test_hotlists dict of features_service in testing/fake
+ # 'other_hotlist' is overwritten by 'hotlist'
+ # TODO(jojwang): edit the fake features_service to allow hotlists
+ # with the same name but different owners
+ pass
diff --git a/features/test/inboundemail_test.py b/features/test/inboundemail_test.py
new file mode 100644
index 0000000..6c13827
--- /dev/null
+++ b/features/test/inboundemail_test.py
@@ -0,0 +1,400 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unittests for monorail.feature.inboundemail."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+import webapp2
+from mock import patch
+
+import mox
+import time
+
+from google.appengine.ext.webapp.mail_handlers import BounceNotificationHandler
+
+import settings
+from businesslogic import work_env
+from features import alert2issue
+from features import commitlogcommands
+from features import inboundemail
+from framework import authdata
+from framework import emailfmt
+from framework import monorailcontext
+from framework import permissions
+from proto import project_pb2
+from proto import tracker_pb2
+from proto import user_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+from tracker import tracker_helpers
+
+
+class InboundEmailTest(unittest.TestCase):
+
+ def setUp(self):
+ self.cnxn = 'fake cnxn'
+ self.services = service_manager.Services(
+ config=fake.ConfigService(),
+ issue=fake.IssueService(),
+ user=fake.UserService(),
+ usergroup=fake.UserGroupService(),
+ project=fake.ProjectService())
+ self.project = self.services.project.TestAddProject(
+ 'proj', project_id=987, process_inbound_email=True,
+ contrib_ids=[111])
+ self.project_addr = 'proj@monorail.example.com'
+
+ self.issue = tracker_pb2.Issue()
+ self.issue.project_id = 987
+ self.issue.local_id = 100
+ self.services.issue.TestAddIssue(self.issue)
+
+ self.msg = testing_helpers.MakeMessage(
+ testing_helpers.HEADER_LINES, 'awesome!')
+
+ request, _ = testing_helpers.GetRequestObjects()
+ self.inbound = inboundemail.InboundEmail(request, None, self.services)
+ self.mox = mox.Mox()
+
+ def tearDown(self):
+ self.mox.UnsetStubs()
+ self.mox.ResetAll()
+
+ def testTemplates(self):
+ for name, template_path in self.inbound._templates.items():
+ assert(name in inboundemail.MSG_TEMPLATES)
+ assert(
+ template_path.GetTemplatePath().endswith(
+ inboundemail.MSG_TEMPLATES[name]))
+
+ def testProcessMail_MsgTooBig(self):
+ self.mox.StubOutWithMock(emailfmt, 'IsBodyTooBigToParse')
+ emailfmt.IsBodyTooBigToParse(mox.IgnoreArg()).AndReturn(True)
+ self.mox.ReplayAll()
+
+ email_tasks = self.inbound.ProcessMail(self.msg, self.project_addr)
+ self.mox.VerifyAll()
+ self.assertEqual(1, len(email_tasks))
+ email_task = email_tasks[0]
+ self.assertEqual('user@example.com', email_task['to'])
+ self.assertEqual('Email body too long', email_task['subject'])
+
+ def testProcessMail_NoProjectOnToLine(self):
+ self.mox.StubOutWithMock(emailfmt, 'IsProjectAddressOnToLine')
+ emailfmt.IsProjectAddressOnToLine(
+ self.project_addr, [self.project_addr]).AndReturn(False)
+ self.mox.ReplayAll()
+
+ ret = self.inbound.ProcessMail(self.msg, self.project_addr)
+ self.mox.VerifyAll()
+ self.assertIsNone(ret)
+
+ def testProcessMail_IssueUnidentified(self):
+ self.mox.StubOutWithMock(emailfmt, 'IdentifyProjectVerbAndLabel')
+ emailfmt.IdentifyProjectVerbAndLabel(self.project_addr).AndReturn(('proj',
+ None, None))
+
+ self.mox.StubOutWithMock(emailfmt, 'IdentifyIssue')
+ emailfmt.IdentifyIssue('proj', mox.IgnoreArg()).AndReturn((None))
+
+ self.mox.ReplayAll()
+
+ ret = self.inbound.ProcessMail(self.msg, self.project_addr)
+ self.mox.VerifyAll()
+ self.assertIsNone(ret)
+
+ def testProcessMail_ProjectNotLive(self):
+ self.services.user.TestAddUser('user@example.com', 111)
+ self.project.state = project_pb2.ProjectState.DELETABLE
+ email_tasks = self.inbound.ProcessMail(self.msg, self.project_addr)
+ email_task = email_tasks[0]
+ self.assertEqual('user@example.com', email_task['to'])
+ self.assertEqual('Project not found', email_task['subject'])
+
+ def testProcessMail_ProjectInboundEmailDisabled(self):
+ self.services.user.TestAddUser('user@example.com', 111)
+ self.project.process_inbound_email = False
+ email_tasks = self.inbound.ProcessMail(self.msg, self.project_addr)
+ email_task = email_tasks[0]
+ self.assertEqual('user@example.com', email_task['to'])
+ self.assertEqual(
+ 'Email replies are not enabled in project proj', email_task['subject'])
+
+ def testProcessMail_NoRefHeader(self):
+ self.services.user.TestAddUser('user@example.com', 111)
+ self.mox.StubOutWithMock(emailfmt, 'ValidateReferencesHeader')
+ emailfmt.ValidateReferencesHeader(
+ mox.IgnoreArg(), self.project, mox.IgnoreArg(),
+ mox.IgnoreArg()).AndReturn(False)
+ emailfmt.ValidateReferencesHeader(
+ mox.IgnoreArg(), self.project, mox.IgnoreArg(),
+ mox.IgnoreArg()).AndReturn(False)
+ self.mox.ReplayAll()
+
+ email_tasks = self.inbound.ProcessMail(self.msg, self.project_addr)
+ self.mox.VerifyAll()
+ self.assertEqual(1, len(email_tasks))
+ email_task = email_tasks[0]
+ self.assertEqual('user@example.com', email_task['to'])
+ self.assertEqual(
+ 'Your message is not a reply to a notification email',
+ email_task['subject'])
+
+ def testProcessMail_NoAccount(self):
+ # Note: not calling TestAddUser().
+ email_tasks = self.inbound.ProcessMail(self.msg, self.project_addr)
+ self.mox.VerifyAll()
+ self.assertEqual(1, len(email_tasks))
+ email_task = email_tasks[0]
+ self.assertEqual('user@example.com', email_task['to'])
+ self.assertEqual(
+ 'Could not determine account of sender', email_task['subject'])
+
+ def testProcessMail_BannedAccount(self):
+ user_pb = self.services.user.TestAddUser('user@example.com', 111)
+ user_pb.banned = 'banned'
+
+ self.mox.StubOutWithMock(emailfmt, 'ValidateReferencesHeader')
+ emailfmt.ValidateReferencesHeader(
+ mox.IgnoreArg(), self.project, mox.IgnoreArg(),
+ mox.IgnoreArg()).AndReturn(True)
+ self.mox.ReplayAll()
+
+ email_tasks = self.inbound.ProcessMail(self.msg, self.project_addr)
+ self.mox.VerifyAll()
+ self.assertEqual(1, len(email_tasks))
+ email_task = email_tasks[0]
+ self.assertEqual('user@example.com', email_task['to'])
+ self.assertEqual(
+ 'You are banned from using this issue tracker', email_task['subject'])
+
+ def testProcessMail_Success(self):
+ self.services.user.TestAddUser('user@example.com', 111)
+
+ self.mox.StubOutWithMock(emailfmt, 'ValidateReferencesHeader')
+ emailfmt.ValidateReferencesHeader(
+ mox.IgnoreArg(), self.project, mox.IgnoreArg(),
+ mox.IgnoreArg()).AndReturn(True)
+
+ self.mox.StubOutWithMock(self.inbound, 'ProcessIssueReply')
+ self.inbound.ProcessIssueReply(
+ mox.IgnoreArg(), self.project, 123, self.project_addr,
+ 'awesome!')
+
+ self.mox.ReplayAll()
+
+ ret = self.inbound.ProcessMail(self.msg, self.project_addr)
+ self.mox.VerifyAll()
+ self.assertIsNone(ret)
+
+ def testProcessMail_Success_with_AlertNotification(self):
+ """Test ProcessMail with an alert notification message.
+
+ This is a sanity check for alert2issue.ProcessEmailNotification to ensure
+ that it can be successfully invoked in ProcessMail. Each function of
+ alert2issue module should be tested in aler2issue_test.
+ """
+ project_name = self.project.project_name
+ verb = 'alert'
+ trooper_queue = 'my-trooper'
+ project_addr = '%s+%s+%s@example.com' % (project_name, verb, trooper_queue)
+
+ self.mox.StubOutWithMock(emailfmt, 'IsProjectAddressOnToLine')
+ emailfmt.IsProjectAddressOnToLine(
+ project_addr, mox.IgnoreArg()).AndReturn(True)
+
+ class MockAuthData(object):
+ def __init__(self):
+ self.user_pb = user_pb2.MakeUser(111)
+ self.effective_ids = set([1, 2, 3])
+ self.user_id = 111
+ self.email = 'user@example.com'
+
+ mock_auth_data = MockAuthData()
+ self.mox.StubOutWithMock(authdata.AuthData, 'FromEmail')
+ authdata.AuthData.FromEmail(
+ mox.IgnoreArg(), settings.alert_service_account, self.services,
+ autocreate=True).AndReturn(mock_auth_data)
+
+ self.mox.StubOutWithMock(alert2issue, 'ProcessEmailNotification')
+ alert2issue.ProcessEmailNotification(
+ self.services, mox.IgnoreArg(), self.project, project_addr,
+ mox.IgnoreArg(), mock_auth_data, mox.IgnoreArg(), 'awesome!', '',
+ self.msg, trooper_queue)
+
+ self.mox.ReplayAll()
+ ret = self.inbound.ProcessMail(self.msg, project_addr)
+ self.mox.VerifyAll()
+ self.assertIsNone(ret)
+
+ def testProcessIssueReply_NoIssue(self):
+ nonexistant_local_id = 200
+ mc = monorailcontext.MonorailContext(
+ self.services, cnxn=self.cnxn, requester='user@example.com')
+ mc.LookupLoggedInUserPerms(self.project)
+
+ email_tasks = self.inbound.ProcessIssueReply(
+ mc, self.project, nonexistant_local_id, self.project_addr,
+ 'awesome!')
+ self.assertEqual(1, len(email_tasks))
+ email_task = email_tasks[0]
+ self.assertEqual('user@example.com', email_task['to'])
+ self.assertEqual(
+ 'Could not find issue %d in project %s' %
+ (nonexistant_local_id, self.project.project_name),
+ email_task['subject'])
+
+ def testProcessIssueReply_DeletedIssue(self):
+ self.issue.deleted = True
+ mc = monorailcontext.MonorailContext(
+ self.services, cnxn=self.cnxn, requester='user@example.com')
+ mc.LookupLoggedInUserPerms(self.project)
+
+ email_tasks = self.inbound.ProcessIssueReply(
+ mc, self.project, self.issue.local_id, self.project_addr,
+ 'awesome!')
+ self.assertEqual(1, len(email_tasks))
+ email_task = email_tasks[0]
+ self.assertEqual('user@example.com', email_task['to'])
+ self.assertEqual(
+ 'Could not find issue %d in project %s' %
+ (self.issue.local_id, self.project.project_name), email_task['subject'])
+
+ def VerifyUserHasNoPerm(self, perms):
+ mc = monorailcontext.MonorailContext(
+ self.services, cnxn=self.cnxn, requester='user@example.com')
+ mc.perms = perms
+
+ email_tasks = self.inbound.ProcessIssueReply(
+ mc, self.project, self.issue.local_id, self.project_addr,
+ 'awesome!')
+ self.assertEqual(1, len(email_tasks))
+ email_task = email_tasks[0]
+ self.assertEqual('user@example.com', email_task['to'])
+ self.assertEqual(
+ 'User does not have permission to add a comment', email_task['subject'])
+
+ def testProcessIssueReply_NoViewPerm(self):
+ self.VerifyUserHasNoPerm(permissions.EMPTY_PERMISSIONSET)
+
+ def testProcessIssueReply_CantViewRestrictedIssue(self):
+ self.issue.labels.append('Restrict-View-CoreTeam')
+ self.VerifyUserHasNoPerm(permissions.USER_PERMISSIONSET)
+
+ def testProcessIssueReply_NoAddIssuePerm(self):
+ self.VerifyUserHasNoPerm(permissions.READ_ONLY_PERMISSIONSET)
+
+ def testProcessIssueReply_NoEditIssuePerm(self):
+ self.services.user.TestAddUser('user@example.com', 111)
+ mc = monorailcontext.MonorailContext(
+ self.services, cnxn=self.cnxn, requester='user@example.com')
+ mc.perms = permissions.USER_PERMISSIONSET
+ mock_uia = commitlogcommands.UpdateIssueAction(self.issue.local_id)
+
+ self.mox.StubOutWithMock(commitlogcommands, 'UpdateIssueAction')
+ commitlogcommands.UpdateIssueAction(self.issue.local_id).AndReturn(mock_uia)
+
+ self.mox.StubOutWithMock(mock_uia, 'Parse')
+ mock_uia.Parse(
+ self.cnxn, self.project.project_name, 111, ['awesome!'], self.services,
+ strip_quoted_lines=True)
+ self.mox.StubOutWithMock(mock_uia, 'Run')
+ # mc.perms does not contain permission EDIT_ISSUE.
+ mock_uia.Run(mc, self.services)
+
+ self.mox.ReplayAll()
+ ret = self.inbound.ProcessIssueReply(
+ mc, self.project, self.issue.local_id, self.project_addr,
+ 'awesome!')
+ self.mox.VerifyAll()
+ self.assertIsNone(ret)
+
+ def testProcessIssueReply_Success(self):
+ self.services.user.TestAddUser('user@example.com', 111)
+ mc = monorailcontext.MonorailContext(
+ self.services, cnxn=self.cnxn, requester='user@example.com')
+ mc.perms = permissions.COMMITTER_ACTIVE_PERMISSIONSET
+ mock_uia = commitlogcommands.UpdateIssueAction(self.issue.local_id)
+
+ self.mox.StubOutWithMock(commitlogcommands, 'UpdateIssueAction')
+ commitlogcommands.UpdateIssueAction(self.issue.local_id).AndReturn(mock_uia)
+
+ self.mox.StubOutWithMock(mock_uia, 'Parse')
+ mock_uia.Parse(
+ self.cnxn, self.project.project_name, 111, ['awesome!'], self.services,
+ strip_quoted_lines=True)
+ self.mox.StubOutWithMock(mock_uia, 'Run')
+ mock_uia.Run(mc, self.services)
+
+ self.mox.ReplayAll()
+ ret = self.inbound.ProcessIssueReply(
+ mc, self.project, self.issue.local_id, self.project_addr,
+ 'awesome!')
+ self.mox.VerifyAll()
+ self.assertIsNone(ret)
+
+
+class BouncedEmailTest(unittest.TestCase):
+
+ def setUp(self):
+ self.cnxn = 'fake cnxn'
+ self.services = service_manager.Services(
+ user=fake.UserService())
+ self.user = self.services.user.TestAddUser('user@example.com', 111)
+
+ app = webapp2.WSGIApplication(config={'services': self.services})
+ app.set_globals(app=app)
+
+ self.servlet = inboundemail.BouncedEmail()
+ self.mox = mox.Mox()
+
+ def tearDown(self):
+ self.mox.UnsetStubs()
+ self.mox.ResetAll()
+
+ def testPost_Normal(self):
+ """Normally, our post() just calls BounceNotificationHandler post()."""
+ self.mox.StubOutWithMock(BounceNotificationHandler, 'post')
+ BounceNotificationHandler.post()
+ self.mox.ReplayAll()
+
+ self.servlet.post()
+ self.mox.VerifyAll()
+
+ def testPost_Exception(self):
+ """Our post() method works around an escaping bug."""
+ self.servlet.request = webapp2.Request.blank(
+ '/', POST={'raw-message': 'this is an email message'})
+
+ self.mox.StubOutWithMock(BounceNotificationHandler, 'post')
+ BounceNotificationHandler.post().AndRaise(AttributeError())
+ BounceNotificationHandler.post()
+ self.mox.ReplayAll()
+
+ self.servlet.post()
+ self.mox.VerifyAll()
+
+ def testReceive_Normal(self):
+ """Find the user that bounced and set email_bounce_timestamp."""
+ self.assertEqual(0, self.user.email_bounce_timestamp)
+
+ bounce_message = testing_helpers.Blank(original={'to': 'user@example.com'})
+ self.servlet.receive(bounce_message)
+
+ self.assertNotEqual(0, self.user.email_bounce_timestamp)
+
+ def testReceive_NoSuchUser(self):
+ """When not found, log it and ignore without creating a user record."""
+ self.servlet.request = webapp2.Request.blank(
+ '/', POST={'raw-message': 'this is an email message'})
+ bounce_message = testing_helpers.Blank(
+ original={'to': 'nope@example.com'},
+ notification='notification')
+ self.servlet.receive(bounce_message)
+ self.assertEqual(1, len(self.services.user.users_by_id))
diff --git a/features/test/notify_helpers_test.py b/features/test/notify_helpers_test.py
new file mode 100644
index 0000000..615da38
--- /dev/null
+++ b/features/test/notify_helpers_test.py
@@ -0,0 +1,617 @@
+# -*- coding: utf-8 -*-
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Tests for notify_helpers.py."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import json
+import mock
+import unittest
+import os
+
+from features import features_constants
+from features import notify_helpers
+from features import notify_reasons
+from framework import emailfmt
+from framework import framework_views
+from framework import urls
+from proto import user_pb2
+from services import service_manager
+from testing import fake
+
+
+REPLY_NOT_ALLOWED = notify_reasons.REPLY_NOT_ALLOWED
+REPLY_MAY_COMMENT = notify_reasons.REPLY_MAY_COMMENT
+REPLY_MAY_UPDATE = notify_reasons.REPLY_MAY_UPDATE
+
+
+class TaskQueueingFunctionsTest(unittest.TestCase):
+
+ @mock.patch('framework.cloud_tasks_helpers._get_client')
+ def testAddAllEmailTasks(self, get_client_mock):
+ notify_helpers.AddAllEmailTasks(
+ tasks=[{'to': 'user'}, {'to': 'user2'}])
+
+ self.assertEqual(get_client_mock().create_task.call_count, 2)
+
+ queue_call_args = get_client_mock().queue_path.call_args_list
+ ((_app_id, _region, queue), _kwargs) = queue_call_args[0]
+ self.assertEqual(queue, features_constants.QUEUE_OUTBOUND_EMAIL)
+ ((_app_id, _region, queue), _kwargs) = queue_call_args[1]
+ self.assertEqual(queue, features_constants.QUEUE_OUTBOUND_EMAIL)
+
+ task_call_args = get_client_mock().create_task.call_args_list
+ ((_parent, task), _kwargs) = task_call_args[0]
+ expected_task = {
+ 'app_engine_http_request':
+ {
+ 'relative_uri': urls.OUTBOUND_EMAIL_TASK + '.do',
+ 'body': json.dumps({
+ 'to': 'user'
+ }).encode(),
+ 'headers': {
+ 'Content-type': 'application/json'
+ }
+ }
+ }
+ self.assertEqual(task, expected_task)
+ ((_parent, task), _kwargs) = task_call_args[1]
+ expected_task = {
+ 'app_engine_http_request':
+ {
+ 'relative_uri': urls.OUTBOUND_EMAIL_TASK + '.do',
+ 'body': json.dumps({
+ 'to': 'user2'
+ }).encode(),
+ 'headers': {
+ 'Content-type': 'application/json'
+ }
+ }
+ }
+ self.assertEqual(task, expected_task)
+
+
+class MergeLinkedAccountReasonsTest(unittest.TestCase):
+
+ def setUp(self):
+ parent = user_pb2.User(
+ user_id=111, email='parent@example.org',
+ linked_child_ids=[222])
+ child = user_pb2.User(
+ user_id=222, email='child@example.org',
+ linked_parent_id=111)
+ user_3 = user_pb2.User(
+ user_id=333, email='user4@example.org')
+ user_4 = user_pb2.User(
+ user_id=444, email='user4@example.org')
+ self.addr_perm_parent = notify_reasons.AddrPerm(
+ False, parent.email, parent, notify_reasons.REPLY_NOT_ALLOWED,
+ user_pb2.UserPrefs())
+ self.addr_perm_child = notify_reasons.AddrPerm(
+ False, child.email, child, notify_reasons.REPLY_NOT_ALLOWED,
+ user_pb2.UserPrefs())
+ self.addr_perm_3 = notify_reasons.AddrPerm(
+ False, user_3.email, user_3, notify_reasons.REPLY_NOT_ALLOWED,
+ user_pb2.UserPrefs())
+ self.addr_perm_4 = notify_reasons.AddrPerm(
+ False, user_4.email, user_4, notify_reasons.REPLY_NOT_ALLOWED,
+ user_pb2.UserPrefs())
+ self.addr_perm_5 = notify_reasons.AddrPerm(
+ False, 'alias@example.com', None, notify_reasons.REPLY_NOT_ALLOWED,
+ user_pb2.UserPrefs())
+
+ def testEmptyDict(self):
+ """Zero users to notify."""
+ self.assertEqual(
+ {},
+ notify_helpers._MergeLinkedAccountReasons({}, {}))
+
+ def testNormal(self):
+ """No users are related."""
+ addr_to_addrperm = {
+ self.addr_perm_parent.address: self.addr_perm_parent,
+ self.addr_perm_3.address: self.addr_perm_3,
+ self.addr_perm_4.address: self.addr_perm_4,
+ self.addr_perm_5.address: self.addr_perm_5,
+ }
+ addr_to_reasons = {
+ self.addr_perm_parent.address: [notify_reasons.REASON_CCD],
+ self.addr_perm_3.address: [notify_reasons.REASON_OWNER],
+ self.addr_perm_4.address: [notify_reasons.REASON_CCD],
+ self.addr_perm_5.address: [notify_reasons.REASON_CCD],
+ }
+ self.assertEqual(
+ {self.addr_perm_parent.address: [notify_reasons.REASON_CCD],
+ self.addr_perm_3.address: [notify_reasons.REASON_OWNER],
+ self.addr_perm_4.address: [notify_reasons.REASON_CCD],
+ self.addr_perm_5.address: [notify_reasons.REASON_CCD]
+ },
+ notify_helpers._MergeLinkedAccountReasons(
+ addr_to_addrperm, addr_to_reasons))
+
+ def testMerged(self):
+ """A child is merged into parent notification."""
+ addr_to_addrperm = {
+ self.addr_perm_parent.address: self.addr_perm_parent,
+ self.addr_perm_child.address: self.addr_perm_child,
+ }
+ addr_to_reasons = {
+ self.addr_perm_parent.address: [notify_reasons.REASON_OWNER],
+ self.addr_perm_child.address: [notify_reasons.REASON_CCD],
+ }
+ self.assertEqual(
+ {self.addr_perm_parent.address:
+ [notify_reasons.REASON_OWNER,
+ notify_reasons.REASON_LINKED_ACCOUNT]
+ },
+ notify_helpers._MergeLinkedAccountReasons(
+ addr_to_addrperm, addr_to_reasons))
+
+
+class MakeBulletedEmailWorkItemsTest(unittest.TestCase):
+
+ def setUp(self):
+ self.project = fake.Project(project_name='proj1')
+ self.commenter_view = framework_views.StuffUserView(
+ 111, 'test@example.com', True)
+ self.issue = fake.MakeTestIssue(
+ self.project.project_id, 1234, 'summary', 'New', 111)
+ self.detail_url = 'http://test-detail-url.com/id=1234'
+
+ def testEmptyAddrs(self):
+ """Test the case where we found zero users to notify."""
+ email_tasks = notify_helpers.MakeBulletedEmailWorkItems(
+ [], self.issue, 'link only body', 'non-member body', 'member body',
+ self.project, 'example.com',
+ self.commenter_view, self.detail_url)
+ self.assertEqual([], email_tasks)
+ email_tasks = notify_helpers.MakeBulletedEmailWorkItems(
+ [([], 'reason')], self.issue, 'link only body', 'non-member body',
+ 'member body', self.project,
+ 'example.com', self.commenter_view, self.detail_url)
+ self.assertEqual([], email_tasks)
+
+
+class LinkOnlyLogicTest(unittest.TestCase):
+
+ def setUp(self):
+ self.user_prefs = user_pb2.UserPrefs()
+ self.user = user_pb2.User()
+ self.issue = fake.MakeTestIssue(
+ 789, 1, 'summary one', 'New', 111)
+ self.rvg_issue = fake.MakeTestIssue(
+ 789, 2, 'summary two', 'New', 111, labels=['Restrict-View-Google'])
+ self.more_restricted_issue = fake.MakeTestIssue(
+ 789, 3, 'summary three', 'New', 111, labels=['Restrict-View-Core'])
+ self.both_restricted_issue = fake.MakeTestIssue(
+ 789, 4, 'summary four', 'New', 111,
+ labels=['Restrict-View-Google', 'Restrict-View-Core'])
+ self.addr_perm = notify_reasons.AddrPerm(
+ False, 'user@example.com', self.user, notify_reasons.REPLY_MAY_COMMENT,
+ self.user_prefs)
+
+ def testGetNotifyRestrictedIssues_NoPrefsPassed(self):
+ """AlsoNotify and all-issues addresses have no UserPrefs. None is used."""
+ actual = notify_helpers._GetNotifyRestrictedIssues(
+ None, 'user@example.com', self.user)
+ self.assertEqual('notify with link only', actual)
+
+ self.user.last_visit_timestamp = 123456789
+ actual = notify_helpers._GetNotifyRestrictedIssues(
+ None, 'user@example.com', self.user)
+ self.assertEqual('notify with details', actual)
+
+ def testGetNotifyRestrictedIssues_PrefIsSet(self):
+ """When the notify_restricted_issues pref is set, we use it."""
+ self.user_prefs.prefs.extend([
+ user_pb2.UserPrefValue(name='x', value='y'),
+ user_pb2.UserPrefValue(name='notify_restricted_issues', value='z'),
+ ])
+ actual = notify_helpers._GetNotifyRestrictedIssues(
+ self.user_prefs, 'user@example.com', self.user)
+ self.assertEqual('z', actual)
+
+ def testGetNotifyRestrictedIssues_UserHasVisited(self):
+ """If user has ever visited, we know that they are not a mailing list."""
+ self.user.last_visit_timestamp = 123456789
+ actual = notify_helpers._GetNotifyRestrictedIssues(
+ self.user_prefs, 'user@example.com', self.user)
+ self.assertEqual('notify with details', actual)
+
+ def testGetNotifyRestrictedIssues_GooglerNeverVisited(self):
+ """It could be a noogler or google mailing list."""
+ actual = notify_helpers._GetNotifyRestrictedIssues(
+ self.user_prefs, 'user@google.com', self.user)
+ self.assertEqual('notify with details: Google', actual)
+
+ def testGetNotifyRestrictedIssues_NonGooglerNeverVisited(self):
+ """It could be a new non-noogler or public mailing list."""
+ actual = notify_helpers._GetNotifyRestrictedIssues(
+ self.user_prefs, 'user@example.com', self.user)
+ self.assertEqual('notify with link only', actual)
+
+ # If email does not match any known user, user object will be None.
+ actual = notify_helpers._GetNotifyRestrictedIssues(
+ self.user_prefs, 'user@example.com', None)
+ self.assertEqual('notify with link only', actual)
+
+ def testShouldUseLinkOnly_UnrestrictedIssue(self):
+ """Issue is not restricted, so go ahead and send comment details."""
+ self.assertFalse(notify_helpers.ShouldUseLinkOnly(
+ self.addr_perm, self.issue))
+
+ def testShouldUseLinkOnly_AlwaysDetailed(self):
+ """Issue is not restricted, so go ahead and send comment details."""
+ self.assertFalse(
+ notify_helpers.ShouldUseLinkOnly(self.addr_perm, self.issue, True))
+
+ @mock.patch('features.notify_helpers._GetNotifyRestrictedIssues')
+ def testShouldUseLinkOnly_NotifyWithDetails(self, fake_gnri):
+ """Issue is restricted, and user is allowed to get full comment details."""
+ fake_gnri.return_value = notify_helpers.NOTIFY_WITH_DETAILS
+ self.assertFalse(notify_helpers.ShouldUseLinkOnly(
+ self.addr_perm, self.rvg_issue))
+ self.assertFalse(notify_helpers.ShouldUseLinkOnly(
+ self.addr_perm, self.more_restricted_issue))
+ self.assertFalse(notify_helpers.ShouldUseLinkOnly(
+ self.addr_perm, self.both_restricted_issue))
+
+
+class MakeEmailWorkItemTest(unittest.TestCase):
+
+ def setUp(self):
+ self.project = fake.Project(project_name='proj1')
+ self.project.process_inbound_email = True
+ self.project2 = fake.Project(project_name='proj2')
+ self.project2.issue_notify_always_detailed = True
+ self.commenter_view = framework_views.StuffUserView(
+ 111, 'test@example.com', True)
+ self.expected_html_footer = (
+ 'You received this message because:<br/> 1. reason<br/><br/>You may '
+ 'adjust your notification preferences at:<br/><a href="https://'
+ 'example.com/hosting/settings">https://example.com/hosting/settings'
+ '</a>')
+ self.services = service_manager.Services(
+ user=fake.UserService())
+ self.member = self.services.user.TestAddUser('member@example.com', 222)
+ self.issue = fake.MakeTestIssue(
+ self.project.project_id, 1234, 'summary', 'New', 111,
+ project_name='proj1')
+ self.detail_url = 'http://test-detail-url.com/id=1234'
+
+ @mock.patch('features.notify_helpers.ShouldUseLinkOnly')
+ def testBodySelection_LinkOnly(self, mock_sulo):
+ """We send a link-only body when ShouldUseLinkOnly() is true."""
+ mock_sulo.return_value = True
+ email_task = notify_helpers._MakeEmailWorkItem(
+ notify_reasons.AddrPerm(
+ True, 'a@a.com', self.member, REPLY_NOT_ALLOWED,
+ user_pb2.UserPrefs()),
+ ['reason'], self.issue,
+ 'body link-only', 'body mem', 'body mem', self.project,
+ 'example.com', self.commenter_view, self.detail_url)
+ self.assertIn('body link-only', email_task['body'])
+
+ def testBodySelection_Member(self):
+ """We send members the email body that is indented for members."""
+ email_task = notify_helpers._MakeEmailWorkItem(
+ notify_reasons.AddrPerm(
+ True, 'a@a.com', self.member, REPLY_NOT_ALLOWED,
+ user_pb2.UserPrefs()),
+ ['reason'], self.issue,
+ 'body link-only', 'body mem', 'body mem', self.project,
+ 'example.com', self.commenter_view, self.detail_url)
+ self.assertIn('body mem', email_task['body'])
+
+ def testBodySelection_AlwaysDetailed(self):
+ """Always send full email when project configuration requires it."""
+ email_task = notify_helpers._MakeEmailWorkItem(
+ notify_reasons.AddrPerm(
+ True, 'a@a.com', self.member, REPLY_NOT_ALLOWED,
+ user_pb2.UserPrefs()), ['reason'], self.issue, 'body link-only',
+ 'body mem', 'body mem', self.project2, 'example.com',
+ self.commenter_view, self.detail_url)
+ self.assertIn('body mem', email_task['body'])
+
+ def testBodySelection_NonMember(self):
+ """We send non-members the email body that is indented for non-members."""
+ email_task = notify_helpers._MakeEmailWorkItem(
+ notify_reasons.AddrPerm(
+ False, 'a@a.com', self.member, REPLY_NOT_ALLOWED,
+ user_pb2.UserPrefs()),
+ ['reason'], self.issue,
+ 'body link-only', 'body non', 'body mem', self.project,
+ 'example.com', self.commenter_view, self.detail_url)
+
+ self.assertEqual('a@a.com', email_task['to'])
+ self.assertEqual('Issue 1234 in proj1: summary', email_task['subject'])
+ self.assertIn('body non', email_task['body'])
+ self.assertEqual(
+ emailfmt.FormatFromAddr(self.project, commenter_view=self.commenter_view,
+ can_reply_to=False),
+ email_task['from_addr'])
+ self.assertEqual(emailfmt.NoReplyAddress(), email_task['reply_to'])
+
+ def testHtmlBody(self):
+ """"An html body is sent if a detail_url is specified."""
+ email_task = notify_helpers._MakeEmailWorkItem(
+ notify_reasons.AddrPerm(
+ False, 'a@a.com', self.member, REPLY_NOT_ALLOWED,
+ user_pb2.UserPrefs()),
+ ['reason'], self.issue,
+ 'body link-only', 'body non', 'body mem', self.project,
+ 'example.com', self.commenter_view, self.detail_url)
+
+ expected_html_body = (
+ notify_helpers.HTML_BODY_WITH_GMAIL_ACTION_TEMPLATE % {
+ 'url': self.detail_url,
+ 'body': 'body non-- <br/>%s' % self.expected_html_footer})
+ self.assertEqual(expected_html_body, email_task['html_body'])
+
+ def testHtmlBody_WithUnicodeChars(self):
+ """"An html body is sent if a detail_url is specified."""
+ unicode_content = '\xe2\x9d\xa4 â â'
+ email_task = notify_helpers._MakeEmailWorkItem(
+ notify_reasons.AddrPerm(
+ False, 'a@a.com', self.member, REPLY_NOT_ALLOWED,
+ user_pb2.UserPrefs()),
+ ['reason'], self.issue,
+ 'body link-only', unicode_content, 'unused body mem',
+ self.project, 'example.com', self.commenter_view, self.detail_url)
+
+ expected_html_body = (
+ notify_helpers.HTML_BODY_WITH_GMAIL_ACTION_TEMPLATE % {
+ 'url': self.detail_url,
+ 'body': '%s-- <br/>%s' % (unicode_content.decode('utf-8'),
+ self.expected_html_footer)})
+ self.assertEqual(expected_html_body, email_task['html_body'])
+
+ def testHtmlBody_WithLinks(self):
+ """"An html body is sent if a detail_url is specified."""
+ email_task = notify_helpers._MakeEmailWorkItem(
+ notify_reasons.AddrPerm(
+ False, 'a@a.com', self.member, REPLY_NOT_ALLOWED,
+ user_pb2.UserPrefs()),
+ ['reason'], self.issue,
+ 'body link-only', 'test google.com test', 'unused body mem',
+ self.project, 'example.com', self.commenter_view, self.detail_url)
+
+ expected_html_body = (
+ notify_helpers.HTML_BODY_WITH_GMAIL_ACTION_TEMPLATE % {
+ 'url': self.detail_url,
+ 'body': (
+ 'test <a href="http://google.com">google.com</a> test-- <br/>%s' % (
+ self.expected_html_footer))})
+ self.assertEqual(expected_html_body, email_task['html_body'])
+
+ def testHtmlBody_LinkWithinTags(self):
+ """"An html body is sent with correct <a href>s."""
+ email_task = notify_helpers._MakeEmailWorkItem(
+ notify_reasons.AddrPerm(
+ False, 'a@a.com', self.member, REPLY_NOT_ALLOWED,
+ user_pb2.UserPrefs()),
+ ['reason'], self.issue,
+ 'body link-only', 'a <http://google.com> z', 'unused body',
+ self.project, 'example.com', self.commenter_view,
+ self.detail_url)
+
+ expected_html_body = (
+ notify_helpers.HTML_BODY_WITH_GMAIL_ACTION_TEMPLATE % {
+ 'url': self.detail_url,
+ 'body': (
+ 'a <<a href="http://google.com">http://google.com</a>> '
+ 'z-- <br/>%s' % self.expected_html_footer)})
+ self.assertEqual(expected_html_body, email_task['html_body'])
+
+ def testHtmlBody_EmailWithinTags(self):
+ """"An html body is sent with correct <a href>s."""
+ email_task = notify_helpers._MakeEmailWorkItem(
+ notify_reasons.AddrPerm(
+ False, 'a@a.com', self.member, REPLY_NOT_ALLOWED,
+ user_pb2.UserPrefs()),
+ ['reason'], self.issue,
+ 'body link-only', 'a <tt@chromium.org> <aa@chromium.org> z',
+ 'unused body mem', self.project, 'example.com', self.commenter_view,
+ self.detail_url)
+
+ expected_html_body = (
+ notify_helpers.HTML_BODY_WITH_GMAIL_ACTION_TEMPLATE % {
+ 'url': self.detail_url,
+ 'body': (
+ 'a <<a href="mailto:tt@chromium.org">tt@chromium.org</a>>'
+ ' <<a href="mailto:aa@chromium.org">aa@chromium.org</a>> '
+ 'z-- <br/>%s' % self.expected_html_footer)})
+ self.assertEqual(expected_html_body, email_task['html_body'])
+
+ def testHtmlBody_WithEscapedHtml(self):
+ """"An html body is sent with html content escaped."""
+ body_with_html_content = (
+ '<a href="http://www.google.com">test</a> \'something\'')
+ email_task = notify_helpers._MakeEmailWorkItem(
+ notify_reasons.AddrPerm(
+ False, 'a@a.com', self.member, REPLY_NOT_ALLOWED,
+ user_pb2.UserPrefs()),
+ ['reason'], self.issue,
+ 'body link-only', body_with_html_content, 'unused body mem',
+ self.project, 'example.com', self.commenter_view, self.detail_url)
+
+ escaped_body_with_html_content = (
+ '<a href="http://www.google.com">test</a> '
+ ''something'')
+ notify_helpers._MakeNotificationFooter(
+ ['reason'], REPLY_NOT_ALLOWED, 'example.com')
+ expected_html_body = (
+ notify_helpers.HTML_BODY_WITH_GMAIL_ACTION_TEMPLATE % {
+ 'url': self.detail_url,
+ 'body': '%s-- <br/>%s' % (escaped_body_with_html_content,
+ self.expected_html_footer)})
+ self.assertEqual(expected_html_body, email_task['html_body'])
+
+ def doTestAddHTMLTags(self, body, expected):
+ actual = notify_helpers._AddHTMLTags(body)
+ self.assertEqual(expected, actual)
+
+ def testAddHTMLTags_Email(self):
+ """An email address produces <a href="mailto:...">...</a>."""
+ self.doTestAddHTMLTags(
+ 'test test@example.com.',
+ ('test <a href="mailto:test@example.com">'
+ 'test@example.com</a>.'))
+
+ def testAddHTMLTags_EmailInQuotes(self):
+ """Quoted "test@example.com" produces "<a href="...">...</a>"."""
+ self.doTestAddHTMLTags(
+ 'test "test@example.com".',
+ ('test "<a href="mailto:test@example.com">'
+ 'test@example.com</a>".'))
+
+ def testAddHTMLTags_EmailInAngles(self):
+ """Bracketed <test@example.com> produces <<a href="...">...</a>>."""
+ self.doTestAddHTMLTags(
+ 'test <test@example.com>.',
+ ('test <<a href="mailto:test@example.com">'
+ 'test@example.com</a>>.'))
+
+ def testAddHTMLTags_Website(self):
+ """A website URL produces <a href="http:...">...</a>."""
+ self.doTestAddHTMLTags(
+ 'test http://www.example.com.',
+ ('test <a href="http://www.example.com">'
+ 'http://www.example.com</a>.'))
+
+ def testAddHTMLTags_WebsiteInQuotes(self):
+ """A link in quotes gets the quotes escaped."""
+ self.doTestAddHTMLTags(
+ 'test "http://www.example.com".',
+ ('test "<a href="http://www.example.com">'
+ 'http://www.example.com</a>".'))
+
+ def testAddHTMLTags_WebsiteInAngles(self):
+ """Bracketed <www.example.com> produces <<a href="...">...</a>>."""
+ self.doTestAddHTMLTags(
+ 'test <http://www.example.com>.',
+ ('test <<a href="http://www.example.com">'
+ 'http://www.example.com</a>>.'))
+
+ def testReplyInvitation(self):
+ """We include a footer about replying that is appropriate for that user."""
+ email_task = notify_helpers._MakeEmailWorkItem(
+ notify_reasons.AddrPerm(
+ True, 'a@a.com', self.member, REPLY_NOT_ALLOWED,
+ user_pb2.UserPrefs()),
+ ['reason'], self.issue,
+ 'body link-only', 'body non', 'body mem', self.project,
+ 'example.com', self.commenter_view, self.detail_url)
+ self.assertEqual(emailfmt.NoReplyAddress(), email_task['reply_to'])
+ self.assertNotIn('Reply to this email', email_task['body'])
+
+ email_task = notify_helpers._MakeEmailWorkItem(
+ notify_reasons.AddrPerm(
+ True, 'a@a.com', self.member, REPLY_MAY_COMMENT,
+ user_pb2.UserPrefs()),
+ ['reason'], self.issue,
+ 'body link-only', 'body non', 'body mem', self.project,
+ 'example.com', self.commenter_view, self.detail_url)
+ self.assertEqual(
+ '%s@%s' % (self.project.project_name, emailfmt.MailDomain()),
+ email_task['reply_to'])
+ self.assertIn('Reply to this email to add a comment', email_task['body'])
+ self.assertNotIn('make changes', email_task['body'])
+
+ email_task = notify_helpers._MakeEmailWorkItem(
+ notify_reasons.AddrPerm(
+ True, 'a@a.com', self.member, REPLY_MAY_UPDATE,
+ user_pb2.UserPrefs()),
+ ['reason'], self.issue,
+ 'body link-only', 'body non', 'body mem', self.project,
+ 'example.com', self.commenter_view, self.detail_url)
+ self.assertEqual(
+ '%s@%s' % (self.project.project_name, emailfmt.MailDomain()),
+ email_task['reply_to'])
+ self.assertIn('Reply to this email to add a comment', email_task['body'])
+ self.assertIn('make updates', email_task['body'])
+
+ def testInboundEmailDisabled(self):
+ """We don't invite replies if they are disabled for this project."""
+ self.project.process_inbound_email = False
+ email_task = notify_helpers._MakeEmailWorkItem(
+ notify_reasons.AddrPerm(
+ True, 'a@a.com', self.member, REPLY_MAY_UPDATE,
+ user_pb2.UserPrefs()),
+ ['reason'], self.issue,
+ 'body link-only', 'body non', 'body mem',
+ self.project, 'example.com', self.commenter_view, self.detail_url)
+ self.assertEqual(emailfmt.NoReplyAddress(), email_task['reply_to'])
+
+ def testReasons(self):
+ """The footer lists reasons why that email was sent to that user."""
+ email_task = notify_helpers._MakeEmailWorkItem(
+ notify_reasons.AddrPerm(
+ True, 'a@a.com', self.member, REPLY_MAY_UPDATE,
+ user_pb2.UserPrefs()),
+ ['Funny', 'Caring', 'Near'], self.issue,
+ 'body link-only', 'body non', 'body mem',
+ self.project, 'example.com', self.commenter_view, self.detail_url)
+ self.assertIn('because:', email_task['body'])
+ self.assertIn('1. Funny', email_task['body'])
+ self.assertIn('2. Caring', email_task['body'])
+ self.assertIn('3. Near', email_task['body'])
+
+ email_task = notify_helpers._MakeEmailWorkItem(
+ notify_reasons.AddrPerm(
+ True, 'a@a.com', self.member, REPLY_MAY_UPDATE,
+ user_pb2.UserPrefs()),
+ [], self.issue,
+ 'body link-only', 'body non', 'body mem',
+ self.project, 'example.com', self.commenter_view, self.detail_url)
+ self.assertNotIn('because', email_task['body'])
+
+
+class MakeNotificationFooterTest(unittest.TestCase):
+
+ def testMakeNotificationFooter_NoReason(self):
+ footer = notify_helpers._MakeNotificationFooter(
+ [], REPLY_NOT_ALLOWED, 'example.com')
+ self.assertEqual('', footer)
+
+ def testMakeNotificationFooter_WithReason(self):
+ footer = notify_helpers._MakeNotificationFooter(
+ ['REASON'], REPLY_NOT_ALLOWED, 'example.com')
+ self.assertIn('REASON', footer)
+ self.assertIn('https://example.com/hosting/settings', footer)
+
+ footer = notify_helpers._MakeNotificationFooter(
+ ['REASON'], REPLY_NOT_ALLOWED, 'example.com')
+ self.assertIn('REASON', footer)
+ self.assertIn('https://example.com/hosting/settings', footer)
+
+ def testMakeNotificationFooter_ManyReasons(self):
+ footer = notify_helpers._MakeNotificationFooter(
+ ['Funny', 'Caring', 'Warmblooded'], REPLY_NOT_ALLOWED,
+ 'example.com')
+ self.assertIn('Funny', footer)
+ self.assertIn('Caring', footer)
+ self.assertIn('Warmblooded', footer)
+
+ def testMakeNotificationFooter_WithReplyInstructions(self):
+ footer = notify_helpers._MakeNotificationFooter(
+ ['REASON'], REPLY_NOT_ALLOWED, 'example.com')
+ self.assertNotIn('Reply', footer)
+ self.assertIn('https://example.com/hosting/settings', footer)
+
+ footer = notify_helpers._MakeNotificationFooter(
+ ['REASON'], REPLY_MAY_COMMENT, 'example.com')
+ self.assertIn('add a comment', footer)
+ self.assertNotIn('make updates', footer)
+ self.assertIn('https://example.com/hosting/settings', footer)
+
+ footer = notify_helpers._MakeNotificationFooter(
+ ['REASON'], REPLY_MAY_UPDATE, 'example.com')
+ self.assertIn('add a comment', footer)
+ self.assertIn('make updates', footer)
+ self.assertIn('https://example.com/hosting/settings', footer)
diff --git a/features/test/notify_reasons_test.py b/features/test/notify_reasons_test.py
new file mode 100644
index 0000000..559e322
--- /dev/null
+++ b/features/test/notify_reasons_test.py
@@ -0,0 +1,407 @@
+# Copyright 2017 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Tests for notify_reasons.py."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+import os
+
+from features import notify_reasons
+from framework import emailfmt
+from framework import framework_views
+from framework import urls
+from proto import user_pb2
+from proto import usergroup_pb2
+from services import service_manager
+from testing import fake
+from tracker import tracker_bizobj
+
+
+REPLY_NOT_ALLOWED = notify_reasons.REPLY_NOT_ALLOWED
+REPLY_MAY_COMMENT = notify_reasons.REPLY_MAY_COMMENT
+REPLY_MAY_UPDATE = notify_reasons.REPLY_MAY_UPDATE
+
+
+class ComputeIssueChangeAddressPermListTest(unittest.TestCase):
+
+ def setUp(self):
+ self.users_by_id = {
+ 111: framework_views.StuffUserView(111, 'owner@example.com', True),
+ 222: framework_views.StuffUserView(222, 'member@example.com', True),
+ 999: framework_views.StuffUserView(999, 'visitor@example.com', True),
+ }
+ self.services = service_manager.Services(
+ project=fake.ProjectService(),
+ config=fake.ConfigService(),
+ issue=fake.IssueService(),
+ user=fake.UserService(),
+ usergroup=fake.UserGroupService())
+ self.owner = self.services.user.TestAddUser('owner@example.com', 111)
+ self.member = self.services.user.TestAddUser('member@example.com', 222)
+ self.visitor = self.services.user.TestAddUser('visitor@example.com', 999)
+ self.project = self.services.project.TestAddProject(
+ 'proj', owner_ids=[111], committer_ids=[222])
+ self.project.process_inbound_email = True
+ self.issue = fake.MakeTestIssue(
+ self.project.project_id, 1, 'summary', 'New', 111)
+
+ def testEmptyIDs(self):
+ cnxn = 'fake cnxn'
+ addr_perm_list = notify_reasons.ComputeIssueChangeAddressPermList(
+ cnxn, [], self.project, self.issue, self.services, [], {})
+ self.assertEqual([], addr_perm_list)
+
+ def testRecipientIsMember(self):
+ cnxn = 'fake cnxn'
+ ids_to_consider = [111, 222, 999]
+ addr_perm_list = notify_reasons.ComputeIssueChangeAddressPermList(
+ cnxn, ids_to_consider, self.project, self.issue, self.services, set(),
+ self.users_by_id, pref_check_function=lambda *args: True)
+ self.assertEqual(
+ [notify_reasons.AddrPerm(
+ True, 'owner@example.com', self.owner, REPLY_MAY_UPDATE,
+ user_pb2.UserPrefs(user_id=111)),
+ notify_reasons.AddrPerm(
+ True, 'member@example.com', self.member, REPLY_MAY_UPDATE,
+ user_pb2.UserPrefs(user_id=222)),
+ notify_reasons.AddrPerm(
+ False, 'visitor@example.com', self.visitor, REPLY_MAY_COMMENT,
+ user_pb2.UserPrefs(user_id=999))],
+ addr_perm_list)
+
+
+class ComputeProjectAndIssueNotificationAddrListTest(unittest.TestCase):
+
+ def setUp(self):
+ self.cnxn = 'fake cnxn'
+ self.services = service_manager.Services(
+ project=fake.ProjectService(),
+ user=fake.UserService())
+ self.project = self.services.project.TestAddProject('project')
+ self.services.user.TestAddUser('alice@gmail.com', 111)
+ self.services.user.TestAddUser('bob@gmail.com', 222)
+ self.services.user.TestAddUser('fred@gmail.com', 555)
+
+ def testNotifyAddress(self):
+ # No mailing list or filter rules are defined
+ addr_perm_list = notify_reasons.ComputeProjectNotificationAddrList(
+ self.cnxn, self.services, self.project, True, set())
+ self.assertListEqual([], addr_perm_list)
+
+ # Only mailing list is notified.
+ self.project.issue_notify_address = 'mailing-list@domain.com'
+ addr_perm_list = notify_reasons.ComputeProjectNotificationAddrList(
+ self.cnxn, self.services, self.project, True, set())
+ self.assertListEqual(
+ [notify_reasons.AddrPerm(
+ False, 'mailing-list@domain.com', None, REPLY_NOT_ALLOWED,
+ user_pb2.UserPrefs())],
+ addr_perm_list)
+
+ # No one is notified because mailing list was already notified.
+ omit_addrs = {'mailing-list@domain.com'}
+ addr_perm_list = notify_reasons.ComputeProjectNotificationAddrList(
+ self.cnxn, self.services, self.project, False, omit_addrs)
+ self.assertListEqual([], addr_perm_list)
+
+ # No one is notified because anon users cannot view.
+ addr_perm_list = notify_reasons.ComputeProjectNotificationAddrList(
+ self.cnxn, self.services, self.project, False, set())
+ self.assertListEqual([], addr_perm_list)
+
+ def testFilterRuleNotifyAddresses(self):
+ issue = fake.MakeTestIssue(
+ self.project.project_id, 1, 'summary', 'New', 555)
+ issue.derived_notify_addrs.extend(['notify@domain.com'])
+
+ addr_perm_list = notify_reasons.ComputeIssueNotificationAddrList(
+ self.cnxn, self.services, issue, set())
+ self.assertListEqual(
+ [notify_reasons.AddrPerm(
+ False, 'notify@domain.com', None, REPLY_NOT_ALLOWED,
+ user_pb2.UserPrefs())],
+ addr_perm_list)
+
+ # Also-notify addresses can be omitted (e.g., if it is the same as
+ # the email address of the user who made the change).
+ addr_perm_list = notify_reasons.ComputeIssueNotificationAddrList(
+ self.cnxn, self.services, issue, {'notify@domain.com'})
+ self.assertListEqual([], addr_perm_list)
+
+
+class ComputeGroupReasonListTest(unittest.TestCase):
+
+ def setUp(self):
+ self.services = service_manager.Services(
+ project=fake.ProjectService(),
+ config=fake.ConfigService(),
+ issue=fake.IssueService(),
+ features=fake.FeaturesService(),
+ user=fake.UserService(),
+ usergroup=fake.UserGroupService())
+ self.project = self.services.project.TestAddProject(
+ 'project', project_id=789)
+ self.config = self.services.config.GetProjectConfig('cnxn', 789)
+ self.alice = self.services.user.TestAddUser('alice@example.com', 111)
+ self.bob = self.services.user.TestAddUser('bob@example.com', 222)
+ self.fred = self.services.user.TestAddUser('fred@example.com', 555)
+ self.users_by_id = framework_views.MakeAllUserViews(
+ 'cnxn', self.services.user, [111, 222, 555])
+ self.issue = fake.MakeTestIssue(
+ self.project.project_id, 1, 'summary', 'New', 555)
+
+ def CheckGroupReasonList(
+ self,
+ actual,
+ reporter_apl=None,
+ owner_apl=None,
+ old_owner_apl=None,
+ default_owner_apl=None,
+ ccd_apl=None,
+ group_ccd_apl=None,
+ default_ccd_apl=None,
+ starrer_apl=None,
+ subscriber_apl=None,
+ also_notified_apl=None,
+ all_notifications_apl=None):
+ (
+ you_report, you_own, you_old_owner, you_default_owner, you_ccd,
+ you_group_ccd, you_default_ccd, you_star, you_subscribe,
+ you_also_notify, all_notifications) = actual
+ self.assertEqual(
+ (reporter_apl or [], notify_reasons.REASON_REPORTER),
+ you_report)
+ self.assertEqual(
+ (owner_apl or [], notify_reasons.REASON_OWNER),
+ you_own)
+ self.assertEqual(
+ (old_owner_apl or [], notify_reasons.REASON_OLD_OWNER),
+ you_old_owner)
+ self.assertEqual(
+ (default_owner_apl or [], notify_reasons.REASON_DEFAULT_OWNER),
+ you_default_owner)
+ self.assertEqual(
+ (ccd_apl or [], notify_reasons.REASON_CCD),
+ you_ccd)
+ self.assertEqual(
+ (group_ccd_apl or [], notify_reasons.REASON_GROUP_CCD), you_group_ccd)
+ self.assertEqual(
+ (default_ccd_apl or [], notify_reasons.REASON_DEFAULT_CCD),
+ you_default_ccd)
+ self.assertEqual(
+ (starrer_apl or [], notify_reasons.REASON_STARRER),
+ you_star)
+ self.assertEqual(
+ (subscriber_apl or [], notify_reasons.REASON_SUBSCRIBER),
+ you_subscribe)
+ self.assertEqual(
+ (also_notified_apl or [], notify_reasons.REASON_ALSO_NOTIFY),
+ you_also_notify)
+ self.assertEqual(
+ (all_notifications_apl or [], notify_reasons.REASON_ALL_NOTIFICATIONS),
+ all_notifications)
+
+ def testComputeGroupReasonList_OwnerAndCC(self):
+ """Fred owns the issue, Alice is CC'd."""
+ self.issue.cc_ids = [self.alice.user_id]
+ actual = notify_reasons.ComputeGroupReasonList(
+ 'cnxn', self.services, self.project, self.issue, self.config,
+ self.users_by_id, [], True)
+ self.CheckGroupReasonList(
+ actual,
+ owner_apl=[notify_reasons.AddrPerm(
+ False, self.fred.email, self.fred, REPLY_NOT_ALLOWED,
+ user_pb2.UserPrefs(user_id=self.fred.user_id))],
+ ccd_apl=[notify_reasons.AddrPerm(
+ False, self.alice.email, self.alice, REPLY_NOT_ALLOWED,
+ user_pb2.UserPrefs(user_id=self.alice.user_id))])
+
+ def testComputeGroupReasonList_DerivedCcs(self):
+ """Check we correctly compute reasons for component and rule ccs."""
+ member_1 = self.services.user.TestAddUser('member_1@example.com', 991)
+ member_2 = self.services.user.TestAddUser('member_2@example.com', 992)
+ member_3 = self.services.user.TestAddUser('member_3@example.com', 993)
+
+ expanded_group = self.services.user.TestAddUser('group@example.com', 999)
+ self.services.usergroup.CreateGroup(
+ 'cnxn', self.services, expanded_group.email, 'owners')
+ self.services.usergroup.TestAddGroupSettings(
+ expanded_group.user_id,
+ expanded_group.email,
+ notify_members=True,
+ notify_group=False)
+ self.services.usergroup.TestAddMembers(
+ expanded_group.user_id, [member_1.user_id, member_2.user_id])
+
+ group = self.services.user.TestAddUser('group_1@example.com', 888)
+ self.services.usergroup.CreateGroup(
+ 'cnxn', self.services, group.email, 'owners')
+ self.services.usergroup.TestAddGroupSettings(
+ group.user_id, group.email, notify_members=False, notify_group=True)
+ self.services.usergroup.TestAddMembers(
+ group.user_id, [member_2.user_id, member_3.user_id])
+
+ users_by_id = framework_views.MakeAllUserViews(
+ 'cnxn', self.services.user, [
+ self.alice.user_id, self.fred.user_id, member_1.user_id,
+ member_2.user_id, member_3.user_id, group.user_id,
+ expanded_group.user_id
+ ])
+
+ comp_id = 123
+ self.config.component_defs = [
+ fake.MakeTestComponentDef(
+ self.project.project_id,
+ comp_id,
+ path='Chicken',
+ cc_ids=[group.user_id, expanded_group.user_id, self.alice.user_id])
+ ]
+ derived_cc_ids = [
+ self.fred.user_id, # cc'd directly due to a rule
+ self.alice.user_id, # cc'd due to the component
+ expanded_group
+ .user_id, # cc'd due to the component, members notified directly
+ group.user_id, # cc'd due to the component
+ # cc'd directly due to a rule,
+ # not removed from rule cc notifications due to transitive cc of
+ # expanded_group.
+ member_1.user_id,
+ member_3.user_id,
+ ]
+ issue = fake.MakeTestIssue(
+ self.project.project_id,
+ 2,
+ 'summary',
+ 'New',
+ 0,
+ derived_cc_ids=derived_cc_ids,
+ component_ids=[comp_id])
+ actual = notify_reasons.ComputeGroupReasonList(
+ 'cnxn', self.services, self.project, issue, self.config, users_by_id,
+ [], True)
+
+ # Asserts list/reason of derived ccs from rules (not components).
+ # The derived ccs list/reason is the 7th tuple returned by
+ # ComputeGroupReasonList()
+ actual_ccd_apl, actual_ccd_reason = actual[6]
+ self.assertEqual(
+ actual_ccd_apl, [
+ notify_reasons.AddrPerm(
+ False, member_3.email, member_3, REPLY_NOT_ALLOWED,
+ user_pb2.UserPrefs(user_id=member_3.user_id)),
+ notify_reasons.AddrPerm(
+ False, self.fred.email, self.fred, REPLY_NOT_ALLOWED,
+ user_pb2.UserPrefs(user_id=self.fred.user_id)),
+ notify_reasons.AddrPerm(
+ False, member_1.email, member_1, REPLY_NOT_ALLOWED,
+ user_pb2.UserPrefs(user_id=member_1.user_id)),
+ ])
+ self.assertEqual(actual_ccd_reason, notify_reasons.REASON_DEFAULT_CCD)
+
+ # Asserts list/reason of derived ccs from components.
+ # The component derived ccs list/reason is hte 8th tuple returned by
+ # ComputeGroupReasonList() when there are component derived ccs.
+ actual_component_apl, actual_comp_reason = actual[7]
+ self.assertEqual(
+ actual_component_apl, [
+ notify_reasons.AddrPerm(
+ False, group.email, group, REPLY_NOT_ALLOWED,
+ user_pb2.UserPrefs(user_id=group.user_id)),
+ notify_reasons.AddrPerm(
+ False, self.alice.email, self.alice, REPLY_NOT_ALLOWED,
+ user_pb2.UserPrefs(user_id=self.alice.user_id)),
+ notify_reasons.AddrPerm(
+ False, member_2.email, member_2, REPLY_NOT_ALLOWED,
+ user_pb2.UserPrefs(user_id=member_2.user_id)),
+ notify_reasons.AddrPerm(
+ False, member_1.email, member_1, REPLY_NOT_ALLOWED,
+ user_pb2.UserPrefs(user_id=member_1.user_id)),
+ ])
+ self.assertEqual(
+ actual_comp_reason,
+ "You are auto-CC'd on all issues in component Chicken")
+
+ def testComputeGroupReasonList_Starrers(self):
+ """Bob and Alice starred it, but Alice opts out of notifications."""
+ self.alice.notify_starred_issue_change = False
+ actual = notify_reasons.ComputeGroupReasonList(
+ 'cnxn', self.services, self.project, self.issue, self.config,
+ self.users_by_id, [], True,
+ starrer_ids=[self.alice.user_id, self.bob.user_id])
+ self.CheckGroupReasonList(
+ actual,
+ owner_apl=[notify_reasons.AddrPerm(
+ False, self.fred.email, self.fred, REPLY_NOT_ALLOWED,
+ user_pb2.UserPrefs(user_id=self.fred.user_id))],
+ starrer_apl=[notify_reasons.AddrPerm(
+ False, self.bob.email, self.bob, REPLY_NOT_ALLOWED,
+ user_pb2.UserPrefs(user_id=self.bob.user_id))])
+
+ def testComputeGroupReasonList_Subscribers(self):
+ """Bob subscribed."""
+ sq = tracker_bizobj.MakeSavedQuery(
+ 1, 'freds issues', 1, 'owner:fred@example.com',
+ subscription_mode='immediate', executes_in_project_ids=[789])
+ self.services.features.UpdateUserSavedQueries(
+ 'cnxn', self.bob.user_id, [sq])
+ actual = notify_reasons.ComputeGroupReasonList(
+ 'cnxn', self.services, self.project, self.issue, self.config,
+ self.users_by_id, [], True)
+ self.CheckGroupReasonList(
+ actual,
+ owner_apl=[notify_reasons.AddrPerm(
+ False, self.fred.email, self.fred, REPLY_NOT_ALLOWED,
+ user_pb2.UserPrefs(user_id=self.fred.user_id))],
+ subscriber_apl=[notify_reasons.AddrPerm(
+ False, self.bob.email, self.bob, REPLY_NOT_ALLOWED,
+ user_pb2.UserPrefs(user_id=self.bob.user_id))])
+
+ # Now with subscriber notifications disabled.
+ actual = notify_reasons.ComputeGroupReasonList(
+ 'cnxn', self.services, self.project, self.issue, self.config,
+ self.users_by_id, [], True, include_subscribers=False)
+ self.CheckGroupReasonList(
+ actual,
+ owner_apl=[notify_reasons.AddrPerm(
+ False, self.fred.email, self.fred, REPLY_NOT_ALLOWED,
+ user_pb2.UserPrefs(user_id=self.fred.user_id))])
+
+ def testComputeGroupReasonList_NotifyAll(self):
+ """Project is configured to always notify issues@example.com."""
+ self.project.issue_notify_address = 'issues@example.com'
+ actual = notify_reasons.ComputeGroupReasonList(
+ 'cnxn', self.services, self.project, self.issue, self.config,
+ self.users_by_id, [], True)
+ self.CheckGroupReasonList(
+ actual,
+ owner_apl=[notify_reasons.AddrPerm(
+ False, self.fred.email, self.fred, REPLY_NOT_ALLOWED,
+ user_pb2.UserPrefs(user_id=self.fred.user_id))],
+ all_notifications_apl=[notify_reasons.AddrPerm(
+ False, 'issues@example.com', None, REPLY_NOT_ALLOWED,
+ user_pb2.UserPrefs())])
+
+ # We don't use the notify-all address when the issue is not public.
+ actual = notify_reasons.ComputeGroupReasonList(
+ 'cnxn', self.services, self.project, self.issue, self.config,
+ self.users_by_id, [], False)
+ self.CheckGroupReasonList(
+ actual,
+ owner_apl=[notify_reasons.AddrPerm(
+ False, self.fred.email, self.fred, REPLY_NOT_ALLOWED,
+ user_pb2.UserPrefs(user_id=self.fred.user_id))])
+
+ # Now with the notify-all address disabled.
+ actual = notify_reasons.ComputeGroupReasonList(
+ 'cnxn', self.services, self.project, self.issue, self.config,
+ self.users_by_id, [], True, include_notify_all=False)
+ self.CheckGroupReasonList(
+ actual,
+ owner_apl=[notify_reasons.AddrPerm(
+ False, self.fred.email, self.fred, REPLY_NOT_ALLOWED,
+ user_pb2.UserPrefs(user_id=self.fred.user_id))])
diff --git a/features/test/notify_test.py b/features/test/notify_test.py
new file mode 100644
index 0000000..00de106
--- /dev/null
+++ b/features/test/notify_test.py
@@ -0,0 +1,708 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Tests for notify.py."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import json
+import mock
+import unittest
+import webapp2
+
+from google.appengine.ext import testbed
+
+from features import notify
+from features import notify_reasons
+from framework import emailfmt
+from framework import urls
+from proto import tracker_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+from tracker import attachment_helpers
+from tracker import tracker_bizobj
+
+from third_party import cloudstorage
+
+
+def MakeTestIssue(project_id, local_id, owner_id, reporter_id, is_spam=False):
+ issue = tracker_pb2.Issue()
+ issue.project_id = project_id
+ issue.local_id = local_id
+ issue.issue_id = 1000 * project_id + local_id
+ issue.owner_id = owner_id
+ issue.reporter_id = reporter_id
+ issue.is_spam = is_spam
+ return issue
+
+
+class NotifyTaskHandleRequestTest(unittest.TestCase):
+
+ def setUp(self):
+ self.services = service_manager.Services(
+ user=fake.UserService(),
+ usergroup=fake.UserGroupService(),
+ project=fake.ProjectService(),
+ config=fake.ConfigService(),
+ issue=fake.IssueService(),
+ issue_star=fake.IssueStarService(),
+ features=fake.FeaturesService())
+ self.requester = self.services.user.TestAddUser('requester@example.com', 1)
+ self.nonmember = self.services.user.TestAddUser('user@example.com', 2)
+ self.member = self.services.user.TestAddUser('member@example.com', 3)
+ self.project = self.services.project.TestAddProject(
+ 'test-project', owner_ids=[1, 3], project_id=12345)
+ self.issue1 = MakeTestIssue(
+ project_id=12345, local_id=1, owner_id=2, reporter_id=1)
+ self.issue2 = MakeTestIssue(
+ project_id=12345, local_id=2, owner_id=2, reporter_id=1)
+ self.services.issue.TestAddIssue(self.issue1)
+
+ self._old_gcs_open = cloudstorage.open
+ cloudstorage.open = fake.gcs_open
+ self.orig_sign_attachment_id = attachment_helpers.SignAttachmentID
+ attachment_helpers.SignAttachmentID = (
+ lambda aid: 'signed_%d' % aid)
+
+ self.testbed = testbed.Testbed()
+ self.testbed.activate()
+ self.testbed.init_memcache_stub()
+ self.testbed.init_datastore_v3_stub()
+
+ def tearDown(self):
+ cloudstorage.open = self._old_gcs_open
+ attachment_helpers.SignAttachmentID = self.orig_sign_attachment_id
+
+ def get_filtered_task_call_args(self, create_task_mock, relative_uri):
+ return [
+ (args, kwargs)
+ for (args, kwargs) in create_task_mock.call_args_list
+ if args[0]['app_engine_http_request']['relative_uri'] == relative_uri
+ ]
+
+ def VerifyParams(self, result, params):
+ self.assertEqual(
+ bool(params['send_email']), result['params']['send_email'])
+ if 'issue_id' in params:
+ self.assertEqual(params['issue_id'], result['params']['issue_id'])
+ if 'issue_ids' in params:
+ self.assertEqual([int(p) for p in params['issue_ids'].split(',')],
+ result['params']['issue_ids'])
+
+ def testNotifyIssueChangeTask_Normal(self):
+ task = notify.NotifyIssueChangeTask(
+ request=None, response=None, services=self.services)
+ params = {'send_email': 1, 'issue_id': 12345001, 'seq': 0,
+ 'commenter_id': 2}
+ mr = testing_helpers.MakeMonorailRequest(
+ user_info={'user_id': 1},
+ params=params,
+ method='POST',
+ services=self.services)
+ result = task.HandleRequest(mr)
+ self.VerifyParams(result, params)
+
+ @mock.patch('framework.cloud_tasks_helpers.create_task')
+ def testNotifyIssueChangeTask_Spam(self, _create_task_mock):
+ issue = MakeTestIssue(
+ project_id=12345, local_id=1, owner_id=1, reporter_id=1,
+ is_spam=True)
+ self.services.issue.TestAddIssue(issue)
+ task = notify.NotifyIssueChangeTask(
+ request=None, response=None, services=self.services)
+ params = {'send_email': 0, 'issue_id': issue.issue_id, 'seq': 0,
+ 'commenter_id': 2}
+ mr = testing_helpers.MakeMonorailRequest(
+ user_info={'user_id': 1},
+ params=params,
+ method='POST',
+ services=self.services)
+ result = task.HandleRequest(mr)
+ self.assertEqual(0, len(result['notified']))
+
+ @mock.patch('framework.cloud_tasks_helpers.create_task')
+ def testNotifyBlockingChangeTask_Normal(self, _create_task_mock):
+ issue2 = MakeTestIssue(
+ project_id=12345, local_id=2, owner_id=2, reporter_id=1)
+ self.services.issue.TestAddIssue(issue2)
+ task = notify.NotifyBlockingChangeTask(
+ request=None, response=None, services=self.services)
+ params = {
+ 'send_email': 1, 'issue_id': issue2.issue_id, 'seq': 0,
+ 'delta_blocker_iids': self.issue1.issue_id, 'commenter_id': 1,
+ 'hostport': 'bugs.chromium.org'}
+ mr = testing_helpers.MakeMonorailRequest(
+ user_info={'user_id': 1},
+ params=params,
+ method='POST',
+ services=self.services)
+ result = task.HandleRequest(mr)
+ self.VerifyParams(result, params)
+
+ def testNotifyBlockingChangeTask_Spam(self):
+ issue2 = MakeTestIssue(
+ project_id=12345, local_id=2, owner_id=2, reporter_id=1,
+ is_spam=True)
+ self.services.issue.TestAddIssue(issue2)
+ task = notify.NotifyBlockingChangeTask(
+ request=None, response=None, services=self.services)
+ params = {
+ 'send_email': 1, 'issue_id': issue2.issue_id, 'seq': 0,
+ 'delta_blocker_iids': self.issue1.issue_id, 'commenter_id': 1}
+ mr = testing_helpers.MakeMonorailRequest(
+ user_info={'user_id': 1},
+ params=params,
+ method='POST',
+ services=self.services)
+ result = task.HandleRequest(mr)
+ self.assertEqual(0, len(result['notified']))
+
+ @mock.patch('framework.cloud_tasks_helpers.create_task')
+ def testNotifyBulkChangeTask_Normal(self, create_task_mock):
+ """We generate email tasks for each user involved in the issues."""
+ issue2 = MakeTestIssue(
+ project_id=12345, local_id=2, owner_id=2, reporter_id=1)
+ issue2.cc_ids = [3]
+ self.services.issue.TestAddIssue(issue2)
+ task = notify.NotifyBulkChangeTask(
+ request=None, response=None, services=self.services)
+ params = {
+ 'send_email': 1, 'seq': 0,
+ 'issue_ids': '%d,%d' % (self.issue1.issue_id, issue2.issue_id),
+ 'old_owner_ids': '1,1', 'commenter_id': 1}
+ mr = testing_helpers.MakeMonorailRequest(
+ user_info={'user_id': 1},
+ params=params,
+ method='POST',
+ services=self.services)
+ result = task.HandleRequest(mr)
+ self.VerifyParams(result, params)
+
+ call_args_list = self.get_filtered_task_call_args(
+ create_task_mock, urls.OUTBOUND_EMAIL_TASK + '.do')
+ self.assertEqual(2, len(call_args_list))
+
+ for (args, _kwargs) in call_args_list:
+ task = args[0]
+ body = json.loads(task['app_engine_http_request']['body'].decode())
+ if 'user' in body['to']:
+ self.assertIn(u'\u2026', body['from_addr'])
+ # Full email for members
+ if 'member' in body['to']:
+ self.assertNotIn(u'\u2026', body['from_addr'])
+
+ @mock.patch('framework.cloud_tasks_helpers.create_task')
+ def testNotifyBulkChangeTask_AlsoNotify(self, create_task_mock):
+ """We generate email tasks for also-notify addresses."""
+ self.issue1.derived_notify_addrs = [
+ 'mailing-list@example.com', 'member@example.com']
+ task = notify.NotifyBulkChangeTask(
+ request=None, response=None, services=self.services)
+ params = {
+ 'send_email': 1, 'seq': 0,
+ 'issue_ids': '%d' % (self.issue1.issue_id),
+ 'old_owner_ids': '1', 'commenter_id': 1}
+ mr = testing_helpers.MakeMonorailRequest(
+ user_info={'user_id': 1},
+ params=params,
+ method='POST',
+ services=self.services)
+ result = task.HandleRequest(mr)
+ self.VerifyParams(result, params)
+
+ call_args_list = self.get_filtered_task_call_args(
+ create_task_mock, urls.OUTBOUND_EMAIL_TASK + '.do')
+ self.assertEqual(3, len(call_args_list))
+
+ self.assertItemsEqual(
+ ['user@example.com', 'mailing-list@example.com', 'member@example.com'],
+ result['notified'])
+ for (args, _kwargs) in call_args_list:
+ task = args[0]
+ body = json.loads(task['app_engine_http_request']['body'].decode())
+ # obfuscated email for non-members
+ if 'user' in body['to']:
+ self.assertIn(u'\u2026', body['from_addr'])
+ # Full email for members
+ if 'member' in body['to']:
+ self.assertNotIn(u'\u2026', body['from_addr'])
+
+ @mock.patch('framework.cloud_tasks_helpers.create_task')
+ def testNotifyBulkChangeTask_ProjectNotify(self, create_task_mock):
+ """We generate email tasks for project.issue_notify_address."""
+ self.project.issue_notify_address = 'mailing-list@example.com'
+ task = notify.NotifyBulkChangeTask(
+ request=None, response=None, services=self.services)
+ params = {
+ 'send_email': 1, 'seq': 0,
+ 'issue_ids': '%d' % (self.issue1.issue_id),
+ 'old_owner_ids': '1', 'commenter_id': 1}
+ mr = testing_helpers.MakeMonorailRequest(
+ user_info={'user_id': 1},
+ params=params,
+ method='POST',
+ services=self.services)
+ result = task.HandleRequest(mr)
+ self.VerifyParams(result, params)
+
+ call_args_list = self.get_filtered_task_call_args(
+ create_task_mock, urls.OUTBOUND_EMAIL_TASK + '.do')
+ self.assertEqual(2, len(call_args_list))
+
+ self.assertItemsEqual(
+ ['user@example.com', 'mailing-list@example.com'],
+ result['notified'])
+
+ for (args, _kwargs) in call_args_list:
+ task = args[0]
+ body = json.loads(task['app_engine_http_request']['body'].decode())
+ # obfuscated email for non-members
+ if 'user' in body['to']:
+ self.assertIn(u'\u2026', body['from_addr'])
+ # Full email for members
+ if 'member' in body['to']:
+ self.assertNotIn(u'\u2026', body['from_addr'])
+
+ @mock.patch('framework.cloud_tasks_helpers.create_task')
+ def testNotifyBulkChangeTask_SubscriberGetsEmail(self, create_task_mock):
+ """If a user subscription matches the issue, notify that user."""
+ task = notify.NotifyBulkChangeTask(
+ request=None, response=None, services=self.services)
+ params = {
+ 'send_email': 1,
+ 'issue_ids': '%d' % (self.issue1.issue_id),
+ 'seq': 0,
+ 'old_owner_ids': '1', 'commenter_id': 1}
+ mr = testing_helpers.MakeMonorailRequest(
+ user_info={'user_id': 1},
+ params=params,
+ method='POST',
+ services=self.services)
+ self.services.user.TestAddUser('subscriber@example.com', 4)
+ sq = tracker_bizobj.MakeSavedQuery(
+ 1, 'all open issues', 2, '', subscription_mode='immediate',
+ executes_in_project_ids=[self.issue1.project_id])
+ self.services.features.UpdateUserSavedQueries('cnxn', 4, [sq])
+ result = task.HandleRequest(mr)
+ self.VerifyParams(result, params)
+
+ call_args_list = self.get_filtered_task_call_args(
+ create_task_mock, urls.OUTBOUND_EMAIL_TASK + '.do')
+ self.assertEqual(2, len(call_args_list))
+
+ @mock.patch('framework.cloud_tasks_helpers.create_task')
+ def testNotifyBulkChangeTask_CCAndSubscriberListsIssueOnce(
+ self, create_task_mock):
+ """If a user both CCs and subscribes, include issue only once."""
+ task = notify.NotifyBulkChangeTask(
+ request=None, response=None, services=self.services)
+ params = {
+ 'send_email': 1,
+ 'issue_ids': '%d' % (self.issue1.issue_id),
+ 'seq': 0,
+ 'old_owner_ids': '1', 'commenter_id': 1}
+ mr = testing_helpers.MakeMonorailRequest(
+ user_info={'user_id': 1},
+ params=params,
+ method='POST',
+ services=self.services)
+ self.services.user.TestAddUser('subscriber@example.com', 4)
+ self.issue1.cc_ids = [4]
+ sq = tracker_bizobj.MakeSavedQuery(
+ 1, 'all open issues', 2, '', subscription_mode='immediate',
+ executes_in_project_ids=[self.issue1.project_id])
+ self.services.features.UpdateUserSavedQueries('cnxn', 4, [sq])
+ result = task.HandleRequest(mr)
+ self.VerifyParams(result, params)
+
+ call_args_list = self.get_filtered_task_call_args(
+ create_task_mock, urls.OUTBOUND_EMAIL_TASK + '.do')
+ self.assertEqual(2, len(call_args_list))
+
+ found = False
+ for (args, _kwargs) in call_args_list:
+ task = args[0]
+ body = json.loads(task['app_engine_http_request']['body'].decode())
+ if body['to'] == 'subscriber@example.com':
+ found = True
+ task_body = body['body']
+ self.assertEqual(1, task_body.count('Issue %d' % self.issue1.local_id))
+ self.assertTrue(found)
+
+ @mock.patch('framework.cloud_tasks_helpers.create_task')
+ def testNotifyBulkChangeTask_Spam(self, _create_task_mock):
+ """A spam issue is excluded from notification emails."""
+ issue2 = MakeTestIssue(
+ project_id=12345, local_id=2, owner_id=2, reporter_id=1,
+ is_spam=True)
+ self.services.issue.TestAddIssue(issue2)
+ task = notify.NotifyBulkChangeTask(
+ request=None, response=None, services=self.services)
+ params = {
+ 'send_email': 1,
+ 'issue_ids': '%d,%d' % (self.issue1.issue_id, issue2.issue_id),
+ 'seq': 0,
+ 'old_owner_ids': '1,1', 'commenter_id': 1}
+ mr = testing_helpers.MakeMonorailRequest(
+ user_info={'user_id': 1},
+ params=params,
+ method='POST',
+ services=self.services)
+ result = task.HandleRequest(mr)
+ self.assertEqual(1, len(result['notified']))
+
+ def testFormatBulkIssues_Normal_Single(self):
+ """A user may see full notification details for all changed issues."""
+ self.issue1.summary = 'one summary'
+ task = notify.NotifyBulkChangeTask(
+ request=None, response=None, services=self.services)
+ users_by_id = {}
+ commenter_view = None
+ config = self.services.config.GetProjectConfig('cnxn', 12345)
+ addrperm = notify_reasons.AddrPerm(
+ False, 'nonmember@example.com', self.nonmember,
+ notify_reasons.REPLY_NOT_ALLOWED, None)
+
+ subject, body = task._FormatBulkIssues(
+ [self.issue1], users_by_id, commenter_view, 'localhost:8080',
+ 'test comment', [], config, addrperm)
+
+ self.assertIn('one summary', subject)
+ self.assertIn('one summary', body)
+ self.assertIn('test comment', body)
+
+ def testFormatBulkIssues_Normal_Multiple(self):
+ """A user may see full notification details for all changed issues."""
+ self.issue1.summary = 'one summary'
+ self.issue2.summary = 'two summary'
+ task = notify.NotifyBulkChangeTask(
+ request=None, response=None, services=self.services)
+ users_by_id = {}
+ commenter_view = None
+ config = self.services.config.GetProjectConfig('cnxn', 12345)
+ addrperm = notify_reasons.AddrPerm(
+ False, 'nonmember@example.com', self.nonmember,
+ notify_reasons.REPLY_NOT_ALLOWED, None)
+
+ subject, body = task._FormatBulkIssues(
+ [self.issue1, self.issue2], users_by_id, commenter_view, 'localhost:8080',
+ 'test comment', [], config, addrperm)
+
+ self.assertIn('2 issues changed', subject)
+ self.assertIn('one summary', body)
+ self.assertIn('two summary', body)
+ self.assertIn('test comment', body)
+
+ def testFormatBulkIssues_LinkOnly_Single(self):
+ """A user may not see full notification details for some changed issue."""
+ self.issue1.summary = 'one summary'
+ self.issue1.labels = ['Restrict-View-Google']
+ task = notify.NotifyBulkChangeTask(
+ request=None, response=None, services=self.services)
+ users_by_id = {}
+ commenter_view = None
+ config = self.services.config.GetProjectConfig('cnxn', 12345)
+ addrperm = notify_reasons.AddrPerm(
+ False, 'nonmember@example.com', self.nonmember,
+ notify_reasons.REPLY_NOT_ALLOWED, None)
+
+ subject, body = task._FormatBulkIssues(
+ [self.issue1], users_by_id, commenter_view, 'localhost:8080',
+ 'test comment', [], config, addrperm)
+
+ self.assertIn('issue 1', subject)
+ self.assertNotIn('one summary', subject)
+ self.assertNotIn('one summary', body)
+ self.assertNotIn('test comment', body)
+
+ def testFormatBulkIssues_LinkOnly_Multiple(self):
+ """A user may not see full notification details for some changed issue."""
+ self.issue1.summary = 'one summary'
+ self.issue1.labels = ['Restrict-View-Google']
+ self.issue2.summary = 'two summary'
+ task = notify.NotifyBulkChangeTask(
+ request=None, response=None, services=self.services)
+ users_by_id = {}
+ commenter_view = None
+ config = self.services.config.GetProjectConfig('cnxn', 12345)
+ addrperm = notify_reasons.AddrPerm(
+ False, 'nonmember@example.com', self.nonmember,
+ notify_reasons.REPLY_NOT_ALLOWED, None)
+
+ subject, body = task._FormatBulkIssues(
+ [self.issue1, self.issue2], users_by_id, commenter_view, 'localhost:8080',
+ 'test comment', [], config, addrperm)
+
+ self.assertIn('2 issues', subject)
+ self.assertNotIn('summary', subject)
+ self.assertNotIn('one summary', body)
+ self.assertIn('two summary', body)
+ self.assertNotIn('test comment', body)
+
+ @mock.patch('framework.cloud_tasks_helpers.create_task')
+ def testNotifyApprovalChangeTask_Normal(self, _create_task_mock):
+ config = self.services.config.GetProjectConfig('cnxn', 12345)
+ config.field_defs = [
+ # issue's User field with any_comment is notified.
+ tracker_bizobj.MakeFieldDef(
+ 121, 12345, 'TL', tracker_pb2.FieldTypes.USER_TYPE,
+ '', '', False, False, False, None, None, None, False, '',
+ None, tracker_pb2.NotifyTriggers.ANY_COMMENT, 'no_action',
+ 'TL, notified on everything', False),
+ # issue's User field with never is not notified.
+ tracker_bizobj.MakeFieldDef(
+ 122, 12345, 'silentTL', tracker_pb2.FieldTypes.USER_TYPE,
+ '', '', False, False, False, None, None, None, False, '',
+ None, tracker_pb2.NotifyTriggers.NEVER, 'no_action',
+ 'TL, notified on nothing', False),
+ # approval's User field with any_comment is notified.
+ tracker_bizobj.MakeFieldDef(
+ 123, 12345, 'otherapprovalTL', tracker_pb2.FieldTypes.USER_TYPE,
+ '', '', False, False, False, None, None, None, False, '',
+ None, tracker_pb2.NotifyTriggers.ANY_COMMENT, 'no_action',
+ 'TL on the approvers team', False, approval_id=3),
+ # another approval's User field with any_comment is not notified.
+ tracker_bizobj.MakeFieldDef(
+ 124, 12345, 'otherapprovalTL', tracker_pb2.FieldTypes.USER_TYPE,
+ '', '', False, False, False, None, None, None, False, '',
+ None, tracker_pb2.NotifyTriggers.ANY_COMMENT, 'no_action',
+ 'TL on another approvers team', False, approval_id=4),
+ tracker_bizobj.MakeFieldDef(
+ 3, 12345, 'Goat-Approval', tracker_pb2.FieldTypes.APPROVAL_TYPE,
+ '', '', False, False, False, None, None, None, False, '',
+ None, tracker_pb2.NotifyTriggers.NEVER, 'no_action',
+ 'Get Approval from Goats', False)
+ ]
+ self.services.config.StoreConfig('cnxn', config)
+
+ # Custom user_type field TLs
+ self.services.user.TestAddUser('TL@example.com', 111)
+ self.services.user.TestAddUser('silentTL@example.com', 222)
+ self.services.user.TestAddUser('approvalTL@example.com', 333)
+ self.services.user.TestAddUser('otherapprovalTL@example.com', 444)
+
+ # Approvers
+ self.services.user.TestAddUser('approver_old@example.com', 777)
+ self.services.user.TestAddUser('approver_new@example.com', 888)
+ self.services.user.TestAddUser('approver_still@example.com', 999)
+ self.services.user.TestAddUser('approver_group@example.com', 666)
+ self.services.user.TestAddUser('group_mem1@example.com', 661)
+ self.services.user.TestAddUser('group_mem2@example.com', 662)
+ self.services.user.TestAddUser('group_mem3@example.com', 663)
+ self.services.usergroup.TestAddGroupSettings(
+ 666, 'approver_group@example.com')
+ self.services.usergroup.TestAddMembers(666, [661, 662, 663])
+ canary_phase = tracker_pb2.Phase(
+ name='Canary', phase_id=1, rank=1)
+ approval_values = [
+ tracker_pb2.ApprovalValue(approval_id=3,
+ approver_ids=[888, 999, 666, 661])]
+ approval_issue = MakeTestIssue(
+ project_id=12345, local_id=2, owner_id=2, reporter_id=1,
+ is_spam=True)
+ approval_issue.phases = [canary_phase]
+ approval_issue.approval_values = approval_values
+ approval_issue.field_values = [
+ tracker_bizobj.MakeFieldValue(121, None, None, 111, None, None, False),
+ tracker_bizobj.MakeFieldValue(122, None, None, 222, None, None, False),
+ tracker_bizobj.MakeFieldValue(123, None, None, 333, None, None, False),
+ tracker_bizobj.MakeFieldValue(124, None, None, 444, None, None, False),
+ ]
+ self.services.issue.TestAddIssue(approval_issue)
+
+ amend = tracker_bizobj.MakeApprovalApproversAmendment([888], [777])
+
+ comment = tracker_pb2.IssueComment(
+ project_id=12345, user_id=999, issue_id=approval_issue.issue_id,
+ amendments=[amend], timestamp=1234567890, content='just a comment.')
+ attach = tracker_pb2.Attachment(
+ attachment_id=4567, filename='sploot.jpg', mimetype='image/png',
+ gcs_object_id='/pid/attachments/abcd', filesize=(1024 * 1023))
+ comment.attachments.append(attach)
+ self.services.issue.TestAddComment(comment, approval_issue.local_id)
+ self.services.issue.TestAddAttachment(
+ attach, comment.id, approval_issue.issue_id)
+
+ task = notify.NotifyApprovalChangeTask(
+ request=None, response=None, services=self.services)
+ params = {
+ 'send_email': 1,
+ 'issue_id': approval_issue.issue_id,
+ 'approval_id': 3,
+ 'comment_id': comment.id,
+ }
+ mr = testing_helpers.MakeMonorailRequest(
+ user_info={'user_id': 1},
+ params=params,
+ method='POST',
+ services=self.services)
+ result = task.HandleRequest(mr)
+ self.assertTrue('just a comment' in result['tasks'][0]['body'])
+ self.assertTrue('Approvers: -appro...' in result['tasks'][0]['body'])
+ self.assertTrue('sploot.jpg' in result['tasks'][0]['body'])
+ self.assertTrue(
+ '/issues/attachment?aid=4567' in result['tasks'][0]['body'])
+ self.assertItemsEqual(
+ ['user@example.com', 'approver_old@example.com',
+ 'approver_new@example.com', 'TL@example.com',
+ 'approvalTL@example.com', 'group_mem1@example.com',
+ 'group_mem2@example.com', 'group_mem3@example.com'],
+ result['notified'])
+
+ # Test no approvers/groups notified
+ # Status change to NEED_INFO does not email approvers.
+ amend2 = tracker_bizobj.MakeApprovalStatusAmendment(
+ tracker_pb2.ApprovalStatus.NEED_INFO)
+ comment2 = tracker_pb2.IssueComment(
+ project_id=12345, user_id=999, issue_id=approval_issue.issue_id,
+ amendments=[amend2], timestamp=1234567891, content='')
+ self.services.issue.TestAddComment(comment2, approval_issue.local_id)
+ task = notify.NotifyApprovalChangeTask(
+ request=None, response=None, services=self.services)
+ params = {
+ 'send_email': 1,
+ 'issue_id': approval_issue.issue_id,
+ 'approval_id': 3,
+ 'comment_id': comment2.id,
+ }
+ mr = testing_helpers.MakeMonorailRequest(
+ user_info={'user_id': 1},
+ params=params,
+ method='POST',
+ services=self.services)
+ result = task.HandleRequest(mr)
+
+ self.assertIsNotNone(result['tasks'][0].get('references'))
+ self.assertEqual(result['tasks'][0]['reply_to'], emailfmt.NoReplyAddress())
+ self.assertTrue('Status: need_info' in result['tasks'][0]['body'])
+ self.assertItemsEqual(
+ ['user@example.com', 'TL@example.com', 'approvalTL@example.com'],
+ result['notified'])
+
+ def testNotifyApprovalChangeTask_GetApprovalEmailRecipients(self):
+ task = notify.NotifyApprovalChangeTask(
+ request=None, response=None, services=self.services)
+ issue = fake.MakeTestIssue(789, 1, 'summary', 'New', 111)
+ approval_value = tracker_pb2.ApprovalValue(
+ approver_ids=[222, 333],
+ status=tracker_pb2.ApprovalStatus.APPROVED)
+ comment = tracker_pb2.IssueComment(
+ project_id=789, user_id=1, issue_id=78901)
+
+ # Comment with not amendments notifies everyone.
+ rids = task._GetApprovalEmailRecipients(
+ approval_value, comment, issue, [777, 888])
+ self.assertItemsEqual(rids, [111, 222, 333, 777, 888])
+
+ # New APPROVED status notifies owners and any_comment users.
+ amendment = tracker_bizobj.MakeApprovalStatusAmendment(
+ tracker_pb2.ApprovalStatus.APPROVED)
+ comment.amendments = [amendment]
+ rids = task._GetApprovalEmailRecipients(
+ approval_value, comment, issue, [777, 888])
+ self.assertItemsEqual(rids, [111, 777, 888])
+
+ # New REVIEW_REQUESTED status notifies approvers.
+ approval_value.status = tracker_pb2.ApprovalStatus.REVIEW_REQUESTED
+ amendment = tracker_bizobj.MakeApprovalStatusAmendment(
+ tracker_pb2.ApprovalStatus.REVIEW_REQUESTED)
+ comment.amendments = [amendment]
+ rids = task._GetApprovalEmailRecipients(
+ approval_value, comment, issue, [777, 888])
+ self.assertItemsEqual(rids, [222, 333])
+
+ # Approvers change notifies everyone.
+ amendment = tracker_bizobj.MakeApprovalApproversAmendment(
+ [222], [555])
+ comment.amendments = [amendment]
+ approval_value.approver_ids = [222]
+ rids = task._GetApprovalEmailRecipients(
+ approval_value, comment, issue, [777], omit_ids=[444, 333])
+ self.assertItemsEqual(rids, [111, 222, 555, 777])
+
+ @mock.patch('framework.cloud_tasks_helpers.create_task')
+ def testNotifyRulesDeletedTask(self, _create_task_mock):
+ self.services.project.TestAddProject(
+ 'proj', owner_ids=[777, 888], project_id=789)
+ self.services.user.TestAddUser('owner1@test.com', 777)
+ self.services.user.TestAddUser('cow@test.com', 888)
+ task = notify.NotifyRulesDeletedTask(
+ request=None, response=None, services=self.services)
+ params = {'project_id': 789,
+ 'filter_rules': 'if green make yellow,if orange make blue'}
+ mr = testing_helpers.MakeMonorailRequest(
+ params=params,
+ method='POST',
+ services=self.services)
+ result = task.HandleRequest(mr)
+ self.assertEqual(len(result['tasks']), 2)
+ body = result['tasks'][0]['body']
+ self.assertTrue('if green make yellow' in body)
+ self.assertTrue('if green make yellow' in body)
+ self.assertTrue('/p/proj/adminRules' in body)
+ self.assertItemsEqual(
+ ['cow@test.com', 'owner1@test.com'], result['notified'])
+
+ def testOutboundEmailTask_Normal(self):
+ """We can send an email."""
+ params = {
+ 'from_addr': 'requester@example.com',
+ 'reply_to': 'user@example.com',
+ 'to': 'user@example.com',
+ 'subject': 'Test subject'}
+ body = json.dumps(params)
+ request = webapp2.Request.blank('/', body=body)
+ task = notify.OutboundEmailTask(
+ request=request, response=None, services=self.services)
+ mr = testing_helpers.MakeMonorailRequest(
+ user_info={'user_id': 1},
+ payload=body,
+ method='POST',
+ services=self.services)
+ result = task.HandleRequest(mr)
+ self.assertEqual(params['from_addr'], result['sender'])
+ self.assertEqual(params['subject'], result['subject'])
+
+ def testOutboundEmailTask_MissingTo(self):
+ """We skip emails that don't specify the To-line."""
+ params = {
+ 'from_addr': 'requester@example.com',
+ 'reply_to': 'user@example.com',
+ 'subject': 'Test subject'}
+ body = json.dumps(params)
+ request = webapp2.Request.blank('/', body=body)
+ task = notify.OutboundEmailTask(
+ request=request, response=None, services=self.services)
+ mr = testing_helpers.MakeMonorailRequest(
+ user_info={'user_id': 1},
+ payload=body,
+ method='POST',
+ services=self.services)
+ result = task.HandleRequest(mr)
+ self.assertEqual('Skipping because no "to" address found.', result['note'])
+ self.assertNotIn('from_addr', result)
+
+ def testOutboundEmailTask_BannedUser(self):
+ """We don't send emails to banned users.."""
+ params = {
+ 'from_addr': 'requester@example.com',
+ 'reply_to': 'user@example.com',
+ 'to': 'banned@example.com',
+ 'subject': 'Test subject'}
+ body = json.dumps(params)
+ request = webapp2.Request.blank('/', body=body)
+ task = notify.OutboundEmailTask(
+ request=request, response=None, services=self.services)
+ mr = testing_helpers.MakeMonorailRequest(
+ user_info={'user_id': 1},
+ payload=body,
+ method='POST',
+ services=self.services)
+ self.services.user.TestAddUser('banned@example.com', 404, banned=True)
+ result = task.HandleRequest(mr)
+ self.assertEqual('Skipping because user is banned.', result['note'])
+ self.assertNotIn('from_addr', result)
diff --git a/features/test/prettify_test.py b/features/test/prettify_test.py
new file mode 100644
index 0000000..07fce43
--- /dev/null
+++ b/features/test/prettify_test.py
@@ -0,0 +1,92 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unittest for the prettify module."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+import ezt
+
+from features import prettify
+
+
+class SourceBrowseTest(unittest.TestCase):
+
+ def testPrepareSourceLinesForHighlighting(self):
+ # String representing an empty source file
+ src = ''
+
+ file_lines = prettify.PrepareSourceLinesForHighlighting(src)
+ self.assertEqual(len(file_lines), 0)
+
+ def testPrepareSourceLinesForHighlightingNoBreaks(self):
+ # seven lines of text with no blank lines
+ src = ' 1\n 2\n 3\n 4\n 5\n 6\n 7'
+
+ file_lines = prettify.PrepareSourceLinesForHighlighting(src)
+ self.assertEqual(len(file_lines), 7)
+ out_lines = [fl.line for fl in file_lines]
+ self.assertEqual('\n'.join(out_lines), src)
+
+ file_lines = prettify.PrepareSourceLinesForHighlighting(src)
+ self.assertEqual(len(file_lines), 7)
+
+ def testPrepareSourceLinesForHighlightingWithBreaks(self):
+ # seven lines of text with line 5 being blank
+ src = ' 1\n 2\n 3\n 4\n\n 6\n 7'
+
+ file_lines = prettify.PrepareSourceLinesForHighlighting(src)
+ self.assertEqual(len(file_lines), 7)
+
+
+class BuildPrettifyDataTest(unittest.TestCase):
+
+ def testNonSourceFile(self):
+ prettify_data = prettify.BuildPrettifyData(0, '/dev/null')
+ self.assertDictEqual(
+ dict(should_prettify=ezt.boolean(False),
+ prettify_class=None),
+ prettify_data)
+
+ prettify_data = prettify.BuildPrettifyData(10, 'readme.txt')
+ self.assertDictEqual(
+ dict(should_prettify=ezt.boolean(False),
+ prettify_class=None),
+ prettify_data)
+
+ def testGenericLanguage(self):
+ prettify_data = prettify.BuildPrettifyData(123, 'trunk/src/hello.php')
+ self.assertDictEqual(
+ dict(should_prettify=ezt.boolean(True),
+ prettify_class=''),
+ prettify_data)
+
+ def testSpecificLanguage(self):
+ prettify_data = prettify.BuildPrettifyData(123, 'trunk/src/hello.java')
+ self.assertDictEqual(
+ dict(should_prettify=ezt.boolean(True),
+ prettify_class='lang-java'),
+ prettify_data)
+
+ def testThirdPartyExtensionLanguages(self):
+ for ext in ['apollo', 'agc', 'aea', 'el', 'scm', 'cl', 'lisp',
+ 'go', 'hs', 'lua', 'fs', 'ml', 'proto', 'scala',
+ 'sql', 'vb', 'vbs', 'vhdl', 'vhd', 'wiki', 'yaml',
+ 'yml', 'clj']:
+ prettify_data = prettify.BuildPrettifyData(123, '/trunk/src/hello.' + ext)
+ self.assertDictEqual(
+ dict(should_prettify=ezt.boolean(True),
+ prettify_class='lang-' + ext),
+ prettify_data)
+
+ def testExactFilename(self):
+ prettify_data = prettify.BuildPrettifyData(123, 'trunk/src/Makefile')
+ self.assertDictEqual(
+ dict(should_prettify=ezt.boolean(True),
+ prettify_class='lang-sh'),
+ prettify_data)
diff --git a/features/test/pubsub_test.py b/features/test/pubsub_test.py
new file mode 100644
index 0000000..2044cf7
--- /dev/null
+++ b/features/test/pubsub_test.py
@@ -0,0 +1,110 @@
+# Copyright 2019 The Chromium Authors. All rights reserved.
+# Use of this source code is govered by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Tests for features.pubsub."""
+
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from mock import Mock
+
+from features import pubsub
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+
+
+class PublishPubsubIssueChangeTaskTest(unittest.TestCase):
+
+ def setUp(self):
+ self.services = service_manager.Services(
+ user=fake.UserService(),
+ project=fake.ProjectService(),
+ config=fake.ConfigService(),
+ issue=fake.IssueService(),
+ features=fake.FeaturesService())
+ self.services.project.TestAddProject(
+ 'test-project', owner_ids=[1, 3],
+ project_id=12345)
+
+ # Stub the pubsub API (there is no pubsub testbed stub).
+ self.pubsub_client_mock = Mock()
+ pubsub.set_up_pubsub_api = Mock(return_value=self.pubsub_client_mock)
+
+ def testPublishPubsubIssueChangeTask_NoIssueIdParam(self):
+ """Test case when issue_id param is not passed."""
+ task = pubsub.PublishPubsubIssueChangeTask(
+ request=None, response=None, services=self.services)
+ mr = testing_helpers.MakeMonorailRequest(
+ user_info={'user_id': 1},
+ params={},
+ method='POST',
+ services=self.services)
+ result = task.HandleRequest(mr)
+ expected_body = {
+ 'error': 'Cannot proceed without a valid issue ID.',
+ }
+ self.assertEqual(result, expected_body)
+
+ def testPublishPubsubIssueChangeTask_PubSubAPIInitFailure(self):
+ """Test case when pub/sub API fails to init."""
+ pubsub.set_up_pubsub_api = Mock(return_value=None)
+ task = pubsub.PublishPubsubIssueChangeTask(
+ request=None, response=None, services=self.services)
+ mr = testing_helpers.MakeMonorailRequest(
+ user_info={'user_id': 1},
+ params={},
+ method='POST',
+ services=self.services)
+ result = task.HandleRequest(mr)
+ expected_body = {
+ 'error': 'Pub/Sub API init failure.',
+ }
+ self.assertEqual(result, expected_body)
+
+ def testPublishPubsubIssueChangeTask_IssueNotFound(self):
+ """Test case when issue is not found."""
+ task = pubsub.PublishPubsubIssueChangeTask(
+ request=None, response=None, services=self.services)
+ mr = testing_helpers.MakeMonorailRequest(
+ user_info={'user_id': 1},
+ params={'issue_id': 314159},
+ method='POST',
+ services=self.services)
+ result = task.HandleRequest(mr)
+ expected_body = {
+ 'error': 'Could not find issue with ID 314159',
+ }
+ self.assertEqual(result, expected_body)
+
+ def testPublishPubsubIssueChangeTask_Normal(self):
+ """Test normal happy-path case."""
+ issue = fake.MakeTestIssue(789, 543, 'sum', 'New', 111, issue_id=78901,
+ project_name='rutabaga')
+ self.services.issue.TestAddIssue(issue)
+ task = pubsub.PublishPubsubIssueChangeTask(
+ request=None, response=None, services=self.services)
+ mr = testing_helpers.MakeMonorailRequest(
+ user_info={'user_id': 1},
+ params={'issue_id': 78901},
+ method='POST',
+ services=self.services)
+ result = task.HandleRequest(mr)
+
+ self.pubsub_client_mock.projects().topics().publish.assert_called_once_with(
+ topic='projects/testing-app/topics/issue-updates',
+ body={
+ 'messages': [{
+ 'attributes': {
+ 'local_id': '543',
+ 'project_name': 'rutabaga',
+ },
+ }],
+ }
+ )
+ self.assertEqual(result, {})
diff --git a/features/test/savedqueries_helpers_test.py b/features/test/savedqueries_helpers_test.py
new file mode 100644
index 0000000..d635fe1
--- /dev/null
+++ b/features/test/savedqueries_helpers_test.py
@@ -0,0 +1,112 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unit tests for savedqueries_helpers feature."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+import mox
+
+from features import savedqueries_helpers
+from testing import fake
+from tracker import tracker_bizobj
+
+
+class SavedQueriesHelperTest(unittest.TestCase):
+
+ def setUp(self):
+ self.features = fake.FeaturesService()
+ self.project = fake.ProjectService()
+ self.cnxn = 'fake cnxn'
+ self.mox = mox.Mox()
+
+ def tearDown(self):
+ self.mox.UnsetStubs()
+ self.mox.ResetAll()
+
+ def testParseSavedQueries(self):
+ post_data = {
+ 'xyz_savedquery_name_1': '',
+ 'xyz_savedquery_name_2': 'name2',
+ 'xyz_savedquery_name_3': 'name3',
+ 'xyz_savedquery_id_1': 1,
+ 'xyz_savedquery_id_2': 2,
+ 'xyz_savedquery_id_3': 3,
+ 'xyz_savedquery_projects_1': '123',
+ 'xyz_savedquery_projects_2': 'abc',
+ 'xyz_savedquery_projects_3': 'def',
+ 'xyz_savedquery_base_1': 4,
+ 'xyz_savedquery_base_2': 5,
+ 'xyz_savedquery_base_3': 6,
+ 'xyz_savedquery_query_1': 'query1',
+ 'xyz_savedquery_query_2': 'query2',
+ 'xyz_savedquery_query_3': 'query3',
+ 'xyz_savedquery_sub_mode_1': 'sub_mode1',
+ 'xyz_savedquery_sub_mode_2': 'sub_mode2',
+ 'xyz_savedquery_sub_mode_3': 'sub_mode3',
+ }
+ self.project.TestAddProject(name='abc', project_id=1001)
+ self.project.TestAddProject(name='def', project_id=1002)
+
+ saved_queries = savedqueries_helpers.ParseSavedQueries(
+ self.cnxn, post_data, self.project, prefix='xyz_')
+ self.assertEqual(2, len(saved_queries))
+
+ # pylint: disable=unbalanced-tuple-unpacking
+ saved_query1, saved_query2 = saved_queries
+ # Assert contents of saved_query1.
+ self.assertEqual(2, saved_query1.query_id)
+ self.assertEqual('name2', saved_query1.name)
+ self.assertEqual(5, saved_query1.base_query_id)
+ self.assertEqual('query2', saved_query1.query)
+ self.assertEqual([1001], saved_query1.executes_in_project_ids)
+ self.assertEqual('sub_mode2', saved_query1.subscription_mode)
+ # Assert contents of saved_query2.
+ self.assertEqual(3, saved_query2.query_id)
+ self.assertEqual('name3', saved_query2.name)
+ self.assertEqual(6, saved_query2.base_query_id)
+ self.assertEqual('query3', saved_query2.query)
+ self.assertEqual([1002], saved_query2.executes_in_project_ids)
+ self.assertEqual('sub_mode3', saved_query2.subscription_mode)
+
+ def testSavedQueryToCond(self):
+ class MockSavedQuery:
+ def __init__(self):
+ self.base_query_id = 1
+ self.query = 'query'
+ saved_query = MockSavedQuery()
+
+ cond_for_missing_query = savedqueries_helpers.SavedQueryToCond(None)
+ self.assertEqual('', cond_for_missing_query)
+
+ cond_with_no_base = savedqueries_helpers.SavedQueryToCond(saved_query)
+ self.assertEqual('query', cond_with_no_base)
+
+ self.mox.StubOutWithMock(tracker_bizobj, 'GetBuiltInQuery')
+ tracker_bizobj.GetBuiltInQuery(1).AndReturn('base')
+ self.mox.ReplayAll()
+ cond_with_base = savedqueries_helpers.SavedQueryToCond(saved_query)
+ self.assertEqual('base query', cond_with_base)
+ self.mox.VerifyAll()
+
+ def testSavedQueryIDToCond(self):
+ self.mox.StubOutWithMock(savedqueries_helpers, 'SavedQueryToCond')
+ savedqueries_helpers.SavedQueryToCond(mox.IgnoreArg()).AndReturn('ret')
+ self.mox.ReplayAll()
+ query_cond = savedqueries_helpers.SavedQueryIDToCond(
+ self.cnxn, self.features, 1)
+ self.assertEqual('ret', query_cond)
+ self.mox.VerifyAll()
+
+ self.mox.StubOutWithMock(tracker_bizobj, 'GetBuiltInQuery')
+ tracker_bizobj.GetBuiltInQuery(1).AndReturn('built_in_query')
+ self.mox.ReplayAll()
+ query_cond = savedqueries_helpers.SavedQueryIDToCond(
+ self.cnxn, self.features, 1)
+ self.assertEqual('built_in_query', query_cond)
+ self.mox.VerifyAll()
diff --git a/features/test/savedqueries_test.py b/features/test/savedqueries_test.py
new file mode 100644
index 0000000..08624a2
--- /dev/null
+++ b/features/test/savedqueries_test.py
@@ -0,0 +1,43 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unit tests for savedqueries feature."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from features import savedqueries
+from framework import monorailrequest
+from framework import permissions
+from services import service_manager
+from testing import fake
+
+
+class SavedQueriesTest(unittest.TestCase):
+
+ def setUp(self):
+ self.services = service_manager.Services(
+ user=fake.UserService())
+ self.servlet = savedqueries.SavedQueries(
+ 'req', 'res', services=self.services)
+ self.services.user.TestAddUser('a@example.com', 111)
+
+ def testAssertBasePermission(self):
+ """Only permit site admins and users viewing themselves."""
+ mr = monorailrequest.MonorailRequest(self.services)
+ mr.viewed_user_auth.user_id = 111
+ mr.auth.user_id = 222
+
+ self.assertRaises(permissions.PermissionException,
+ self.servlet.AssertBasePermission, mr)
+
+ mr.auth.user_id = 111
+ self.servlet.AssertBasePermission(mr)
+
+ mr.auth.user_id = 222
+ mr.auth.user_pb.is_site_admin = True
+ self.servlet.AssertBasePermission(mr)
diff --git a/features/test/send_notifications_test.py b/features/test/send_notifications_test.py
new file mode 100644
index 0000000..435a67d
--- /dev/null
+++ b/features/test/send_notifications_test.py
@@ -0,0 +1,141 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Tests for prepareandsend.py"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import mock
+import unittest
+import urlparse
+
+from features import send_notifications
+from framework import urls
+from tracker import tracker_bizobj
+
+
+class SendNotificationTest(unittest.TestCase):
+
+ def _get_filtered_task_call_args(self, create_task_mock, relative_uri):
+ return [
+ (args, _kwargs)
+ for (args, _kwargs) in create_task_mock.call_args_list
+ if args[0]['app_engine_http_request']['relative_uri'].startswith(
+ relative_uri)
+ ]
+
+ def _get_create_task_path_and_params(self, call):
+ (args, _kwargs) = call
+ path = args[0]['app_engine_http_request']['relative_uri']
+ encoded_params = args[0]['app_engine_http_request']['body']
+ params = {
+ k: v[0] for k, v in urlparse.parse_qs(encoded_params, True).items()
+ }
+ return path, params
+
+ @mock.patch('framework.cloud_tasks_helpers.create_task')
+ def testPrepareAndSendIssueChangeNotification(self, create_task_mock):
+ send_notifications.PrepareAndSendIssueChangeNotification(
+ issue_id=78901,
+ hostport='testbed-test.appspotmail.com',
+ commenter_id=1,
+ old_owner_id=2,
+ send_email=True)
+
+ call_args_list = self._get_filtered_task_call_args(
+ create_task_mock, urls.NOTIFY_ISSUE_CHANGE_TASK + '.do')
+ self.assertEqual(1, len(call_args_list))
+
+ @mock.patch('framework.cloud_tasks_helpers.create_task')
+ def testPrepareAndSendIssueBlockingNotification(self, create_task_mock):
+ send_notifications.PrepareAndSendIssueBlockingNotification(
+ issue_id=78901,
+ hostport='testbed-test.appspotmail.com',
+ delta_blocker_iids=[],
+ commenter_id=1,
+ send_email=True)
+
+ call_args_list = self._get_filtered_task_call_args(
+ create_task_mock, urls.NOTIFY_BLOCKING_CHANGE_TASK + '.do')
+ self.assertEqual(0, len(call_args_list))
+
+ send_notifications.PrepareAndSendIssueBlockingNotification(
+ issue_id=78901,
+ hostport='testbed-test.appspotmail.com',
+ delta_blocker_iids=[2],
+ commenter_id=1,
+ send_email=True)
+
+ call_args_list = self._get_filtered_task_call_args(
+ create_task_mock, urls.NOTIFY_BLOCKING_CHANGE_TASK + '.do')
+ self.assertEqual(1, len(call_args_list))
+
+ @mock.patch('framework.cloud_tasks_helpers.create_task')
+ def testPrepareAndSendApprovalChangeNotification(self, create_task_mock):
+ send_notifications.PrepareAndSendApprovalChangeNotification(
+ 78901, 3, 'testbed-test.appspotmail.com', 55)
+
+ call_args_list = self._get_filtered_task_call_args(
+ create_task_mock, urls.NOTIFY_APPROVAL_CHANGE_TASK + '.do')
+ self.assertEqual(1, len(call_args_list))
+
+ @mock.patch('framework.cloud_tasks_helpers.create_task')
+ def testSendIssueBulkChangeNotification_CommentOnly(self, create_task_mock):
+ send_notifications.SendIssueBulkChangeNotification(
+ issue_ids=[78901],
+ hostport='testbed-test.appspotmail.com',
+ old_owner_ids=[2],
+ comment_text='comment',
+ commenter_id=1,
+ amendments=[],
+ send_email=True,
+ users_by_id=2)
+
+ call_args_list = self._get_filtered_task_call_args(
+ create_task_mock, urls.NOTIFY_BULK_CHANGE_TASK + '.do')
+ self.assertEqual(1, len(call_args_list))
+ _path, params = self._get_create_task_path_and_params(call_args_list[0])
+ self.assertEqual(params['comment_text'], 'comment')
+ self.assertEqual(params['amendments'], '')
+
+ @mock.patch('framework.cloud_tasks_helpers.create_task')
+ def testSendIssueBulkChangeNotification_Normal(self, create_task_mock):
+ send_notifications.SendIssueBulkChangeNotification(
+ issue_ids=[78901],
+ hostport='testbed-test.appspotmail.com',
+ old_owner_ids=[2],
+ comment_text='comment',
+ commenter_id=1,
+ amendments=[
+ tracker_bizobj.MakeStatusAmendment('New', 'Old'),
+ tracker_bizobj.MakeLabelsAmendment(['Added'], ['Removed']),
+ tracker_bizobj.MakeStatusAmendment('New', 'Old'),
+ ],
+ send_email=True,
+ users_by_id=2)
+
+ call_args_list = self._get_filtered_task_call_args(
+ create_task_mock, urls.NOTIFY_BULK_CHANGE_TASK + '.do')
+ self.assertEqual(1, len(call_args_list))
+ _path, params = self._get_create_task_path_and_params(call_args_list[0])
+ self.assertEqual(params['comment_text'], 'comment')
+ self.assertEqual(
+ params['amendments'].split('\n'),
+ [' Status: New', ' Labels: -Removed Added'])
+
+ @mock.patch('framework.cloud_tasks_helpers.create_task')
+ def testPrepareAndSendDeletedFilterRulesNotifications(self, create_task_mock):
+ filter_rule_strs = ['if yellow make orange', 'if orange make blue']
+ send_notifications.PrepareAndSendDeletedFilterRulesNotification(
+ 789, 'testbed-test.appspotmail.com', filter_rule_strs)
+
+ call_args_list = self._get_filtered_task_call_args(
+ create_task_mock, urls.NOTIFY_RULES_DELETED_TASK + '.do')
+ self.assertEqual(1, len(call_args_list))
+ _path, params = self._get_create_task_path_and_params(call_args_list[0])
+ self.assertEqual(params['project_id'], '789')
+ self.assertEqual(
+ params['filter_rules'], 'if yellow make orange,if orange make blue')
diff --git a/features/test/spammodel_test.py b/features/test/spammodel_test.py
new file mode 100644
index 0000000..3e99c8f
--- /dev/null
+++ b/features/test/spammodel_test.py
@@ -0,0 +1,39 @@
+# Copyright 2020 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+"""Tests for the spammodel module."""
+
+from __future__ import absolute_import
+from __future__ import division
+from __future__ import print_function
+
+import mock
+import unittest
+import webapp2
+
+from features import spammodel
+from framework import urls
+
+
+class TrainingDataExportTest(unittest.TestCase):
+
+ def test_handler_definition(self):
+ instance = spammodel.TrainingDataExport()
+ self.assertIsInstance(instance, webapp2.RequestHandler)
+
+ @mock.patch('framework.cloud_tasks_helpers._get_client')
+ def test_enqueues_task(self, get_client_mock):
+ spammodel.TrainingDataExport().get()
+ task = {
+ 'app_engine_http_request':
+ {
+ 'relative_uri': urls.SPAM_DATA_EXPORT_TASK + '.do',
+ 'body': '',
+ 'headers': {
+ 'Content-type': 'application/x-www-form-urlencoded'
+ }
+ }
+ }
+ get_client_mock().create_task.assert_called_once()
+ ((_parent, called_task), _kwargs) = get_client_mock().create_task.call_args
+ self.assertEqual(called_task, task)
diff --git a/features/userhotlists.py b/features/userhotlists.py
new file mode 100644
index 0000000..330ab73
--- /dev/null
+++ b/features/userhotlists.py
@@ -0,0 +1,83 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Page for showing a user's hotlists."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import ezt
+
+from features import features_bizobj
+from features import hotlist_views
+from framework import framework_views
+from framework import servlet
+
+
+class UserHotlists(servlet.Servlet):
+ """Servlet to display all of a user's hotlists."""
+
+ _PAGE_TEMPLATE = 'features/user-hotlists.ezt'
+
+ def GatherPageData(self, mr):
+ viewed_users_hotlists = self.services.features.GetHotlistsByUserID(
+ mr.cnxn, mr.viewed_user_auth.user_id)
+
+ viewed_starred_hids = self.services.hotlist_star.LookupStarredItemIDs(
+ mr.cnxn, mr.viewed_user_auth.user_id)
+ viewed_users_starred_hotlists, _ = self.services.features.GetHotlistsByID(
+ mr.cnxn, viewed_starred_hids)
+
+ viewed_users_relevant_hotlists = viewed_users_hotlists + list(
+ set(viewed_users_starred_hotlists.values()) -
+ set(viewed_users_hotlists))
+
+ users_by_id = framework_views.MakeAllUserViews(
+ mr.cnxn, self.services.user,
+ features_bizobj.UsersInvolvedInHotlists(viewed_users_relevant_hotlists))
+
+ views = [hotlist_views.HotlistView(
+ hotlist_pb, mr.perms, mr.auth, mr.viewed_user_auth.user_id,
+ users_by_id, self.services.hotlist_star.IsItemStarredBy(
+ mr.cnxn, hotlist_pb.hotlist_id, mr.auth.user_id))
+ for hotlist_pb in viewed_users_relevant_hotlists]
+
+ # visible to viewer, not viewed_user
+ visible_hotlists = [view for view in views if view.visible]
+
+ owner_of_hotlists = [hotlist_view for hotlist_view in visible_hotlists
+ if hotlist_view.role_name == 'owner']
+ editor_of_hotlists = [hotlist_view for hotlist_view in visible_hotlists
+ if hotlist_view.role_name == 'editor']
+ follower_of_hotlists = [hotlist_view for hotlist_view in visible_hotlists
+ if hotlist_view.role_name == '']
+ starred_hotlists = [hotlist_view for hotlist_view in visible_hotlists
+ if hotlist_view.hotlist_id in viewed_starred_hids]
+
+ viewed_user_display_name = framework_views.GetViewedUserDisplayName(mr)
+
+ return {
+ 'user_tab_mode': 'st6',
+ 'viewed_user_display_name': viewed_user_display_name,
+ 'owner_of_hotlists': owner_of_hotlists,
+ 'editor_of_hotlists': editor_of_hotlists,
+ 'follower_of_hotlists': follower_of_hotlists,
+ 'starred_hotlists': starred_hotlists,
+ 'viewing_user_page': ezt.boolean(True),
+ }
+
+ def GatherHelpData(self, mr, page_data):
+ """Return a dict of values to drive on-page user help.
+
+ Args:
+ mr: common information parsed from the HTTP request.
+ page_data: Dictionary of base and page template data.
+
+ Returns:
+ A dict of values to drive on-page user help, to be added to page_data.
+ """
+ help_data = super(UserHotlists, self).GatherHelpData(mr, page_data)
+ help_data['cue'] = 'explain_hotlist_starring'
+ return help_data