| # 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 submodule of a git repository.""" |
| |
| import re |
| |
| import attr |
| from PB.recipes.pigweed.submodule_roller import InputProperties |
| from recipe_engine import post_process |
| from six.moves import configparser |
| from six.moves import urllib |
| from six import StringIO |
| |
| DEPS = [ |
| 'fuchsia/auto_roller', |
| 'fuchsia/git', |
| 'fuchsia/status_check', |
| 'pigweed/checkout', |
| 'pigweed/cq_deps', |
| 'pigweed/roll_util', |
| 'recipe_engine/buildbucket', |
| 'recipe_engine/context', |
| 'recipe_engine/file', |
| 'recipe_engine/properties', |
| 'recipe_engine/step', |
| ] |
| |
| PROPERTIES = InputProperties |
| |
| PYTHON_VERSION_COMPATIBILITY = "PY3" |
| |
| |
| @attr.s |
| class _RevisionChange(object): |
| old = attr.ib(type=str) |
| new = attr.ib(type=str) |
| |
| |
| def _update_submodule(api, checkout, path, new_revision): |
| with api.context(cwd=checkout.top): |
| api.git.update_submodule(paths=(path,)) |
| |
| old_revision = api.checkout.get_revision( |
| path, 'get old revision', test_data='1' * 40 |
| ) |
| |
| with api.context(cwd=path): |
| api.git('git fetch', 'fetch', 'origin', new_revision) |
| api.git('git checkout', 'checkout', 'FETCH_HEAD') |
| |
| # In case new_revision is a branch name we need to retrieve the hash it |
| # resolved to. |
| if not re.search(r'^[0-9a-f]{40}$', new_revision): |
| new_revision = api.checkout.get_revision( |
| path, 'get new revision', test_data='2' * 40 |
| ) |
| |
| return _RevisionChange(old=old_revision, new=str(new_revision)) |
| |
| |
| def _process_deps(api, checkout, rolls, submodules, gerrit_name): |
| """Process any dependencies and add them to rolls. |
| |
| Process any dependencies ("Requires:" lines) in the one initial entry in |
| rolls and add them to rolls. |
| |
| Args: |
| api: Recipe api object. |
| checkout (CheckoutContext): Checkout-related data. |
| rolls (dict[str,api.roll_util.Roll]): List of rolls, initially of |
| length one but added to by this function. |
| submodules (list[api.checkout.Submodule]): List of submodules in this |
| checkout. |
| gerrit_name (str): Name of initial gerrit server. |
| |
| Returns: |
| Nothing. |
| """ |
| |
| deps = {} |
| |
| # Initially, there should only be one entry in rolls. |
| assert len(rolls) == 1 |
| submodule_path = list(rolls)[0] |
| submodule_dir = checkout.root.join(submodule_path) |
| |
| for commit in rolls[submodule_path].commits: |
| dependencies, unresolved = api.cq_deps.resolve( |
| gerrit_name, commit.hash, statuses=('MERGED',) |
| ) |
| |
| if unresolved: |
| with api.step.nest('failed to resolve some dependencies') as pres: |
| for dep in unresolved: |
| pres.links[dep.name] = dep.gerrit_url |
| |
| for dep in dependencies: |
| deps[dep.name] = dep |
| |
| # Ok, this is complicated, so I'll go over what it's supposed to do |
| # with an example. Suppose we're trying to roll commit 1 of submodule A. |
| # That commit depends on commit 2 of submodule B and commit 3 of |
| # submodule C. Commit 3 of submodule C depends on commit 4 of submodule |
| # B. Submodule B is referenced twice, with commits 2 and 4. Commit 4 is |
| # on top of commit 2, so we should roll B to commit 4 and skip the |
| # intermediate step of commit 2. |
| |
| # Loop over all the dependencies and apply them all. If we see commit B4 |
| # first, we ignore commit B2 when we see it because it's a backwards |
| # roll to go from B4 to B2. If we see commit B2 first, we roll to it |
| # (B2~1..B2) and then we update the roll when we see B4 (to B2~1..B4). |
| # The end result is that we are only rolling to B4 but the various |
| # commits that roll brings includes B2. |
| |
| # The list of revisions between old_revision and new_revision isn't |
| # processed until api.roll_util.message() is called in the |
| # api.auto_roller.attempt_roll() call, so having an intermediate state |
| # where we have a partial version of the final roll doesn't affect |
| # anything. |
| |
| # Also handle various other cases like where the dependency is not in |
| # the checkout or access to the dependency is forbidden. Does not yet |
| # handle the case where an unrolled parent CL has unrolled dependencies. |
| |
| with api.step.nest('deps') as pres: |
| pres.step_summary_text = repr(deps) |
| |
| for dep in deps.values(): |
| found_dep = False |
| |
| for sub in submodules: |
| if not checkout.remotes_equivalent(dep.remote, sub.remote): |
| continue |
| found_dep = True |
| |
| with api.step.nest('applying {}'.format(dep.name)) as pres: |
| with api.context(cwd=checkout.top): |
| api.git.update_submodule(paths=(sub.relative_path,)) |
| |
| old_revision = sub.hash |
| if sub.relative_path in rolls: |
| old_revision = rolls[sub.relative_path].old_revision |
| with api.context(cwd=sub.path): |
| api.git( |
| 'fetch {}'.format(dep.commit), |
| 'fetch', |
| dep.remote, |
| dep.commit, |
| ) |
| |
| direction = api.roll_util.get_roll_direction( |
| sub.path, old_revision, dep.commit |
| ) |
| |
| if api.roll_util.can_roll(direction): |
| _update_submodule(api, checkout, sub.path, dep.commit) |
| rolls[sub.relative_path] = api.roll_util.Roll( |
| project_name=sub.relative_path, |
| old_revision=old_revision, |
| new_revision=dep.commit, |
| proj_dir=sub.path, |
| direction=direction, |
| ) |
| pres.step_summary_text = 'applied' |
| |
| else: |
| api.roll_util.skip_roll_step( |
| dep.remote, old_revision, dep.commit, |
| ) |
| pres.step_summary_text = 'already applied' |
| |
| if not found_dep: |
| with api.step.nest('skipping required {}'.format(dep.name)) as pres: |
| pres.step_summary_text = 'repository is not in checkout' |
| |
| |
| def RunSteps(api, props): # pylint: disable=invalid-name |
| submodule_path = props.submodule_path |
| submodule_name = props.submodule_name or submodule_path |
| submodule_branch = props.submodule_branch or None |
| dry_run = props.dry_run |
| 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) |
| |
| 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) |
| |
| # Confirm the given path is actually a submodule. |
| gitmodules = api.file.read_text( |
| 'read .gitmodules', checkout.root.join('.gitmodules') |
| ) |
| # Example .gitmodules file: |
| # [submodule "third_party/pigweed"] |
| # path = third_party/pigweed |
| # url = https://pigweed.googlesource.com/pigweed/pigweed |
| |
| # configparser doesn't like leading whitespace on lines, despite what its |
| # documentation says. |
| gitmodules = re.sub(r'\n\s+', '\n', gitmodules) |
| parser = configparser.RawConfigParser() |
| parser.readfp(StringIO(gitmodules)) |
| section = 'submodule "{}"'.format(submodule_name) |
| if not parser.has_section(section): |
| sections = parser.sections() |
| submodules = sorted(re.sub(r'^.*"(.*)"$', r'\1', x) for x in sections) |
| raise api.step.StepFailure( |
| 'no submodule "{}" (submodules: {})'.format( |
| submodule_name, ', '.join('"{}"'.format(x) for x in submodules) |
| ) |
| ) |
| |
| if not submodule_branch: |
| try: |
| submodule_branch = parser.get(section, 'branch') |
| except configparser.NoOptionError: |
| submodule_branch = 'main' |
| |
| # If we still don't have a revision then there wasn't a trigger. (Perhaps |
| # this was manually triggered.) In this case we update to the |
| # property-specified or inferred submodule branch HEAD. |
| if new_revision is None: |
| new_revision = submodule_branch |
| |
| # This isn't used until much later but needs to be invoked before any |
| # submodules get updated. |
| submodules = checkout.submodules(checkout.root) |
| |
| submodule_dir = checkout.root.join(submodule_path) |
| |
| remote = api.roll_util.normalize_remote( |
| parser.get(section, 'url'), checkout.options.remote, |
| ) |
| |
| # If this was triggered by a gitiles poller, check that the triggering |
| # repository matches submodule_path. |
| if bb_remote: |
| if not checkout.remotes_equivalent(remote, bb_remote): |
| raise api.step.StepFailure( |
| 'triggering repository ({}) does not match submodule remote ' |
| '({})'.format(bb_remote, remote) |
| ) |
| |
| change = _update_submodule(api, checkout, submodule_dir, new_revision) |
| |
| direction = api.roll_util.get_roll_direction( |
| submodule_dir, change.old, change.new |
| ) |
| |
| # If the primary roll is not necessary or is backwards we can exit |
| # immediately and don't need to check deps. |
| if not api.roll_util.can_roll(direction): |
| api.roll_util.skip_roll_step(remote, change.old, change.new) |
| return |
| |
| rolls = { |
| submodule_path: api.roll_util.Roll( |
| project_name=str(submodule_path), |
| old_revision=change.old, |
| new_revision=change.new, |
| proj_dir=submodule_dir, |
| direction=direction, |
| ), |
| } |
| |
| gerrit_name = urllib.parse.urlparse(remote).netloc.split('.')[0] |
| |
| if len(rolls[submodule_path].commits) >= 10: |
| with api.step.nest('too many commits, not processing dependencies'): |
| pass |
| |
| else: |
| _process_deps(api, checkout, rolls, submodules, gerrit_name) |
| |
| cc = set() |
| authors = api.roll_util.authors(*rolls.values()) |
| if cc_authors_on_rolls: |
| cc.update(authors) |
| if cc_reviewers_on_rolls: |
| cc.update(api.roll_util.reviewers(*rolls.values())) |
| |
| 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 = [x for x in cc if include_cc(x)] |
| |
| roll_kwargs = {} |
| if always_cc: |
| roll_kwargs['cc_emails'] = [x.email for x in cc] |
| else: |
| roll_kwargs['cc_on_failure_emails'] = [x.email for x in cc] |
| |
| 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( |
| api.auto_roller.Options( |
| remote=checkout.options.remote, |
| upstream_ref=checkout.options.branch, |
| dry_run=dry_run, |
| labels_to_set=api.roll_util.labels_to_set, |
| labels_to_wait_on=api.roll_util.labels_to_wait_on, |
| bot_commit=props.bot_commit, |
| **roll_kwargs |
| ), |
| repo_dir=checkout.root, |
| commit_message=api.roll_util.message(*rolls.values()), |
| author_override=author_override, |
| ) |
| |
| return api.auto_roller.raw_result(change) |
| |
| |
| def GenTests(api): # pylint: disable=invalid-name |
| """Create tests.""" |
| |
| def _url(x): |
| if x.startswith(('https://', 'sso://', '.')): |
| return x |
| return 'https://foo.googlesource.com/' + x |
| |
| def trigger(url, **kwargs): |
| return api.checkout.ci_test_data(git_repo=_url(url), **kwargs) |
| |
| def gitmodules(**submodules): |
| branches = {} |
| for k, v in submodules.items(): |
| if k.endswith('_branch'): |
| branches[k.replace('_branch', '')] = v |
| |
| for x in branches: |
| del submodules['{}_branch'.format(x)] |
| |
| text = [] |
| for k, v in sorted(submodules.items()): |
| text.append( |
| '[submodule "{0}"]\n\tpath = {0}\n\turl = {1}\n'.format( |
| k, _url(v) |
| ) |
| ) |
| if k in branches: |
| text.append('\tbranch = {}\n'.format(branches[k])) |
| |
| return api.step_data( |
| 'read .gitmodules', api.file.read_text(''.join(text)) |
| ) |
| |
| def properties(**kwargs): |
| new_kwargs = api.checkout.git_properties() |
| new_kwargs['forge_author'] = True |
| new_kwargs['dry_run'] = True |
| new_kwargs.update(kwargs) |
| return api.properties(**new_kwargs) |
| |
| def commit_data(name, **kwargs): |
| return api.roll_util.commit_data( |
| name, |
| api.roll_util.commit('a' * 40, 'foo\nbar\n\nChange-Id: I1111'), |
| **kwargs |
| ) |
| |
| yield ( |
| api.status_check.test('success-sso-cc-authors') |
| + properties(submodule_path='a1', cc_authors_on_rolls=True) |
| + api.roll_util.properties(commit_divider='--divider--') |
| + trigger('a1') |
| + commit_data('a1', prefix='') |
| + gitmodules(a1='sso://foo/a1') |
| + api.roll_util.forward_roll() |
| + api.auto_roller.dry_run_success() |
| ) |
| |
| yield ( |
| api.status_check.test('failure-cc-authors', status='failure') |
| + properties(submodule_path='a1', cc_authors_on_rolls=True) |
| + trigger('a1') |
| + commit_data('a1', prefix='') |
| + gitmodules(a1='https://foo.googlesource.com/a1') |
| + api.roll_util.forward_roll() |
| + api.auto_roller.dry_run_failure() |
| ) |
| |
| yield ( |
| api.status_check.test('relative-dot', status='failure') |
| + properties(submodule_path='a1', cc_authors_on_rolls=True) |
| + trigger('https://pigweed.googlesource.com/pigweed/pigweed/a1') |
| + commit_data('a1', prefix='') |
| + gitmodules(a1='./a1') |
| + api.roll_util.forward_roll() |
| + api.auto_roller.dry_run_failure() |
| ) |
| |
| yield ( |
| api.status_check.test('relative-dotdot', status='failure') |
| + properties(submodule_path='a1', cc_authors_on_rolls=True) |
| + trigger('https://pigweed.googlesource.com/pigweed/a1') |
| + commit_data('a1', prefix='') |
| + gitmodules(a1='../a1') |
| + api.roll_util.forward_roll() |
| + api.auto_roller.dry_run_failure() |
| ) |
| |
| yield ( |
| api.status_check.test( |
| 'relative-dotdot-dotdot-always-cc-reviewers', status='failure', |
| ) |
| + properties( |
| submodule_path='a1', cc_reviewers_on_rolls=True, always_cc=True, |
| ) |
| + trigger('https://pigweed.googlesource.com/a1') |
| + commit_data('a1', prefix='') |
| + gitmodules(a1='../../a1') |
| + api.roll_util.forward_roll() |
| + api.auto_roller.dry_run_failure() |
| ) |
| |
| yield ( |
| api.status_check.test('name-not-found', status='failure') |
| + properties(submodule_path='a1') |
| + trigger('a1') |
| + gitmodules(b2='b2', c3='c3', d4='d4') |
| ) |
| |
| yield ( |
| api.status_check.test('trigger-mismatch', status='failure') |
| + properties(submodule_path='a1') |
| + trigger('a1') |
| + gitmodules(a1='b2') |
| ) |
| |
| yield ( |
| api.status_check.test('trigger-mismatch-equivalent') |
| + properties( |
| submodule_path='a1', |
| **api.checkout.git_properties( |
| equivalent_remotes=( |
| ( |
| 'https://foo.googlesource.com/a1', |
| 'https://foo.googlesource.com/b2', |
| ), |
| ), |
| ) |
| ) |
| + trigger('a1') |
| + commit_data('a1', prefix='') |
| + gitmodules(a1='b2') |
| + api.roll_util.forward_roll() |
| + api.auto_roller.dry_run_success() |
| ) |
| |
| yield ( |
| api.status_check.test('with-branch-prop-filter-emails') |
| + properties( |
| submodule_path='a1', |
| submodule_branch='branch', |
| cc_authors_on_rolls=True, |
| cc_reviewers_on_rolls=True, |
| cc_domains=['google.com'], |
| ) |
| + commit_data('a1', prefix='') |
| + gitmodules(a1='a1', a1_branch='not_used') |
| + api.roll_util.forward_roll() |
| + api.auto_roller.dry_run_success() |
| ) |
| |
| yield ( |
| api.status_check.test('no-revision') |
| + properties(submodule_path='a1') |
| + commit_data('a1', prefix='') |
| + gitmodules(a1='a1', a1_branch='custom') |
| + api.roll_util.forward_roll() |
| + api.auto_roller.dry_run_success() |
| ) |
| |
| yield ( |
| api.status_check.test('backwards') |
| + properties(submodule_path='a1') |
| + trigger('a1') |
| + gitmodules(a1='a1') |
| + api.roll_util.backward_roll() |
| ) |
| |
| def assert_too_many(): |
| return api.post_process( |
| post_process.MustRun, |
| 'too many commits, not processing dependencies', |
| ) |
| |
| atoz = 'abcdefghijklmnopqrstuvwxyz' |
| |
| yield ( |
| api.status_check.test('too-many-skip-deps') |
| + properties(submodule_path='a1', cc_authors_on_rolls=True) |
| + trigger('a1') |
| + api.roll_util.commit_data( |
| 'a1', *[api.roll_util.commit(x * 40, x) for x in atoz] |
| ) |
| + gitmodules(a1='sso://foo/a1') |
| + api.roll_util.forward_roll() |
| + api.auto_roller.dry_run_success() |
| + assert_too_many() |
| ) |
| |
| # Much of the step data in the tests using "Requires:" is sufficient to |
| # test the logic that processes the immediate result of the the |
| # corresponding step, but does not make sense as a whole. |
| def requires_test(name, *requires, **kwargs): |
| assert requires |
| status = kwargs.pop('status', 'success') |
| assert not kwargs |
| |
| sub = api.checkout.submodule |
| return ( |
| api.status_check.test(name, status=status) |
| + properties(submodule_path='spam') |
| + trigger('spam', revision='2' * 40) |
| + gitmodules(spam='spam', ham='ham') |
| + api.checkout.submodules( |
| sub('spam', 'https://foo.googlesource.com/spam', '-'), |
| sub('ham', 'https://foo.googlesource.com/ham', ' '), |
| sub('eggs', 'https://foo.googlesource.com/eggs', ' '), |
| prefix='', |
| ) |
| + api.cq_deps.details( |
| 'foo:2000', message='Requires: {}'.format(','.join(requires)), |
| ) |
| + api.roll_util.commit_data( |
| 'spam', api.roll_util.commit('2' * 40), prefix='', |
| ) |
| + api.roll_util.forward_roll() |
| ) |
| |
| # CL 2000 requires CL 444 in ham which has not rolled. |
| yield ( |
| requires_test('with-requires', 'foo:444') |
| + api.cq_deps.details('foo:444', status='MERGED', project='ham') |
| + api.roll_util.commit_data( |
| 'ham', api.roll_util.commit('2' * 40), prefix='applying foo:444.', |
| ) |
| + api.roll_util.forward_roll('applying foo:444.') |
| + api.auto_roller.dry_run_success() |
| ) |
| |
| # CL 2000 requires CL 444 in ham which has already rolled. |
| yield ( |
| requires_test('with-requires-already-applied', 'foo:444') |
| + api.cq_deps.details('foo:444', status='MERGED', project='ham') |
| + api.roll_util.noop_roll('applying foo:444.') |
| + api.auto_roller.dry_run_success() |
| ) |
| |
| # CL 2000 requires CL 444 which is forbidden. |
| yield ( |
| requires_test('with-requires-forbidden', 'foo:444') |
| + api.cq_deps.forbidden('foo:444') |
| + api.auto_roller.dry_run_success() |
| ) |
| |
| # CL 2000 requires CL 444 which is not in this checkout. |
| yield ( |
| requires_test('with-requires-not-in-checkout', 'foo:444') |
| + api.cq_deps.details( |
| 'foo:444', status='MERGED', project='not-in-this-checkout', |
| ) |
| + api.auto_roller.dry_run_success() |
| ) |
| |
| # CL 2000 requires CL 444 in ham which requires CL 555 in eggs. |
| yield ( |
| requires_test('with-requires-transitive', 'foo:444') |
| + api.cq_deps.details( |
| 'foo:444', |
| status='MERGED', |
| project='ham', |
| message='Requires: foo:555', |
| ) |
| + api.roll_util.commit_data( |
| 'ham', api.roll_util.commit('2' * 40), prefix='applying foo:444.', |
| ) |
| + api.roll_util.forward_roll('applying foo:444.') |
| + api.cq_deps.details('foo:555', status='MERGED', project='eggs') |
| + api.roll_util.commit_data( |
| 'eggs', api.roll_util.commit('2' * 40), prefix='applying foo:555.', |
| ) |
| + api.roll_util.forward_roll('applying foo:555.') |
| + api.auto_roller.dry_run_success() |
| ) |
| |
| # CL 2000 requires CL 444 in ham which requires CL 2000. |
| yield ( |
| requires_test('with-requires-loop', 'foo:444') |
| + api.cq_deps.details( |
| 'foo:444', |
| status='MERGED', |
| project='ham', |
| message='Requires: foo:2000', |
| ) |
| + api.roll_util.commit_data( |
| 'ham', api.roll_util.commit('2' * 40), prefix='applying foo:444.', |
| ) |
| + api.roll_util.forward_roll('applying foo:444.') |
| + api.auto_roller.dry_run_success() |
| ) |
| |
| # CL 2000 requires CL 444 in ham and CL 555 in ham, both of which are |
| # submitted. |
| def parent_child_test(*args, **kwargs): |
| return ( |
| requires_test(*args, **kwargs) |
| + api.cq_deps.details('foo:444', status='MERGED', project='ham',) |
| + api.roll_util.commit_data( |
| 'ham', |
| api.roll_util.commit('4' * 40), |
| prefix='applying foo:444.', |
| ) |
| + api.roll_util.forward_roll('applying foo:444.') |
| + api.cq_deps.details('foo:555', status='MERGED', project='ham',) |
| ) |
| |
| # CL 2000 requires CL 444 in ham and CL 555 in ham, both of which are |
| # submitted. CL 444 is a parent of CL 555. |
| yield ( |
| parent_child_test('with-requires-child', 'foo:444', 'foo:555') |
| + api.roll_util.forward_roll('applying foo:555.') |
| + api.roll_util.commit_data( |
| 'ham', api.roll_util.commit('5' * 40), prefix='applying foo:555.', |
| ) |
| + api.auto_roller.dry_run_success() |
| ) |
| |
| # CL 2000 requires CL 444 in ham and CL 555 in ham, both of which are |
| # submitted. CL 555 is a parent of CL 444. |
| yield ( |
| parent_child_test('with-requires-parent', 'foo:444', 'foo:555') |
| + api.roll_util.backward_roll('applying foo:555.') |
| + api.auto_roller.dry_run_success() |
| ) |