blob: b4e985f98cf25ab8bd0f800528c855a7dafa0e6e [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 re
import attr
from recipe_engine import recipe_api
@attr.s
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 = 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 f'{self.gerrit_name}-review.googlesource.com'
@property
def link(self):
return f'https://{self.host}/{self.change}'
@property
def name(self):
return f'{self.gerrit_name}:{self.change}'
@property
def remote(self):
return f'https://{self.gerrit_name}.googlesource.com/{self.project}'
@property
def gerrit_url(self):
return f'https://{self.gerrit_name}-review.googlesource.com/c/{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().__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(
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, 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(f'parents {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(f'resolve deps for {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(
f'details {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]