| # Copyright 2021 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 datetime |
| |
| from PB.go.chromium.org.luci.buildbucket.proto import common as common_pb2 |
| from PB.recipe_engine import result |
| from recipe_engine import recipe_api |
| |
| # Timeout for Gerrit API operations. |
| GERRIT_TIMEOUT = datetime.timedelta(seconds=120) |
| |
| CQ_MESSAGE_TAGS = ( |
| "autogenerated:cv", |
| # TODO(olivernewman): Delete these entries one LUCI CV is used everywhere |
| # and we no longer need to support using the Commit Queue service. |
| "autogenerated:cq:dry-run", |
| "autogenerated:cq:full-run", |
| ) |
| |
| |
| class GerritAutoSubmitApi(recipe_api.RecipeApi): |
| def __init__(self, props, *args, **kwargs): |
| super().__init__(*args, **kwargs) |
| self._gerrit_host = props.gerrit_host or "fuchsia-review.googlesource.com" |
| self._auto_submit_label = props.auto_submit_label or "Fuchsia-Auto-Submit" |
| self._tree_status_host = props.tree_status_host |
| self._max_attempts = props.max_attempts or 4 |
| |
| def _get_change_url(self, change): |
| return "https://%s/c/%s/+/%s" % ( |
| self._gerrit_host, |
| change["project"], |
| change["_number"], |
| ) |
| |
| def _is_auto_submit_service_account(self, email): |
| try: |
| # The current implementation of get_email() raises |
| # NotImplementedError but this lets us switch when it is |
| # implemented. |
| return email == self.m.service_account.default().get_email() |
| except NotImplementedError: |
| # Most service accounts doing auto-submit have "auto-submit" in |
| # their service account email. |
| # TODO(crbug.com/1275620) Remove this except block. |
| return "auto-submit" in email |
| |
| def get_eligible_changes(self): |
| raw_query = " ".join( |
| [ |
| "is:submittable", |
| "is:open", |
| "-is:wip", |
| "-(label:Commit-Queue+1 OR label:Commit-Queue+2)", |
| "label:{}+1", |
| # Ignore changes that haven't been updated in a while to keep |
| # response sizes small. We look back at least a few days to ensure |
| # that we can recover missed CLs in case the auto-submit builder |
| # breaks for a while. |
| "-age:10d", |
| # Ignore changes with unresolved comments so that changes don't |
| # get submitted until all reviewer feedback has been addressed. |
| "-has:unresolved", |
| ] |
| ).format(self._auto_submit_label) |
| changes = self.m.gerrit.change_query( |
| name="get changes for %s" % self._gerrit_host, |
| query_string=raw_query, |
| query_params=["CURRENT_REVISION"], |
| host=self._gerrit_host, |
| max_attempts=3, |
| timeout=GERRIT_TIMEOUT, |
| ).json.output |
| |
| # API returns None if there is no search results instead of []. |
| if not changes: |
| return [] |
| |
| eligible_changes = [] |
| for change in changes: |
| change_id = self.m.url.unquote(change["id"]) |
| |
| with self.m.step.nest( |
| "get messages for %s" % change["_number"] |
| ) as presentation: |
| presentation.links["change"] = self._get_change_url(change) |
| messages = self.m.gerrit.change_details( |
| "get details", |
| change_id, |
| host=self._gerrit_host, |
| max_attempts=3, |
| timeout=GERRIT_TIMEOUT, |
| test_data=self.m.json.test_api.output({"messages": []}), |
| ).json.output["messages"] |
| |
| # Some prod accounts don't have emails so we need to use .get(). |
| updates = [ |
| msg["author"].get("email", "") |
| for msg in messages |
| if msg.get("tag") not in CQ_MESSAGE_TAGS |
| ] |
| |
| # Stop retrying if the last `max_attempts` comments are all |
| # from the auto-submit service account. |
| if len(updates) >= self._max_attempts and all( |
| self._is_auto_submit_service_account(email) |
| for email in updates[-self._max_attempts :] |
| ): |
| continue |
| |
| with self.m.step.nest( |
| "get details for %s" % change["_number"] |
| ) as presentation: |
| presentation.links["change"] = self._get_change_url(change) |
| other_changes_info = self.m.gerrit.changes_submitted_together( |
| "find dependent changes", |
| change_id, |
| query_params=["NON_VISIBLE_CHANGES"], |
| host=self._gerrit_host, |
| max_attempts=3, |
| timeout=GERRIT_TIMEOUT, |
| ).json.output |
| |
| if ( |
| len(other_changes_info["changes"]) > 1 |
| or other_changes_info.get("non_visible_changes", 0) != 0 |
| ): |
| continue |
| try: |
| change_mergeability = self.m.gerrit.get_mergeable( |
| "get mergeable", |
| change_id, |
| host=self._gerrit_host, |
| max_attempts=3, |
| timeout=GERRIT_TIMEOUT, |
| ).json.output |
| except self.m.step.StepFailure: # pragma: no cover |
| # If this query fails we shouldn't fail the entire |
| # auto-submit run, and instead skip this change and continue |
| # trying to process other changes. |
| continue |
| if not change_mergeability["mergeable"]: |
| # Best-effort attempt at a trivial rebase. If it fails, that |
| # means there's a true merge conflict that must be resolved |
| # manually by the CL owner, so we won't try to CQ the change. |
| # TODO(olivernewman) `get_eligible_changes()` shouldn't modify |
| # state; refactor to do this in a post-process filtering step. |
| if self.m.gerrit.rebase( |
| "rebase", |
| change_id, |
| host=self._gerrit_host, |
| ok_ret="any", |
| timeout=GERRIT_TIMEOUT, |
| ).retcode: |
| continue |
| eligible_changes.append(change) |
| return eligible_changes |
| |
| def set_commit_queue(self, change): |
| step = self.m.gerrit.set_review( |
| name="%s" % change["_number"], |
| change_id=self.m.url.unquote(change["id"]), |
| labels={"Commit-Queue": 2}, |
| host=self._gerrit_host, |
| notify="NONE", |
| ok_ret="any", |
| timeout=GERRIT_TIMEOUT, |
| ) |
| step.presentation.links["change"] = self._get_change_url(change) |
| return step.retcode == 0 |
| |
| def raw_result(self, changes, dry_run): |
| if changes: |
| contents = [ |
| "Submitted the following CLs{}:".format(" (dry run)" if dry_run else "") |
| ] |
| contents.extend(self._get_change_url(change) for change in changes) |
| else: |
| contents = ["No CLs to submit."] |
| |
| return result.RawResult( |
| summary_markdown=" \n".join(contents), |
| status=common_pb2.SUCCESS, |
| ) |
| |
| def filter_state(self, now, state): |
| delete = [] |
| for k, v in state.items(): |
| if now - v["last_action_time_secs"] > 24 * 60 * 60: |
| delete.append(k) |
| for k in delete: |
| del state[k] |
| return state |
| |
| def can_try_submit(self, now, change_state, tree_status): |
| # Never seen this change before. |
| if not change_state: |
| return True |
| elif tree_status and not tree_status.open: |
| # Don't retry CQ if the tree is closed, to avoid overwhelming CQ during |
| # widespread breakages. |
| return False |
| |
| # Check if we attempted to submit too recently. |
| if ( |
| now - change_state["last_action_time_secs"] |
| > 2 ** change_state["attempts"] * 30 * 60 |
| ): |
| if change_state["attempts"] < self._max_attempts: |
| return True |
| return False |
| |
| def submit(self, now, state, changes, tree_status): |
| submitted_changes = [] |
| for change in changes: |
| current_revision = change["current_revision"] |
| change_state = state.get(current_revision, {}) |
| if self.can_try_submit(now, change_state, tree_status): |
| if self.set_commit_queue(change): |
| change_state["attempts"] = change_state.get("attempts", 0) + 1 |
| change_state["last_action_time_secs"] = now |
| state[current_revision] = change_state |
| submitted_changes.append(change) |
| return submitted_changes |
| |
| # TODO(nmulcahey): Add a Change class to the GerritApi to simplify this, and |
| # other logic in recipes. |
| def __call__(self, dry_run): |
| tree_status = None |
| if self._tree_status_host: |
| tree_status = self.m.tree_status.get(self._tree_status_host) |
| state = self.m.builder_state.fetch_previous_state() |
| changes = None |
| try: |
| with self.m.step.nest("get eligible") as presentation: |
| changes = self.get_eligible_changes() |
| if not changes: |
| presentation.step_text = "\nno eligible changes." |
| |
| if changes: |
| with self.m.step.nest("cq") as presentation: |
| if dry_run: |
| for change in [ |
| self._get_change_url(change) for change in changes |
| ]: |
| presentation.links[change] = change |
| else: |
| now = int(self.m.time.time()) |
| # Drop all state for changes that are no longer eligible |
| # (e.g. because they've been submitted) |
| state = self.filter_state(now, state) |
| # Attempt to submit all the changes we found. |
| changes = self.submit(now, state, changes, tree_status) |
| |
| finally: |
| self.m.builder_state.save(state) |
| |
| return self.raw_result(changes, dry_run) |