pw_presubmit: Add check for TODO() format

Change-Id: If77b3cbd35d2ad09d2892453aaafd17df9fc1cbd
Bug: b/242060498
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/105930
Commit-Queue: Auto-Submit <auto-submit@pigweed.google.com.iam.gserviceaccount.com>
Pigweed-Auto-Submit: Rob Mohr <mohrr@google.com>
Reviewed-by: Wyatt Hepler <hepler@google.com>
diff --git a/pw_presubmit/docs.rst b/pw_presubmit/docs.rst
index e9a393e..a5919cb 100644
--- a/pw_presubmit/docs.rst
+++ b/pw_presubmit/docs.rst
@@ -124,6 +124,19 @@
 C/C++ headers is ``#pragma once``. This is enabled by adding
 ``pw_presubmit.pragma_once`` to a presubmit program.
 
+.. todo-check: disable
+
+TODO(b/###) Formatting
+^^^^^^^^^^^^^^^^^^^^^^^^^
+There's a check that confirms ``TODO`` lines match a given format. Upstream
+Pigweed expects these to look like ``TODO(b/###): Explanation``, but makes it
+easy for projects to define their own pattern instead.
+
+To use this check add ``todo_check.create(todo_check.BUGS_OR_USERNAMES)`` to a
+presubmit program.
+
+.. todo-check: enable
+
 Python Checks
 ^^^^^^^^^^^^^
 There are two checks in the ``pw_presubmit.python_checks`` module, ``gn_pylint``
diff --git a/pw_presubmit/py/BUILD.gn b/pw_presubmit/py/BUILD.gn
index ee0712c..def2262 100644
--- a/pw_presubmit/py/BUILD.gn
+++ b/pw_presubmit/py/BUILD.gn
@@ -36,6 +36,7 @@
     "pw_presubmit/presubmit.py",
     "pw_presubmit/python_checks.py",
     "pw_presubmit/shell_checks.py",
+    "pw_presubmit/todo_check.py",
     "pw_presubmit/tools.py",
   ]
   tests = [
diff --git a/pw_presubmit/py/pw_presubmit/pigweed_presubmit.py b/pw_presubmit/py/pw_presubmit/pigweed_presubmit.py
index f33a44f..910d628 100755
--- a/pw_presubmit/py/pw_presubmit/pigweed_presubmit.py
+++ b/pw_presubmit/py/pw_presubmit/pigweed_presubmit.py
@@ -46,6 +46,7 @@
     call,
     filter_paths,
     inclusive_language,
+    npm_presubmit,
     plural,
     presubmit,
     PresubmitContext,
@@ -53,7 +54,7 @@
     Programs,
     python_checks,
     shell_checks,
-    npm_presubmit,
+    todo_check,
 )
 from pw_presubmit.install_hook import install_git_hook
 
@@ -350,13 +351,13 @@
     '-//pw_blob_store/...:all',
     '-//pw_boot/...:all',
     '-//pw_cpu_exception_cortex_m/...:all',
-    '-//pw_crypto/...:all',  # TODO(b/236321905)
+    '-//pw_crypto/...:all',  # TODO(b/236321905) Remove when passing.
     '-//pw_file/...:all',
-    '-//pw_function:function_test',  # TODO(b/241821115)
-    '-//pw_hdlc/rpc_example',  # TODO(b/241575924)
+    '-//pw_function:function_test',  # TODO(b/241821115) Remove when passing.
+    '-//pw_hdlc/rpc_example',  # TODO(b/241575924) Remove when passing.
     '-//pw_i2c_mcuxpresso/...:all',
     '-//pw_kvs/...:all',
-    '-//pw_log:log_proto_py_pb2',  # TODO(b/241456982)
+    '-//pw_log:log_proto_py_pb2',  # TODO(b/241456982) Remove when passing.
     '-//pw_log:log_proto_py_pb2_genproto',
     '-//pw_log_null/...:all',
     '-//pw_log_string/...:all',
@@ -389,7 +390,8 @@
     '-//pw_tls_client/...:all',
     '-//pw_tls_client_boringssl/...:all',
     '-//pw_tls_client_mbedtls/...:all',
-    '-//pw_tokenizer:tokenizer_proto_py_pb2',  # TODO(b/241456982)
+    # TODO(b/241456982) Remove when passing.
+    '-//pw_tokenizer:tokenizer_proto_py_pb2',
     '-//pw_tokenizer:tokenizer_proto_py_pb2_genproto',
     '-//pw_trace/...:all',
     '-//pw_trace_tokenized/...:all',
@@ -750,6 +752,7 @@
     static_analysis,
     stm32f429i,
     npm_presubmit.npm_test,
+    todo_check.create(todo_check.BUGS_OR_USERNAMES),
 )
 
 CRYPTO = (
diff --git a/pw_presubmit/py/pw_presubmit/todo_check.py b/pw_presubmit/py/pw_presubmit/todo_check.py
new file mode 100644
index 0000000..b7e9e30
--- /dev/null
+++ b/pw_presubmit/py/pw_presubmit/todo_check.py
@@ -0,0 +1,78 @@
+# 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.
+"""Check the formatting of TODOs."""
+
+import re
+from typing import Sequence
+
+from pw_presubmit import PresubmitContext, filter_paths
+
+EXCLUDE: Sequence[str] = (
+    # Metadata
+    r'^docker/tag$',
+    r'\byarn.lock$',
+    # Data files
+    r'\.bin$',
+    r'\.csv$',
+    r'\.elf$',
+    r'\.gif$',
+    r'\.jpg$',
+    r'\.json$',
+    r'\.png$',
+    r'\.svg$',
+    r'\.xml$',
+)
+
+# todo-check: disable
+BUGS_ONLY = re.compile(r'\bTODO\(b/\d+\).*\w')
+BUGS_OR_USERNAMES = re.compile(r'\bTODO\((?:b/\d+|[a-z]+)\).*\w')
+_TODO = re.compile(r'\bTODO\b')
+# todo-check: enable
+
+# If seen, ignore this line and the next.
+_IGNORE = 'todo-check: ignore'
+
+# Ignore a whole section. Please do not change the order of these lines.
+_DISABLE = 'todo-check: disable'
+_ENABLE = 'todo-check: enable'
+
+
+def create(todo_pattern: re.Pattern = BUGS_ONLY,
+           exclude: Sequence[str] = EXCLUDE):
+    """Create a todo_check presubmit step that uses the given pattern."""
+    @filter_paths(exclude=exclude)
+    def todo_check(ctx: PresubmitContext):
+        """Presubmit check that makes sure todo lines match the pattern."""
+        for path in ctx.paths:
+            with open(path) as file:
+                enabled = True
+                prev = ''
+
+                for i, line in enumerate(file, 1):
+                    if _DISABLE in line:
+                        enabled = False
+                    elif _ENABLE in line:
+                        enabled = True
+
+                    if enabled:
+                        if _IGNORE not in line and _IGNORE not in prev:
+                            if _TODO.search(line):
+                                if not todo_pattern.search(line):
+                                    # todo-check: ignore
+                                    ctx.fail(f'Bad TODO on line {i}:', path)
+                                    ctx.fail(f'    {line.strip()}')
+
+                    prev = line
+
+    return todo_check