blob: 96c5038f17ec25aae2620dae012ed4300e77bfca [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
# 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
class Change(object):
"""Details of a matching change.
This is meant to match the GerritChange message defined at
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))
def host(self):
return '{}'.format(self.gerrit_name)
def link(self):
return 'https://{}/{}'.format(, self.change)
def name(self):
return '{}:{}'.format(self.gerrit_name, self.change)
def remote(self):
return 'https://{}{}'.format(
self.gerrit_name, self.project
def gerrit_url(self):
return 'https://{}{}'.format(
self.gerrit_name, self.change
def __str__(self):
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),
# 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.
[{'_number': sum(ord(x) for x in commit),}]
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,,,,
gerrit_name (str): Name of Gerrit host of initial change.
number (int|str): Gerrit number or git commit hash of initial
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
x for x in current.parents if not in seen
with self.m.step.nest(
'parents {}'.format(
) as pres:
if current.parents:
pres.step_summary_text = ', '.join( for x in current.parents
pres.step_summary_text = (
'all parents already submitted'
for dep in self._resolve(current):
if not in deps:
details = self._change_details(dep)
if not details:
if details['status'] not in statuses:
if not in seen:
deps[] = 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 in deps:
del deps[]
del deps[]
for dep in deps.values():
pres.links[] =
# 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'):
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,,,,
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:
name, value = line.split(':', 1)
if name != 'Requires':
values = value.split(',')
for val in values:
val = val.strip()
if not val:
gerrit_name = change.gerrit_name
if ':' in val:
gerrit_name, number = val.split(':')
number = val
if not number.isdigit():
if self._ignore_errors:
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.
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):
pres.links[] =
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 in self._details:
return self._details[]
step = self.m.gerrit.change_details(
'details {}'.format(change),
'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[] = 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:
parent_change = Change(
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(,
return self._details[]