blob: 9483a62bfca539ea15bd7803b2b95ba92b65f01f [file] [log] [blame]
# Copyright 2020 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.
"""Roll a project in an Android Repo Tool workspace."""
import collections
import re
import urllib
import xml.etree.ElementTree
from PB.recipes.pigweed.repo_roller import InputProperties
from PB.recipe_modules.pigweed.checkout.options import (
Options as CheckoutOptions,
)
DEPS = [
'fuchsia/auto_roller',
'fuchsia/sso',
'fuchsia/status_check',
'pigweed/checkout',
'pigweed/roll_util',
'recipe_engine/buildbucket',
'recipe_engine/file',
'recipe_engine/path',
'recipe_engine/properties',
'recipe_engine/raw_io',
'recipe_engine/step',
]
PROPERTIES = InputProperties
class _TreeBuilder(xml.etree.ElementTree.TreeBuilder):
def comment(self, data):
self.start(xml.etree.ElementTree.Comment, {})
self.data(data)
self.end(xml.etree.ElementTree.Comment)
def _is_branch(revision):
"""Return True if revision appears to be a branch name."""
if re.search(r'^[0-9a-fA-F]{40}$', revision):
return False
return not revision.startswith('refs/tags/')
# ElementTree orders attributes differently in Python 2 and 3, but if given
# attributes in a specific order it preserves that order.
def _order_attributes(root):
for el in root.iter():
if len(el.attrib) > 1:
new_attrib = sorted(el.attrib.items())
el.attrib.clear()
el.attrib.update(new_attrib)
def RunSteps(api, props): # pylint: disable=invalid-name
path_to_update = str(props.path_to_update)
cc_authors_on_rolls = props.cc_authors_on_rolls
cc_reviewers_on_rolls = props.cc_reviewers_on_rolls
cc_domains = props.cc_domains
always_cc = props.always_cc
props.checkout_options.use_trigger = False
checkout = api.checkout(props.checkout_options)
filepath = checkout.root.join(checkout.options.manifest_file)
new_revision = None
# Try to get new_revision from the trigger.
bb_remote = None
commit = api.buildbucket.build.input.gitiles_commit
if commit and commit.project:
new_revision = commit.id
host = commit.host
bb_remote = 'https://{}/{}'.format(host, commit.project)
tree = _TreeBuilder()
parser = xml.etree.ElementTree.XMLParser(target=tree)
parser.feed(api.file.read_text('read manifest', filepath))
parser.close()
root = tree.close()
defaults = {}
for default in root.findall('default'):
defaults.update(default.attrib)
remotes = {}
for rem in root.findall('remote'):
remotes[rem.attrib['name']] = rem.attrib
# Search for path_to_update and check the repository it refers to matches
# the triggering repository. This check is mostly a config check and
# shouldn't be triggered in real runs unless somebody moves around project
# paths in the manifest without updating the builder definition.
paths_found = []
proj = None
proj_attrib = {}
for proj in root.findall('project'):
# Sometimes projects don't define something in which case we need to
# fall back on the remote specified by the project or the default for
# the entire manifest.
proj_attrib = {}
proj_attrib.update(defaults)
remote_name = proj.attrib.get('remote', defaults.get('remote', None))
assert remote_name
proj_attrib.update(remotes[remote_name])
proj_attrib.update(proj.attrib)
proj_attrib['fetch'] = api.sso.sso_to_https(proj_attrib['fetch'])
# Apparently if path is left off name is used. This is mildly
# infuriating, but easy to work around.
if 'path' not in proj_attrib:
proj_attrib['path'] = proj_attrib['name']
paths_found.append(proj_attrib['path'])
if proj_attrib['path'] == path_to_update:
fetch_host = proj_attrib['fetch'].strip('/')
if fetch_host.startswith('..'):
parsed = urllib.parse.urlparse(checkout.options.remote)
fetch_host = '{}://{}{}'.format(
parsed.scheme, parsed.netloc, fetch_host[2:]
)
manifest_remote = fetch_host + '/' + proj_attrib['name'].strip('/')
if bb_remote and not checkout.remotes_equivalent(
manifest_remote, bb_remote
):
raise api.step.StepFailure(
"repo paths don't match: {} from manifest "
"and {} from buildbucket".format(manifest_remote, bb_remote)
)
break
# Reset proj_attrib to None if this entry wasn't a match, so if this is
# the last iteration the condition a few lines down will work.
proj_attrib = {}
if not proj_attrib:
raise api.step.StepFailure(
'cannot find "{}" in manifest (found {})'.format(
path_to_update, ', '.join('"{}"'.format(x) for x in paths_found)
)
)
if 'upstream' in proj_attrib:
proj_branch = proj_attrib['upstream']
elif 'revision' in proj_attrib and _is_branch(proj_attrib['revision']):
proj_branch = proj_attrib['revision']
else:
proj_branch = 'main'
# If we still don't have a revision then it wasn't in the trigger. (Perhaps
# this was manually triggered.) In this case we need to determine the
# latest revision of this repository. The use_trigger flag should have no
# effect but using it to be explicit. Checking out even if not needed so
# commit messages can be collected later.
proj_dir = api.path['start_dir'].join('project')
proj_checkout = api.checkout(
CheckoutOptions(
remote=manifest_remote, branch=proj_branch, use_trigger=False
),
root=proj_dir,
)
if new_revision is None:
new_revision = api.checkout.get_revision(proj_dir)
assert new_revision
new_revision = str(new_revision)
# If upstream not set we may be transitioning from tracking a branch to
# rolling. In that case set upstream to be the revision, but only if the
# revision appears to be a branch.
if 'upstream' not in proj_attrib:
if _is_branch(proj_attrib['revision']):
# Explicitly update both proj.attrib and proj_attrib to minimize
# confusion if these statements are moved around later.
proj.attrib['upstream'] = proj_attrib['revision']
proj_attrib['upstream'] = proj.attrib['upstream']
else:
raise api.step.StepFailure(
'upstream not set and revision is not a branch, aborting'
)
old_revision = proj_attrib['revision']
# Explicitly update both proj.attrib and proj_attrib to minimize confusion.
proj_attrib['revision'] = proj.attrib['revision'] = new_revision
direction = api.roll_util.Direction.FORWARD
if not _is_branch(old_revision):
direction = api.roll_util.get_roll_direction(
proj_dir, old_revision, new_revision
)
if not api.roll_util.can_roll(direction):
api.roll_util.skip_roll_step(
manifest_remote, old_revision, new_revision
)
return
_order_attributes(root)
api.file.write_text(
'write manifest',
filepath,
'<?xml version="1.0" encoding="UTF-8"?>\n{}\n'.format(
xml.etree.ElementTree.tostring(root).decode(),
),
)
roll = api.roll_util.Roll(
project_name=path_to_update,
old_revision=old_revision,
new_revision=new_revision,
proj_dir=proj_dir,
direction=direction,
)
cc = set()
authors = api.roll_util.authors(roll)
if cc_authors_on_rolls:
cc.update(authors)
if cc_reviewers_on_rolls:
cc.update(api.roll_util.reviewers(roll))
def include_cc(email):
return api.roll_util.include_cc(
email, cc_domains, checkout.gerrit_host()
)
# include_cc() writes steps, so we want things sorted before calling it.
cc = sorted(set(cc))
cc_emails = [x.email for x in cc if include_cc(x)]
if always_cc:
props.auto_roller_options.cc_emails.extend(cc_emails)
else:
props.auto_roller_options.cc_on_failure_emails.extend(cc_emails)
author_override = None
with api.step.nest('authors') as pres:
pres.step_summary_text = repr(authors)
if len(authors) == 1 and props.forge_author:
author_override = api.roll_util.fake_author(
next(iter(authors))
)._asdict()
change = api.auto_roller.attempt_roll(
props.auto_roller_options,
repo_dir=checkout.root,
commit_message=api.roll_util.message(roll),
author_override=author_override,
)
return api.auto_roller.raw_result(change)
def GenTests(api): # pylint: disable=invalid-name
"""Create tests."""
manifest = 'https://host.googlesource.com/manifest'
def properties(api, path, dry_run=True, equivalent_remotes=(), **kwargs):
props = api.checkout.git_properties(
remote=manifest,
equivalent_remotes=equivalent_remotes,
match_branch=True,
)
props['forge_author'] = True
props['path_to_update'] = path
props['auto_roller_options'] = {'dry_run': dry_run, 'remote': manifest}
props.update(**kwargs)
return api.properties(**props)
def read_step_data():
return api.step_data(
'read manifest',
api.file.read_text(
"""
<?xml version="1.0" encoding="UTF-8"?>
<manifest>
<!-- single-line comment -->
<remote name="foo" fetch="sso://foo" review="sso://foo" />
<remote name="bar" fetch="sso://bar" review="sso://bar" />
<remote name="host" fetch=".." review="sso://host" />
<remote name="dotdot-prefix" fetch="../prefix" review="sso://host/prefix" />
<remote name="host-prefix" fetch="sso://host/prefix"
review="sso://host/prefix" />
<default remote="bar" />
<project name="a" path="a1" remote="foo"
revision="1111111111111111111111111111111111111111" upstream="main"/>
<project name="b" path="b2"
revision="2222222222222222222222222222222222222222" upstream="main"/>
<!--
multi
line
comment
-->
<project name="c" path="c3" revision="main"/>
<project name="d" path="d4"
revision="0000000000111111111122222222223333333333"/>
<project name="e5" revision="refs/tags/e"/>
<project name="f" path="f6" remote="host" revision="main"/>
<project name="g" path="g7" remote="dotdot-prefix" revision="main"/>
<project name="h" path="h8" remote="host-prefix" revision="main"/>
</manifest>
""".lstrip()
),
)
def commit_data(name):
return api.roll_util.commit_data(
name, api.roll_util.commit('a' * 40, 'foo\nbar')
)
yield (
api.status_check.test('success')
+ properties(api, path='a1', cc_authors_on_rolls=True, always_cc=True)
+ api.checkout.ci_test_data(git_repo='https://foo.googlesource.com/a')
+ commit_data('a1')
+ read_step_data()
+ api.roll_util.forward_roll()
+ api.auto_roller.dry_run_success()
)
yield (
api.status_check.test('name-not-found', status='failure')
+ properties(api, path='missing')
+ api.checkout.ci_test_data(git_repo='https://bar.googlesource.com/b')
+ read_step_data()
)
yield (
api.status_check.test('equivalent')
+ properties(
api,
path='b2',
equivalent_remotes=(
(
'https://equiv.googlesource.com/b',
'https://bar.googlesource.com/b',
),
),
cc_reviewers_on_rolls=True,
)
+ api.checkout.ci_test_data(git_repo='https://equiv.googlesource.com/b')
+ commit_data('b2')
+ read_step_data()
+ api.roll_util.forward_roll()
+ api.auto_roller.dry_run_success()
)
yield (
api.status_check.test('upstream-not-set')
+ properties(api, path='c3')
+ api.checkout.ci_test_data(git_repo='https://bar.googlesource.com/c')
+ commit_data('c3')
+ read_step_data()
+ api.auto_roller.dry_run_success()
)
yield (
api.status_check.test(
'upstream-not-set-revision-not-branch', status='failure'
)
+ properties(api, path='d4')
+ api.checkout.ci_test_data(git_repo='https://bar.googlesource.com/c')
+ read_step_data()
)
yield (
api.status_check.test('no-trigger-with-upstream')
+ properties(api, path='a1')
+ commit_data('a1')
+ read_step_data()
+ api.step_data(
'git log',
stdout=api.raw_io.output_text('hash-from-special-checkout'),
)
+ api.roll_util.forward_roll()
+ api.auto_roller.dry_run_success()
)
yield (
api.status_check.test('no-trigger-with-revision-branch')
+ properties(api, path='c3')
+ commit_data('c3')
+ read_step_data()
+ api.step_data(
'git log',
stdout=api.raw_io.output_text('hash-from-special-checkout'),
)
+ api.auto_roller.dry_run_success()
)
yield (
api.status_check.test('no-trigger-with-revision-hash', status='failure')
+ properties(api, path='d4')
+ read_step_data()
+ api.step_data(
'git log',
stdout=api.raw_io.output_text('hash-from-special-checkout'),
)
)
yield (
api.status_check.test('no-trigger-with-revision-tag', status='failure')
+ properties(api, path='e5')
+ read_step_data()
+ api.step_data(
'git log',
stdout=api.raw_io.output_text('hash-from-special-checkout'),
)
)
yield (
api.status_check.test('backwards')
+ properties(api, path='a1')
+ api.checkout.ci_test_data(git_repo='https://foo.googlesource.com/a')
+ read_step_data()
+ api.roll_util.backward_roll()
)
yield (
api.status_check.test('host-dot-dot')
+ properties(api, path='f6')
+ api.checkout.ci_test_data(git_repo='https://host.googlesource.com/f')
+ commit_data('f6')
+ read_step_data()
+ api.auto_roller.dry_run_success()
)
yield (
api.status_check.test('dotdot-prefix')
+ properties(api, path='g7')
+ api.checkout.ci_test_data(
git_repo='https://host.googlesource.com/prefix/g'
)
+ commit_data('g7')
+ read_step_data()
+ api.auto_roller.dry_run_success()
)
yield (
api.status_check.test('host-prefix')
+ properties(api, path='h8')
+ api.checkout.ci_test_data(
git_repo='https://host.googlesource.com/prefix/h'
)
+ commit_data('h8')
+ read_step_data()
+ api.auto_roller.dry_run_success()
)