blob: e72a98df314a99ec3583a3271367d77e3f51a16e [file] [log] [blame]
#!/usr/bin/env python3
# Copyright 2020 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Find test owners.
This script assumes that it's run in the root directory of a Fuchsia
checkout.
"""
import functools
import json
import os
import re
import sys
import tempfile
import unittest
# OWNERS file lines that match this regex (a very crude email matcher) will be
# considered to represent owners.
OWNER_REGEX = re.compile(r"^\S+@\S+$")
# An OWNERS file can import another OWNERS file using a line of the form
# `include /path/to/other/OWNERS`.
INCLUDE_REGEX = re.compile(r"^include (\S+)$")
# METADATA.textproto lines that match this regex indicate Issue Tracker bug
# components. Note that this definition is too tight: a textproto can split the
# component and its definition across multiple lines.
# TODO(olivernewman): Use the actual proto schema to parse METADATA.textproto
# files instead of using regexes.
METADATA_COMPONENT_ID_REGEX = re.compile(
r".*component_id:\s*(?P<component_id>\d+).*",
flags=re.MULTILINE | re.DOTALL | re.IGNORECASE,
)
def main():
args = sys.argv[1:]
if len(args) != 2:
print(f"Usage: {sys.argv[0]} <input path> <output path>")
sys.exit(1)
input_path, output_path = args[0], args[1]
output = find_owners(input_path)
output.sort(key=lambda t: t["test_name"])
with open(output_path, "w") as f:
json.dump(output, f, indent=1, separators=(",", ": "))
def find_owners(test_manifest_path):
checkout_dir = os.getcwd()
with open(test_manifest_path) as f:
test_manifest = json.load(f)
result = []
for test in unique_dicts(test_manifest):
test_name, gn_label = test["test_name"], test["gn_label"]
if not gn_label:
continue
owners, component_id = find_test_owners(checkout_dir, gn_label)
result.append(
{
"test_name": test_name,
"owners": owners,
"issue_tracker_component_id": component_id,
"gn_label": gn_label,
}
)
return result
def unique_dicts(tests):
"""Ensure that every entry in the test manifest is unique.
This should theoretically always be the case, but bugs in various parts
of the Fuchsia test database system might cause dupes, in which case we
don't want them to clutter up the output file.
"""
s = set(tuple(t.items()) for t in tests)
return list(map(dict, s))
def find_test_owners(checkout_dir, gn_label):
"""Find a test's owners and bug component given its GN label."""
# Strip off toolchain, target name, and leading slashes.
rel_test_dir = gn_label.split("(")[0].split(":")[0].strip("/")
owners = []
component_id = None
current_dir = os.path.join(checkout_dir, *rel_test_dir.split("/"), "BUILD.gn")
# Give up if we reach the checkout root before finding an OWNERS file. We
# want to avoid falling back to global owners since global owners are
# for large-scale change reviews and aren't responsible for arbitrary tests.
while (current_dir := os.path.dirname(current_dir)) != checkout_dir and not owners:
# If no component is defined, look for the component in the METADATA.textproto.
# Only one component is allowed in metadata files.
# If no component is found, fall back to OWNERS later.
metadata_file = os.path.join(current_dir, "METADATA.textproto")
if os.path.exists(metadata_file) and not component_id:
component_id = load_metadata(metadata_file)
owners_file = os.path.join(current_dir, "OWNERS")
if os.path.exists(owners_file):
owners = load_owners(owners_file)
return owners, component_id
@functools.lru_cache(maxsize=None)
def load_owners(owners_file):
"""Given an OWNERS file, return a list of owner emails.
Ignores any per-file owners, only returning owner emails that are on
their own lines.
"""
owners = []
with open(owners_file) as f:
for line in f.readlines():
line = line.strip()
include_match = INCLUDE_REGEX.match(line)
if include_match:
included_file_parts = include_match.group(1).lstrip("/").split("/")
# Included paths can be relative.
if included_file_parts[0].startswith("."):
path = os.path.abspath(
os.path.join(os.path.dirname(owners_file), *included_file_parts)
)
else:
path = os.path.join(os.getcwd(), *included_file_parts)
try:
included_owners = load_owners(path)
except FileNotFoundError:
# Ignore include statements that reference nonexistent
# files. Ideally such invalid includes would be prevented by
# presubmit checks, but until that's possible the
# test-owners builder shouldn't fall over when such a
# breakage is introduced.
pass
else:
owners.extend(included_owners)
if OWNER_REGEX.match(line):
owners.append(line)
return owners
def load_metadata(metadata_file):
"""Given a METADATA.textproto file, return a bug component ID."""
with open(metadata_file) as f: # pylint: disable=unspecified-encoding
line = f.read().strip()
if match := METADATA_COMPONENT_ID_REGEX.match(line):
return int(match.group("component_id"))
return None
# Run tests with recipes/test_owners.resources/run_resource_tests.sh
class TestLoadOwners(unittest.TestCase):
def setUp(self):
self.tempdir = tempfile.TemporaryDirectory("find_owners_tests")
os.chdir(self.tempdir.name)
def tearDown(self):
self.tempdir.cleanup()
def _write_file(self, path, lines):
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as f:
f.write("\n".join(lines))
def test_basic_file(self):
self._write_file(
os.path.join(self.tempdir.name, "foo", "OWNERS"),
[
"foo@example.com",
"",
"# This is a comment",
"bar@example.com",
],
)
owners, component_id = find_test_owners(self.tempdir.name, "//foo:foo")
self.assertEqual(owners, ["foo@example.com", "bar@example.com"])
self.assertEqual(component_id, None)
def test_include(self):
self._write_file(
os.path.join(self.tempdir.name, "foo", "OWNERS"),
[
"include /foo/bar/OWNERS",
"bar@example.com",
],
)
self._write_file(
os.path.join(self.tempdir.name, "foo", "bar", "OWNERS"),
[
"included@example.com",
],
)
owners, component_id = find_test_owners(self.tempdir.name, "//foo:foo")
self.assertEqual(owners, ["included@example.com", "bar@example.com"])
self.assertEqual(component_id, None)
def test_invalid_include(self):
self._write_file(
os.path.join(self.tempdir.name, "foo", "OWNERS"),
[
"include /does-not-exist/OWNERS",
"bar@example.com",
],
)
owners, component_id = find_test_owners(self.tempdir.name, "//foo:foo")
self.assertEqual(owners, ["bar@example.com"])
self.assertEqual(component_id, None)
def test_owners_alongside_build_file(self):
gn_label = "//foo/bar($toolchain)"
self._write_file(
# OWNERS file is in the same directory as the BUILD.gn file that
# declared the test.
os.path.join(self.tempdir.name, "foo", "bar", "OWNERS"),
["foo@example.com"],
)
owners, component_id = find_test_owners(self.tempdir.name, gn_label)
self.assertEqual(owners, ["foo@example.com"])
self.assertEqual(component_id, None)
def test_metadata_file_in_parent_dir(self):
gn_label = "//foo/bar:bar($toolchain)"
self._write_file(
os.path.join(self.tempdir.name, "foo", "OWNERS"),
["foo@example.com"],
)
self._write_file(
os.path.join(self.tempdir.name, "foo", "METADATA.textproto"),
# Intentionally use wacky whitespace.
[
"""
trackers: {
issue_tracker: {
component_id:
12345
}
}
"""
],
)
owners, component_id = find_test_owners(self.tempdir.name, gn_label)
self.assertEqual(owners, ["foo@example.com"])
self.assertEqual(component_id, 12345)
if __name__ == "__main__":
main()