1# Copyright 2015 The Chromium Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4"""General functions which are useful throughout this project."""
5from __future__ import print_function
6from __future__ import division
7from __future__ import absolute_import
8
9import collections
10import logging
11import os
12import re
13import time
14import urllib
15
16from apiclient import discovery
17from apiclient import errors
18from google.appengine.api import app_identity
19from google.appengine.api import memcache
20from google.appengine.api import oauth
21from google.appengine.api import urlfetch
22from google.appengine.api import urlfetch_errors
23from google.appengine.api import users
24from google.appengine.ext import ndb
25import httplib2
26from oauth2client import client
27
28from dashboard.common import stored_object
29
30SHERIFF_DOMAINS_KEY = 'sheriff_domains_key'
31IP_ALLOWLIST_KEY = 'ip_whitelist'
32SERVICE_ACCOUNT_KEY = 'service_account'
33PINPOINT_REPO_EXCLUSION_KEY = 'pinpoint_repo_exclusions'
34EMAIL_SCOPE = 'https://www.googleapis.com/auth/userinfo.email'
35_PROJECT_ID_KEY = 'project_id'
36_DEFAULT_CUSTOM_METRIC_VAL = 1
37OAUTH_SCOPES = ('https://www.googleapis.com/auth/userinfo.email',)
38OAUTH_ENDPOINTS = ['/api/', '/add_histograms', '/add_point', '/uploads']
39
40_AUTOROLL_DOMAINS = (
41    'chops-service-accounts.iam.gserviceaccount.com',
42    'skia-corp.google.com.iam.gserviceaccount.com',
43    'skia-public.iam.gserviceaccount.com',
44)
45STATISTICS = ['avg', 'count', 'max', 'min', 'std', 'sum']
46
47# TODO(crbug.com/1116480): This list should come from a call to a Monorail API.
48MONORAIL_PROJECTS = [
49    'angleproject', 'aomedia', 'apvi', 'boringssl', 'chromedriver', 'chromium',
50    'crashpad', 'dawn', 'gerrit', 'git', 'gn', 'google-breakpad', 'gyp',
51    'libyuv', 'linux-syscall-support', 'monorail', 'nativeclient', 'openscreen',
52    'oss-fuzz', 'pdfium', 'pigweed', 'project-zero', 'skia', 'swiftshader',
53    'tint', 'v8', 'webm', 'webp', 'webports', 'webrtc'
54]
55
56
57class _SimpleCache(
58    collections.namedtuple('_SimpleCache', ('timestamp', 'value'))):
59
60  def IsStale(self, ttl):
61    return time.time() - self.timestamp > ttl
62
63
64_PINPOINT_REPO_EXCLUSION_TTL = 60  # seconds
65_PINPOINT_REPO_EXCLUSION_CACHED = _SimpleCache(0, None)
66
67
68def IsDevAppserver():
69  return app_identity.get_application_id() == 'None'
70
71
72def _GetNowRfc3339():
73  """Returns the current time formatted per RFC 3339."""
74  return time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
75
76
77def GetEmail():
78  """Returns email address of the current user.
79
80  Uses OAuth2 for /api/ requests, otherwise cookies.
81
82  Returns:
83    The email address as a string or None if there is no user logged in.
84
85  Raises:
86    OAuthRequestError: The request was not a valid OAuth request.
87    OAuthServiceFailureError: An unknown error occurred.
88  """
89  request_uri = os.environ.get('REQUEST_URI', '')
90  if any(request_uri.startswith(e) for e in OAUTH_ENDPOINTS):
91    # Prevent a CSRF whereby a malicious site posts an api request without an
92    # Authorization header (so oauth.get_current_user() is None), but while the
93    # user is signed in, so their cookies would make users.get_current_user()
94    # return a non-None user.
95    if 'HTTP_AUTHORIZATION' not in os.environ:
96      # The user is not signed in. Avoid raising OAuthRequestError.
97      return None
98    user = oauth.get_current_user(OAUTH_SCOPES)
99  else:
100    user = users.get_current_user()
101  return user.email() if user else None
102
103
104@ndb.transactional(propagation=ndb.TransactionOptions.INDEPENDENT, xg=True)
105def TickMonitoringCustomMetric(metric_name):
106  """Increments the stackdriver custom metric with the given name.
107
108  This is used for cron job monitoring; if these metrics stop being received
109  an alert mail is sent. For more information on custom metrics, see
110  https://cloud.google.com/monitoring/custom-metrics/using-custom-metrics
111
112  Args:
113    metric_name: The name of the metric being monitored.
114  """
115  credentials = client.GoogleCredentials.get_application_default()
116  monitoring = discovery.build('monitoring', 'v3', credentials=credentials)
117  now = _GetNowRfc3339()
118  project_id = stored_object.Get(_PROJECT_ID_KEY)
119  points = [{
120      'interval': {
121          'startTime': now,
122          'endTime': now,
123      },
124      'value': {
125          'int64Value': _DEFAULT_CUSTOM_METRIC_VAL,
126      },
127  }]
128  write_request = monitoring.projects().timeSeries().create(
129      name='projects/%s' % project_id,
130      body={
131          'timeSeries': [{
132              'metric': {
133                  'type': 'custom.googleapis.com/%s' % metric_name,
134              },
135              'points': points
136          }]
137      })
138  write_request.execute()
139
140
141def TestPath(key):
142  """Returns the test path for a TestMetadata from an ndb.Key.
143
144  A "test path" is just a convenient string representation of an ndb.Key.
145  Each test path corresponds to one ndb.Key, which can be used to get an
146  entity.
147
148  Args:
149    key: An ndb.Key where all IDs are string IDs.
150
151  Returns:
152    A test path string.
153  """
154  if key.kind() == 'Test':
155    # The Test key looks like ('Master', 'name', 'Bot', 'name', 'Test' 'name'..)
156    # Pull out every other entry and join with '/' to form the path.
157    return '/'.join(key.flat()[1::2])
158  assert key.kind() == 'TestMetadata' or key.kind() == 'TestContainer'
159  return key.id()
160
161
162def TestSuiteName(test_key):
163  """Returns the test suite name for a given TestMetadata key."""
164  assert test_key.kind() == 'TestMetadata'
165  parts = test_key.id().split('/')
166  return parts[2]
167
168
169def TestKey(test_path):
170  """Returns the ndb.Key that corresponds to a test path."""
171  if test_path is None:
172    return None
173  path_parts = test_path.split('/')
174  if path_parts is None:
175    return None
176  if len(path_parts) < 3:
177    key_list = [('Master', path_parts[0])]
178    if len(path_parts) > 1:
179      key_list += [('Bot', path_parts[1])]
180    return ndb.Key(pairs=key_list)
181  return ndb.Key('TestMetadata', test_path)
182
183
184def TestMetadataKey(key_or_string):
185  """Convert the given (Test or TestMetadata) key or test_path string to a
186     TestMetadata key.
187
188  We are in the process of converting from Test entities to TestMetadata.
189  Unfortunately, we haver trillions of Row entities which have a parent_test
190  property set to a Test, and it's not possible to migrate them all. So we
191  use the Test key in Row queries, and convert between the old and new format.
192
193  Note that the Test entities which the keys refer to may be deleted; the
194  queries over keys still work.
195  """
196  if key_or_string is None:
197    return None
198  if isinstance(key_or_string, basestring):
199    return ndb.Key('TestMetadata', key_or_string)
200  if key_or_string.kind() == 'TestMetadata':
201    return key_or_string
202  if key_or_string.kind() == 'Test':
203    return ndb.Key('TestMetadata', TestPath(key_or_string))
204
205
206def OldStyleTestKey(key_or_string):
207  """Get the key for the old style Test entity corresponding to this key or
208     test_path.
209
210  We are in the process of converting from Test entities to TestMetadata.
211  Unfortunately, we haver trillions of Row entities which have a parent_test
212  property set to a Test, and it's not possible to migrate them all. So we
213  use the Test key in Row queries, and convert between the old and new format.
214
215  Note that the Test entities which the keys refer to may be deleted; the
216  queries over keys still work.
217  """
218  if key_or_string is None:
219    return None
220  elif isinstance(key_or_string, ndb.Key) and key_or_string.kind() == 'Test':
221    return key_or_string
222  if (isinstance(key_or_string, ndb.Key)
223      and key_or_string.kind() == 'TestMetadata'):
224    key_or_string = key_or_string.id()
225  assert isinstance(key_or_string, basestring)
226  path_parts = key_or_string.split('/')
227  key_parts = ['Master', path_parts[0], 'Bot', path_parts[1]]
228  for part in path_parts[2:]:
229    key_parts += ['Test', part]
230  return ndb.Key(*key_parts)
231
232
233def ParseStatisticNameFromChart(chart_name):
234  chart_name_parts = chart_name.split('_')
235  statistic_name = ''
236
237  if chart_name_parts[-1] in STATISTICS:
238    chart_name = '_'.join(chart_name_parts[:-1])
239    statistic_name = chart_name_parts[-1]
240  return chart_name, statistic_name
241
242
243def MostSpecificMatchingPattern(test, pattern_data_tuples):
244  """Takes a test and a list of (pattern, data) tuples and returns the data
245  for the pattern which most closely matches the test. It does this by
246  ordering the matching patterns, and choosing the one with the most specific
247  top level match.
248
249  For example, if there was a test Master/Bot/Foo/Bar, then:
250
251  */*/*/Bar would match more closely than */*/*/*
252  */*/*/Bar would match more closely than */*/*/Bar.*
253  */*/*/Bar.* would match more closely than */*/*/*
254  """
255
256  # To implement this properly, we'll use a matcher trie. This trie data
257  # structure will take the tuple of patterns like:
258  #
259  #   */*/*/Bar
260  #   */*/*/Bar.*
261  #   */*/*/*
262  #
263  # and create a trie of the following form:
264  #
265  #   (all, *) -> (all, *) -> (all, *) -> (specific, Bar)
266  #                               T
267  #                               + -> (partial, Bar.*)
268  #                               |
269  #                               + -> (all, *)
270  #
271  #
272  # We can then traverse this trie, where we order the matchers by exactness,
273  # and return the deepest pattern that matches.
274  #
275  # For now, we'll keep this as is.
276  #
277  # TODO(dberris): Refactor this to build a trie.
278
279  matching_patterns = []
280  for p, v in pattern_data_tuples:
281    if not TestMatchesPattern(test, p):
282      continue
283    matching_patterns.append([p, v])
284
285  if not matching_patterns:
286    return None
287
288  if isinstance(test, ndb.Key):
289    test_path = TestPath(test)
290  else:
291    test_path = test.test_path
292  test_path_parts = test_path.split('/')
293
294  # This ensures the ordering puts the closest match at index 0
295  def CmpPatterns(a, b):
296    a_parts = a[0].split('/')
297    b_parts = b[0].split('/')
298    for a_part, b_part, test_part in reversed(
299        zip(a_parts, b_parts, test_path_parts)):
300      # We favour a specific match over a partial match, and a partial
301      # match over a catch-all * match.
302      if a_part == b_part:
303        continue
304      if a_part == test_part:
305        return -1
306      if b_part == test_part:
307        return 1
308      if a_part != '*':
309        return -1
310      if b_part != '*':
311        return 1
312      return 0
313
314    # In the case when we find that the patterns are the same, we should return
315    # 0 to indicate that we've found an equality.
316    return 0
317
318  matching_patterns.sort(cmp=CmpPatterns)  # pylint: disable=using-cmp-argument
319
320  return matching_patterns[0][1]
321
322
323class ParseTelemetryMetricFailed(Exception):
324  pass
325
326
327def ParseTelemetryMetricParts(test_path):
328  """Parses a test path and returns the grouping_label, measurement, and story.
329
330  Args:
331    test_path_parts: A test path.
332
333  Returns:
334    A tuple of (grouping_label, measurement, story), or None if this doesn't
335    appear to be a telemetry test.
336  """
337  test_path_parts = test_path.split('/')
338  metric_parts = test_path_parts[3:]
339
340  if len(metric_parts) > 3 or len(metric_parts) == 0:
341    raise ParseTelemetryMetricFailed(test_path)
342
343  # Normal test path structure, ie. M/B/S/foo/bar.html
344  if len(metric_parts) == 2:
345    return '', metric_parts[0], metric_parts[1]
346
347  # 3 part structure, so there's a grouping label in there.
348  # ie. M/B/S/timeToFirstMeaningfulPaint_avg/load_tools/load_tools_weather
349  if len(metric_parts) == 3:
350    return metric_parts[1], metric_parts[0], metric_parts[2]
351
352  # Should be something like M/B/S/EventsDispatching where the trace_name is
353  # left empty and implied to be summary.
354  assert len(metric_parts) == 1
355  return '', metric_parts[0], ''
356
357
358def TestMatchesPattern(test, pattern):
359  """Checks whether a test matches a test path pattern.
360
361  Args:
362    test: A TestMetadata entity or a TestMetadata key.
363    pattern: A test path which can include wildcard characters (*).
364
365  Returns:
366    True if it matches, False otherwise.
367  """
368  if not test:
369    return False
370  if isinstance(test, ndb.Key):
371    test_path = TestPath(test)
372  else:
373    test_path = test.test_path
374  test_path_parts = test_path.split('/')
375  pattern_parts = pattern.split('/')
376  if len(test_path_parts) != len(pattern_parts):
377    return False
378  for test_path_part, pattern_part in zip(test_path_parts, pattern_parts):
379    if not _MatchesPatternPart(pattern_part, test_path_part):
380      return False
381  return True
382
383
384def _MatchesPatternPart(pattern_part, test_path_part):
385  """Checks whether a pattern (possibly with a *) matches the given string.
386
387  Args:
388    pattern_part: A string which may contain a wildcard (*).
389    test_path_part: Another string.
390
391  Returns:
392    True if it matches, False otherwise.
393  """
394  if pattern_part == '*' or pattern_part == test_path_part:
395    return True
396  if '*' not in pattern_part:
397    return False
398  # Escape any other special non-alphanumeric characters.
399  pattern_part = re.escape(pattern_part)
400  # There are not supposed to be any other asterisk characters, so all
401  # occurrences of backslash-asterisk can now be replaced with dot-asterisk.
402  re_pattern = re.compile('^' + pattern_part.replace('\\*', '.*') + '$')
403  return re_pattern.match(test_path_part)
404
405
406def TimestampMilliseconds(datetime):
407  """Returns the number of milliseconds since the epoch."""
408  return int(time.mktime(datetime.timetuple()) * 1000)
409
410
411def GetTestContainerKey(test):
412  """Gets the TestContainer key for the given TestMetadata.
413
414  Args:
415    test: Either a TestMetadata entity or its ndb.Key.
416
417  Returns:
418    ndb.Key('TestContainer', test path)
419  """
420  test_path = None
421  if isinstance(test, ndb.Key):
422    test_path = TestPath(test)
423  else:
424    test_path = test.test_path
425  return ndb.Key('TestContainer', test_path)
426
427
428def GetMulti(keys):
429  """Gets a list of entities from a list of keys.
430
431  If this user is logged in, this is the same as ndb.get_multi. However, if the
432  user is logged out and any of the data is internal only, an AssertionError
433  will be raised.
434
435  Args:
436    keys: A list of ndb entity keys.
437
438  Returns:
439    A list of entities, but no internal_only ones if the user is not logged in.
440  """
441  if IsInternalUser():
442    return ndb.get_multi(keys)
443  # Not logged in. Check each key individually.
444  entities = []
445  for key in keys:
446    try:
447      entities.append(key.get())
448    except AssertionError:
449      continue
450  return entities
451
452
453def MinimumAlertRange(alerts):
454  """Returns the intersection of the revision ranges for a set of alerts.
455
456  Args:
457    alerts: An iterable of Alerts.
458
459  Returns:
460    A pair (start, end) if there is a valid minimum range,
461    or None if the ranges are not overlapping.
462  """
463  ranges = [(a.start_revision, a.end_revision) for a in alerts if a]
464  return MinimumRange(ranges)
465
466
467def MinimumRange(ranges):
468  """Returns the intersection of the given ranges, or None."""
469  if not ranges:
470    return None
471  starts, ends = zip(*ranges)
472  start, end = (max(starts), min(ends))
473  if start > end:
474    return None
475  return start, end
476
477
478def IsInternalUser():
479  """Checks whether the user should be able to see internal-only data."""
480  if IsDevAppserver():
481    return True
482  email = GetEmail()
483  if not email:
484    return False
485  cached = GetCachedIsInternalUser(email)
486  if cached is not None:
487    return cached
488  is_internal_user = IsGroupMember(identity=email, group='chromeperf-access')
489  SetCachedIsInternalUser(email, is_internal_user)
490  return is_internal_user
491
492
493def IsAdministrator():
494  """Checks whether the user is an administrator of the Dashboard."""
495  if IsDevAppserver():
496    return True
497  email = GetEmail()
498  if not email:
499    return False
500  cached = GetCachedIsAdministrator(email)
501  if cached is not None:
502    return cached
503  is_administrator = IsGroupMember(
504      identity=email, group='project-chromeperf-admins')
505  SetCachedIsAdministrator(email, is_administrator)
506  return is_administrator
507
508
509def GetCachedIsInternalUser(email):
510  return memcache.get(_IsInternalUserCacheKey(email))
511
512
513def SetCachedIsInternalUser(email, value):
514  memcache.set(_IsInternalUserCacheKey(email), value, time=60 * 60 * 24)
515
516
517def GetCachedIsAdministrator(email):
518  return memcache.get(_IsAdministratorUserCacheKey(email))
519
520
521def SetCachedIsAdministrator(email, value):
522  memcache.set(_IsAdministratorUserCacheKey(email), value, time=60 * 60 * 24)
523
524
525def _IsInternalUserCacheKey(email):
526  return 'is_internal_user_{}'.format(email)
527
528
529def _IsAdministratorUserCacheKey(email):
530  return 'is_administrator_{}'.format(email)
531
532
533def ShouldTurnOnUploadCompletionTokenExperiment():
534  """Checks whether current request should be part of upload completeon token
535  experiment.
536
537  True for requests from project-chromeperf-upload-token-experiment
538  group members. Does not guarantee, that multiple calls for one request will
539  return the same results. So call it only once and than pass the decision to
540  other parts of the code manually.
541  """
542  if IsDevAppserver():
543    return True
544  email = GetEmail()
545  if not email:
546    return False
547  return IsGroupMember(
548      identity=email, group='project-chromeperf-upload-token-experiment')
549
550
551def IsGroupMember(identity, group):
552  """Checks if a user is a group member of using chrome-infra-auth.appspot.com.
553
554  Args:
555    identity: User email address.
556    group: Group name.
557
558  Returns:
559    True if confirmed to be a member, False otherwise.
560  """
561  cached = GetCachedIsGroupMember(identity, group)
562  if cached is not None:
563    return cached
564  try:
565    discovery_url = ('https://chrome-infra-auth.appspot.com'
566                     '/_ah/api/discovery/v1/apis/{api}/{apiVersion}/rest')
567    service = discovery.build(
568        'auth',
569        'v1',
570        discoveryServiceUrl=discovery_url,
571        http=ServiceAccountHttp())
572    request = service.membership(identity=identity, group=group)
573    response = request.execute()
574    is_member = response['is_member']
575    SetCachedIsGroupMember(identity, group, is_member)
576    return is_member
577  except (errors.HttpError, KeyError, AttributeError) as e:
578    logging.error('Failed to check membership of %s: %s', identity, e)
579    return False
580
581
582def GetCachedIsGroupMember(identity, group):
583  return memcache.get(_IsGroupMemberCacheKey(identity, group))
584
585
586def SetCachedIsGroupMember(identity, group, value):
587  memcache.set(
588      _IsGroupMemberCacheKey(identity, group), value, time=60 * 60 * 24)
589
590
591def _IsGroupMemberCacheKey(identity, group):
592  return 'is_group_member_%s_%s' % (identity, group)
593
594
595@ndb.transactional(propagation=ndb.TransactionOptions.INDEPENDENT, xg=True)
596def ServiceAccountEmail(scope=EMAIL_SCOPE):
597  account_details = stored_object.Get(SERVICE_ACCOUNT_KEY)
598  if not account_details:
599    raise KeyError('Service account credentials not found.')
600
601  assert scope, "ServiceAccountHttp scope must not be None."
602
603  return account_details['client_email'],
604
605
606@ndb.transactional(propagation=ndb.TransactionOptions.INDEPENDENT, xg=True)
607def ServiceAccountHttp(scope=EMAIL_SCOPE, timeout=None):
608  """Returns the Credentials of the service account if available."""
609  account_details = stored_object.Get(SERVICE_ACCOUNT_KEY)
610  if not account_details:
611    raise KeyError('Service account credentials not found.')
612
613  assert scope, "ServiceAccountHttp scope must not be None."
614
615  client.logger.setLevel(logging.WARNING)
616  credentials = client.SignedJwtAssertionCredentials(
617      service_account_name=account_details['client_email'],
618      private_key=account_details['private_key'],
619      scope=scope)
620
621  http = httplib2.Http(timeout=timeout)
622  credentials.authorize(http)
623  return http
624
625
626@ndb.transactional(propagation=ndb.TransactionOptions.INDEPENDENT, xg=True)
627def IsValidSheriffUser():
628  """Checks whether the user should be allowed to triage alerts."""
629  email = GetEmail()
630  if not email:
631    return False
632
633  sheriff_domains = stored_object.Get(SHERIFF_DOMAINS_KEY)
634  domain_matched = sheriff_domains and any(
635      email.endswith('@' + domain) for domain in sheriff_domains)
636  return domain_matched or IsTryjobUser()
637
638
639def IsTryjobUser():
640  email = GetEmail()
641  return bool(email) and IsGroupMember(
642      identity=email, group='project-pinpoint-tryjob-access')
643
644
645@ndb.transactional(propagation=ndb.TransactionOptions.INDEPENDENT, xg=True)
646def GetIpAllowlist():
647  """Returns a list of IP addresses allowed to post data."""
648  return stored_object.Get(IP_ALLOWLIST_KEY)
649
650
651def GetRepositoryExclusions():
652  # TODO(abennetts): determine if this caching hack is useful.
653  global _PINPOINT_REPO_EXCLUSION_CACHED
654  if _PINPOINT_REPO_EXCLUSION_CACHED.IsStale(_PINPOINT_REPO_EXCLUSION_TTL):
655    _PINPOINT_REPO_EXCLUSION_CACHED = _SimpleCache(time.time(),
656                                                   _GetRepositoryExclusions())
657  return _PINPOINT_REPO_EXCLUSION_CACHED.value
658
659
660@ndb.transactional(propagation=ndb.TransactionOptions.INDEPENDENT, xg=True)
661def _GetRepositoryExclusions():
662  """Returns a list of repositories to exclude from bisection."""
663  # TODO(dberris): Move this to git-hosted configurations later.
664  return stored_object.Get(PINPOINT_REPO_EXCLUSION_KEY) or []
665
666
667def GetRequestId():
668  """Returns the request log ID which can be used to find a specific log."""
669  return os.environ.get('REQUEST_LOG_ID')
670
671
672def Validate(expected, actual):
673  """Generic validator for expected keys, values, and types.
674
675  Values are also considered equal if |actual| can be converted to |expected|'s
676  type.  For instance:
677    _Validate([3], '3')  # Returns True.
678
679  See utils_test.py for more examples.
680
681  Args:
682    expected: Either a list of expected values or a dictionary of expected
683        keys and type.  A dictionary can contain a list of expected values.
684    actual: A value.
685  """
686
687  def IsValidType(expected, actual):
688    if isinstance(expected, type) and not isinstance(actual, expected):
689      try:
690        expected(actual)
691      except ValueError:
692        return False
693    return True
694
695  def IsInList(expected, actual):
696    for value in expected:
697      try:
698        if type(value)(actual) == value:
699          return True
700      except ValueError:
701        pass
702    return False
703
704  if not expected:
705    return
706  expected_type = type(expected)
707  actual_type = type(actual)
708  if expected_type is list:
709    if not IsInList(expected, actual):
710      raise ValueError('Invalid value. Expected one of the following: '
711                       '%s. Actual: %s.' % (','.join(expected), actual))
712  elif expected_type is dict:
713    if actual_type is not dict:
714      raise ValueError('Invalid type. Expected: %s. Actual: %s.' %
715                       (expected_type, actual_type))
716    missing = set(expected.keys()) - set(actual.keys())
717    if missing:
718      raise ValueError('Missing the following properties: %s' %
719                       ','.join(missing))
720    for key in expected:
721      Validate(expected[key], actual[key])
722  elif not IsValidType(expected, actual):
723    raise ValueError('Invalid type. Expected: %s. Actual: %s.' %
724                     (expected, actual_type))
725
726
727def FetchURL(request_url, skip_status_code=False):
728  """Wrapper around URL fetch service to make request.
729
730  Args:
731    request_url: URL of request.
732    skip_status_code: Skips return code check when True, default is False.
733
734  Returns:
735    Response object return by URL fetch, otherwise None when there's an error.
736  """
737  logging.info('URL being fetched: ' + request_url)
738  try:
739    response = urlfetch.fetch(request_url)
740  except urlfetch_errors.DeadlineExceededError:
741    logging.error('Deadline exceeded error checking %s', request_url)
742    return None
743  except urlfetch_errors.DownloadError as err:
744    # DownloadError is raised to indicate a non-specific failure when there
745    # was not a 4xx or 5xx status code.
746    logging.error('DownloadError: %r', err)
747    return None
748  if skip_status_code:
749    return response
750  elif response.status_code != 200:
751    logging.error('ERROR %s checking %s', response.status_code, request_url)
752    return None
753  return response
754
755
756def GetBuildDetailsFromStdioLink(stdio_link):
757  no_details = (None, None, None, None, None)
758  m = re.match(r'\[(.+?)\]\((.+?)\)', stdio_link)
759  if not m:
760    # This wasn't the markdown-style link we were expecting.
761    return no_details
762  _, link = m.groups()
763  m = re.match(
764      r'(https{0,1}://.*/([^\/]*)/builders/)'
765      r'([^\/]+)/builds/(\d+)/steps/([^\/]+)', link)
766  if not m:
767    # This wasn't a buildbot formatted link.
768    return no_details
769  base_url, master, bot, buildnumber, step = m.groups()
770  bot = urllib.unquote(bot)
771  return base_url, master, bot, buildnumber, step
772
773
774def GetStdioLinkFromRow(row):
775  """Returns the markdown-style buildbot stdio link.
776
777  Due to crbug.com/690630, many row entities have this set to "a_a_stdio_uri"
778  instead of "a_stdio_uri".
779  """
780  return (getattr(row, 'a_stdio_uri', None)
781          or getattr(row, 'a_a_stdio_uri', None))
782
783
784def GetBuildbotStatusPageUriFromStdioLink(stdio_link):
785  base_url, _, bot, buildnumber, _ = GetBuildDetailsFromStdioLink(stdio_link)
786  if not base_url:
787    # Can't parse status page
788    return None
789  return '%s%s/builds/%s' % (base_url, urllib.quote(bot), buildnumber)
790
791
792def GetLogdogLogUriFromStdioLink(stdio_link):
793  base_url, master, bot, buildnumber, step = GetBuildDetailsFromStdioLink(
794      stdio_link)
795  if not base_url:
796    # Can't parse status page
797    return None
798  bot = re.sub(r'[ \(\)]', '_', bot)
799  s_param = urllib.quote(
800      'chrome/bb/%s/%s/%s/+/recipes/steps/%s/0/stdout' %
801      (master, bot, buildnumber, step),
802      safe='')
803  return 'https://luci-logdog.appspot.com/v/?s=%s' % s_param
804
805
806def GetRowKey(testmetadata_key, revision):
807  test_container_key = GetTestContainerKey(testmetadata_key)
808  return ndb.Key('Row', revision, parent=test_container_key)
809
810
811def GetSheriffForAutorollCommit(author, message):
812  if author.split('@')[-1] not in _AUTOROLL_DOMAINS:
813    # Not an autoroll.
814    return None
815  # This is an autoroll. The sheriff should be the first person on TBR list.
816  m = re.search(r'TBR[=:]\s*([^,^\s]*)', message, flags=re.IGNORECASE)
817  if not m:
818    return None
819  return m.group(1)
820
821
822def IsMonitored(sheriff_client, test_path):
823  """Checks if the test is monitored by sherrifs."""
824
825  subscriptions, _ = sheriff_client.Match(test_path, check=True)
826  if subscriptions:
827    return True
828  return False
829