blob: 3cffd57980b601e0338ef514fbe1d01302e5b8dc [file] [log] [blame]
# 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)