requirement_nudger: Initial commit

Change-Id: I986af19cc0feaeb345b8294f142d0b85f666a781
Bug: b/416550827
Reviewed-on: https://pigweed-review.googlesource.com/c/infra/recipes/+/299252
Lint: Lint 🤖 <android-build-ayeaye@system.gserviceaccount.com>
Reviewed-by: Oliver Newman <olivernewman@google.com>
Commit-Queue: Rob Mohr <mohrr@google.com>
Pigweed-Auto-Submit: Rob Mohr <mohrr@google.com>
Presubmit-Verified: CQ Bot Account <pigweed-scoped@luci-project-accounts.iam.gserviceaccount.com>
diff --git a/recipes/requirement_nudger.proto b/recipes/requirement_nudger.proto
new file mode 100644
index 0000000..09e3e3c
--- /dev/null
+++ b/recipes/requirement_nudger.proto
@@ -0,0 +1,45 @@
+// Copyright 2025 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.
+
+syntax = "proto3";
+
+import "recipe_modules/pigweed/checkout/options.proto";
+
+package recipes.pigweed.requirement_nudger;
+
+message HostConfig {
+  // Label to trigger auto-submission.
+  string auto_submit_label = 1;
+
+  // Gerrit host to run against.
+  string gerrit_host = 2;
+}
+
+message InputProperties {
+  repeated HostConfig host_configs = 1;
+
+  // Number of days to store history about commenting on changes. After this
+  // it's possible a change will be commented on again.
+  int32 history_retention_days = 2;
+
+  // Maximum amount of time without activity to comment. Default: 2. Mostly this
+  // is to prevent resurrecting old changes when this is turned on.
+  int32 max_age_days = 3;
+
+  // Minimum amount of time without activity before commenting. Default: 30.
+  int32 min_age_minutes = 4;
+
+  // Whether to actually comment on changes.
+  bool dry_run = 5;
+}
diff --git a/recipes/requirement_nudger.py b/recipes/requirement_nudger.py
new file mode 100644
index 0000000..def2172
--- /dev/null
+++ b/recipes/requirement_nudger.py
@@ -0,0 +1,486 @@
+# Copyright 2025 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.
+"""Comment on changes that are missing "trivial" submit requirements."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+import dataclasses
+import datetime
+import json
+import re
+from typing import Any, Generator, TypedDict
+
+from google.protobuf import json_format
+from PB.go.chromium.org.luci.buildbucket.proto import (
+    build as build_pb,
+    common as common_pb,
+)
+from PB.recipes.pigweed.requirement_nudger import (
+    HostConfig,
+    InputProperties,
+)
+from PB.recipe_engine import result as result_pb
+from recipe_engine import (
+    config_types,
+    engine_types,
+    post_process,
+    recipe_api,
+    recipe_test_api,
+)
+
+DEPS = [
+    'fuchsia/buildbucket_util',
+    'fuchsia/builder_state',
+    'fuchsia/gerrit',
+    'recipe_engine/buildbucket',
+    'recipe_engine/json',
+    'recipe_engine/properties',
+    'recipe_engine/step',
+    'recipe_engine/time',
+]
+
+PROPERTIES = InputProperties
+
+_GOOD_REQUIREMENT_STATUSES = frozenset(
+    'SATISFIED OVERRIDDEN NOT_APPLICABLE FORCED'.split()
+)
+_BAD_REQUIREMENT_STATUSES = frozenset('UNSATISFIED ERROR'.split())
+_REQUIREMENT_STATUSES = _GOOD_REQUIREMENT_STATUSES.union(
+    _BAD_REQUIREMENT_STATUSES
+)
+
+
+def _find_changes(
+    api: recipe_api.RecipeScriptApi,
+    gerrit_host: str,
+    auto_submit_label: str,
+    max_age_days: int,
+    min_age_minutes: int,
+):
+    assert gerrit_host
+    assert auto_submit_label
+
+    search_parts: list[str] = []
+
+    # Ignore WIP changes. People don't expect them to be ready to submit.
+    search_parts.append('-is:wip')
+
+    # Ignore changes without CR+2.
+    search_parts.append('label:Code-Review=MAX')
+
+    # Only process changes with auto-submit set. People only expect these
+    # changes to end up submitted without further interaction.
+    search_parts.append(f'label:{auto_submit_label}=MAX')
+
+    # Ignore submitted/abandoned changes.
+    search_parts.append('status:NEW')
+
+    # Ignore changes with all requirements met. We definitely don't need to
+    # comment on these changes.
+    search_parts.append('-is:submittable')
+
+    # Ignore changes with unresolved comments. The unresolved comments
+    # should be sufficiently visible to change owners.
+    search_parts.append('-has:unresolved')
+
+    # Only look at stuff from the past couple days.
+    search_parts.append(f'-age:{max_age_days}d')
+
+    # Wait until a change has been left alone for a short period.
+    search_parts.append(f'age:{min_age_minutes}m')
+
+    return api.gerrit.change_query(
+        'find changes',
+        ' '.join(search_parts),
+        host=gerrit_host,
+        max_attempts=2,
+        timeout=30,
+    ).json.output
+
+
+def _process_change(
+    api: recipe_api.RecipeScriptApi,
+    pres: engine_types.StepPresentation,
+    dry_run: bool,
+    now: int,
+    state: dict[str, dict[str, int]],
+    host: str,
+    change_id: int,
+) -> int:
+    if host in state and change_id in state[host]:
+        pres.step_summary_text = 'Already commented on this change'
+        api.step.empty('already commented')
+        return 0
+
+    bb_input = api.buildbucket.build.input
+    details: dict[str, Any] = api.gerrit.change_details(
+        'change details',
+        change_id=str(change_id),
+        host=host,
+        query_params=['SUBMIT_REQUIREMENTS'],
+    ).json.output
+
+    submit_requirements = {
+        x['name']: x for x in details.get('submit_requirements', [])
+    }
+
+    unmet: set[str] = set()
+    for name, req in submit_requirements.items():
+        if req['status'] in _BAD_REQUIREMENT_STATUSES:
+            unmet.add(name)
+        elif req['status'] not in _GOOD_REQUIREMENT_STATUSES:
+            assert False, f'bad status {req["status"]}'  # pragma: no cover
+
+    if not unmet:
+        # This shouldn't really happen, because we should only be getting
+        # changes that are unsubmittable. However, the change could have been
+        # modified between searching for changes and getting change details.
+        pres.step_summary_text = 'All requirements met'
+
+    unhandled: set[str] = set()
+    for name in unmet:
+        if (
+            name not in ('Docs', 'Images-Not-Allowed')
+            and not name.endswith('-Tests')
+            and not name.endswith('-Readability')
+        ):
+            unhandled.add(name)
+
+    if unhandled:
+        pres.step_summary_text = (
+            'Unhandled unsatisfied submit '
+            f'requirement{"" if len(unhandled) == 1 else "s"}: '
+            f'{", ".join(sorted(unhandled))}'
+        )
+        # Clear unmet instead of returning so we get the "commented about" step.
+        unmet = set()
+
+    def comment(name, message):
+        if dry_run:
+            pres = api.step.empty(name).presentation
+            pres.step_summary_text = message
+
+        else:
+            api.gerrit.set_review(
+                name,
+                change_id,
+                host=host,
+                message=message,
+                revision=details['current_revision'],
+                notify='OWNER_REVIEWERS',
+            )
+
+    comments = []
+    for name in unmet:
+        if name == 'Docs':
+            comment(
+                'Docs',
+                'The "Docs" submit requirement is not met. If there are no '
+                'updates to documentation in this commit, add them. '
+                'Alternatively, vote +1 on the Docs-Not-Needed label. If '
+                'Docs-Not-Needed already has a +1 vote, then there are docs '
+                'updates in this change and the change owner has voted +1. '
+                'Remove the +1 vote and the change will become submittable.',
+            )
+            comments.append(name)
+
+        elif name == 'Images-Not-Allowed':
+            comment(
+                'Images-Not-Allowed',
+                'Images are not allowed in the Pigweed repository. If you '
+                'believe this is an exception, get the current Pigweed oncall '
+                'user to vote +1 on Images-Allowed. See the [Pigweed console]'
+                '(https://ci.chromium.org/p/pigweed/g/pigweed.pigweed/console) '
+                'to find out who is oncall. If you are oncall, get a Pigweed '
+                'Gerrit admin to vote +1. They will need a bug to reference to '
+                'gain this permission.',
+            )
+            comments.append(name)
+
+        elif name.endswith('-Tests'):
+            language = name.rsplit('-', 1)[0]
+            comment(
+                name,
+                f'Changes to {language} files also require updates to test '
+                'files. Add changes to test files (with a "_test" suffix on '
+                "the name). If this change doesn't need tests, vote +1 on "
+                'Tests-Not-Needed. If you already have a +1 vote on that label '
+                'then you likely already have an update to a test, and you '
+                'need to remove the vote.',
+            )
+            comments.append(name)
+
+        elif name.endswith('-Readability'):
+            language = name.rsplit('-', 1)[0]
+            comment(
+                name,
+                f'Changes to {language} files also require readability '
+                'approval.',
+            )
+            comments.append(name)
+
+    if comments:
+        state.setdefault(host, {})
+        state[host][change_id] = now
+        api.builder_state.save(state)
+
+    api.step.empty(
+        f'commented about {len(comments)} '
+        f'requirement{"" if len(comments) == 1 else "s"}',
+    )
+
+    return len(comments)
+
+
+def _gerrit_link(host: str, number: int | str) -> str:
+    return (
+        f'[{host}:{number}](https://{host}-review.googlesource.com/c/{number})'
+    )
+
+
+def RunSteps(
+    api: recipe_api.RecipeScriptApi,
+    props: InputProperties,
+) -> result_pb.RawResult | None:  # pragma: no cover
+    """Comment on changes that are missing "trivial" submit requirements."""
+    props.dry_run = props.dry_run or api.buildbucket_util.is_dev_or_try
+    props.history_retention_days = props.history_retention_days or 30
+    props.max_age_days = props.max_age_days or 2
+    props.min_age_minutes = props.min_age_minutes or 30
+
+    # We could just grab the time at different points throughout the recipe, but
+    # things are easier to test if we pretend the runtime is zero.
+    now = int(api.time.time())
+
+    with api.step.nest('state'):
+        old_state: dict[str, dict[str, int]] = (
+            api.builder_state.fetch_previous_state()
+        )
+
+        threshold = now - props.history_retention_days * 24 * 60 * 60
+        pres = api.step.empty('threshold').presentation
+        pres.step_summary_text = str(threshold)
+
+        # Only keep entries newer than the threshold.
+        state: dict[str, dict[str, int]] = {}
+        for host, entries in old_state.items():
+            for change_id, timestamp in entries.items():
+                if timestamp > threshold:
+                    state.setdefault(host, {})
+                    state[host][change_id] = timestamp
+
+        api.builder_state.save(state)
+
+    summary = []
+    for host_config in props.host_configs:
+        short_host = api.gerrit.host_basename(host_config.gerrit_host)
+        with api.step.nest(short_host):
+            changes = _find_changes(
+                api,
+                gerrit_host=short_host,
+                auto_submit_label=host_config.auto_submit_label,
+                max_age_days=props.max_age_days,
+                min_age_minutes=props.min_age_minutes,
+            )
+
+            for change in changes:
+                change_id = str(change['_number'])
+                with api.step.nest(change_id) as pres:
+                    num_comments = _process_change(
+                        api=api,
+                        pres=pres,
+                        dry_run=props.dry_run,
+                        now=now,
+                        state=state,
+                        host=short_host,
+                        change_id=change_id,
+                    )
+
+                    if num_comments:
+                        summary.append(
+                            f'* {_gerrit_link(short_host, change_id)}: '
+                            f'{num_comments}'
+                        )
+
+    return result_pb.RawResult(
+        summary_markdown='\n'.join(summary),
+        status=common_pb.SUCCESS,
+    )
+
+
+def GenTests(api) -> Generator[recipe_test_api.TestData, None, None]:
+    """Create tests."""
+
+    @dataclasses.dataclass
+    class Entry:
+        change_id: int
+        timestamp: int
+        host: str = 'pigweed'
+
+    def state(*entries):
+        res = {}
+        for entry in entries:
+            res.setdefault(entry.host, {})
+            res[entry.host][entry.change_id] = entry.timestamp
+        return api.builder_state(res, prefix='state')
+
+    def host_config(host: str = 'pigweed', label: str = 'Pigweed-Auto-Submit'):
+        return HostConfig(gerrit_host=host, auto_submit_label=label)
+
+    def props(*host_configs, **kwargs):
+        if not host_configs:
+            host_configs = (host_config(),)
+        res = InputProperties(**kwargs)
+        res.host_configs.extend(host_configs)
+        return api.properties(res)
+
+    def search_results(*change_number: int, host: str = 'pigweed'):
+        return api.step_data(
+            f'{host}.find changes',
+            api.json.output([{'_number': int(x)} for x in change_number]),
+        )
+
+    def change_details(
+        change_number: int,
+        met_requirements: Sequence[str] = (),
+        unmet_requirements: Sequence[str] = (),
+        host: str = 'pigweed',
+    ):
+        reqs = []
+        for i, req in enumerate(met_requirements):
+            status = 'SATISFIED OVERRIDDEN NOT_APPLICABLE'.split()[i % 3]
+            reqs.append({'name': req, 'status': status})
+
+        for req in unmet_requirements:
+            reqs.append({'name': req, 'status': 'UNSATISFIED'})
+
+        return api.step_data(
+            f'{host}.{change_number}.change details',
+            api.json.output(
+                {'current_revision': 'HASH', 'submit_requirements': reqs},
+            ),
+        )
+
+    def assert_already_commented(
+        change_number: int,
+        gerrit_host: str = 'pigweed',
+    ):
+        return api.post_process(
+            post_process.MustRun,
+            f'{gerrit_host}.{change_number}.already commented',
+        )
+
+    def assert_commented(
+        change_number: int,
+        requirement_name: str,
+        gerrit_host: str = 'pigweed',
+    ):
+        return api.post_process(
+            post_process.MustRun,
+            f'{gerrit_host}.{change_number}.{requirement_name}',
+        )
+
+    def assert_num_comments(
+        change_number: int,
+        num: int,
+        gerrit_host: str = 'pigweed',
+    ):
+        return api.post_process(
+            post_process.MustRun,
+            f'{gerrit_host}.{change_number}.commented about {num} '
+            f'requirement{"" if num == 1 else "s"}',
+        )
+
+    def drop_expectations_must_be_last():
+        # No need for expectation files, everything of note here is tested by
+        # assertions. This must be the last thing added to the test.
+        return api.post_process(post_process.DropExpectation)
+
+    yield api.test(
+        'empty',
+        props(),
+        search_results(),
+        drop_expectations_must_be_last(),
+    )
+
+    yield api.test(
+        'none',
+        props(),
+        search_results(1234),
+        change_details(1234),
+        assert_num_comments(1234, 0),
+        drop_expectations_must_be_last(),
+    )
+
+    yield api.test(
+        'no-code-review',
+        props(),
+        search_results(1234),
+        change_details(
+            1234,
+            unmet_requirements=['Code-Review', 'Docs'],
+            met_requirements=['Code-Owners', 'API-Review', 'Python-Tests'],
+        ),
+        assert_num_comments(1234, 0),
+        drop_expectations_must_be_last(),
+    )
+
+    yield api.test(
+        'docs-purgestate',
+        state(Entry(1234, 1000000000)),
+        props(),
+        search_results(1234),
+        change_details(1234, unmet_requirements=['Docs']),
+        assert_commented(1234, 'Docs'),
+        assert_num_comments(1234, 1),
+        drop_expectations_must_be_last(),
+    )
+
+    yield api.test(
+        'images-readability',
+        props(dry_run=True),
+        search_results(1234),
+        change_details(
+            1234,
+            unmet_requirements=['Images-Not-Allowed', 'Batch-Readability'],
+        ),
+        assert_commented(1234, 'Images-Not-Allowed'),
+        assert_commented(1234, 'Batch-Readability'),
+        assert_num_comments(1234, 2),
+        drop_expectations_must_be_last(),
+    )
+
+    yield api.test(
+        'tests',
+        props(),
+        search_results(1234),
+        change_details(
+            1234,
+            unmet_requirements=['Python-Tests', 'Batch-Tests'],
+        ),
+        assert_commented(1234, 'Python-Tests'),
+        assert_commented(1234, 'Batch-Tests'),
+        assert_num_comments(1234, 2),
+        drop_expectations_must_be_last(),
+    )
+
+    yield api.test(
+        'docs-state',
+        state(Entry(1234, 1335000000)),
+        props(),
+        search_results(1234),
+        assert_already_commented(1234),
+        drop_expectations_must_be_last(),
+    )