| # 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. |
| """Find dependencies on pending CLs.""" |
| |
| import re |
| |
| import attr |
| from recipe_engine import recipe_api |
| |
| |
| @attr.s |
| class Change(object): |
| """Details of a matching change. |
| |
| This is meant to match the GerritChange message defined at |
| https://source.chromium.org/chromium/infra/infra/+/HEAD:go/src/go.chromium.org/luci/buildbucket/proto/common.proto?q=GerritChange |
| A few fields have been added for use by this module. |
| """ |
| |
| gerrit_name = attr.ib(type=str) |
| change = attr.ib(converter=int) |
| project = attr.ib(type=str, default=None) |
| patchset = attr.ib(type=int, default=None) |
| status = attr.ib(type=str, default=None) |
| commit = attr.ib(type=str, default=None) |
| parents = attr.ib(type=list, default=attr.Factory(list)) |
| |
| @property |
| def host(self): |
| return '{}-review.googlesource.com'.format(self.gerrit_name) |
| |
| @property |
| def link(self): |
| return 'https://{}/{}'.format(self.host, self.change) |
| |
| @property |
| def name(self): |
| return '{}:{}'.format(self.gerrit_name, self.change) |
| |
| @property |
| def remote(self): |
| return 'https://{}.googlesource.com/{}'.format( |
| self.gerrit_name, self.project |
| ) |
| |
| @property |
| def gerrit_url(self): |
| return 'https://{}-review.googlesource.com/c/{}'.format( |
| self.gerrit_name, self.change |
| ) |
| |
| def __str__(self): |
| return self.name |
| |
| |
| class CqDepsApi(recipe_api.RecipeApi): |
| """Find dependencies on pending CLs.""" |
| |
| def __init__(self, props, *args, **kwargs): |
| super(CqDepsApi, self).__init__(*args, **kwargs) |
| self._details = {} |
| self._enabled = not props.disabled |
| self._ignore_errors = props.ignore_errors |
| |
| def _lookup_cl(self, gerrit_name, commit): |
| query_results = self.m.gerrit.change_query( |
| 'number {}'.format(commit), |
| 'commit:{}'.format(commit), |
| host='{}-review.googlesource.com'.format(gerrit_name), |
| max_attempts=2, |
| timeout=30, |
| # The strange value for _number is to ensure different hashes result |
| # in different CL numbers by default. This way we don't accidentally |
| # create a cycle by specifying a fixed default value. |
| test_data=self.m.json.test_api.output( |
| [{'_number': sum(ord(x) for x in commit),}] |
| ), |
| ).json.output |
| if query_results: |
| return query_results[0] |
| return None |
| |
| def resolve(self, gerrit_name, number, statuses=('NEW',)): |
| """Recursively resolve dependencies of a CL. |
| |
| Resolve dependencies of a CL by parsing its commit message. Looks for |
| lines like below, and only in the last "paragraph". |
| |
| Requires: gerritname:1, commas-supported:2 |
| Requires: name:4,spaces-not-required:3 |
| Requires: name:5,,,,extra-commas-ignored:6,,,, |
| |
| Args: |
| gerrit_name (str): Name of Gerrit host of initial change. |
| number (int|str): Gerrit number or git commit hash of initial |
| change. |
| statuses (seq(str)): CL statuses to accept. |
| |
| Returns a tuple of resolved Change objects (excluding the given change) |
| and unresolved Change objects (which are not completely populated). |
| This includes dependencies of parent changes with 'NEW' status but not |
| the parent changes themselves. |
| """ |
| |
| with self.m.step.nest('resolve CL deps', status='last') as pres: |
| if not self._enabled: |
| with self.m.step.nest('dependency processing disabled'): |
| return ((), ()) |
| |
| with self.m.context(infra_steps=True): |
| if isinstance(number, str): |
| cl = self._lookup_cl(gerrit_name, number) |
| if not cl: |
| return ((), ()) |
| number = cl['_number'] |
| |
| seen = set() |
| deps = {} |
| parents = [] |
| unresolved = [] |
| initial_change = Change(gerrit_name, number) |
| to_process = [initial_change] + initial_change.parents |
| # Want all initial details calls to come from this step level so |
| # their step names are predictable. Further calls can be made |
| # anywhere and will hit the cache. |
| _ = self._change_details(initial_change) |
| |
| while to_process: |
| current = to_process.pop() |
| deps[current.name] = current |
| seen.add(current.name) |
| |
| parents.extend(current.parents) |
| to_process.extend( |
| x for x in current.parents if x.name not in seen |
| ) |
| |
| with self.m.step.nest( |
| 'parents {}'.format(current.name) |
| ) as pres: |
| if current.parents: |
| pres.step_summary_text = ', '.join( |
| x.name for x in current.parents |
| ) |
| else: |
| pres.step_summary_text = ( |
| 'all parents already submitted' |
| ) |
| |
| for dep in self._resolve(current): |
| if dep.name not in deps: |
| details = self._change_details(dep) |
| if not details: |
| unresolved.append(dep) |
| continue |
| if details['status'] not in statuses: |
| continue |
| |
| if dep.name not in seen: |
| to_process.append(dep) |
| deps[dep.name] = dep |
| |
| # If any dependency is a parent of another dependency remove the |
| # parent since it's already being accounted for by patching in |
| # its child. |
| for parent in parents: |
| if parent.name in deps: |
| del deps[parent.name] |
| |
| del deps[initial_change.name] |
| |
| for dep in deps.values(): |
| pres.links[dep.name] = dep.link |
| |
| # Make sure the last step passes because _change_details might |
| # fail for an individual CL in ways we don't care about. |
| with self.m.step.nest('pass'): |
| pass |
| |
| return deps.values(), unresolved |
| |
| def _parse_commit_message(self, change): |
| """Convert a commit message into a dependency list. |
| |
| For example, |
| |
| Requires: gerritname:1, commas-supported:2 |
| Requires: name:4,spaces-not-required:3 |
| Requires: 5,,,,extra-commas-ignored:6,,,, |
| |
| becomes |
| |
| [ |
| Change("gerritname", 1), |
| Change("commas-supported", 2), |
| Change("spaces-not-required", 3), |
| Change("name", 4), |
| Change(change.gerrit_name, 5), |
| Change("extra-commas-ignored", 6), |
| ] |
| """ |
| |
| details = self._change_details(change) |
| current_revision = details['revisions'][details['current_revision']] |
| commit_message = current_revision['commit']['message'] |
| last_paragraph = commit_message.split('\n\n')[-1] |
| |
| for line in last_paragraph.split('\n'): |
| if ':' not in line: |
| continue |
| name, value = line.split(':', 1) |
| |
| if name != 'Requires': |
| continue |
| |
| values = value.split(',') |
| for val in values: |
| val = val.strip() |
| if not val: |
| continue |
| |
| gerrit_name = change.gerrit_name |
| if ':' in val: |
| gerrit_name, number = val.split(':') |
| else: |
| number = val |
| |
| if not number.isdigit(): |
| if self._ignore_errors: |
| continue |
| |
| else: |
| with self.m.step.nest('invalid requirement') as pres: |
| pres.step_summary_text = ( |
| 'Format is "<gerrit-host>:<cl-number>" or ' |
| '"<cl-number>", {!r} does not match'.format(val) |
| ) |
| pres.status = 'FAILURE' |
| raise self.m.step.StepFailure('invalid requirement') |
| |
| yield Change(gerrit_name, number) |
| |
| def _resolve(self, change): |
| """Resolve dependencies of the given change. |
| |
| Retrieve and parse the commit message of the given change, extracting |
| from "Requires:" lines (see resolve function above for details). Does |
| not attempt to verify if these dependent changes are accessible or |
| even valid. |
| |
| Args: |
| change (Change): Change to resolve dependencies for. |
| |
| Returns a list of changes on which the given change depends. This |
| includes dependencies of parent changes with 'NEW' status but not the |
| parent changes themselves. |
| """ |
| |
| with self.m.step.nest('resolve deps for {}'.format(change)) as pres: |
| deps = [] |
| for dep in self._parse_commit_message(change): |
| deps.append(dep) |
| pres.links[dep.name] = dep.link |
| if not deps: |
| pres.step_summary_text = 'no dependencies' |
| return deps |
| |
| def _change_details(self, change, _kw_only=(), test_status='NEW'): |
| assert not _kw_only |
| if change.name in self._details: |
| return self._details[change.name] |
| |
| try: |
| step = self.m.gerrit.change_details( |
| 'details {}'.format(change), |
| change_id=str(change.change), |
| host=change.host, |
| query_params=['CURRENT_COMMIT', 'CURRENT_REVISION',], |
| max_attempts=2, |
| timeout=30, |
| test_data=self.m.json.test_api.output( |
| { |
| 'current_revision': 'HASH', |
| 'revisions': { |
| 'HASH': { |
| '_number': 1, |
| 'commit': { |
| 'message': '', |
| 'parents': [{'commit': 'PARENT',}], |
| }, |
| } |
| }, |
| 'project': 'project', |
| 'status': test_status, |
| } |
| ), |
| ) |
| details = step.json.output |
| |
| except self.m.step.StepFailure: |
| details = {} |
| |
| self._details[change.name] = details |
| if details: |
| change.project = details['project'] |
| current_revision = details['revisions'][details['current_revision']] |
| change.patchset = current_revision['_number'] |
| change.status = details['status'] |
| change.commit = details['current_revision'] |
| |
| # If this CL is unsubmitted grab its parent and put it in the |
| # cache. We could have several submitted parents in self._details, |
| # but that's ok. |
| if details['status'] == 'NEW': |
| for parent in current_revision['commit']['parents']: |
| parent_result = self._lookup_cl( |
| change.gerrit_name, parent['commit'], |
| ) |
| # There could be no parent CL because this is the first |
| # commit or because the parent was submitted without going |
| # through Gerrit. |
| if not parent_result: |
| continue |
| |
| parent_change = Change( |
| gerrit_name=change.gerrit_name, |
| change=parent_result['_number'], |
| ) |
| self._change_details(parent_change, test_status='SUBMITTED') |
| if parent_change.status == 'NEW': |
| if parent_change.commit != parent['commit']: |
| raise self.m.step.StepFailure( |
| '{} has parent {} which is not the current ' |
| 'revision of its parent, {} ({})'.format( |
| change.name, |
| parent['commit'], |
| parent_change.name, |
| parent_change.commit, |
| ) |
| ) |
| |
| change.parents.append(parent_change) |
| |
| return self._details[change.name] |