Commit 1a028f30 authored by Kyle Ju's avatar Kyle Ju Committed by Commit Bot

Surface cross-browsers failures from a chromium export to its corresponding CL.

Design doc:
https://docs.google.com/document/d/1MtdbUcWBDZyvmV0FOdsTWw_Jv16YtE6KW5BnnCVYX4c/edit#heading=h.7nki9mck5t64

Running Command:
python third_party/blink/tools/wpt_export.py --dry-run --credentials <your credentials> --surface-failures-to-gerrit

Tested on a mock CL
https://chromium-review.googlesource.com/c/chromium/src/+/1784803

Change-Id: I368d6fc5b0f7f3e5364fca1fe26cef3514585f96
Bug: 996383
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/1749720
Commit-Queue: Kyle Ju <kyleju@chromium.org>
Reviewed-by: default avatarRobert Ma <robertma@chromium.org>
Cr-Commit-Position: refs/heads/master@{#715167}
parent 0c1376c3
# 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.
"""Sends notifications after automatic exports.
Automatically comments on a Gerrit CL when its corresponding PR fails the Taskcluster check. In
other words, surfaces cross-browser WPT regressions from Github to Gerrit.
Design doc: https://docs.google.com/document/d/1MtdbUcWBDZyvmV0FOdsTWw_Jv16YtE6KW5BnnCVYX4c
"""
import logging
from blinkpy.w3c.common import WPT_REVISION_FOOTER
from blinkpy.w3c.gerrit import GerritError
from blinkpy.w3c.wpt_github import GitHubError
_log = logging.getLogger(__name__)
class ExportNotifier(object):
def __init__(self, host, wpt_github, gerrit, dry_run=True):
self.host = host
self.wpt_github = wpt_github
self.gerrit = gerrit
self.dry_run = dry_run
def main(self):
"""Surfaces stability check failures to Gerrit through comments."""
gerrit_dict = {}
try:
_log.info('Searching for recent failiing chromium exports.')
prs = self.wpt_github.recent_failing_chromium_exports()
except GitHubError as e:
_log.info(
'Surfacing Taskcluster failures cannot be completed due to the following error:')
_log.error(str(e))
return True
if len(prs) > 100:
_log.error(
'Too many open failing PRs: %s; abort.', len(prs))
return True
_log.info('Found %d failing PRs.', len(prs))
for pr in prs:
statuses = self.get_taskcluster_statuses(pr.number)
if not statuses:
continue
taskcluster_status = self.get_failure_taskcluster_status(
statuses, pr.number)
if not taskcluster_status:
continue
gerrit_id = self.wpt_github.extract_metadata(
'Change-Id: ', pr.body)
if not gerrit_id:
_log.error(
'Can not retrieve Change-Id for %s.', pr.number)
continue
gerrit_sha = self.wpt_github.extract_metadata(
WPT_REVISION_FOOTER + ' ', pr.body)
gerrit_dict[gerrit_id] = PRStatusInfo(
taskcluster_status['node_id'],
taskcluster_status['target_url'],
gerrit_sha)
self.process_failing_prs(gerrit_dict)
return False
def get_taskcluster_statuses(self, number):
"""Retrieves Taskcluster status through PR number.
Returns:
A JSON object representing all Taskcluster statuses for this PR.
"""
try:
branch = self.wpt_github.get_pr_branch(number)
statuses = self.wpt_github.get_branch_statuses(branch)
except GitHubError as e:
_log.error(str(e))
return None
return statuses
def process_failing_prs(self, gerrit_dict):
"""Processes and comments on CLs with failed TackCluster status."""
_log.info('Processing %d CLs with failed Taskcluster status.',
len(gerrit_dict))
for change_id, pr_status_info in gerrit_dict.items():
try:
cl = self.gerrit.query_cl_comments_and_revisions(change_id)
has_commented = self.has_latest_taskcluster_status_commented(
cl.messages, pr_status_info)
if has_commented:
continue
revision = cl.revisions.get(pr_status_info.gerrit_sha)
if revision:
cl_comment = pr_status_info.to_gerrit_comment(
revision['_number'])
else:
cl_comment = pr_status_info.to_gerrit_comment()
if self.dry_run:
_log.info(
'[dry_run] Would have commented on CL %s\n', change_id)
_log.debug(
'Comments are:\n %s\n', cl_comment)
else:
_log.info('Commenting on CL %s\n', change_id)
cl.post_comment(cl_comment)
except GerritError as e:
_log.error(
'Could not process Gerrit CL %s: %s', change_id, str(e))
continue
def has_latest_taskcluster_status_commented(self, messages, pr_status_info):
"""Determines if the Taskcluster status has already been commented on the messages of a CL.
Args:
messages: messagese of a CL in JSON Array format, in chronological order.
pr_status_info: PRStatusInfo object.
"""
for message in reversed(messages):
existing_status = PRStatusInfo.from_gerrit_comment(
message['message'])
if existing_status:
return existing_status.node_id == pr_status_info.node_id
return False
def get_failure_taskcluster_status(self, taskcluster_status, pr_number):
"""Parses Taskcluster status from Taskcluster statuses description field.
Args:
taskcluster_status: array; is of following format:
[
{
"url": "https://b",
"avatar_url": "https://a",
"id": 1,
"node_id": "A",
"state": "failure",
"description": "TaskGroup: failure",
"target_url": "https://tools.taskcluster.net/task-group-inspector/#/abc",
"context": "Community-TC (pull_request)",
"created_at": "2019-08-05T22:52:08Z",
"updated_at": "2019-08-05T22:52:08Z"
}
]
e.g. https://api.github.com/repos/web-platform-tests/wpt/commits/chromium-export-cl-1407433/status
pr_number: The PR number.
Returns:
Taskcluster status dictionary if it has a failure status; None otherwise.
"""
status = None
for status_dict in taskcluster_status:
if status_dict['context'] == 'Community-TC (pull_request)':
status = status_dict
break
if status and status['state'] == 'failure':
return status
if status is None:
return None
assert status['state'] == 'success'
_log.debug('Taskcluster status for PR %s is %s', pr_number, status)
return None
class PRStatusInfo(object):
NODE_ID_TAG = 'Taskcluster Node ID: '
LINK_TAG = 'Taskcluster Link: '
CL_SHA_TAG = 'Gerrit CL SHA: '
PATCHSET_TAG = 'Patchset Number: '
def __init__(self, node_id, link, gerrit_sha=None):
self._node_id = node_id
self._link = link
if gerrit_sha:
self._gerrit_sha = gerrit_sha
else:
self._gerrit_sha = 'Latest'
@property
def node_id(self):
return self._node_id
@property
def link(self):
return self._link
@property
def gerrit_sha(self):
return self._gerrit_sha
@staticmethod
def from_gerrit_comment(comment):
tags = [PRStatusInfo.NODE_ID_TAG,
PRStatusInfo.LINK_TAG,
PRStatusInfo.CL_SHA_TAG]
values = ['', '', '']
for line in comment.splitlines():
for index, tag in enumerate(tags):
if line.startswith(tag):
values[index] = line[len(tag):]
for val in values:
if not val:
return None
return PRStatusInfo(*values)
def to_gerrit_comment(self, patchset=None):
status_line = ('The exported PR for the current patch failed Taskcluster check(s) '
'on GitHub, which could indict cross-broswer failures on the '
'exportable changes. Please contact ecosystem-infra@ team for '
'more information.')
node_id_line = ('\n\n{}{}').format(
PRStatusInfo.NODE_ID_TAG, self.node_id)
link_line = ('\n{}{}').format(PRStatusInfo.LINK_TAG, self.link)
sha_line = ('\n{}{}').format(PRStatusInfo.CL_SHA_TAG, self.gerrit_sha)
comment = status_line + node_id_line + link_line + sha_line
if patchset is not None:
comment += ('\n{}{}').format(PRStatusInfo.PATCHSET_TAG, patchset)
return comment
# 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.
from blinkpy.common.host_mock import MockHost
from blinkpy.common.system.log_testing import LoggingTestCase
from blinkpy.w3c.gerrit_mock import MockGerritAPI, MockGerritCL
from blinkpy.w3c.export_notifier import ExportNotifier, PRStatusInfo
from blinkpy.w3c.wpt_github import PullRequest
from blinkpy.w3c.wpt_github_mock import MockWPTGitHub
class ExportNotifierTest(LoggingTestCase):
def setUp(self):
super(ExportNotifierTest, self).setUp()
self.host = MockHost()
self.git = self.host.git()
self.gerrit = MockGerritAPI()
self.notifier = ExportNotifier(self.host, self.git, self.gerrit)
def test_from_gerrit_comment_success(self):
gerrit_comment = ('The exported PR for the current patch failed Taskcluster check(s) '
'on GitHub, which could indict cross-broswer failures on the '
'exportable changes. Please contact ecosystem-infra@ team for '
'more information.\n\n'
'Taskcluster Node ID: foo\n'
'Taskcluster Link: bar\n'
'Gerrit CL SHA: num')
actual = PRStatusInfo.from_gerrit_comment(gerrit_comment)
self.assertEqual(actual.node_id, 'foo')
self.assertEqual(actual.link, 'bar')
self.assertEqual(actual.gerrit_sha, 'num')
def test_from_gerrit_comment_missing_info(self):
gerrit_comment = ('The exported PR for the current patch failed Taskcluster check(s) '
'on GitHub, which could indict cross-broswer failures on the '
'exportable changes. Please contact ecosystem-infra@ team for '
'more information.\n\n'
'Taskcluster Node ID: \n'
'Taskcluster Link: bar\n'
'Gerrit CL SHA: num')
actual = PRStatusInfo.from_gerrit_comment(gerrit_comment)
self.assertIsNone(actual)
def test_from_gerrit_comment_fail(self):
gerrit_comment = 'ABC'
actual = PRStatusInfo.from_gerrit_comment(gerrit_comment)
self.assertIsNone(actual)
def test_to_gerrit_comment(self):
pr_status_info = PRStatusInfo('foo', 'bar', 'num')
expected = ('The exported PR for the current patch failed Taskcluster check(s) '
'on GitHub, which could indict cross-broswer failures on the '
'exportable changes. Please contact ecosystem-infra@ team for '
'more information.\n\n'
'Taskcluster Node ID: foo\n'
'Taskcluster Link: bar\n'
'Gerrit CL SHA: num')
actual = pr_status_info.to_gerrit_comment()
self.assertEqual(expected, actual)
def test_to_gerrit_comment_latest(self):
pr_status_info = PRStatusInfo('foo', 'bar', None)
expected = ('The exported PR for the current patch failed Taskcluster check(s) '
'on GitHub, which could indict cross-broswer failures on the '
'exportable changes. Please contact ecosystem-infra@ team for '
'more information.\n\n'
'Taskcluster Node ID: foo\n'
'Taskcluster Link: bar\n'
'Gerrit CL SHA: Latest')
actual = pr_status_info.to_gerrit_comment()
self.assertEqual(expected, actual)
def test_to_gerrit_comment_with_patchset(self):
pr_status_info = PRStatusInfo('foo', 'bar', 'num')
expected = ('The exported PR for the current patch failed Taskcluster check(s) '
'on GitHub, which could indict cross-broswer failures on the '
'exportable changes. Please contact ecosystem-infra@ team for '
'more information.\n\n'
'Taskcluster Node ID: foo\n'
'Taskcluster Link: bar\n'
'Gerrit CL SHA: num\n'
'Patchset Number: 3')
actual = pr_status_info.to_gerrit_comment(3)
self.assertEqual(expected, actual)
def test_get_failure_taskcluster_status_success(self):
taskcluster_status = [
{
"state": "failure",
"context": "Community-TC (pull_request)",
},
{
"state": "success",
"context": "random",
}
]
self.assertEqual(
self.notifier.get_failure_taskcluster_status(
taskcluster_status, 123),
{
"state": "failure",
"context": "Community-TC (pull_request)",
}
)
def test_get_failure_taskcluster_status_fail(self):
taskcluster_status = [
{
"state": "success",
"context": "Community-TC (pull_request)",
},
]
self.assertEqual(self.notifier.get_failure_taskcluster_status(
taskcluster_status, 123), None)
def test_has_latest_taskcluster_status_commented_false(self):
pr_status_info = PRStatusInfo('foo', 'bar', 'num')
messages = [
{
"date": "2019-08-20 17:42:05.000000000",
"message": "Uploaded patch set 1.\nInitial upload",
"_revision_number": 1
}
]
actual = self.notifier.has_latest_taskcluster_status_commented(
messages, pr_status_info)
self.assertFalse(actual)
def test_has_latest_taskcluster_status_commented_true(self):
pr_status_info = PRStatusInfo('foo', 'bar', 'num')
messages = [
{
"date": "2019-08-20 17:42:05.000000000",
"message": "Uploaded patch set 1.\nInitial upload",
"_revision_number": 1
},
{
"date": "2019-08-21 17:41:05.000000000",
"message": ('The exported PR for the current patch failed Taskcluster check(s) '
'on GitHub, which could indict cross-broswer failures on the '
'exportable changes. Please contact ecosystem-infra@ team for '
'more information.\n\n'
'Taskcluster Node ID: foo\n'
'Taskcluster Link: bar\n'
'Gerrit CL SHA: num\n'
'Patchset Number: 3'),
"_revision_number": 2
},
]
actual = self.notifier.has_latest_taskcluster_status_commented(
messages, pr_status_info)
self.assertTrue(actual)
self.assertTrue(actual)
def test_get_taskcluster_statuses_success(self):
self.notifier.wpt_github = MockWPTGitHub(pull_requests=[
PullRequest(title='title1', number=1234,
body='description\nWPT-Export-Revision: 1',
state='open', labels=[]),
])
status = [
{
"description": "foo"
}
]
self.notifier.wpt_github.status = status
actual = self.notifier.get_taskcluster_statuses(123)
self.assertEqual(actual, status)
self.assertEqual(self.notifier.wpt_github.calls, [
'get_pr_branch',
'get_branch_statuses',
])
def test_process_failing_prs_success(self):
self.notifier.dry_run = False
self.notifier.gerrit = MockGerritAPI()
self.notifier.gerrit.cl = MockGerritCL(
data={
'change_id': 'abc',
'messages': [
{
"date": "2019-08-20 17:42:05.000000000",
"message": "Uploaded patch set 1.\nInitial upload",
"_revision_number": 1
},
{
"date": "2019-08-21 17:41:05.000000000",
"message": ('The exported PR for the current patch failed Taskcluster check(s) '
'on GitHub, which could indict cross-broswer failures on the '
'exportable changes. Please contact ecosystem-infra@ team for '
'more information.\n\n'
'Taskcluster Node ID: notfoo\n'
'Taskcluster Link: bar\n'
'Gerrit CL SHA: notnum\n'
'Patchset Number: 3'),
"_revision_number": 2
},
],
'revisions': {
'num': {
'_number': 1
}
}
},
api=self.notifier.gerrit
)
gerrit_dict = {'abc': PRStatusInfo('foo', 'bar', 'num')}
expected = ('The exported PR for the current patch failed Taskcluster check(s) '
'on GitHub, which could indict cross-broswer failures on the '
'exportable changes. Please contact ecosystem-infra@ team for '
'more information.\n\n'
'Taskcluster Node ID: foo\n'
'Taskcluster Link: bar\n'
'Gerrit CL SHA: num\n'
'Patchset Number: 1')
self.notifier.process_failing_prs(gerrit_dict)
self.assertEqual(self.notifier.gerrit.cls_queried, ['abc'])
self.assertEqual(self.notifier.gerrit.request_posted, [
('/a/changes/abc/revisions/current/review', {'message': expected})])
def test_process_failing_prs_has_commented(self):
self.notifier.dry_run = False
self.notifier.gerrit = MockGerritAPI()
self.notifier.gerrit.cl = MockGerritCL(
data={
'change_id': 'abc',
'messages': [
{
"date": "2019-08-20 17:42:05.000000000",
"message": "Uploaded patch set 1.\nInitial upload",
"_revision_number": 1
},
{
"date": "2019-08-21 17:41:05.000000000",
"message": ('The exported PR for the current patch failed Taskcluster check(s) '
'on GitHub, which could indict cross-broswer failures on the '
'exportable changes. Please contact ecosystem-infra@ team for '
'more information.\n\n'
'Taskcluster Node ID: foo\n'
'Taskcluster Link: bar\n'
'Gerrit CL SHA: notnum\n'
'Patchset Number: 3'),
"_revision_number": 2
},
],
'revisions': {
'num': {
'_number': 1
}
}
},
api=self.notifier.gerrit
)
gerrit_dict = {'abc': PRStatusInfo('foo', 'bar', 'num')}
self.notifier.process_failing_prs(gerrit_dict)
self.assertEqual(self.notifier.gerrit.cls_queried, ['abc'])
self.assertEqual(self.notifier.gerrit.request_posted, [])
def test_process_failing_prs_with_latest_sha(self):
self.notifier.dry_run = False
self.notifier.gerrit = MockGerritAPI()
self.notifier.gerrit.cl = MockGerritCL(
data={
'change_id': 'abc',
'messages': [
{
"date": "2019-08-20 17:42:05.000000000",
"message": "Uploaded patch set 1.\nInitial upload",
"_revision_number": 1
},
{
"date": "2019-08-21 17:41:05.000000000",
"message": ('The exported PR for the current patch failed Taskcluster check(s) '
'on GitHub, which could indict cross-broswer failures on the '
'exportable changes. Please contact ecosystem-infra@ team for '
'more information.\n\n'
'Taskcluster Node ID: not foo\n'
'Taskcluster Link: bar\n'
'Gerrit CL SHA: notnum\n'
'Patchset Number: 3'),
"_revision_number": 2
},
],
'revisions': {
'num': {
'_number': 1
}
}
},
api=self.notifier.gerrit
)
expected = ('The exported PR for the current patch failed Taskcluster check(s) '
'on GitHub, which could indict cross-broswer failures on the '
'exportable changes. Please contact ecosystem-infra@ team for '
'more information.\n\n'
'Taskcluster Node ID: foo\n'
'Taskcluster Link: bar\n'
'Gerrit CL SHA: Latest')
gerrit_dict = {'abc': PRStatusInfo('foo', 'bar', None)}
self.notifier.process_failing_prs(gerrit_dict)
self.assertEqual(self.notifier.gerrit.cls_queried, ['abc'])
self.assertEqual(self.notifier.gerrit.request_posted, [
('/a/changes/abc/revisions/current/review', {'message': expected})])
def test_process_failing_prs_raise_gerrit_error(self):
self.notifier.dry_run = False
self.notifier.gerrit = MockGerritAPI(raise_error=True)
gerrit_dict = {'abc': PRStatusInfo('foo', 'bar', 'num')}
self.notifier.process_failing_prs(gerrit_dict)
self.assertEqual(self.notifier.gerrit.cls_queried, ['abc'])
self.assertEqual(self.notifier.gerrit.request_posted, [])
self.assertLog(
['INFO: Processing 1 CLs with failed Taskcluster status.\n',
'ERROR: Could not process Gerrit CL abc: Error from query_cl\n'])
def test_export_notifier_success(self):
self.notifier.wpt_github = MockWPTGitHub(pull_requests=[])
self.notifier.wpt_github.recent_failing_pull_requests = [
PullRequest(title='title1', number=1234,
body='description\nWPT-Export-Revision: hash\nChange-Id: decafbad',
state='open', labels=[''])]
status = [
{
"state": "failure",
"context": "Community-TC (pull_request)",
"node_id": "foo",
"target_url": "bar"
}
]
self.notifier.wpt_github.status = status
self.notifier.dry_run = False
self.notifier.gerrit = MockGerritAPI()
self.notifier.gerrit.cl = MockGerritCL(
data={
'change_id': 'decafbad',
'messages': [
{
"date": "2019-08-20 17:42:05.000000000",
"message": "Uploaded patch set 1.\nInitial upload",
"_revision_number": 1
},
{
"date": "2019-08-21 17:41:05.000000000",
"message": ('The exported PR for the current patch failed Taskcluster check(s) '
'on GitHub, which could indict cross-broswer failures on the '
'exportable changes. Please contact ecosystem-infra@ team for '
'more information.\n\n'
'Taskcluster Node ID: notfoo\n'
'Taskcluster Link: bar\n'
'Gerrit CL SHA: notnum\n'
'Patchset Number: 3'),
"_revision_number": 2
},
],
'revisions': {
'hash': {
'_number': 2
}
}
},
api=self.notifier.gerrit
)
expected = ('The exported PR for the current patch failed Taskcluster check(s) '
'on GitHub, which could indict cross-broswer failures on the '
'exportable changes. Please contact ecosystem-infra@ team for '
'more information.\n\n'
'Taskcluster Node ID: foo\n'
'Taskcluster Link: bar\n'
'Gerrit CL SHA: hash\n'
'Patchset Number: 2')
exit_code = self.notifier.main()
self.assertFalse(exit_code)
self.assertEqual(self.notifier.wpt_github.calls, [
'recent_failing_chromium_exports',
'get_pr_branch',
'get_branch_statuses',
])
self.assertEqual(self.notifier.gerrit.cls_queried, ['decafbad'])
self.assertEqual(self.notifier.gerrit.request_posted, [
('/a/changes/decafbad/revisions/current/review', {'message': expected})])
...@@ -58,9 +58,13 @@ class GerritAPI(object): ...@@ -58,9 +58,13 @@ class GerritAPI(object):
} }
return self.host.web.request('POST', url, data=json.dumps(data), headers=headers) return self.host.web.request('POST', url, data=json.dumps(data), headers=headers)
def query_cl(self, change_id): def query_cl_comments_and_revisions(self, change_id):
"""Quries a commit information from Gerrit.""" """Queries a CL with comments and revisions information."""
path = '/changes/chromium%2Fsrc~master~{}?{}'.format(change_id, QUERY_OPTIONS) return self.query_cl(change_id, 'o=MESSAGES&o=ALL_REVISIONS')
def query_cl(self, change_id, query_options=QUERY_OPTIONS):
"""Queries a commit information from Gerrit."""
path = '/changes/chromium%2Fsrc~master~{}?{}'.format(change_id, query_options)
try: try:
cl_data = self.get(path) cl_data = self.get(path)
except NetworkTimeout: except NetworkTimeout:
...@@ -134,6 +138,14 @@ class GerritCL(object): ...@@ -134,6 +138,14 @@ class GerritCL(object):
def status(self): def status(self):
return self._data['status'] return self._data['status']
@property
def messages(self):
return self._data['messages']
@property
def revisions(self):
return self._data['revisions']
def post_comment(self, message): def post_comment(self, message):
"""Posts a comment to the CL.""" """Posts a comment to the CL."""
path = '/a/changes/{change_id}/revisions/current/review'.format( path = '/a/changes/{change_id}/revisions/current/review'.format(
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
# Use of this source code is governed by a BSD-style license that can be # Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file. # found in the LICENSE file.
from blinkpy.w3c.gerrit import GerritCL, GerritError from blinkpy.w3c.gerrit import GerritCL, GerritError, QUERY_OPTIONS
# Some unused arguments may be included to match the real class's API. # Some unused arguments may be included to match the real class's API.
# pylint: disable=unused-argument # pylint: disable=unused-argument
...@@ -20,7 +20,10 @@ class MockGerritAPI(object): ...@@ -20,7 +20,10 @@ class MockGerritAPI(object):
def query_exportable_open_cls(self): def query_exportable_open_cls(self):
return self.exportable_open_cls return self.exportable_open_cls
def query_cl(self, change_id): def query_cl_comments_and_revisions(self, change_id):
return self.query_cl(change_id, 'o=MESSAGES&o=ALL_REVISIONS')
def query_cl(self, change_id, query_options=QUERY_OPTIONS):
self.cls_queried.append(change_id) self.cls_queried.append(change_id)
if self.raise_error: if self.raise_error:
raise GerritError("Error from query_cl") raise GerritError("Error from query_cl")
......
...@@ -19,6 +19,7 @@ from blinkpy.w3c.common import ( ...@@ -19,6 +19,7 @@ from blinkpy.w3c.common import (
) )
from blinkpy.w3c.gerrit import GerritAPI, GerritCL, GerritError from blinkpy.w3c.gerrit import GerritAPI, GerritCL, GerritError
from blinkpy.w3c.wpt_github import WPTGitHub, MergeError from blinkpy.w3c.wpt_github import WPTGitHub, MergeError
from blinkpy.w3c.export_notifier import ExportNotifier
_log = logging.getLogger(__name__) _log = logging.getLogger(__name__)
...@@ -84,7 +85,19 @@ class TestExporter(object): ...@@ -84,7 +85,19 @@ class TestExporter(object):
for error in git_errors: for error in git_errors:
_log.error(error) _log.error(error)
return not (gerrit_error or git_errors) export_error = gerrit_error or git_errors
if export_error:
return not export_error
_log.info('Automatic export process has finished successfully.')
export_notifier_failure = False
if options.surface_failures_to_gerrit:
_log.info('Starting surfacing cross-browser failures to Gerrit.')
export_notifier_failure = ExportNotifier(
self.host, self.wpt_github, self.gerrit, self.dry_run).main()
return not export_notifier_failure
def parse_args(self, argv): def parse_args(self, argv):
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
...@@ -99,6 +112,10 @@ class TestExporter(object): ...@@ -99,6 +112,10 @@ class TestExporter(object):
'--credentials-json', required=True, '--credentials-json', required=True,
help='A JSON file with an object containing zero or more of the ' help='A JSON file with an object containing zero or more of the '
'following keys: GH_USER, GH_TOKEN, GERRIT_USER, GERRIT_TOKEN') 'following keys: GH_USER, GH_TOKEN, GERRIT_USER, GERRIT_TOKEN')
parser.add_argument(
'--surface-failures-to-gerrit', action='store_true',
help='Indicates whether to run the service that surfaces GitHub '
'faliures to Gerrit through comments.')
return parser.parse_args(argv) return parser.parse_args(argv)
def process_gerrit_cls(self, gerrit_cls): def process_gerrit_cls(self, gerrit_cls):
......
...@@ -32,7 +32,8 @@ class TestExporterTest(LoggingTestCase): ...@@ -32,7 +32,8 @@ class TestExporterTest(LoggingTestCase):
def test_dry_run_stops_before_creating_pr(self): def test_dry_run_stops_before_creating_pr(self):
test_exporter = TestExporter(self.host) test_exporter = TestExporter(self.host)
test_exporter.wpt_github = MockWPTGitHub(pull_requests=[ test_exporter.wpt_github = MockWPTGitHub(pull_requests=[
PullRequest(title='title1', number=1234, body='', state='open', labels=[]), PullRequest(title='title1', number=1234,
body='', state='open', labels=[]),
]) ])
test_exporter.gerrit = MockGerritAPI() test_exporter.gerrit = MockGerritAPI()
test_exporter.gerrit.exportable_open_cls = [MockGerritCL( test_exporter.gerrit.exportable_open_cls = [MockGerritCL(
...@@ -52,10 +53,13 @@ class TestExporterTest(LoggingTestCase): ...@@ -52,10 +53,13 @@ class TestExporterTest(LoggingTestCase):
body='fake body', change_id='I001') body='fake body', change_id='I001')
)] )]
test_exporter.get_exportable_commits = lambda: ([ test_exporter.get_exportable_commits = lambda: ([
MockChromiumCommit(self.host, position='refs/heads/master@{#458475}'), MockChromiumCommit(
MockChromiumCommit(self.host, position='refs/heads/master@{#458476}'), self.host, position='refs/heads/master@{#458475}'),
MockChromiumCommit(
self.host, position='refs/heads/master@{#458476}'),
], []) ], [])
success = test_exporter.main(['--credentials-json', '/tmp/credentials.json', '--dry-run']) success = test_exporter.main(
['--credentials-json', '/tmp/credentials.json', '--dry-run'])
self.assertTrue(success) self.assertTrue(success)
self.assertEqual(test_exporter.wpt_github.calls, [ self.assertEqual(test_exporter.wpt_github.calls, [
...@@ -67,14 +71,19 @@ class TestExporterTest(LoggingTestCase): ...@@ -67,14 +71,19 @@ class TestExporterTest(LoggingTestCase):
def test_creates_pull_request_for_all_exportable_commits(self): def test_creates_pull_request_for_all_exportable_commits(self):
test_exporter = TestExporter(self.host) test_exporter = TestExporter(self.host)
test_exporter.wpt_github = MockWPTGitHub(pull_requests=[], create_pr_fail_index=1) test_exporter.wpt_github = MockWPTGitHub(
pull_requests=[], create_pr_fail_index=1)
test_exporter.gerrit = MockGerritAPI() test_exporter.gerrit = MockGerritAPI()
test_exporter.get_exportable_commits = lambda: ([ test_exporter.get_exportable_commits = lambda: ([
MockChromiumCommit(self.host, position='refs/heads/master@{#1}', change_id='I001', subject='subject 1', body='body 1'), MockChromiumCommit(
MockChromiumCommit(self.host, position='refs/heads/master@{#2}', change_id='I002', subject='subject 2', body='body 2'), self.host, position='refs/heads/master@{#1}', change_id='I001', subject='subject 1', body='body 1'),
MockChromiumCommit(self.host, position='refs/heads/master@{#3}', change_id='I003', subject='subject 3', body='body 3'), MockChromiumCommit(
self.host, position='refs/heads/master@{#2}', change_id='I002', subject='subject 2', body='body 2'),
MockChromiumCommit(
self.host, position='refs/heads/master@{#3}', change_id='I003', subject='subject 3', body='body 3'),
], []) ], [])
success = test_exporter.main(['--credentials-json', '/tmp/credentials.json']) success = test_exporter.main(
['--credentials-json', '/tmp/credentials.json'])
self.assertTrue(success) self.assertTrue(success)
self.assertEqual(test_exporter.wpt_github.calls, [ self.assertEqual(test_exporter.wpt_github.calls, [
...@@ -92,8 +101,10 @@ class TestExporterTest(LoggingTestCase): ...@@ -92,8 +101,10 @@ class TestExporterTest(LoggingTestCase):
'add_label "chromium-export"', 'add_label "chromium-export"',
]) ])
self.assertEqual(test_exporter.wpt_github.pull_requests_created, [ self.assertEqual(test_exporter.wpt_github.pull_requests_created, [
('chromium-export-96862edfc1', 'subject 1', 'body 1\n\nChange-Id: I001\n'), ('chromium-export-96862edfc1', 'subject 1',
('chromium-export-ce0e78bf18', 'subject 3', 'body 3\n\nChange-Id: I003\n'), 'body 1\n\nChange-Id: I001\n'),
('chromium-export-ce0e78bf18', 'subject 3',
'body 3\n\nChange-Id: I003\n'),
]) ])
def test_creates_and_merges_pull_requests(self): def test_creates_and_merges_pull_requests(self):
...@@ -136,13 +147,19 @@ class TestExporterTest(LoggingTestCase): ...@@ -136,13 +147,19 @@ class TestExporterTest(LoggingTestCase):
], unsuccessful_merge_index=3) # Mark the last PR as unmergable. ], unsuccessful_merge_index=3) # Mark the last PR as unmergable.
test_exporter.gerrit = MockGerritAPI() test_exporter.gerrit = MockGerritAPI()
test_exporter.get_exportable_commits = lambda: ([ test_exporter.get_exportable_commits = lambda: ([
MockChromiumCommit(self.host, position='refs/heads/master@{#458475}', change_id='I0005'), MockChromiumCommit(
MockChromiumCommit(self.host, position='refs/heads/master@{#458476}', change_id='I0476'), self.host, position='refs/heads/master@{#458475}', change_id='I0005'),
MockChromiumCommit(self.host, position='refs/heads/master@{#458477}', change_id='Idead'), MockChromiumCommit(
MockChromiumCommit(self.host, position='refs/heads/master@{#458478}', change_id='I0118'), self.host, position='refs/heads/master@{#458476}', change_id='I0476'),
MockChromiumCommit(self.host, position='refs/heads/master@{#458479}', change_id='I0147'), MockChromiumCommit(
self.host, position='refs/heads/master@{#458477}', change_id='Idead'),
MockChromiumCommit(
self.host, position='refs/heads/master@{#458478}', change_id='I0118'),
MockChromiumCommit(
self.host, position='refs/heads/master@{#458479}', change_id='I0147'),
], []) ], [])
success = test_exporter.main(['--credentials-json', '/tmp/credentials.json']) success = test_exporter.main(
['--credentials-json', '/tmp/credentials.json'])
self.assertTrue(success) self.assertTrue(success)
self.assertEqual(test_exporter.wpt_github.calls, [ self.assertEqual(test_exporter.wpt_github.calls, [
...@@ -171,7 +188,8 @@ class TestExporterTest(LoggingTestCase): ...@@ -171,7 +188,8 @@ class TestExporterTest(LoggingTestCase):
'merge_pr', 'merge_pr',
]) ])
self.assertEqual(test_exporter.wpt_github.pull_requests_created, [ self.assertEqual(test_exporter.wpt_github.pull_requests_created, [
('chromium-export-52c3178508', 'Fake subject', 'Fake body\n\nChange-Id: I0476\n'), ('chromium-export-52c3178508', 'Fake subject',
'Fake body\n\nChange-Id: I0476\n'),
]) ])
self.assertEqual(test_exporter.wpt_github.pull_requests_merged, [3456]) self.assertEqual(test_exporter.wpt_github.pull_requests_merged, [3456])
...@@ -236,7 +254,8 @@ class TestExporterTest(LoggingTestCase): ...@@ -236,7 +254,8 @@ class TestExporterTest(LoggingTestCase):
api=test_exporter.gerrit, api=test_exporter.gerrit,
chromium_commit=MockChromiumCommit(self.host) chromium_commit=MockChromiumCommit(self.host)
)] )]
success = test_exporter.main(['--credentials-json', '/tmp/credentials.json']) success = test_exporter.main(
['--credentials-json', '/tmp/credentials.json'])
self.assertTrue(success) self.assertTrue(success)
self.assertEqual(test_exporter.wpt_github.calls, [ self.assertEqual(test_exporter.wpt_github.calls, [
...@@ -297,7 +316,8 @@ class TestExporterTest(LoggingTestCase): ...@@ -297,7 +316,8 @@ class TestExporterTest(LoggingTestCase):
MockChromiumCommit(self.host, change_id='decafbad'), MockChromiumCommit(self.host, change_id='decafbad'),
], []) ], [])
test_exporter.gerrit = MockGerritAPI() test_exporter.gerrit = MockGerritAPI()
success = test_exporter.main(['--credentials-json', '/tmp/credentials.json']) success = test_exporter.main(
['--credentials-json', '/tmp/credentials.json'])
self.assertTrue(success) self.assertTrue(success)
self.assertEqual(test_exporter.wpt_github.calls, [ self.assertEqual(test_exporter.wpt_github.calls, [
...@@ -320,7 +340,8 @@ class TestExporterTest(LoggingTestCase): ...@@ -320,7 +340,8 @@ class TestExporterTest(LoggingTestCase):
MockChromiumCommit(self.host, change_id='decafbad'), MockChromiumCommit(self.host, change_id='decafbad'),
], []) ], [])
test_exporter.gerrit = MockGerritAPI() test_exporter.gerrit = MockGerritAPI()
success = test_exporter.main(['--credentials-json', '/tmp/credentials.json']) success = test_exporter.main(
['--credentials-json', '/tmp/credentials.json'])
self.assertTrue(success) self.assertTrue(success)
self.assertEqual(test_exporter.wpt_github.calls, [ self.assertEqual(test_exporter.wpt_github.calls, [
...@@ -359,7 +380,8 @@ class TestExporterTest(LoggingTestCase): ...@@ -359,7 +380,8 @@ class TestExporterTest(LoggingTestCase):
api=test_exporter.gerrit, api=test_exporter.gerrit,
chromium_commit=MockChromiumCommit(self.host) chromium_commit=MockChromiumCommit(self.host)
)] )]
success = test_exporter.main(['--credentials-json', '/tmp/credentials.json']) success = test_exporter.main(
['--credentials-json', '/tmp/credentials.json'])
self.assertTrue(success) self.assertTrue(success)
self.assertEqual(test_exporter.wpt_github.calls, []) self.assertEqual(test_exporter.wpt_github.calls, [])
...@@ -375,7 +397,8 @@ class TestExporterTest(LoggingTestCase): ...@@ -375,7 +397,8 @@ class TestExporterTest(LoggingTestCase):
test_exporter.get_exportable_commits = lambda: ([], []) test_exporter.get_exportable_commits = lambda: ([], [])
test_exporter.gerrit = MockGerritAPI() test_exporter.gerrit = MockGerritAPI()
test_exporter.gerrit.query_exportable_open_cls = raise_gerrit_error test_exporter.gerrit.query_exportable_open_cls = raise_gerrit_error
success = test_exporter.main(['--credentials-json', '/tmp/credentials.json']) success = test_exporter.main(
['--credentials-json', '/tmp/credentials.json'])
self.assertFalse(success) self.assertFalse(success)
self.assertLog(['INFO: Cloning GitHub web-platform-tests/wpt into /tmp/wpt\n', self.assertLog(['INFO: Cloning GitHub web-platform-tests/wpt into /tmp/wpt\n',
...@@ -388,9 +411,11 @@ class TestExporterTest(LoggingTestCase): ...@@ -388,9 +411,11 @@ class TestExporterTest(LoggingTestCase):
def test_run_returns_false_on_patch_failure(self): def test_run_returns_false_on_patch_failure(self):
test_exporter = TestExporter(self.host) test_exporter = TestExporter(self.host)
test_exporter.wpt_github = MockWPTGitHub(pull_requests=[]) test_exporter.wpt_github = MockWPTGitHub(pull_requests=[])
test_exporter.get_exportable_commits = lambda: ([], ['There was an error with the rutabaga.']) test_exporter.get_exportable_commits = lambda: (
[], ['There was an error with the rutabaga.'])
test_exporter.gerrit = MockGerritAPI() test_exporter.gerrit = MockGerritAPI()
success = test_exporter.main(['--credentials-json', '/tmp/credentials.json']) success = test_exporter.main(
['--credentials-json', '/tmp/credentials.json'])
self.assertFalse(success) self.assertFalse(success)
self.assertLog(['INFO: Cloning GitHub web-platform-tests/wpt into /tmp/wpt\n', self.assertLog(['INFO: Cloning GitHub web-platform-tests/wpt into /tmp/wpt\n',
......
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
# found in the LICENSE file. # found in the LICENSE file.
import base64 import base64
import datetime
import json import json
import logging import logging
import re import re
...@@ -208,6 +209,48 @@ class WPTGitHub(object): ...@@ -208,6 +209,48 @@ class WPTGitHub(object):
state=item['state'], state=item['state'],
labels=labels) labels=labels)
def recent_failing_chromium_exports(self):
"""Fetches open PRs with an export label, failing status, and updated
within the last month.
API doc: https://developer.github.com/v3/search/#search-issues-and-pull-requests
Returns:
A list of PullRequest namedtuples.
"""
one_month_ago = datetime.date.today() - datetime.timedelta(days=31)
path = (
'/search/issues'
'?q=repo:{}/{}%20type:pr+is:open%20label:{}%20status:failure%20updated:>{}'
'&sort=updated'
'&page=1'
'&per_page={}'
).format(
WPT_GH_ORG,
WPT_GH_REPO_NAME,
EXPORT_PR_LABEL,
one_month_ago.isoformat(),
MAX_PER_PAGE
)
failing_prs = []
while path is not None:
response = self.request(path, method='GET')
if response.status_code == 200:
if response.data['incomplete_results']:
raise GitHubError('complete results', 'incomplete results',
'fetch failing open chromium exports', path)
prs = [self.make_pr_from_item(item) for item in response.data['items']]
failing_prs += prs
else:
raise GitHubError(200, response.status_code,
'fetch failing open chromium exports', path)
path = self.extract_link_next(response.getheader('Link'))
_log.info('Fetched %d PRs from GitHub.', len(failing_prs))
return failing_prs
@memoized @memoized
def all_pull_requests(self): def all_pull_requests(self):
"""Fetches the most recent (open and closed) PRs with the export label. """Fetches the most recent (open and closed) PRs with the export label.
...@@ -218,7 +261,7 @@ class WPTGitHub(object): ...@@ -218,7 +261,7 @@ class WPTGitHub(object):
can't really find *all* of them; we fetch the most recently updated ones can't really find *all* of them; we fetch the most recently updated ones
because we only check whether recent commits have been exported. because we only check whether recent commits have been exported.
API doc: https://developer.github.com/v3/search/#search-issues API doc: https://developer.github.com/v3/search/#search-issues-and-pull-requests
Returns: Returns:
A list of PullRequest namedtuples. A list of PullRequest namedtuples.
...@@ -258,6 +301,26 @@ class WPTGitHub(object): ...@@ -258,6 +301,26 @@ class WPTGitHub(object):
_log.info('Fetched %d PRs from GitHub.', len(all_prs)) _log.info('Fetched %d PRs from GitHub.', len(all_prs))
return all_prs return all_prs
def get_branch_statuses(self, branch_name):
"""Gets the status of a PR.
API doc: https://developer.github.com/v3/repos/statuses/#get-the-combined-status-for-a-specific-ref
Returns:
The list of check statuses of the PR.
"""
path = '/repos/{}/{}/commits/{}/status'.format(
WPT_GH_ORG,
WPT_GH_REPO_NAME,
branch_name
)
response = self.request(path, method='GET')
if response.status_code != 200:
raise GitHubError(200, response.status_code, 'get the statuses of PR %d' % branch_name)
return response.data['statuses']
def get_pr_branch(self, pr_number): def get_pr_branch(self, pr_number):
"""Gets the remote branch name of a PR. """Gets the remote branch name of a PR.
......
...@@ -23,6 +23,7 @@ class MockWPTGitHub(object): ...@@ -23,6 +23,7 @@ class MockWPTGitHub(object):
merged. (-1 means none is merged.) merged. (-1 means none is merged.)
""" """
self.pull_requests = pull_requests self.pull_requests = pull_requests
self.recent_failing_pull_requests = []
self.calls = [] self.calls = []
self.pull_requests_created = [] self.pull_requests_created = []
self.pull_requests_merged = [] self.pull_requests_merged = []
...@@ -30,11 +31,16 @@ class MockWPTGitHub(object): ...@@ -30,11 +31,16 @@ class MockWPTGitHub(object):
self.create_pr_index = 0 self.create_pr_index = 0
self.create_pr_fail_index = create_pr_fail_index self.create_pr_fail_index = create_pr_fail_index
self.merged_index = merged_index self.merged_index = merged_index
self.status = ''
def all_pull_requests(self, limit=30): def all_pull_requests(self, limit=30):
self.calls.append('all_pull_requests') self.calls.append('all_pull_requests')
return self.pull_requests return self.pull_requests
def recent_failing_chromium_exports(self):
self.calls.append("recent_failing_chromium_exports")
return self.recent_failing_pull_requests
def is_pr_merged(self, number): def is_pr_merged(self, number):
for index, pr in enumerate(self.pull_requests): for index, pr in enumerate(self.pull_requests):
if pr.number == number: if pr.number == number:
...@@ -54,7 +60,8 @@ class MockWPTGitHub(object): ...@@ -54,7 +60,8 @@ class MockWPTGitHub(object):
self.calls.append('create_pr') self.calls.append('create_pr')
if self.create_pr_fail_index != self.create_pr_index: if self.create_pr_fail_index != self.create_pr_index:
self.pull_requests_created.append((remote_branch_name, desc_title, body)) self.pull_requests_created.append(
(remote_branch_name, desc_title, body))
self.create_pr_index += 1 self.create_pr_index += 1
return 5678 return 5678
...@@ -79,6 +86,10 @@ class MockWPTGitHub(object): ...@@ -79,6 +86,10 @@ class MockWPTGitHub(object):
self.calls.append('get_pr_branch') self.calls.append('get_pr_branch')
return 'fake_branch_PR_%d' % number return 'fake_branch_PR_%d' % number
def get_branch_statuses(self, branch_name):
self.calls.append('get_branch_statuses')
return self.status
def pr_for_chromium_commit(self, commit): def pr_for_chromium_commit(self, commit):
self.calls.append('pr_for_chromium_commit') self.calls.append('pr_for_chromium_commit')
for pr in self.pull_requests: for pr in self.pull_requests:
......
...@@ -50,6 +50,35 @@ class WPTGitHubTest(unittest.TestCase): ...@@ -50,6 +50,35 @@ class WPTGitHubTest(unittest.TestCase):
def test_extract_link_next_not_found(self): def test_extract_link_next_not_found(self):
self.assertIsNone(self.wpt_github.extract_link_next('')) self.assertIsNone(self.wpt_github.extract_link_next(''))
def test_recent_failing_chromium_exports_single_page(self):
self.wpt_github = WPTGitHub(MockHost(), user='rutabaga', token='decafbad', pr_history_window=1)
self.wpt_github.host.web.responses = [
{'status_code': 200,
'headers': {'Link': ''},
'body': json.dumps({'incomplete_results': False, 'items': [self.generate_pr_item(1)]})},
]
self.assertEqual(len(self.wpt_github.recent_failing_chromium_exports()), 1)
def test_recent_failing_chromium_exports_all_pages(self):
self.wpt_github = WPTGitHub(MockHost(), user='rutabaga', token='decafbad', pr_history_window=1)
self.wpt_github.host.web.responses = [
{'status_code': 200,
'headers': {'Link': '<https://api.github.com/resources?page=2>; rel="next"'},
'body': json.dumps({'incomplete_results': False, 'items': [self.generate_pr_item(1)]})},
{'status_code': 200,
'headers': {'Link': ''},
'body': json.dumps({'incomplete_results': False, 'items': [self.generate_pr_item(2)]})},
]
self.assertEqual(len(self.wpt_github.recent_failing_chromium_exports()), 2)
def test_recent_failing_chromium_exports_throws_github_error(self):
self.wpt_github.host.web.responses = [
{'status_code': 204},
]
with self.assertRaises(GitHubError):
self.wpt_github.recent_failing_chromium_exports()
def test_all_pull_requests_single_page(self): def test_all_pull_requests_single_page(self):
self.wpt_github = WPTGitHub(MockHost(), user='rutabaga', token='decafbad', pr_history_window=1) self.wpt_github = WPTGitHub(MockHost(), user='rutabaga', token='decafbad', pr_history_window=1)
self.wpt_github.host.web.responses = [ self.wpt_github.host.web.responses = [
...@@ -122,6 +151,13 @@ class WPTGitHubTest(unittest.TestCase): ...@@ -122,6 +151,13 @@ class WPTGitHubTest(unittest.TestCase):
with self.assertRaises(GitHubError): with self.assertRaises(GitHubError):
self.wpt_github.create_pr('branch', 'title', 'body') self.wpt_github.create_pr('branch', 'title', 'body')
def test_get_branch_statuses(self):
self.wpt_github.host.web.responses = [
{'status_code': 200,
'body': json.dumps({'statuses': [{'description': 'abc'}]})},
]
self.assertEqual(self.wpt_github.get_branch_statuses('1234')[0]['description'], 'abc')
def test_get_pr_branch(self): def test_get_pr_branch(self):
self.wpt_github.host.web.responses = [ self.wpt_github.host.web.responses = [
{'status_code': 200, {'status_code': 200,
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment