blob: 9b21f1d2211ece4abc75ab7e54a6e5cc7647c487 [file] [log] [blame] [edit]
# 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.
"""Recipe module for updating projects in an Android Repo Tool manifest.
This module provides the `RepoRollApi` class, which allows for automated
updates (rolls) of individual projects defined within a `repo` manifest file
(typically an XML file like `default.xml`). It handles:
- Reading and parsing the manifest file.
- Identifying the target project based on its path.
- Determining the current remote URL and branch/revision of the project.
- Resolving the latest commit hash for the project from its Git repository,
often using information from Buildbucket triggers or a specified branch.
- Updating the project's `revision` attribute in the manifest file.
- Handling various manifest configurations, including default remotes and
project-specific remotes.
- Generating `Roll` objects suitable for creating commit messages.
"""
from __future__ import annotations
import collections
import dataclasses
import json
import re
from typing import Sequence, TYPE_CHECKING
import urllib
import xml.etree.ElementTree
from PB.recipe_modules.pigweed.checkout.options import (
Options as CheckoutOptions,
)
from recipe_engine import recipe_api
if TYPE_CHECKING: # pragma: no cover
from recipe_engine import config_types
from RECIPE_MODULES.fuchsia.git_roll_util import api as git_roll_util_api
from RECIPE_MODULES.pigweed.checkout import api as checkout_api
from PB.recipe_modules.pigweed.repo_roll import Project
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)
class RepoRollApi(recipe_api.RecipeApi):
"""Provides methods to update projects within an Android Repo Tool manifest.
This API handles reading the repo manifest (typically an XML file),
identifying the project to update, resolving the new revision for that
project (often based on Gitiles commit triggers or a specified branch),
and then updating the manifest file with the new revision.
"""
def is_branch(self, revision: str) -> bool:
"""Return True if revision appears to be a branch name.
Determines if a given revision string is likely a branch name rather
than a specific commit hash or tag.
Args:
revision: The revision string to check.
Returns:
True if the revision is likely a branch name, False otherwise.
"""
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(self, root: xml.etree.ElementTree.Element) -> None:
"""Sorts XML attributes for consistent output.
Iterates through an XML tree and sorts the attributes of each element
alphabetically. This helps in producing a canonical XML output,
making diffs more predictable. ElementTree preserves attribute order
if they are provided in a specific order.
Args:
root: The root Element of the XML tree to process.
"""
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 update_project(
self,
checkout: checkout_api.CheckoutContext,
project: Project,
) -> list[git_roll_util_api.Roll]:
"""Updates a single project in the repo manifest to its latest revision.
This method identifies the specified project within the manifest,
determines its current remote and branch, and resolves the latest
commit on that branch. It then updates the project's revision in the
manifest file.
Args:
checkout: The checkout context, providing information about the
current repository state, including the manifest path.
project: A protobuf message specifying the project to update,
typically by its 'path_to_update' attribute.
Returns:
A list containing a single `Roll` object if an update was made,
or an empty list if the project is already up-to-date or if a
backwards roll was detected.
Raises:
StepFailure: If the project cannot be found in the manifest,
if the manifest remote and triggering remote (from
Buildbucket) mismatch, or if 'upstream' is not set
and the current revision is not a branch.
"""
with self.m.step.nest(project.path_to_update):
new_revision = None
# Try to get new_revision from the trigger.
bb_remote = None
commit = self.m.buildbucket.build.input.gitiles_commit
if commit and commit.project:
new_revision = commit.id
host = commit.host
bb_remote = f'https://{host}/{commit.project}'
tree = _TreeBuilder()
parser = xml.etree.ElementTree.XMLParser(target=tree)
parser.feed(
self.m.file.read_text('read manifest', checkout.manifest_path)
)
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 fail 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'] = self.m.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'] == project.path_to_update:
fetch_host = proj_attrib['fetch'].strip('/')
if fetch_host.startswith('..'):
fetch_host = urllib.parse.urljoin(
checkout.options.remote,
fetch_host,
)
manifest_remote = (
fetch_host.rstrip('/')
+ '/'
+ proj_attrib['name'].strip('/')
)
if bb_remote and not checkout.remotes_equivalent(
manifest_remote, bb_remote
):
raise self.m.step.StepFailure(
f"repo paths don't match: {manifest_remote!r} from "
f'manifest and {bb_remote!r} from buildbucket'
)
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 self.m.step.StepFailure(
'cannot find "{}" in manifest (found {})'.format(
project.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 self.is_branch(
proj_attrib['revision']
):
proj_branch = proj_attrib['revision']
else:
proj_branch = 'main'
new_revision = self.m.git_roll_util.resolve_new_revision(
manifest_remote,
proj_branch,
checkout.remotes_equivalent,
)
# 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 self.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 self.m.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
try:
roll = self.m.git_roll_util.get_roll(
repo_url=manifest_remote,
repo_short_name=project.name or project.path_to_update,
old_rev=old_revision,
new_rev=new_revision,
)
self._order_attributes(root)
self.m.file.write_text(
'write manifest',
checkout.manifest_path,
'<?xml version="1.0" encoding="UTF-8"?>\n{}\n'.format(
xml.etree.ElementTree.tostring(root).decode(),
),
)
return [roll]
except self.m.git_roll_util.BackwardsRollError:
props = self.m.step.empty('output property').presentation
props.properties[project.path_to_update] = {
'remote': manifest_remote,
'revision': new_revision,
}
return []