git-cl: Use buildbucket v2 to schedule try jobs.
Change-Id: I3bad4314973cda7e285b5b9cb823f61cd7fb2dff
Bug: 976104
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/tools/depot_tools/+/1809516
Commit-Queue: Edward Lesmes <ehmaldonado@chromium.org>
Reviewed-by: Andrii Shyshkalov <tandrii@google.com>
diff --git a/git_cl.py b/git_cl.py
index 19b7c95..42f689b 100755
--- a/git_cl.py
+++ b/git_cl.py
@@ -71,6 +71,8 @@
# Used to redact the cookies from the gitcookies file.
GITCOOKIES_REDACT_RE = re.compile(r'1/.*')
+MAX_ATTEMPTS = 3
+
# The maximum number of traces we will keep. Multiplied by 3 since we store
# 3 files per trace.
MAX_TRACES = 3 * 10
@@ -341,6 +343,8 @@
return properties
+# TODO(crbug.com/976104): Remove this function once git-cl try-results has
+# migrated to use buildbucket v2
def _buildbucket_retry(operation_name, http, *args, **kwargs):
"""Retries requests to buildbucket service and returns parsed json content."""
try_count = 0
@@ -379,6 +383,38 @@
assert False, 'unreachable'
+def _call_buildbucket(http, buildbucket_host, method, request=None):
+ """Calls a buildbucket v2 method and returns the parsed json response."""
+ headers = {
+ 'Accept': 'application/json',
+ 'Content-Type': 'application/json',
+ }
+ request = json.dumps(request)
+ url = 'https://%s/prpc/buildbucket.v2.Builds/%s' % (buildbucket_host, method)
+
+ logging.info('POST %s with %s' % (url, request))
+
+ attempts = 1
+ time_to_sleep = 1
+ while True:
+ response, content = http.request(url, 'POST', body=request, headers=headers)
+ if response.status == 200:
+ return json.loads(content[4:])
+ if attempts >= MAX_ATTEMPTS or 400 <= response.status < 500:
+ msg = '%s error when calling POST %s with %s: %s' % (
+ response.status, url, request, content)
+ raise BuildbucketResponseException(msg)
+ logging.debug(
+ '%s error when calling POST %s with %s. '
+ 'Sleeping for %d seconds and retrying...' % (
+ response.status, url, request, time_to_sleep))
+ time.sleep(time_to_sleep)
+ time_to_sleep *= 2
+ attempts += 1
+
+ assert False, 'unreachable'
+
+
def _get_bucket_map(changelist, options, option_parser):
"""Returns a dict mapping bucket names to builders and tests,
for triggering tryjobs.
@@ -407,6 +443,21 @@
'Please specify the bucket, e.g. "-B luci.chromium.try".')
+def _parse_bucket(bucket):
+ if '/' in bucket:
+ return tuple(bucket.split('/', 1))
+ # Legacy buckets.
+ print('WARNING Please specify buckets as <project>/<bucket>.')
+ # Assume luci.<project>.<bucket>.
+ if bucket.startswith('luci.'):
+ return tuple(bucket[len('luci.'):].split('.', 1))
+ # Otherwise, assume prefix is also the project name.
+ if '.' in bucket:
+ project = bucket.split('.')[0]
+ return project, bucket
+ return None, None
+
+
def _trigger_try_jobs(auth_config, changelist, buckets, options, patchset):
"""Sends a request to Buildbucket to trigger tryjobs for a changelist.
@@ -416,81 +467,76 @@
buckets: A nested dict mapping bucket names to builders to tests.
options: Command-line options.
"""
- assert changelist.GetIssue(), 'CL must be uploaded first'
- codereview_url = changelist.GetCodereviewServer()
- assert codereview_url, 'CL must be uploaded first'
- patchset = patchset or changelist.GetMostRecentPatchset()
- assert patchset, 'CL must be uploaded first'
+ print('Scheduling jobs on:')
+ for bucket, builders_and_tests in sorted(buckets.iteritems()):
+ print('Bucket:', bucket)
+ print('\n'.join(
+ ' %s: %s' % (builder, tests)
+ for builder, tests in sorted(builders_and_tests.iteritems())))
+ print('To see results here, run: git cl try-results')
+ print('To see results in browser, run: git cl web')
+ gerrit_changes = [changelist.GetGerritChange()]
+ shared_properties = {
+ 'category': options.category,
+ }
+ if options.clobber:
+ shared_properties['clobber'] = True
+ shared_properties.update(_get_properties_from_options(options) or {})
+
+ requests = []
+ for raw_bucket, builders_and_tests in sorted(buckets.iteritems()):
+ project, bucket = _parse_bucket(raw_bucket)
+ if not project or not bucket:
+ print('WARNING Could not parse bucket "%s". Skipping.' % raw_bucket)
+ continue
+
+ for builder, tests in sorted(builders_and_tests.iteritems()):
+ properties = shared_properties.copy()
+ if 'presubmit' in builder.lower():
+ properties['dry_run'] = 'true'
+ if tests:
+ properties['testfilter'] = tests
+
+ requests.append({
+ 'scheduleBuild': {
+ 'requestId': str(uuid.uuid4()),
+ 'builder': {
+ 'project': options.project or project,
+ 'bucket': bucket,
+ 'builder': builder,
+ },
+ 'gerritChanges': gerrit_changes,
+ 'properties': properties,
+ 'tags': [
+ {'key': 'builder', 'value': builder},
+ {'key': 'user_agent', 'value': 'git_cl_try'},
+ ],
+ }
+ })
+
+ if not requests:
+ return
+
+ codereview_url = changelist.GetCodereviewServer()
codereview_host = urlparse.urlparse(codereview_url).hostname
- # Cache the buildbucket credentials under the codereview host key, so that
- # users can use different credentials for different buckets.
+
authenticator = auth.get_authenticator_for_host(codereview_host, auth_config)
http = authenticator.authorize(httplib2.Http())
http.force_exception_to_status_code = True
- buildbucket_put_url = (
- 'https://{hostname}/_ah/api/buildbucket/v1/builds/batch'.format(
- hostname=options.buildbucket_host))
- buildset = 'patch/gerrit/{hostname}/{issue}/{patch}'.format(
- hostname=codereview_host,
- issue=changelist.GetIssue(),
- patch=patchset)
+ batch_request = {'requests': requests}
+ batch_response = _call_buildbucket(
+ http, options.buildbucket_host, 'Batch', request=batch_request)
- shared_parameters_properties = changelist.GetTryJobProperties(patchset)
- shared_parameters_properties['category'] = options.category
- if options.clobber:
- shared_parameters_properties['clobber'] = True
- extra_properties = _get_properties_from_options(options)
- if extra_properties:
- shared_parameters_properties.update(extra_properties)
-
- batch_req_body = {'builds': []}
- print_text = []
- print_text.append('Tried jobs on:')
- for bucket, builders_and_tests in sorted(buckets.iteritems()):
- print_text.append('Bucket: %s' % bucket)
- for builder, tests in sorted(builders_and_tests.iteritems()):
- print_text.append(' %s: %s' % (builder, tests))
- parameters = {
- 'builder_name': builder,
- 'changes': [{
- 'author': {'email': changelist.GetIssueOwner()},
- 'revision': options.revision,
- }],
- 'properties': shared_parameters_properties.copy(),
- }
- if 'presubmit' in builder.lower():
- parameters['properties']['dry_run'] = 'true'
- if tests:
- parameters['properties']['testfilter'] = tests
-
- tags = [
- 'builder:%s' % builder,
- 'buildset:%s' % buildset,
- 'user_agent:git_cl_try',
- ]
-
- batch_req_body['builds'].append(
- {
- 'bucket': bucket,
- 'parameters_json': json.dumps(parameters),
- 'client_operation_id': str(uuid.uuid4()),
- 'tags': tags,
- }
- )
-
- _buildbucket_retry(
- 'triggering tryjobs',
- http,
- buildbucket_put_url,
- 'PUT',
- body=json.dumps(batch_req_body),
- headers={'Content-Type': 'application/json'}
- )
- print_text.append('To see results here, run: git cl try-results')
- print_text.append('To see results in browser, run: git cl web')
- print('\n'.join(print_text))
+ errors = [
+ ' ' + response['error']['message']
+ for response in batch_response.get('responses', [])
+ if 'error' in response
+ ]
+ if errors:
+ raise BuildbucketResponseException(
+ 'Failed to schedule builds for some bots:\n%s' % '\n'.join(errors))
def fetch_try_jobs(auth_config, changelist, buildbucket_host,
@@ -2690,26 +2736,27 @@
if data['status'] in ('ABANDONED', 'MERGED'):
return 'CL %s is closed' % self.GetIssue()
- def GetTryJobProperties(self, patchset=None):
- """Returns dictionary of properties to launch a tryjob."""
- data = self._GetChangeDetail(['ALL_REVISIONS'])
+ def GetGerritChange(self, patchset=None):
+ """Returns a buildbucket.v2.GerritChange message for the current issue."""
+ host = urlparse.urlparse(self.GetCodereviewServer()).hostname
+ issue = self.GetIssue()
patchset = int(patchset or self.GetPatchset())
- assert patchset
- revision_data = None # Pylint wants it to be defined.
- for revision_data in data['revisions'].itervalues():
- if int(revision_data['_number']) == patchset:
- break
- else:
+ data = self._GetChangeDetail(['ALL_REVISIONS'])
+
+ assert host and issue and patchset, 'CL must be uploaded first'
+
+ has_patchset = any(
+ int(revision_data['_number']) == patchset
+ for revision_data in data['revisions'].itervalues())
+ if not has_patchset:
raise Exception('Patchset %d is not known in Gerrit change %d' %
(patchset, self.GetIssue()))
+
return {
- 'patch_issue': self.GetIssue(),
- 'patch_set': patchset or self.GetPatchset(),
- 'patch_project': data['project'],
- 'patch_storage': 'gerrit',
- 'patch_ref': revision_data['fetch']['http']['ref'],
- 'patch_repository_url': revision_data['fetch']['http']['url'],
- 'patch_gerrit_url': self.GetCodereviewServer(),
+ 'host': host,
+ 'change': issue,
+ 'project': data['project'],
+ 'patchset': patchset,
}
def GetIssueOwner(self):