blob: 9d2fe21f52faaa19f18d2fcab018cbee996f69c2 [file] [log] [blame]
#!/usr/bin/env python
# Copyright (c) 2018 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
import argparse
import difflib
import os
import string
import sys
# Path to the root of the current chromium checkout.
CHROMIUM_DIR = os.path.abspath(os.path.join(
os.path.dirname(__file__), '..', '..', '..'))
MD_HEADER = """# List of CQ builders
This page is auto generated using the script
//infra/config/branch/cq_config_presubmit.py. Do not manually edit.
[TOC]
Each builder name links to that builder on Milo. The "Backing builders" links
point to the file used to determine which configurations a builder should copy
when running. These links might 404 or error; they are hard-coded right now,
using common assumptions about how builders are configured.
"""
REQUIRED_HEADER = """
These builders must pass before a CL may land."""
OPTIONAL_HEADER = """These builders optionally run, depending on the files in a
CL. For example, a CL which touches `//gpu/BUILD.gn` would trigger the builder
`android_optional_gpu_tests_rel`, due to the `path_regexp` values for that
builder."""
EXPERIMENTAL_HEADER = """
These builders are run on some percentage of builds. Their results are ignored
by CQ. These are often used to test new configurations before they are added
as required builders."""
BUILDER_VIEW_URL = (
'https://ci.chromium.org/p/chromium/builders/luci.chromium.try/')
CODE_SEARCH_BASE = 'https://cs.chromium.org/'
TRYBOT_SOURCE_URL = CODE_SEARCH_BASE + 'search/?q=file:trybots.py+'
CQ_CONFIG_LOCATION_URL = (
CODE_SEARCH_BASE + 'search/?q=package:%5Echromium$+file:cq.cfg+')
REGEX_SEARCH_URL = CODE_SEARCH_BASE + 'search/?q=package:%5Echromium$+'
def parse_text_proto_message(lines):
"""Parses a text proto. LOW QUALITY, MAY EASILY BREAK.
If you really need to parse text protos, use the actual python library for
protobufs. This exists because the .proto file for cq.cfg lives in another
repository.
"""
data = {}
linenum = 0
# Tracks the current comment. Gets cleared if there's a blank line. Is added
# to submessages, to allow for builders to contain comments.
current_comment = None
while linenum < len(lines):
line = lines[linenum].strip()
if not line:
current_comment = None
linenum += 1
elif line.startswith('#'):
if current_comment:
current_comment += '\n' + line[1:]
else:
current_comment = line[1:]
linenum += 1
elif '{' in line:
# Sub message. Put before the ':' clause so that it correctly handles one
# line messages.
end = linenum
count = 0
newlines = []
while end < len(lines):
inner_line = lines[end]
if '{' in inner_line:
count += 1
if '}' in inner_line:
count -= 1
if end == linenum:
newline = inner_line.split('{', 1)[1]
if count == 0:
newline = newline.split('}')[0]
newlines.append(newline)
elif count == 0:
newlines.append(inner_line.split('}')[0])
else:
newlines.append(inner_line)
end += 1
if count == 0:
break
name = line.split('{')[0].strip()
value = parse_text_proto_message(newlines)
if current_comment:
value['comment'] = current_comment
current_comment = None
if name in data:
data[name].append(value)
else:
data[name] = [value]
linenum = end
elif ':' in line:
# It's a field
name, value = line.split(':', 1)
value = value.strip()
if value.startswith('"'):
value = value.strip('"')
if name in data:
data[name].append(value)
else:
data[name] = [value]
linenum += 1
else:
raise ValueError('Invalid line (number %d):\n%s' % (linenum, line))
return data
class BuilderList(object):
def __init__(self, builders):
self.builders = builders
def sort(self):
"""Sorts the builder list.
Sorts the builders in place. Orders them into three groups: experimental,
required, and optional."""
self.builders.sort(key=lambda b: '%s|%s|%s' % (
'z' if b.get('experiment_percentage') else 'a',
'z' if b.get('path_regexp') else 'a',
b['name']))
def by_section(self):
required = []
experimental = []
optional = []
for b in self.builders:
# Don't handle if something is both optional and experimental
if b.get('path_regexp'):
optional.append(b)
elif b.get('experiment_percentage'):
experimental.append(b)
else:
required.append(b)
return required, optional, experimental
class CQConfig(object):
def __init__(self, lines):
self._value = parse_text_proto_message(lines)
@staticmethod
def from_file(path):
with open(path) as f:
lines = f.readlines()
return CQConfig(lines)
@property
def version(self):
return int(self._value['version'][0])
def builder_list(self, pred=None):
"""Returns a list of builders.
pred is a predicate used to decide if a builder should be returned. It takes
the bucket and builder as arguments."""
items = []
for bucket in (
self._value['verifiers'][0]['try_job'][0]['buckets']):
for b in bucket['builders']:
if pred and not pred(bucket, b):
continue
items.append(b)
return BuilderList(items)
def get_markdown_doc(self):
lines = []
for l in MD_HEADER.split('\n'):
lines.append(l)
bl = self.builder_list()
req, opt, exp = bl.by_section()
for title, header, builders in (
('Required builders', REQUIRED_HEADER, req),
('Optional builders', OPTIONAL_HEADER, opt),
('Experimental builders', EXPERIMENTAL_HEADER, exp),
):
lines.append('## %s' % title)
lines.append('')
for l in header.strip().split('\n'):
lines.append(l)
lines.append('')
for b in builders:
lines.append(
'* [%s](%s) ([`cq.cfg` entry](%s)) ([matching builders](%s))' % (
b['name'][0], BUILDER_VIEW_URL + b['name'][0],
CQ_CONFIG_LOCATION_URL + b['name'][0],
TRYBOT_SOURCE_URL + b['name'][0],))
lines.append('')
if 'comment' in b:
for l in b['comment'].split('\n'):
lines.append(' ' + l.strip())
lines.append('')
if 'path_regexp' in b:
lines.append(' Path regular expressions:')
for regex in b['path_regexp']:
regex_title = '//' + regex.lstrip('/')
url = None
if regex.endswith('.+'):
regex = regex[:-len('.+')]
if all(
# Equals sign and dashes used by layout tests.
c in string.ascii_letters + string.digits + '/-_='
for c in regex):
# Assume the regex is targeting a single path, direct link to
# it. Check to make sure we don't have weird characters, like
# ()|, which could mean it's a regex.
url = CODE_SEARCH_BASE + 'chromium/src/' + regex
lines.append(' * [`%s`](%s)' % (
regex_title, url or REGEX_SEARCH_URL + 'file:' + regex))
lines.append('')
if 'experiment_percentage' in b:
lines.append(' * Experimental percentage: %s' % (
b['experiment_percentage'][0]))
lines.append('')
lines.append('')
return '\n'.join(lines)
def main():
parser = argparse.ArgumentParser()
parser.add_argument(
'-c', '--check', action='store_true', help=
'Do consistency checks of cq.cfg and generated files. Used during'
'presubmit. Causes the tool to not generate any files.')
args = parser.parse_args()
exit_code = 0
cfg = CQConfig.from_file(os.path.join(
CHROMIUM_DIR, 'infra', 'config', 'branch', 'cq.cfg'))
if cfg.version != 1:
raise ValueError("Expected version 1, got %r" % cfg.version)
# Only force sorting on luci.chromium.try builders. Others should go away soon
# anyways...
bl = cfg.builder_list(lambda bucket, builder: bucket == 'luci.chromium.try')
names = [b['name'][0] for b in bl.builders]
bl.sort() # Changes the bl, so the next line is sorted.
sorted_names = [b['name'][0] for b in bl.builders]
if sorted_names != names:
print 'ERROR: cq.cfg is unsorted.',
if args.check:
print
else:
print ' Please sort as follows:'
for line in difflib.unified_diff(
names,
sorted_names, fromfile='current', tofile='sorted'):
print line
exit_code = 1
if args.check:
# TODO(martiniss): Add a check for path_regexp, to make sure they're valid
# paths.
with open(os.path.join(
CHROMIUM_DIR, 'docs', 'infra', 'cq_builders.md')) as f:
if cfg.get_markdown_doc() != f.read():
print (
'Markdown file is out of date. Please run '
'`//infra/config/branch/cq_cfg_presubmit.py to regenerate the '
'docs.')
exit_code = 1
else:
with open(os.path.join(
CHROMIUM_DIR, 'docs', 'infra', 'cq_builders.md'), 'w') as f:
f.write(cfg.get_markdown_doc())
return exit_code
if __name__ == '__main__':
sys.exit(main())