blob: 572f3d00406e0f5ed2a35e977c8864b4f3ee3ba2 [file]
# Copyright 2026 The Pigweed Authors
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may not
# use this file except in compliance with the License. You may obtain a copy of
# the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under
# the License.
"""Check if the current tryjob just passed on the commit being rolled."""
from __future__ import annotations
import contextlib
import dataclasses
import datetime
import re
from collections.abc import Sequence
from recipe_engine import engine_types, recipe_api
from PB.go.chromium.org.luci.buildbucket.proto import (
builder_common as builder_common_pb,
)
from PB.go.chromium.org.luci.buildbucket.proto import (
builds_service as builds_service_pb,
)
from PB.go.chromium.org.luci.buildbucket.proto import common as common_pb
from PB.recipe_engine import result as result_pb
LUCI_PROJECT_ACCOUNTS = 'luci-project-accounts.iam.gserviceaccount.com'
@dataclasses.dataclass
class Change:
gerrit_name: str
number: int
commit_message: str
uploader: str
class RollTryjobReuseApi(recipe_api.RecipeApi):
"""Check if the current tryjob just passed on the commit being rolled."""
Change = Change
def __init__(self, props, *args, **kwargs):
super().__init__(*args, **kwargs)
self._dry_run = props.dry_run
self._enabled = props.enabled
self._max_age = datetime.timedelta(hours=props.max_age_hours)
self._verbose = props.verbose
def exit_early_if_applicable(
self,
changes: Sequence[Change],
*,
verbose: bool = False,
) -> result_pb.RawResult | None:
try:
return self._exit_early_if_applicable(
changes=changes,
verbose=verbose,
)
except Exception as e: # pragma: no cover
# If there's an error in this module we should just ignore it and
# run the full build.
pres = self.m.step.empty('roll tryjob reuse exception').presentation
pres.step_summary_text = repr(e)
pass
def _exit_early_if_applicable(
self,
changes: Sequence[Change],
*,
verbose: bool = False,
) -> result_pb.RawResult | None:
nest = lambda: self.m.step.nest('checking roll tryjob status')
verbose = verbose or self._verbose
with contextlib.ExitStack() as stack:
checking_pres: engine_types.StepPresentation | None = None
if verbose:
checking_pres = stack.enter_context(nest())
if not self._enabled:
if verbose:
self.m.step.empty('disabled')
checking_pres.step_summary_text = 'disabled'
return
if not self.m.buildbucket_util.is_tryjob:
if verbose:
self.m.step.empty('not a tryjob')
checking_pres.step_summary_text = 'not a tryjob'
return
if self.m.recipe_testing.enabled:
if verbose:
self.m.step.empty('in recipe testing')
checking_pres.step_summary_text = 'in recipe testing'
return
if len(changes) != 1:
if verbose:
num_changes = f'num changes ({len(changes)}) != 1'
self.m.step.empty(num_changes)
checking_pres.step_summary_text = num_changes
return
roll_change = changes[0]
if '@' not in (roll_change.uploader or ''):
if verbose:
self.m.step.empty(f'bad uploader: {roll_change.uploader!r}')
return
domain = roll_change.uploader.split('@', 1)[1]
if domain not in (
'pigweed-service-accounts.iam.gserviceaccount.com',
'pw-internal-service-accounts.iam.gserviceaccount.com',
):
if verbose:
not_roller = (
f'{roll_change.uploader} is not roller service account'
)
self.m.step.empty(not_roller)
checking_pres.step_summary_text = not_roller
return
if not verbose:
checking_pres = stack.enter_context(nest())
original_gerrit_change_url: str | None = None
original_gerrit_change: common_pb.GerritChange | None = None
for line in roll_change.commit_message.splitlines():
if match := re.match(r'^Original-Reviewed-on: (.*)\s*$', line):
if verbose:
pres = self.m.step.empty(
'found Original-Reviewed-on line'
).presentation
pres.step_summary_text = repr(line)
original_gerrit_change_url = match.group(1)
original_gerrit_change = self.m.gerrit.parse_change_url(
original_gerrit_change_url,
)
original_gerrit_change.host = (
original_gerrit_change.host.replace(
'.git.corp.google.com',
'.googlesource.com',
)
)
break
# If we don't break out of the previous for loop then we don't have
# tryjobs to compare with so we return.
if not original_gerrit_change:
could_not_find = 'could not find "Original-Reviewed-on:" line'
self.m.step.empty(could_not_find)
checking_pres.step_summary_text = could_not_find
return
details = self.m.gerrit.change_details(
name=str(original_gerrit_change.change),
change_id=original_gerrit_change.change,
host=original_gerrit_change.host,
).json.output
submitter = details['submitter']['email']
submitted_patch = details['current_revision_number']
if submitter.split('@', 1)[1] != LUCI_PROJECT_ACCOUNTS:
not_scoped = (
'submitter is not a LUCI project-scoped service account'
)
self.m.step.empty(not_scoped)
checking_pres.step_summary_text = not_scoped
return
# Since we got an "Original-Reviewed-on:" line, the original change
# had a "Reviewed-on:" line. This is added by Gerrit on submission.
# There should be no tryjobs on this last patchset since it didn't
# exist until submission. Look at the prior patchset, but in case
# something weird happened make sure we don't go below 1.
original_gerrit_change.patchset = max(submitted_patch - 1, 0)
builder_id = builder_common_pb.BuilderID(
project=self.m.buildbucket.build.builder.project,
bucket=self.m.buildbucket.build.builder.bucket.removesuffix(
'.shadow',
),
builder=self.m.buildbucket.build.builder.builder,
)
predicate = builds_service_pb.BuildPredicate(
builder=builder_id,
status=common_pb.SUCCESS,
gerrit_changes=[original_gerrit_change],
experiments=['-pigweed.non_production'],
)
predicate.create_time.start_time.FromDatetime(
self.m.time.utcnow() - self._max_age,
)
builds = self.m.buildbucket.search(predicate=predicate)
if not builds:
self.m.step.empty('no recent passing builds')
checking_pres.step_summary_text = 'no recent passing builds'
return
full_name = '/'.join(
(builder_id.project, builder_id.bucket, builder_id.builder),
)
pres = self.m.step.empty(
'found recent passing builds',
).presentation
for build in builds:
pres.links[str(build.id)] = self.m.buildbucket.build_url(
build=build,
)
builds = [
b
for b in builds
if 'early_exit_reason' not in b.output.properties
]
if not builds:
self.m.step.empty('all builds exited early')
checking_pres.step_summary_text = 'all builds exited early'
return
if self._dry_run:
self.m.step.empty('dry-run, continuing build')
checking_pres.step_summary_text = 'dry-run, continuing build'
return
assert checking_pres
checking_pres.step_summary_text = (
'Exiting early since this tryjob passed on the rolling change '
'immediately before the roll.'
)
prior_tryjob_link = self.m.buildbucket.build_url(build=builds[0])
checking_pres.links[full_name] = prior_tryjob_link
checking_pres.properties['early_exit_reason'] = 'roll tryjob reuse'
checking_pres.properties['rolling change'] = (
original_gerrit_change_url
)
return result_pb.RawResult(
summary_markdown=(
f'Exiting early since [{full_name}]({prior_tryjob_link}) '
'passed on the '
f'[rolling change]({original_gerrit_change_url}) '
'immediately before the roll.'
),
status=common_pb.SUCCESS,
)