| # 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. |
| |
| from future.moves.urllib.parse 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/main" |
| |
| |
| class BuildInputResolverApi(recipe_api.RecipeApi): |
| """API for resolving the Gitiles commit 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/main. |
| |
| # Usage |
| |
| Call `resolve` to modify the input build in-place. Calling `resolve` multiple |
| times will always result in the same result. This should be called at the |
| start of recipe execution. Code that executes after resolution can read the |
| resolved input directly from api.buildbucket. |
| """ |
| |
| def __init__(self, *args, **kwargs): |
| super(BuildInputResolverApi, self).__init__(*args, **kwargs) |
| self._resolved_commit = None |
| |
| def resolve(self, default_project_url=None): |
| """Resolves the current Buildbucket input commit by modifying it in-place. |
| |
| Args: |
| 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 |
| """ |
| with self.m.context(infra_steps=True): |
| if not self._resolved_commit: |
| with self.m.step.nest("resolve base commit"): |
| self._resolved_commit = self._resolve_once(default_project_url) |
| self._bb_input.gitiles_commit.CopyFrom(self._resolved_commit) |
| |
| @property |
| def _bb_input(self): |
| """Convenience alias for accessing the buildbucket input.""" |
| return self.m.buildbucket.build.input |
| |
| def _resolve_once(self, default_project_url): |
| assert not self._resolved_commit, "input has already been resolved!" |
| # Empty proto message is still trueish. |
| if str(self._bb_input.gitiles_commit).strip(): |
| return self._resolve_gitiles_commit() |
| elif self._bb_input.gerrit_changes: |
| return self._resolve_gerrit_change() |
| assert default_project_url, "need default_project_url. build input is empty" |
| return self._resolve_default(default_project_url) |
| |
| def _resolve_gerrit_change(self): |
| """Resolves a commit from a Gerrit change.""" |
| change = self._bb_input.gerrit_changes[0] |
| details = self.m.gerrit.change_details( |
| "get gerrit details", |
| change_id=str(change.change), |
| host=change.host, |
| max_attempts=5, |
| test_data=self.m.json.test_api.output({"branch": "main"}), |
| ).json.output |
| host = change.host.replace("-review.googlesource", ".googlesource") |
| url = "https://{}/{}".format(host, change.project) |
| # This doesn't work for branches like 'refs/meta/config', but that |
| # shouldn't be an issue. |
| ref = "refs/heads/%s" % details["branch"] |
| return common_pb2.GitilesCommit( |
| project=change.project, |
| host=host, |
| ref=ref, |
| id=self._resolve_ref(url, ref), |
| ) |
| |
| def _resolve_gitiles_commit(self): |
| """Resolves a commit revision from the input commit. |
| |
| The input commit may hold a ref that could point to different |
| commits at different points in time, or it may not point to a |
| specific ref at all. In either case, we want to resolve a fixed |
| commit hash. |
| """ |
| commit = self._bb_input.gitiles_commit |
| if commit.id and commit.id != "HEAD": |
| return commit |
| ref = commit.ref or self.m.properties.get("branch", _DEFAULT_REF) |
| url = "https://{}/{}".format(commit.host, commit.project) |
| revision = self._resolve_ref(url, ref) |
| return common_pb2.GitilesCommit( |
| host=commit.host, |
| project=commit.project, |
| ref=ref, |
| id=revision, |
| ) |
| |
| def _resolve_default(self, 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) |
| return common_pb2.GitilesCommit( |
| host=url.netloc, |
| project=url.path.lstrip("/"), # /foo -> foo |
| ref=ref, |
| id=revision, |
| ) |
| |
| def _resolve_ref(self, url, ref): |
| """Resolves the given url and ref into a commit hash. |
| |
| Raises: |
| AssertionError if the ref is not found. |
| """ |
| revision = self.m.git.get_remote_branch_head(url, ref) |
| assert revision, "no revision found for {} in {}".format(url, ref) |
| return revision |