blob: b3e0e201c27703b6b71ba34f74591bd2f341a20b [file] [log] [blame]
# -*- coding: utf-8 -*-
# Copyright 2020 The Chromium OS Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Used to create sweeping changes by creating CLs in many repos.
This recipe is currently focused on the use case of running gen_config in
program and project repositories. Invocation is most easily handled via the
cl_factory script in the chromiumos/config repo's bin directory:
https://chromium.googlesource.com/chromiumos/config/+/HEAD/bin/cl_factory
That script is a wrapper around the `bb add` command which ends up executing
something that looks like this:
bb add \
-cl https://chrome-internal-review.googlesource.com/c/chromeos/program/galaxy/+/3095418 \
-p 'repo_regexes=["src/project/galaxy"]' \
-p 'message_template=Hello world\n\nBUG=chromium:1092954\nTEST=None' \
-p 'reviewers=["reviewer@google.com"]' \
-p 'hashtags=["mondo-update"]' \
-p 'replace_strings=[{"file_glob": "*.star", "before": "_CLAMSHELL", "after": "_CONVERTIBLE"}]' \
chromeos/infra/ClFactory
For more details on the input properties, see cl_factory.proto.
"""
import re
from recipe_engine import post_process
from PB.go.chromium.org.luci.buildbucket.proto import common as common_pb2
from PB.recipes.chromeos.cl_factory import ClFactoryProperties
PROPERTIES = ClFactoryProperties
DEPS = [
'recipe_engine/buildbucket',
'recipe_engine/context',
'recipe_engine/file',
'recipe_engine/path',
'recipe_engine/properties',
'recipe_engine/raw_io',
'recipe_engine/step',
'cros_cq_depends',
'cros_sdk',
'cros_source',
'easy',
'gerrit',
'git',
'repo',
'src_state',
]
_CHROMIOUS_CONFIG_PROJECT = 'chromiumos/config'
_CHANGE_ID_REGEX = re.compile(r'^Change-Id: ', re.MULTILINE)
def RunSteps(api, properties):
_validate_inputs(properties)
# Note that gerrit_changes is allowed to be empty.
gerrit_changes = api.buildbucket.build.input.gerrit_changes
with api.cros_source.checkout_overlays_context():
# We must sync the cache before we touch the workspace (which includes
# calling api.repo methods). Start by syncing just the projects in
# gerrit_changes.
# Specify a reference, to reduce network traffic when we sync additional
# projects from properties.repo_regexes.
sync_projects = [gc.project for gc in gerrit_changes]
api.cros_source.ensure_synced_cache(
cache_path_override=api.cros_source.workspace_path,
init_opts=dict(reference=api.cros_source.cache_path, verbose=True),
sync_opts=dict(verbose=True), projects=sync_projects)
with api.context(cwd=api.cros_source.workspace_path):
with api.step.nest('find gerrit change repos') as pres:
gc_infos = []
if gerrit_changes:
gc_infos = api.repo.project_infos(
[gc.project for gc in gerrit_changes])
else:
pres.step_text = 'no input gerrit changes'
with api.step.nest('find regex matching CL repos'):
cl_infos = api.repo.project_infos(regexes=list(properties.repo_regexes))
if len(cl_infos) == 0:
regexes = ', '.join(
map(lambda v: '"{}"'.format(v), properties.repo_regexes))
raise ValueError(
'No matching projects found, for regex: {}'.format(regexes))
with api.step.nest('find additional repos to sync'):
sync_projects = _determine_sync_projects(api, gc_infos, cl_infos,
properties)
api.repo.sync(projects=sync_projects, verbose=True)
if properties.manifest_branch:
api.cros_source.checkout_branch(api.src_state.internal_manifest.url,
properties.manifest_branch)
with api.step.nest('cherry-pick gerrit changes') as pres:
if gerrit_changes:
api.cros_source.apply_gerrit_changes(gerrit_changes)
else:
pres.step_text = 'no input gerrit changes to apply'
# Start development branches for each project.
api.repo.start('cl-factory', projects=[info.name for info in cl_infos])
with api.step.nest('generate project change lists') as pres:
changes, diffs = _make_changes(api, cl_infos, gerrit_changes,
properties)
if changes and gerrit_changes and properties.set_source_depends:
_set_source_cq_depends(api, changes, gc_infos, gerrit_changes)
with api.step.nest('summarize results') as pres:
if not changes:
pres.step_text = 'no change lists generated'
else:
pres.logs['unified_diff'] = _make_unified_diff(changes, diffs)
gerrit_commands = _make_gerrit_commands(api, properties.hashtags,
changes)
if gerrit_commands:
pres.logs['gerrit_commands'] = gerrit_commands
def _validate_inputs(properties):
"""Validates the inputs to this recipe.
Validates that the inputs to this recipe. An exception is thrown if a problem
is found.
Args:
properties (ClFactoryProperties): recipe input properties.
"""
if not properties.repo_regexes:
raise ValueError('Projects to operate on must be specified by the '
'repo_regexes property.')
if not properties.message_template:
raise ValueError('A message_template property must specify how to create '
'commit messages')
def _make_changes(api, cl_infos, gerrit_changes, properties):
"""Creates and returns the generated change lists and their diffs
Args:
api (RecipeApi): See RunSteps documentation.
cl_infos (List[ProjectInfo]}: List of infos for the projects CLs are being
made in. Corresponds to that found via repo_regexes.
gerrit_changes (List[GerritChange]): gerrit change inputs to the recipe,
if any.
properties (ClFactoryProperties): Input properties to the recipe.
Returns:
(List([GerritChange]), List([str])) tuple of generated changes and the diffs
for those changes
"""
diffs = []
changes = []
for info in cl_infos:
with api.step.nest('working on project {}'.format(info.name)) as pres, \
api.context(cwd=api.cros_source.workspace_path.join(info.path)):
_replace_strings(api, properties)
_gen_config(api)
# Note that we don't use api.git.diff_check here because it doesn't
# handle empty repos well. The ls-files will report an error condition.
if api.git.get_working_dir_diff_files():
pres.step_text = 'diff'
cl, diff = _make_cl(api, info, properties, gerrit_changes)
changes.append(cl)
diffs.append(diff)
else:
pres.step_text = 'no diff'
return changes, diffs
def _make_cl(api, info, properties, gerrit_changes):
"""Makes and returns a generated gerrit change and their diffs.
Makes and returns a generated gerrit change, assumes cwd is the project path.
Args:
api (RecipeApi): See RunSteps documentation.
info (ProjectInfo): project info of project making a change list for.
properties (ClFactoryProperties): recipe input properties.
gerrit_changes (List[GerritChange]): gerrit change inputs to the recipe,
if any.
Returns:
(List([GerritChange]), List([str])) tuple of generated changes and the diffs
for those changes
"""
project_path = api.context.cwd
commit_message = _make_commit_message(api, info, gerrit_changes, properties)
api.git.add([project_path])
test_stdout = '- before text\n+ after text'
diff = api.easy.stdout_step('grab a diff', ['git', 'diff', '--cached'],
test_stdout=test_stdout)
api.git.commit(commit_message)
cl = api.gerrit.create_change(
project=project_path,
reviewers=list(properties.reviewers),
ccs=list(properties.ccs),
hashtags=list(properties.hashtags),
)
return cl, diff
def _set_source_cq_depends(api, changes, gc_infos, gerrit_changes):
"""Sets Cq-Depend on the input gerrit changes.
Args:
api (RecipeApi): See RunSteps documentation.
changes (List[GerritChange]): List of generated gerrit changes.
gc_infos (List[ProjectInfo]): List of infos for the input gerrit changes.
gerrit_changes (List[GerritChange]): gerrit change inputs to the recipe,
if any.
"""
cq_depend = api.cros_cq_depends.get_cq_depend(changes)
replacement = cq_depend + '\nChange-Id: '
with api.step.nest('applying Cq-Depend to input projects'):
for info, change in zip(gc_infos, gerrit_changes):
with api.step.nest('applying Cq-Depend to {}'
.format(info.name)) as pres, \
api.context(cwd=api.cros_source.workspace_path.join(info.path)):
# For the change description commands to work we must be operating
# on a tracking branch.
api.step('create cl_factory branch', [
'git', 'checkout', '-b', '__cl_factory', '--track', '{}/{}'.format(
info.remote, info.branch_name)
])
description = api.gerrit.get_change_description(change)
description = re.sub(_CHANGE_ID_REGEX, replacement, description)
api.gerrit.set_change_description(change, description)
def _determine_sync_projects(api, gc_infos, cl_infos, properties):
"""Determines projects needing to be synced and returns them.
Args:
api (RecipeApi): See RunSteps documentation.
gc_infos (List[ProjectInfo]): List of infos for the input gerrit changes.
cl_infos (List[ProjectInfo]}: List of infos for the projects CLs are being
made in. Corresponds to that found via repo_regexes.
properties (ClFactoryProperties): Input properties to the recipe.
Returns:
List([str]) of projects to sync, returning an empty list to sync all.
"""
if properties.full_repo_sync:
return []
# For now since this only does gen_config we are assuming as such.
# Thus make sure that all projects that could be involved in config generation
# are up to date even when not specified by gerrit changes or repos to make
# CLs in. This would include chromiumos/config and all program repos.
projects = set([i.name for i in gc_infos + cl_infos])
projects.add(_CHROMIOUS_CONFIG_PROJECT)
program_infos = api.repo.project_infos(regexes=['chromeos/program'])
projects.update(set([i.name for i in program_infos]))
return list(projects)
def _replace_strings(api, properties):
"""Executes string replacements with cwd repo.
Args:
api (RecipeApi): See RunSteps documentation.
properties (ClFactoryProperties): recipe input properties.
"""
with api.step.nest('replace_strings'):
for rs in properties.replace_strings:
for filename in api.file.glob_paths('find replace string files',
api.context.cwd, rs.file_glob,
test_data=['file.txt', 'file.star']):
filedata = api.file.read_raw('read {}'.format(filename), filename,
test_data='hello world')
filedata = filedata.replace(rs.before, rs.after)
api.file.write_raw('write {}'.format(filename), filename, str(filedata))
def _gen_config(api):
"""Executes gen_config within cwd device configuration repo.
Executes gen_config within the cwd device configuration repo. If gen_config or
config.star are not found in the usual locations this step will ignore this
repo. This is handy because the chromeos/project/private repo, for example, is
not a regular device configuration repo but the easy regex, "src/project",
will of course hit it.
Args:
api (RecipeApi): See RunSteps documentation.
"""
gen_config_path = api.context.cwd.join('config', 'bin', 'gen_config')
config_path = api.context.cwd.join('config.star')
api.path.mock_add_paths(gen_config_path)
api.path.mock_add_paths(config_path)
with api.step.nest('gen_config') as pres:
if (not api.path.exists(gen_config_path) or
not api.path.exists(config_path)): # pragma: nocover
pres.step_text = 'gen_config files not found'
else:
api.step('run gen_config config.star', [gen_config_path, config_path])
def _make_commit_message(api, info, gerrit_changes, properties):
"""Makes the commit message for a generated gerrit change.
Args:
api (RecipeApi): See RunSteps documentation.
info (ProjectInfo): project info of project making a commit message for.
gerrit_changes (List[GerritChange]): gerrit change inputs to the recipe,
if any.
properties (ClFactoryProperties): Input properties to the recipe.
Returns:
str commit message.
"""
replacements = {
'project': info.name,
'path': info.path,
'remote': info.remote,
}
try:
message = properties.message_template.format(**replacements)
except KeyError:
# Unrecognized key in template. Use the template without interpolation.
message = properties.message_template
message += '\n\nGenerated by ClFactory, see {} for job details.'.format(
api.buildbucket.build_url())
if gerrit_changes:
message += '\n\n{}'.format(
api.cros_cq_depends.get_cq_depend(gerrit_changes))
return message
def _make_unified_diff(changes, diffs):
"""Makes a unified, single string, concatenation of all the diffs.
Combines the given diffs into a single string with a small header that shows
what project each particular diff comes from. The intention is for the user
to get an overall view of what has changed and thus may be able to make an
overall approval decision without visiting each change in gerrit.
Args:
changes (List[GerritChange]): List of generated gerrit changes.
diffs (List[str]): List of diffs for the generated gerrit changes.
Returns:
str unified diff.
"""
unified_diff = ''
for cl, diff in zip(changes, diffs):
unified_diff += cl.project + '\n'
unified_diff += '=' * 80
unified_diff += '\n' + diff + '\n\n'
return unified_diff
_CR_TEMPLATE = 'gerrit label-cr `gerrit {} --raw search "{} status:open {}"` 2'
_V_TEMPLATE = 'gerrit label-v `gerrit {} --raw search "{} status:open {}"` 1'
_CQ_TEMPLATE = 'gerrit label-cq `gerrit {} --raw search "{} status:open {}"` 2'
_ABANDON_TEMPLATE = (
'gerrit abandon `./gerrit {} --raw search "{} status:open {}"`')
def _make_gerrit_commands(api, hashtags, changes):
"""Returns a the gerrit commands to review, verify, commit the changes.
Returns a string containing the gerrit command line commands that can be used
to review, verify, and commit the changes.
Args:
api (RecipeApi): See RunSteps documentation.
hashtags (List[str]): List of hashtags applied to generated gerrit changes.
changes (List[GerritChange]): List of generated gerrit changes.
Returns:
str of approval commands suitable for presenting to the user, or None
if no commands could be determined.
"""
owner_constraint = 'owner:{}'.format(
api.buildbucket.build.infra.swarming.task_service_account)
hashtag_constraints = ' '.join(
map(lambda t: 'hashtag:{}'.format(t), hashtags))
reviews = []
verifies = []
commits = []
abandons = []
if _has_changes_on_host(changes, 'chromium-review.googlesource.com'):
format = ['', owner_constraint, hashtag_constraints]
reviews.append(_CR_TEMPLATE.format(*format))
verifies.append(_V_TEMPLATE.format(*format))
commits.append(_CQ_TEMPLATE.format(*format))
abandons.append(_ABANDON_TEMPLATE.format(*format))
if _has_changes_on_host(
changes, 'chrome-internal-review.googlesource.com'): # pragma: nocover
format = ['-i', owner_constraint, hashtag_constraints]
reviews.append(_CR_TEMPLATE.format(*format))
verifies.append(_V_TEMPLATE.format(*format))
commits.append(_CQ_TEMPLATE.format(*format))
abandons.append(_ABANDON_TEMPLATE.format(*format))
if not reviews: # pragma: nocover
return None
commands = 'Review commands:\n' + '\n'.join(reviews)
commands += '\n\nVerify commands:\n' + '\n'.join(verifies)
commands += '\n\nCommit commands:\n' + '\n'.join(commits)
commands += '\n\nAbandon commands:\n' + '\n'.join(abandons)
return commands
def _has_changes_on_host(changes, host):
"""Returns whether the changes have a change on host.
Returns whether or not the list of changes have a change that is on the
give gerrit host.
Args:
changes (List[GerritChange]): List of generated gerrit changes.
host (str): Host to check.
Returns:
bool whether or not the changes have a change on host.
"""
for change in changes:
if host in change.host:
return True
return False
def GenTests(api):
message_template = """Regenerate audio config in project {project}.
Regenerate the audio config in project {project}
per the changes in program galaxy.
BUG=https://crbug.com/1087514
TEST=CQ
"""
cls = [
common_pb2.GerritChange(host='chromium-review.googlesource.com',
project='chromiumos/config', change=1234),
common_pb2.GerritChange(host='chrome-internal-review.googlesource.com',
project='chromeos/program/galaxy', change=1234),
]
def build(changes=True):
build_message = api.buildbucket.ci_build_message(project='chromeos',
bucket='infra',
builder='cl_factory')
if changes:
build_message.input.gerrit_changes.extend(cls)
return api.buildbucket.build(build_message)
def forall_data(*projects):
"""Returns a generator for project_infos_step_data."""
return (dict(project=p) for p in projects)
def no_git_diff_step_data(project):
step_name = 'generate project change lists.working on project {}.git status'
return api.step_data(
step_name.format(project), stdout=api.raw_io.output(''))
yield api.test(
'with_diff',
build(),
api.properties(
ClFactoryProperties(
repo_regexes=['src/project/galaxy'],
reviewers=['johndoe@google.com'],
ccs=['bob@google.com', 'martin@google.com'],
hashtags=['refactor-audio-config'],
message_template=message_template,
manifest_branch='release-R12-34567.B',
)),
api.post_check(post_process.StatusSuccess),
)
yield api.test(
'set_source_depends',
build(),
api.properties(
ClFactoryProperties(
repo_regexes=['src/project/galaxy'],
reviewers=['johndoe@google.com'],
ccs=['bob@google.com', 'martin@google.com'],
hashtags=['refactor-audio-config'],
message_template=message_template,
set_source_depends=True,
)),
api.post_check(post_process.StatusSuccess),
)
yield api.test(
'without_diff',
build(),
api.properties(
ClFactoryProperties(
repo_regexes=['src/project/galaxy'],
reviewers=['johndoe@google.com'],
hashtags=['refactor-audio-config'],
message_template=message_template,
)),
no_git_diff_step_data('project-a'),
no_git_diff_step_data('project-b'),
no_git_diff_step_data('project-c'),
api.post_check(post_process.StatusSuccess),
)
yield api.test(
'with_replace_strings',
build(),
api.properties(
ClFactoryProperties(
repo_regexes=['src/project/galaxy'],
reviewers=['johndoe@google.com'],
hashtags=['refactor-audio-config'],
message_template=message_template,
replace_strings=[
ClFactoryProperties.ReplaceString(
file_glob='*.star',
before='o',
after='X',
)
],
)),
api.post_check(post_process.StatusSuccess),
)
# Users may want to only replace strings, with no gerrit changes inputs
# specified.
yield api.test(
'no_gerrit_changes_specified',
build(changes=False),
api.properties(
ClFactoryProperties(
repo_regexes=['src/project/galaxy'],
reviewers=['johndoe@google.com'],
hashtags=['refactor-audio-config'],
message_template=message_template,
replace_strings=[
ClFactoryProperties.ReplaceString(
file_glob='*.star',
before='1345.6',
after='1421.9',
)
],
)),
api.post_check(post_process.StatusSuccess),
)
yield api.test(
'with_full_sync',
build(),
api.properties(
ClFactoryProperties(
repo_regexes=['src/project/galaxy'],
reviewers=['johndoe@google.com'],
hashtags=['refactor-audio-config'],
message_template=message_template,
full_repo_sync=True,
)),
api.post_check(post_process.StatusSuccess),
)
# Here we replace the canned forall return to exercise the set logic
# that determines repos to sync in a partial sync.
yield api.test(
'with_partial_sync_set_logic',
build(),
api.properties(
ClFactoryProperties(
repo_regexes=['src/project/galaxy'],
reviewers=['johndoe@google.com'],
hashtags=['refactor-audio-config'],
message_template=message_template,
)),
api.repo.project_infos_step_data('find gerrit change repos',
forall_data('a', 'b')),
api.repo.project_infos_step_data('find regex matching CL repos',
forall_data('c', 'd')),
api.repo.project_infos_step_data('find additional repos to sync',
forall_data('c', 'e')),
api.post_check(post_process.StatusSuccess),
)
# Here we replace the canned forall return to simulate the user providing
# a regex that didn't match anything.
yield api.test(
'bad_regex',
build(),
api.properties(
ClFactoryProperties(
repo_regexes=['src/project/galaxy'],
reviewers=['johndoe@google.com'],
hashtags=['refactor-audio-config'],
message_template=message_template,
)),
api.repo.project_infos_step_data('find gerrit change repos',
forall_data('a', 'b')),
api.repo.project_infos_step_data('find regex matching CL repos', {}),
api.expect_exception('ValueError'),
api.post_process(post_process.ResultReasonRE, '.*No matching projects.*'),
api.post_process(post_process.DropExpectation),
)
yield api.test(
'invalid_message_template_interpolation',
build(),
api.properties(
ClFactoryProperties(
repo_regexes=['src/project/galaxy'],
reviewers=['johndoe@google.com'],
hashtags=['refactor-audio-config'],
message_template='No such {interpolation}.',
)),
api.post_check(post_process.StatusSuccess),
)
yield api.test(
'no_repos_specified',
build(),
api.properties(
ClFactoryProperties(
reviewers=['johndoe@google.com'],
hashtags=['refactor-audio-config'],
message_template=message_template,
)),
api.expect_exception('ValueError'),
api.post_process(post_process.ResultReasonRE,
'.*Projects to operate on must be specified.*'),
api.post_process(post_process.DropExpectation),
)
yield api.test(
'no_message_template_specified',
build(),
api.properties(
ClFactoryProperties(
repo_regexes=['src/project/galaxy'],
reviewers=['johndoe@google.com'],
hashtags=['refactor-audio-config'],
)),
api.expect_exception('ValueError'),
api.post_process(post_process.ResultReasonRE,
'.*A message_template property must specify.*'),
api.post_process(post_process.DropExpectation),
)