Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
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