blob: 40c8390d126bba01e08e46004cfd0820f8c6a7ac [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.
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
class GerritAutoSubmitApi(recipe_api.RecipeApi):
def __init__(self, props, *args, **kwargs):
super(GerritAutoSubmitApi, self).__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 get_eligible_changes(self):
raw_query = (
"is:submittable -is:wip -(label:Commit-Queue+1 OR label:Commit-Queue+2) "
"label:{}+1"
).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=5,
timeout=30,
).json.output
# API returns None if there is no search results instead of [].
if not changes:
return []
eligible_changes = []
for change in changes:
if change["unresolved_comment_count"]:
continue
change_id = self.m.url.unquote(change["id"])
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=5,
timeout=30,
).json.output
if (
len(other_changes_info["changes"]) > 1
or other_changes_info.get("non_visible_changes", 0) != 0
):
continue
change_mergeability = self.m.gerrit.get_mergeable(
"get mergeable",
change_id,
host=self._gerrit_host,
max_attempts=5,
timeout=30,
).json.output
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=30,
).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=30,
)
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)