pw_presubmit: Add OWNERS check

Change-Id: I5a638c2c2819afc2677491734327727f5b41a80e
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/125691
Reviewed-by: Wyatt Hepler <hepler@google.com>
Commit-Queue: Auto-Submit <auto-submit@pigweed.google.com.iam.gserviceaccount.com>
Reviewed-by: Greg Pataky <gregpataky@google.com>
Pigweed-Auto-Submit: Rob Mohr <mohrr@google.com>
diff --git a/pw_presubmit/docs.rst b/pw_presubmit/docs.rst
index 5b3636a..2fcde5b 100644
--- a/pw_presubmit/docs.rst
+++ b/pw_presubmit/docs.rst
@@ -315,6 +315,16 @@
 .. In case things get moved around in the previous paragraphs the enable line
 .. is repeated here: inclusive-language: enable.
 
+OWNERS
+^^^^^^
+There's a check that requires folders matching specific patterns contain
+``OWNERS`` files. It can be included by adding
+``module_owners.presubmit_check()`` to a presubmit program. This function takes
+a callable as an argument that indicates, for a given file, where a controlling
+``OWNERS`` file should be, or returns None if no ``OWNERS`` file is necessary.
+Formatting of ``OWNERS`` files is handled similary to formatting of other
+source files and is discussed in `Code Formatting`.
+
 pw_presubmit
 ------------
 .. automodule:: pw_presubmit
diff --git a/pw_presubmit/py/BUILD.gn b/pw_presubmit/py/BUILD.gn
index 81714d5..4d6724d 100644
--- a/pw_presubmit/py/BUILD.gn
+++ b/pw_presubmit/py/BUILD.gn
@@ -34,6 +34,7 @@
     "pw_presubmit/inclusive_language.py",
     "pw_presubmit/install_hook.py",
     "pw_presubmit/keep_sorted.py",
+    "pw_presubmit/module_owners.py",
     "pw_presubmit/ninja_parser.py",
     "pw_presubmit/npm_presubmit.py",
     "pw_presubmit/owners_checks.py",
diff --git a/pw_presubmit/py/pw_presubmit/module_owners.py b/pw_presubmit/py/pw_presubmit/module_owners.py
new file mode 100644
index 0000000..f16d6e3
--- /dev/null
+++ b/pw_presubmit/py/pw_presubmit/module_owners.py
@@ -0,0 +1,82 @@
+# Copyright 2022 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.
+"""Ensure all modules have OWNERS files."""
+
+import logging
+from pathlib import Path
+from typing import Callable, Optional, Tuple
+
+from pw_presubmit import (
+    PresubmitContext,
+    PresubmitFailure,
+    presubmit,
+)
+
+_LOG: logging.Logger = logging.getLogger(__name__)
+
+
+def upstream_pigweed_applicability(
+    ctx: PresubmitContext,
+    path: Path,
+) -> Optional[Path]:
+    """Return a parent of path required to have an OWNERS file, or None."""
+    parts: Tuple[str, ...] = path.relative_to(ctx.root).parts
+
+    if len(parts) >= 2 and parts[0].startswith('pw_'):
+        return ctx.root / parts[0]
+    if len(parts) >= 3 and parts[0] in ('targets', 'third_party'):
+        return ctx.root / parts[0] / parts[1]
+
+    return None
+
+
+ApplicabilityFunc = Callable[[PresubmitContext, Path], Optional[Path]]
+
+
+def presubmit_check(
+    applicability: ApplicabilityFunc = upstream_pigweed_applicability,
+) -> presubmit.Check:
+    """Create a presubmit check for the presence of OWNERS files."""
+
+    @presubmit.check(name='module_owners')
+    def check(ctx: PresubmitContext) -> None:
+        """Presubmit check that ensures all modules have OWNERS files."""
+
+        modules_to_check = set()
+
+        for path in ctx.paths:
+            result = applicability(ctx, path)
+            if result:
+                modules_to_check.add(result)
+
+        errors = 0
+        for module in sorted(modules_to_check):
+            _LOG.debug('Checking module %s', module)
+            owners_path = module / 'OWNERS'
+            if not owners_path.is_file():
+                _LOG.error('%s is missing an OWNERS file', module)
+                errors += 1
+                continue
+
+            with owners_path.open() as ins:
+                contents = [x.strip() for x in ins.read().strip().splitlines()]
+                wo_comments = [x for x in contents if not x.startswith('#')]
+                owners = [x for x in wo_comments if 'per-file' not in x]
+                if len(owners) < 1:
+                    _LOG.error('%s is too short: add owners', owners_path)
+
+        if errors:
+            raise PresubmitFailure
+
+    return check
diff --git a/pw_presubmit/py/pw_presubmit/pigweed_presubmit.py b/pw_presubmit/py/pw_presubmit/pigweed_presubmit.py
index 5aa300d..be287d1 100755
--- a/pw_presubmit/py/pw_presubmit/pigweed_presubmit.py
+++ b/pw_presubmit/py/pw_presubmit/pigweed_presubmit.py
@@ -47,6 +47,7 @@
     filter_paths,
     inclusive_language,
     keep_sorted,
+    module_owners,
     npm_presubmit,
     owners_checks,
     plural,
@@ -891,6 +892,7 @@
     gitmodules.create(),
     gn_clang_build,
     gn_combined_build_check,
+    module_owners.presubmit_check(),
     npm_presubmit.npm_test,
     pw_transfer_integration_test,
     static_analysis,