blob: f94cc8d0d499a4c1822734bf3414b8cd9f30a77d [file] [log] [blame]
# Copyright 2019 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
import copy
from urlparse import urlparse
from recipe_engine import recipe_api
from PB.go.chromium.org.luci.buildbucket.proto import common as common_pb2
_DEFAULT_REF = 'refs/heads/master'
class BuildInputResolverApi(recipe_api.RecipeApi):
"""API for resolving the Buildbucket BuildInput for the current task.
# Overview
The build input is provided by LUCI's BuildBucketApi. Recipes use it to
understand which Gerrit changes or Gitiles commits to test. If it points to a
repository's HEAD, this can lead to subtle errors where separate pieces of
code resolve HEAD to different revisions. If it is empty, such as when testing
with led or running a job triggered on cron schedule, builds fail. This API
ensures a Gitiles commit is always present and resolved to a particular
revision.
# Resolution
If the original build input is a Gerrit change, the Gitiles commit is the
resolved HEAD that the Gerrit change should be tested against and submitted on
top of. If there are multiple Gerrit changes (rare), only the first change
is considered.
If the original build input is a Gitiles commit, and its revision is HEAD, its
revision is resolved to the HEAD commit. Otherwise it is unchanged.
If the original build input contained no changes, the Gitiles commit is
resolved to the HEAD commit in `default_project_url` refs/heads/master.
# Usage
Call `resolve` to get the resolved BuildInput. Calling `resolve` multiple
times will always result in the same output. This should be called at the
start of recipe execution. Code that executes after resolution should inject a
a resolved BuildInput rather than reading the unresolved input directly from
BuildBucketApi.
"""
def __init__(self, *args, **kwargs):
super(BuildInputResolverApi, self).__init__(*args, **kwargs)
self._resolved_input = None
def resolve(self, orig, default_project_url=None):
"""Resolves the current Buildbucket build input.
Args:
orig: The Buildbucket build input. This is modified in-place with the
resolved commit.
default_project_url (string): URL to the project to set in the
output BuildInput when the original build input is empty. Must
but a valid URL matching: $scheme://$netloc/$path
Returns:
The resolved build input.
"""
with self.m.context(infra_steps=True):
if not self._resolved_input:
self._resolved_input = self._resolve_once(orig, default_project_url)
orig.CopyFrom(self._resolved_input)
return self._resolved_input
def _resolve_once(self, orig, default_project_url):
assert not self._resolved_input, 'input has already been resolved!'
b = copy.deepcopy(orig)
if b.gerrit_changes:
return self._resolve_gerrit_change(b)
elif str(b.gitiles_commit).strip(): # Empty proto message is still trueish.
return self._resolve_gitiles_commit(b)
assert default_project_url, 'need default_project_url. build input is empty'
return self._resolve_default(b, default_project_url)
def _resolve_gerrit_change(self, b):
"""Resolves a BuildInput from a Gerrit change."""
change = b.gerrit_changes[0]
details = self.m.gerrit.change_details(
'get_gerrit_details',
change_id=str(change.change),
gerrit_host='https://{}'.format(change.host))
# This doesn't work for branches like 'refs/meta/config', but that
# shouldn't be an issue.
ref = 'refs/heads/%s' % details['branch']
host = change.host.replace('-review.googlesource', '.googlesource')
url = 'https://{}/{}'.format(host, change.project)
revision = self._resolve_ref(url, ref)
b.gitiles_commit.CopyFrom(
common_pb2.GitilesCommit(
project=change.project,
host=host,
ref=ref,
id=revision,
))
return b
def _resolve_gitiles_commit(self, b):
"""Resolves a BuildInput from a Gitiles commit."""
commit = b.gitiles_commit
if not commit.id or commit.id == 'HEAD':
ref = commit.ref or self.m.properties.get('branch', _DEFAULT_REF)
url = 'https://{}/{}'.format(commit.host, commit.project)
revision = self._resolve_ref(url, ref)
b.gitiles_commit.CopyFrom(
common_pb2.GitilesCommit(
host=commit.host,
project=commit.project,
ref=ref,
id=revision,
))
return b
def _resolve_default(self, b, project_url):
"""Resolves a BuildInput from an empty Buildbucket build input."""
ref = _DEFAULT_REF
revision = self._resolve_ref(project_url, ref)
url = urlparse(project_url)
b.gitiles_commit.CopyFrom(
common_pb2.GitilesCommit(
host=url.netloc,
project=url.path.lstrip('/'), # /foo -> foo
ref=ref,
id=revision,
))
return b
def _resolve_ref(self, url, ref):
"""Resolves the given url and ref into a commit hash.
Raises:
AssertionError if the ref is not found.
"""
# TODO(crbug.com/961538): When refspath is e.g. "refs/heads/master" the
# output JSON has a malformed key "refs/heads/master/refs/heads/master". We
# Fix the tool and use the input ref directly.
revision = self.m.gitiles.refs(url, refspath='refs/heads').get(ref, None)
assert revision, 'no revision found for {} in {}'.format(url, ref)
return revision