blob: b76cc9aedad4147a119d0391a82c7b000876f2f3 [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."""
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
ChangeState = Literal['NEW', 'MERGED', 'ABANDONED']
class CqDepsApi(recipe_api.RecipeApi):
"""Find dependencies on pending CLs."""
ChangeState = ChangeState
Change = Change
def __init__(self, props: InputProperties, *args, **kwargs):
super().__init__(*args, **kwargs)
self._details = {}
self._patches_json_enabled: bool = not props.patches_json_disabled
self._ignore_errors: bool = props.ignore_errors
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,
statuses: Sequence[ChangeState] = ('NEW',),
) -> tuple[Sequence[Change], Sequence[Change]]:
"""Recursively resolve dependencies of a CL.
Resolve dependencies of a CL by parsing its patches.json file.
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).
"""
with self.m.step.nest('resolve CL deps', status='last') as pres:
if not self._patches_json_enabled:
with self.m.step.nest('patches.json 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: 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)
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'] not in statuses:
continue
if dep.name not in seen:
to_process.append(dep)
deps[dep.name] = dep
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_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]