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