| # Copyright 2017 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 functools |
| from urllib.parse import urlparse |
| |
| from recipe_engine import recipe_api |
| |
| |
| class GerritApi(recipe_api.RecipeApi): |
| """Module for querying a Gerrit host through the Gerrit API.""" |
| |
| def normalize_host(self, host): |
| """Remove most elements if present, then add them back.""" |
| host = host.rstrip("/") |
| if host.startswith("https://"): |
| host = host[len("https://") :] |
| if host.endswith(".googlesource.com"): |
| host = host[0 : -len(".googlesource.com")] |
| if host.endswith("-review"): |
| host = host[0 : -len("-review")] |
| return f"{host}-review.googlesource.com" |
| |
| def host_from_remote_url(self, remote): |
| """Given a git remote URL, returns the Gerrit host.""" |
| # urlparse doesn't work as expected on scheme-less URLs. |
| if "://" not in remote: |
| remote = f"https://{remote}" |
| parsed_remote = urlparse(self.m.sso.sso_to_https(remote)) |
| host = parsed_remote.netloc |
| host_parts = host.split(".") |
| host_parts[0] += "-review" |
| return ".".join(host_parts) |
| |
| def project_from_remote_url(self, remote): |
| """Given a git remote URL, returns the Gerrit project.""" |
| # urlparse doesn't work as expected on scheme-less URLs. |
| if "://" not in remote: |
| remote = f"https://{remote}" |
| parsed_remote = urlparse(self.m.sso.sso_to_https(remote)) |
| return parsed_remote.path.strip("/") |
| |
| def resolve_change(self, change): |
| """Resolve a GerritChange to a URL and ref.""" |
| url = f"https://{change.host.replace('-review', '')}/{change.project}" |
| ref = "refs/changes/%02d/%d/%d" % ( |
| change.change % 100, |
| change.change, |
| change.patchset, |
| ) |
| return url, ref |
| |
| @property |
| def _gerrit_path(self): |
| return self.m.ensure_tool("gerrit", self.resource("tool_manifest.json")) |
| |
| @functools.cached_property |
| def host(self): |
| bb_input = self.m.buildbucket.build.input |
| if bb_input.gerrit_changes: |
| return bb_input.gerrit_changes[0].host |
| elif bb_input.gitiles_commit.host: |
| gitiles_host = bb_input.gitiles_commit.host |
| return gitiles_host.replace(".", "-review.", 1) |
| return None # pragma: no cover |
| |
| def abandon(self, name, change_id, message=None, notify=None, **kwargs): |
| """Abandons a change. |
| |
| Returns the details of the change, after attempting to abandon. |
| |
| Args: |
| name (str): The name of the step. |
| change_id (str): A change ID that uniquely defines a change on the |
| host. |
| message (str): A message explaining the reason for abandoning the |
| change. |
| notify (str): One of "NONE", "OWNER", "OWNER_REVIEWERS", "ALL" |
| specifying whom to send notifications to. |
| **kwargs: Passed through to __call__(). |
| """ |
| input_json = {"change_id": str(change_id)} |
| if message: |
| input_json["input"] = {"message": message} |
| if notify: |
| assert notify in ("NONE", "OWNER", "OWNER_REVIEWERS", "ALL") |
| input_json["input"]["notify"] = notify |
| |
| return self._run( |
| name=name, subcmd="change-abandon", input_json=input_json, **kwargs |
| ) |
| |
| def create_change(self, name, project, subject, branch, topic=None, **kwargs): |
| """Creates a new change for a given project on the gerrit host. |
| |
| Returns the details of the newly-created change. |
| |
| Args: |
| name (str): The name of the step. |
| project (str): The name of the project on the host to create a change |
| for. |
| subject (str): The subject of the new change. |
| branch (str): The branch onto which the change will be made. |
| topic (str): A gerrit topic that can be used to atomically land the |
| change with other changes in the same topic. |
| **kwargs: Passed through to __call__(). |
| """ |
| input_json = { |
| "input": {"project": project, "subject": subject, "branch": branch} |
| } |
| if topic: |
| input_json["input"]["topic"] = topic |
| return self._run( |
| name=name, subcmd="change-create", input_json=input_json, **kwargs |
| ) |
| |
| def set_review( |
| self, |
| name, |
| change_id, |
| labels=None, |
| message=None, |
| reviewers=None, |
| ccs=None, |
| revision="current", |
| notify=None, |
| tag=None, |
| patchset_level_comment=None, |
| **kwargs, |
| ): |
| """Sets a change at a revision for review. Can optionally set labels, reviewers, |
| CCs, and tag. |
| |
| Returns updated labels, reviewers, and whether the change is ready for |
| review as a JSON dict. |
| |
| Args: |
| name (str): The name of the step. |
| change_id (str): A change ID that uniquely defines a change on the |
| host. |
| labels (dict): A map of labels (with names as strings, e.g. |
| 'Code-Review') to the integral values you wish to set them to. |
| message (str): A message to be added as a review comment. |
| reviewers (list): A list of strings containing reviewer IDs (e.g. |
| email addresses). |
| ccs (list): A list of strings containing reviewer IDs (e.g. email |
| addresses). |
| revision (str): A revision ID that identifies a revision for the |
| change (default is 'current'). |
| notify (str): One of "NONE", "OWNER", "OWNER_REVIEWERS", "ALL" |
| specifying whom to send notifications to. |
| tag (str): A tag to attach to the message, votes and comments. |
| patchset_level_comment (dict): A dict in this format |
| https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-input |
| to add as a comment to the entire patchset. |
| **kwargs: Passed through to __call__(). |
| """ |
| input_json = { |
| "change_id": str(change_id), |
| "revision_id": revision, |
| "input": {}, |
| } |
| if labels: |
| input_json["input"]["labels"] = labels |
| if reviewers or ccs: |
| input_json["input"]["reviewers"] = [] |
| if reviewers: |
| input_json["input"]["reviewers"] += [{"reviewer": i} for i in reviewers] |
| if ccs: |
| input_json["input"]["reviewers"] += [ |
| {"reviewer": i, "state": "CC"} for i in ccs |
| ] |
| if notify: |
| assert notify in ("NONE", "OWNER", "OWNER_REVIEWERS", "ALL") |
| input_json["input"]["notify"] = notify |
| if tag: |
| input_json["input"]["tag"] = tag |
| |
| # TODO(akbiggs): Consider merging the logic that |
| # sets "message" and "comments" into just setting |
| # "comments", since a patchset-level resolved |
| # comment is functionally similar to a message. |
| if message: |
| input_json["input"]["message"] = message |
| if patchset_level_comment: |
| input_json["input"]["comments"] = { |
| "/PATCHSET_LEVEL": [patchset_level_comment] |
| } |
| |
| return self._run( |
| name=name, subcmd="set-review", input_json=input_json, **kwargs |
| ) |
| |
| def submit(self, name, change_id, **kwargs): |
| """Submit a change a repository. |
| |
| Returns a JSON dict of details regarding the change. |
| |
| Args: |
| name (str): The name of the step. |
| change_id (str): A change ID that uniquely defines a change on the |
| host. |
| **kwargs: Passed through to __call__(). |
| """ |
| return self._run( |
| name=name, |
| subcmd="submit", |
| input_json={"change_id": str(change_id)}, |
| **kwargs, |
| ) |
| |
| def rebase(self, name, change_id, on_behalf_of_uploader=False, **kwargs): |
| """Rebase a change. |
| |
| Returns a JSON dict of details regarding the change. |
| |
| Args: |
| name (str): The name of the step. |
| change_id (str): A change ID that uniquely defines a change on the |
| host. |
| on_behalf_of_uploader (bool): Rebase on behalf of the user that |
| uploaded the change instead of as the caller. |
| **kwargs: Passed through to __call__(). |
| """ |
| input_json = {"change_id": str(change_id), "input": {}} |
| if on_behalf_of_uploader: |
| input_json["input"]["on_behalf_of_uploader"] = True |
| |
| return self._run( |
| name=name, |
| subcmd="rebase", |
| input_json=input_json, |
| **kwargs, |
| ) |
| |
| def change_details(self, name, change_id, query_params=None, **kwargs): |
| """Returns a JSON dict of details regarding a specific change. |
| |
| Args: |
| name (str): The name of the step. |
| change_id (str): A change ID that uniquely defines a change on the |
| host. |
| query_params (list): A list of Gerrit REST query parameters (strings). |
| Documented at |
| https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#query-options |
| **kwargs: Passed through to __call__(). |
| """ |
| query_params = query_params or [] |
| input_json = {"change_id": str(change_id)} |
| if query_params: |
| input_json["params"] = {"o": query_params} |
| return self._run( |
| name=name, subcmd="change-detail", input_json=input_json, **kwargs |
| ) |
| |
| def restore_change(self, name, change_id, message=None, **kwargs): |
| """Restores a change. |
| |
| Returns the details of the change, after attempting to restore. |
| |
| Args: |
| name (str): The name of the step. |
| change_id (str): A change ID that uniquely defines a change on the |
| host. |
| message (str): An optional message explaining the reason for restoring |
| the change. |
| **kwargs: Passed through to __call__(). |
| """ |
| input_json = {"change_id": str(change_id)} |
| if message: |
| input_json["input"] = {"message": message} |
| return self._run(name=name, subcmd="restore", input_json=input_json, **kwargs) |
| |
| def change_query(self, name, query_string, query_params=None, **kwargs): |
| """Searches for changes. |
| |
| Returns a list of matched changes (or None if no changes were matched). |
| See the Gerrit documentation for supported query params: |
| |
| Args: |
| name (str): The name of the step. |
| query_string (str): The parameters to use for the search, e.g. |
| 'status:open+-status:wip. See the Gerrit docs for supported parameters: |
| https://gerrit-review.googlesource.com/Documentation/user-search.html#_search_operators |
| query_params (list): A list of Gerrit REST query parameters (strings). |
| Documented at |
| https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#query-options |
| **kwargs: Passed through to __call__(). |
| |
| Returns: |
| A `StepData` with the search results in its `json.output`. |
| """ |
| input_json = {"params": {"q": query_string}} |
| if query_params: |
| input_json["params"]["o"] = query_params |
| return self._run( |
| name=name, subcmd="change-query", input_json=input_json, **kwargs |
| ) |
| |
| def get_mergeable(self, name, change_id, revision_id="current", **kwargs): |
| """Query whether a change is submittable. |
| |
| Returns a JSON dict of details regarding the change. |
| |
| Args: |
| name (str): The name of the step. |
| change_id (str): A change ID that uniquely defines a change on the |
| host. |
| revision_id (str): An ID that uniquely identifies the patchset in |
| the change. |
| **kwargs: Passed through to __call__(). |
| """ |
| return self._run( |
| name=name, |
| subcmd="get-mergeable", |
| input_json={"change_id": str(change_id), "revision_id": revision_id}, |
| **kwargs, |
| ) |
| |
| def changes_submitted_together(self, name, change_id, query_params=None, **kwargs): |
| """Returns a JSON dict of changes that would be submitted with the |
| current change. |
| |
| Args: |
| name (str): The name of the step. |
| change_id (str): A change ID that uniquely defines a change on the |
| host. |
| query_params (list): A list of Gerrit REST query parameters (strings). |
| Documented at |
| https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#query-options |
| **kwargs: Passed through to __call__(). |
| """ |
| input_json = {"change_id": str(change_id)} |
| if query_params: |
| input_json["params"] = {"o": query_params} |
| return self._run( |
| name=name, |
| subcmd="changes-submitted-together", |
| input_json=input_json, |
| **kwargs, |
| ) |
| |
| def list_change_comments(self, name, change_id, **kwargs): |
| """List comments on a change. |
| |
| Returns a list of comments on the given change. |
| See the Gerrit documentation for supported query params: |
| |
| Args: |
| name (str): The name of the step. |
| change_id (str): A change ID that uniquely defines a change on the |
| host. |
| **kwargs: Passed through to __call__(). |
| |
| Returns: |
| A `StepData` with the results in its `json.output`. |
| """ |
| input_json = {"change_id": str(change_id)} |
| return self._run( |
| name=name, subcmd="list-change-comments", input_json=input_json, **kwargs |
| ) |
| |
| def create_branch(self, name, project, ref, revision, **kwargs): |
| """Create a branch. |
| |
| Args: |
| name (str): The name of the step. |
| project (str): The name of the project. |
| ref (str): The name of the branch to create. |
| revision (str): The revision that the branch should point to. |
| """ |
| input_json = { |
| "project_id": project, |
| "input": {"ref": ref, "revision": revision}, |
| } |
| return self._run( |
| name=name, subcmd="create-branch", input_json=input_json, **kwargs |
| ) |
| |
| def account_query(self, name, query_string, query_params=None, **kwargs): |
| """Search for accounts. |
| |
| Returns a list of matching accounts. |
| See the Gerrit documentation for supported query params: |
| |
| Args: |
| name (str): The name of the step. |
| query_string (str): The parameters to use for the search, e.g. |
| 'email:nobody@example.com'. See the Gerrit docs for supported parameters: |
| https://gerrit-review.googlesource.com/Documentation/user-search-accounts.html |
| query_params (list): A list of Gerrit REST query parameters (strings). |
| Documented at |
| https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html |
| **kwargs: Passed through to __call__(). |
| |
| Returns: |
| A `StepData` with the results in its `json.output`. |
| """ |
| input_json = {"params": {"q": query_string}} |
| if query_params: |
| input_json["params"]["o"] = query_params |
| return self._run( |
| name=name, subcmd="account-query", input_json=input_json, **kwargs |
| ) |
| |
| def _run( |
| self, |
| name, |
| subcmd, |
| input_json, |
| host=None, |
| test_data=None, |
| max_attempts=1, |
| infra_step=True, |
| **kwargs, |
| ): |
| """Invoke the gerrit command-line interface. |
| |
| Args: |
| name (str): The name of the step. |
| subcmd (str): The subcommand to invoke. |
| input_json (json): Input to the subcommand. |
| host (str): The Gerrit host to make the query against. |
| If not set, inferred from Buildbucket input. |
| test_data (recipe_test_api.StepTestData): Test JSON output data for |
| this step. |
| max_attempts (int): The maximum number of times to attempt this |
| command. |
| infra_step (bool): Whether this should be considered an infra step. |
| **kwargs (dict): Passed through to api.step(). |
| """ |
| if not host: |
| assert self.host |
| host = self.host |
| host = self.normalize_host(host) |
| |
| cmd = [ |
| self._gerrit_path, |
| subcmd, |
| "-host", |
| "https://" + host, |
| "-input", |
| self.m.json.input(input_json), |
| "-output", |
| self.m.json.output(), |
| ] |
| |
| # If there's test data, create a factory for it. |
| step_test_data = None |
| if test_data is not None: |
| step_test_data = lambda: test_data |
| |
| kwargs.setdefault("timeout", 10 * 60) |
| |
| sleep = 5 |
| for i in range(max_attempts): |
| try: |
| # Run the gerrit client command. |
| return self.m.step( |
| name, |
| cmd, |
| step_test_data=step_test_data, |
| infra_step=infra_step, |
| **kwargs, |
| ) |
| except self.m.step.StepFailure: |
| # Raise exception if not a transient failure or on the last attempt |
| step = self.m.step.active_result |
| # Most gerrit command failures result in an exit code of 1, but |
| # timeouts have an exit code of -15 because the recipe engine |
| # kills them with SIGTERM which is 15. |
| if step.retcode not in (1, -15) or i == max_attempts - 1: |
| raise |
| self.m.time.sleep(sleep) |
| sleep *= 2 |
| finally: |
| self.m.step.active_result.presentation.logs[ |
| "json.input" |
| ] = self.m.json.dumps(input_json, indent=2).splitlines() |
| |
| # If this call operates on a single change id, link to it. |
| if "change_id" in input_json: |
| self.m.step.active_result.presentation.links[ |
| "gerrit link" |
| ] = f"https://{host}/q/{input_json['change_id']}" |