blob: 516231a0feb93bd2aaf55497dc29a6325353550f [file] [log] [blame]
# Copyright 2016 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""An interface to git-cl.
The git-cl tool is responsible for communicating with Gerrit and Buildbucket to
manage changelists and try jobs associated with them.
"""
import collections
import json
import logging
import re
from blinkpy.common.checkout.git import Git
from blinkpy.common.net.results_fetcher import Build, filter_latest_builds
from blinkpy.common.net.luci_auth import LuciAuth
_log = logging.getLogger(__name__)
# A refresh token may be needed for some commands, such as git cl try,
# in order to authenticate with buildbucket.
_COMMANDS_THAT_TAKE_REFRESH_TOKEN = ('try', )
# These characters always appear at the beginning of the SearchBuilds response
# from BuildBucket.
SEARCHBUILDS_RESPONSE_PREFIX = ")]}'"
class CLStatus(
collections.namedtuple('CLStatus', ('status', 'try_job_results'))):
"""Represents the current status of a particular CL.
It contains both the CL's status as reported by `git-cl status' as well as
a mapping of Build objects to TryJobStatus objects.
"""
pass
class TryJobStatus(
collections.namedtuple('TryJobStatus', ('status', 'result'))):
"""Represents a current status of a particular job.
Specifically, whether it is scheduled or started or finished, and if
it is finished, whether it failed or succeeded. If it failed,
"""
def __new__(cls, status, result=None):
assert status in ('SCHEDULED', 'STARTED', 'COMPLETED')
assert result in (None, 'FAILURE', 'SUCCESS', 'CANCELED')
return super(TryJobStatus, cls).__new__(cls, status, result)
@staticmethod
def from_bb_status(bb_status):
"""Converts a buildbucket status into a TryJobStatus object."""
assert bb_status in ('SCHEDULED', 'STARTED', 'SUCCESS', 'FAILURE',
'INFRA_FAILURE', 'CANCELLED')
if bb_status in ('SCHEDULED', 'STARTED'):
return TryJobStatus(bb_status, None)
else:
# Map result INFRA_FAILURE to FAILURE to avoid introducing a new
# result, and it amounts to the same thing anyway.
return TryJobStatus(
'COMPLETED',
'FAILURE' if bb_status == 'INFRA_FAILURE' else bb_status)
class GitCL(object):
def __init__(self, host, auth_refresh_token_json=None, cwd=None):
self._host = host
self._auth_refresh_token_json = auth_refresh_token_json
self._cwd = cwd
self._git_executable_name = Git.find_executable_name(
host.executive, host.platform)
def run(self, args):
"""Runs git-cl with the given arguments and returns the output.
Args:
args: A list of arguments passed to `git cl`.
Returns:
A string (the output from git-cl).
"""
command = [self._git_executable_name, 'cl'] + args
if (self._auth_refresh_token_json
and args[0] in _COMMANDS_THAT_TAKE_REFRESH_TOKEN):
command += [
'--auth-refresh-token-json', self._auth_refresh_token_json
]
# Suppress the stderr of git-cl because git-cl will show a warning when
# running on Swarming bots with local git cache.
return self._host.executive.run_command(
command, cwd=self._cwd, return_stderr=False, ignore_stderr=True)
def trigger_try_jobs(self, builders, bucket=None):
"""Triggers try jobs on the given builders.
Args:
builder: A list of builder names.
bucket: When specified, all jobs are triggered to be in this bucket
(instead of the configured or default buckets).
"""
if bucket:
builders_by_bucket = {bucket: builders}
else:
builders_by_bucket = self._group_builders_by_bucket(builders)
# Sort both buckets and builders to ensure stable unit tests.
for bucket in sorted(builders_by_bucket):
command = ['try']
# Buckets are required by `git cl try`. When no bucket is specified,
# use the default bucket.
command.extend(['-B', bucket or 'luci.chromium.try'])
for builder in sorted(builders_by_bucket[bucket]):
command.extend(['-b', builder])
self.run(command)
def _group_builders_by_bucket(self, builders):
builders_by_bucket = collections.defaultdict(list)
for builder in builders:
bucket = self._host.builders.bucket_for_builder(builder)
builders_by_bucket[bucket].append(builder)
return dict(builders_by_bucket)
def get_issue_number(self):
"""Returns the issue number as a string, or "None"."""
# Expected output of git cl issue looks like:
# "<Optional message> Issue number: 1234 (<url>)".
# Note: git cl gets the number from local git config, e.g.
# by running `git config branch.<branchname>.gerritissue`.
output = self.run(['issue']).split()
if 'number:' in output:
return output[output.index('number:') + 1]
return 'None'
def _get_cl_status(self):
return self.run(['status', '--field=status']).strip()
def _get_latest_patchset(self):
return self.run(['status', '--field=patch']).strip()
def wait_for_try_jobs(self,
poll_delay_seconds=10 * 60,
timeout_seconds=120 * 60,
cq_only=False):
"""Waits until all try jobs are finished and returns results, or None.
This function can also be interrupted if the corresponding CL is
closed while the try jobs are still running.
Returns:
None if a timeout occurs, a CLStatus tuple otherwise.
"""
def finished_try_job_results_or_none():
cl_status = self._get_cl_status()
_log.debug('Fetched CL status: %s', cl_status)
issue_number = self.get_issue_number()
try_job_results = self.latest_try_jobs(
issue_number, cq_only=cq_only)
_log.debug('Fetched try results: %s', try_job_results)
if (cl_status == 'closed' or
(try_job_results and self.all_finished(try_job_results))):
return CLStatus(
status=cl_status, try_job_results=try_job_results)
return None
return self._wait_for(
finished_try_job_results_or_none,
poll_delay_seconds,
timeout_seconds,
message=' for try jobs')
def wait_for_closed_status(self,
poll_delay_seconds=2 * 60,
timeout_seconds=30 * 60):
"""Waits until git cl reports that the current CL is closed."""
def closed_status_or_none():
status = self._get_cl_status()
_log.debug('CL status is: %s', status)
if status == 'closed':
self._host.print_('CL is closed.')
return status
return None
return self._wait_for(
closed_status_or_none,
poll_delay_seconds,
timeout_seconds,
message=' for closed status')
def _wait_for(self,
poll_function,
poll_delay_seconds,
timeout_seconds,
message=''):
"""Waits for the given poll_function to return something other than None.
Args:
poll_function: A function with no args that returns something
when ready, or None when not ready.
poll_delay_seconds: Time to wait between fetching results.
timeout_seconds: Time to wait before aborting.
message: Message to print indicate what is being waited for.
Returns:
The value returned by poll_function, or None on timeout.
"""
start = self._host.time()
self._host.print_(
'Waiting%s, timeout: %d seconds.' % (message, timeout_seconds))
while (self._host.time() - start) < timeout_seconds:
self._host.sleep(poll_delay_seconds)
value = poll_function()
if value is not None:
return value
self._host.print_('Waiting%s. %d seconds passed.' %
(message, self._host.time() - start))
self._host.sleep(poll_delay_seconds)
self._host.print_('Timed out waiting%s.' % message)
return None
def latest_try_jobs(self,
issue_number=None,
builder_names=None,
cq_only=False,
patchset=None):
"""Fetches a dict of Build to TryJobStatus for the latest try jobs.
This variant fetches try job data from buildbucket directly.
This includes jobs that are not yet finished and builds with infra
failures, so if a build is in this list, that doesn't guarantee that
there are results.
Args:
issue_number: The git cl/issue number we're working with.
builder_names: Optional list of builders used to filter results.
cq_only: If True, only include CQ jobs.
patchset: If given, use this patchset instead of the latest.
Returns:
A dict mapping Build objects to TryJobStatus objects, with
only the latest jobs included.
"""
if not issue_number:
issue_number = self.get_issue_number()
return self.filter_latest(
self.try_job_results(
issue_number,
builder_names,
cq_only=cq_only,
patchset=patchset))
@staticmethod
def filter_latest(try_results):
"""Returns the latest entries from from a Build to TryJobStatus dict."""
if try_results is None:
return None
latest_builds = filter_latest_builds(try_results.keys())
return {b: s for b, s in try_results.items() if b in latest_builds}
def try_job_results(self,
issue_number=None,
builder_names=None,
cq_only=False,
patchset=None):
"""Returns a dict mapping Build objects to TryJobStatus objects."""
if not issue_number:
issue_number = self.get_issue_number()
raw_results_json = self.fetch_raw_try_job_results(
issue_number, patchset)
build_to_status = {}
if 'builds' not in raw_results_json:
return build_to_status
for build in raw_results_json['builds']:
builder_name = build['builder']['builder']
if builder_names and builder_name not in builder_names:
continue
is_cq = 'tags' in build and {
'key': 'user_agent',
'value': 'cq'
} in build['tags']
is_experimental = 'tags' in build and {
'key': 'cq_experimental',
'value': 'true'
} in build['tags']
if cq_only and not (is_cq and not is_experimental):
continue
build_number = build.get('number')
status = build['status']
build_to_status[Build(
builder_name,
build_number)] = TryJobStatus.from_bb_status(status)
return build_to_status
def fetch_raw_try_job_results(self, issue_number, patchset=None):
"""Gets try job results for the specified CL from buildbucket.
This uses the SearchBuilds rpc format specified in
https://cs.chromium.org/chromium/infra/go/src/go.chromium.org/luci/buildbucket/proto/rpc.proto
The response is a list of dicts of the following form:
{
"builds": [
{
"status": <status>
"builder": {
"builder": <builder_name>
},
"number": <build_number>,
"tags": [
{
"key": <tag key>
"value": <tag value>
},
... more tags
]
},
... more builds
}
This method returns the JSON representation of the above response.
"""
if not patchset:
patchset = self._get_latest_patchset()
luci_token = LuciAuth(self._host).get_access_token()
hed = {
'Authorization': 'Bearer ' + luci_token,
'Accept': 'application/json',
'Content-Type': 'application/json',
}
data = {
'predicate': {
'gerritChanges': [{
'host': 'chromium-review.googlesource.com',
'project': 'chromium/src',
'change': issue_number,
'patchset': patchset
}]
},
'fields':
'builds.*.builder.builder,builds.*.status,builds.*.tags,builds.*.number'
}
url = 'https://cr-buildbucket.appspot.com/prpc/buildbucket.v2.Builds/SearchBuilds'
req_body = json.dumps(data)
_log.debug("Sending SearchBuilds request. Url: %s with Body: %s" %
(url, req_body))
response = self._host.web.request(
'POST', url, data=req_body, headers=hed)
if response.getcode() == 200:
response_body = response.read()
if response_body.startswith(SEARCHBUILDS_RESPONSE_PREFIX):
response_body = response_body[len(SEARCHBUILDS_RESPONSE_PREFIX
):]
return json.loads(response_body)
_log.error(
"Failed to fetch tryjob results from buildbucket (status=%s)" %
response.status)
_log.debug("Full SearchBuilds response: %s" % str(response))
return None
@staticmethod
def _build(result_dict):
"""Converts a parsed try result dict to a Build object."""
builder_name = result_dict['builder_name']
url = result_dict['url']
if url is None:
return Build(builder_name, None)
# LUCI jobs
# TODO(martiniss): Switch to using build number once `git cl
# try-results` uses buildbucket v2 API.
tags = result_dict.get('tags', [])
for tag in tags:
if tag.startswith("build_address:"):
build_number = tag.split('/')[-1]
return Build(builder_name, int(build_number))
# BuildBot jobs
match = re.match(r'.*/builds/(\d+)/?$', url)
if match:
build_number = match.group(1)
return Build(builder_name, int(build_number))
# Swarming tasks
match = re.match(r'.*/task/([0-9a-f]+)(/?|\?.*)$', url)
assert match, '%s did not match expected format' % url
task_id = match.group(1)
return Build(builder_name, task_id)
@staticmethod
def _try_job_status(result_dict):
"""Converts a parsed try result dict to a TryJobStatus object."""
return TryJobStatus(result_dict['status'], result_dict['result'])
@staticmethod
def all_finished(try_results):
return all(s.status == 'COMPLETED' for s in try_results.values())
@staticmethod
def all_success(try_results):
return all(s.status == 'COMPLETED' and s.result == 'SUCCESS'
for s in try_results.values())
@staticmethod
def some_failed(try_results):
return any(s.result == 'FAILURE' for s in try_results.values())