| # 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. |
| """Update an Android Repo Tool project.""" |
| |
| 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): |
| """Update an Android Repo Tool project.""" |
| |
| def is_branch(self, 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(self, 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 update_project( |
| self, |
| checkout: checkout_api.CheckoutContext, |
| project: Project, |
| ) -> list[git_roll_util_api.Roll]: |
| 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.path_to_update, |
| old_rev=old_revision, |
| new_rev=new_revision, |
| ) |
| |
| except self.m.git_roll_util.BackwardsRollError: |
| return [] |
| |
| 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] |