| # 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.""" |
| |
| from __future__ import annotations |
| |
| import base64 |
| import json |
| import re |
| from typing import Any, Generator, Literal, Sequence |
| |
| import attrs |
| from PB.recipe_modules.pigweed.cq_deps.properties import InputProperties |
| from recipe_engine import recipe_api |
| |
| _PATCHES_FILE = 'patches.json' |
| |
| |
| @attrs.define |
| class Change: |
| """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: str |
| change: int = attrs.field(converter=int) |
| project: str | None = None |
| patchset: str | None = None |
| status: str | None = None |
| commit: str | None = None |
| |
| @property |
| def host(self) -> str: |
| return f'{self.gerrit_name}-review.googlesource.com' |
| |
| @property |
| def link(self) -> str: |
| return f'https://{self.host}/{self.change}' |
| |
| @property |
| def name(self) -> str: |
| return f'{self.gerrit_name}:{self.change}' |
| |
| @property |
| def remote(self) -> str: |
| return f'https://{self.gerrit_name}.googlesource.com/{self.project}' |
| |
| @property |
| def gerrit_url(self) -> str: |
| return f'https://{self.gerrit_name}-review.googlesource.com/c/{self.change}' |
| |
| def __str__(self) -> str: |
| return self.name |
| |
| |
| @attrs.define |
| class CqDepsResult: |
| """Result of resolve(), containing references that were found or not.""" |
| |
| resolved: tuple[Change] |
| unresolved: tuple[Change] |
| |
| |
| ChangeState = Literal['NEW', 'MERGED', 'ABANDONED'] |
| |
| |
| class CqDepsApi(recipe_api.RecipeApi): |
| """Find dependencies on pending CLs.""" |
| |
| ChangeState = ChangeState |
| Change = Change |
| Result = CqDepsResult |
| |
| def __init__(self, props: InputProperties, *args, **kwargs): |
| super().__init__(*args, **kwargs) |
| self._details = {} |
| self._patches_json_enabled: bool = not props.patches_json_disabled |
| self._topics_enabled: bool = props.topics_enabled |
| |
| def _lookup_cl( |
| self, gerrit_name: str, commit: str |
| ) -> list[dict[str, Any]] | None: |
| query_results = self.m.gerrit.change_query( |
| f'number {commit}', |
| f'commit:{commit}', |
| host=f'{gerrit_name}-review.googlesource.com', |
| 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: str, |
| number: int | str, |
| topic: str | None = None, |
| ) -> CqDepsResult: |
| """Recursively resolve dependencies of a change. |
| |
| Resolve dependencies of a change. Depending on the module properties, |
| this might be by parsing a 'patches.json' file in the change or by |
| looking up other changes in the same topic. |
| |
| The two systems are evaluated independently. Topics of patches found via |
| 'patches.json' are not evaluated, and 'patches.json' files on other |
| changes in the same topic are not evaluated. |
| |
| Args: |
| gerrit_name: Name of Gerrit host of initial change. |
| number: Gerrit number or git commit hash of initial change. |
| topic: Gerrit topic containing this the change, if any. |
| |
| Returns resolved Change objects (excluding the given change) and |
| unresolved Change objects (which are not completely populated). |
| """ |
| |
| with self.m.step.nest('resolve CL deps', status='last') as pres: |
| with self.m.context(infra_steps=True): |
| if isinstance(number, str): |
| cl = self._lookup_cl(gerrit_name, number) |
| if not cl: |
| return CqDepsResult((), ()) |
| number: int = cl['_number'] |
| |
| seen: set[str] = set() |
| deps: dict[str, Change] = {} |
| unresolved: list[str] = [] |
| initial_change = Change(gerrit_name, number) |
| to_process: list[str] = [initial_change] |
| |
| # 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) |
| |
| if self._patches_json_enabled: |
| while to_process: |
| current: Change = to_process.pop() |
| deps[current.name] = current |
| seen.add(current.name) |
| |
| 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'] != 'NEW': |
| continue |
| |
| if dep.name not in seen: |
| to_process.append(dep) |
| deps[dep.name] = dep |
| |
| if not topic: |
| self.m.step.empty('no topic') |
| |
| else: |
| if self._topics_enabled: |
| assert '"' not in topic, f'invalid topic: {topic!r}' |
| changes = self.m.gerrit.change_query( |
| f'topic {topic}', |
| f'topic:"{topic}" status:new', |
| host=f'{gerrit_name}-review.googlesource.com', |
| max_attempts=2, |
| timeout=30, |
| # Always include the current change as well as the |
| # "next" one. We would only get here if there was a |
| # topic set, so always assume there's another change |
| # in the topic in testing. |
| test_data=self.m.json.test_api.output( |
| [ |
| { |
| '_number': number, |
| }, |
| { |
| '_number': number + 1, |
| }, |
| ] |
| ), |
| ).json.output |
| |
| for change in changes: |
| dep = Change(gerrit_name, change['_number']) |
| deps[dep.name] = dep |
| |
| else: |
| self.m.step.empty('topic set but topics not enabled') |
| |
| if initial_change.name in deps: |
| 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 CqDepsResult(deps.values(), unresolved) |
| |
| def _parse_deps(self, change: Change) -> Generator[Change, None, None]: |
| """Parse dependencies from patches.json in a change. |
| |
| For a change with the following patches.json file, |
| |
| [ |
| {"gerrit_name": "pigweed", "number": 1001}, |
| {"gerrit_name": "pigweed", "number": 1002}, |
| {"gerrit_name": "other", "number": 1003} |
| ] |
| |
| the return value would look like |
| |
| [ |
| Change("pigweed", 1001), |
| Change("pigweed", 1002), |
| Change("other", 1003), |
| ] |
| |
| Args: |
| change (Change): Change object from which to retrieve dependencies |
| |
| Returns a list of direct dependencies of the given change. |
| """ |
| |
| details = self._change_details(change) |
| if not details: |
| return [] # pragma: no cover |
| host = self.m.gerrit.normalize_host(change.host) |
| host = host.replace('-review.', '.') |
| current_revision = details['revisions'][details['current_revision']] |
| |
| # If there is no _PATCHES_FILE in the CL, don't try to retrieve it. |
| if _PATCHES_FILE not in current_revision.get('files', ()): |
| return [] |
| |
| # If _PATCHES_FILE is being deleted, don't try to retrieve it. |
| if current_revision['files'][_PATCHES_FILE].get('status', 'M') == 'D': |
| return [] # pragma: no cover |
| |
| cl = details['_number'] |
| ps = current_revision['_number'] |
| |
| raw_data = self.m.gitiles.fetch( |
| host, |
| details['project'], |
| _PATCHES_FILE, |
| 'refs/changes/{:02}/{}/{}'.format(cl % 100, cl, ps), |
| step_name=f'fetch {_PATCHES_FILE}', |
| ) |
| |
| if not raw_data: |
| raise self.m.step.InfraFailure( |
| f'{_PATCHES_FILE} is in CL but failed to retrieve it' |
| ) |
| |
| deps = json.loads(raw_data) |
| |
| for dep in deps: |
| yield Change(dep['gerrit_name'], dep['number']) |
| |
| def _resolve(self, change: Change) -> list[Change]: |
| """Resolve dependencies of the given change. |
| |
| Retrieve and parse the patches.json file of the given change. 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. |
| """ |
| |
| with self.m.step.nest(f'resolve deps for {change}') as pres: |
| deps = [] |
| for dep in self._parse_deps(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: Change, *, test_status: ChangeState = 'NEW' |
| ) -> dict[str, Any]: |
| if change.name in self._details: |
| return self._details[change.name] |
| |
| try: |
| step = self.m.gerrit.change_details( |
| f'details {change}', |
| change_id=str(change.change), |
| host=change.host, |
| query_params=[ |
| 'CURRENT_COMMIT', |
| 'CURRENT_FILES', |
| 'CURRENT_REVISION', |
| ], |
| max_attempts=2, |
| timeout=30, |
| test_data=self.m.json.test_api.output( |
| { |
| '_number': change.change, |
| 'current_revision': 'HASH', |
| 'revisions': { |
| 'HASH': { |
| '_number': 1, |
| 'commit': {'message': ''}, |
| 'files': { |
| 'filename': {}, |
| }, |
| }, |
| }, |
| 'project': 'project', |
| 'status': test_status, |
| } |
| ), |
| ) |
| details: dict[str, Any] = step.json.output or {} |
| |
| except self.m.step.StepFailure: |
| details: dict[str, Any] = {} |
| |
| 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'] |
| |
| return self._details[change.name] |