blob: f97f51e5f7d8359504691d7e7420d714735ea38c [file] [log] [blame]
# 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 TYPE_CHECKING
import attrs
from PB.recipe_modules.pigweed.cq_deps.properties import InputProperties
from recipe_engine import recipe_api
if TYPE_CHECKING: # pragma: no cover
from typing import Any, Generator, Literal, Sequence
_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]
if TYPE_CHECKING: # pragma: no cover
ChangeState = Literal['NEW', 'MERGED', 'ABANDONED']
class CqDepsApi(recipe_api.RecipeApi):
"""Find dependencies on pending CLs."""
if TYPE_CHECKING: # pragma: no cover
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]