blob: b216980c48aa4ea3e8cb82e3312475b27d76d3ed [file]
# Copyright 2026 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.
"""Performs static checks on Gerrit changes without needing a full checkout.
This recipe module is designed to be run in a CI/CQ environment to validate
Gerrit changes against a set of configurable rules. These checks primarily focus
on the metadata of a change, such as the commit message and file modifications,
rather than the code's functional correctness.
Key checks performed by this module include:
- Commit message regex: Validates the commit message against a custom regular
expression.
- "Do Not Submit" (DNS) / "Work In Progress" (WIP): Checks for markers like
"DO NOT SUBMIT" or "WIP" in the commit message and can optionally mark the
change as WIP on Gerrit.
- "Tested:" line: Verifies the presence of a "Tested:" line in the commit
message.
- Readability labels: Automatically applies a "trivial" readability label
(e.g., "Readability-Trivial") if the changes to specific file types are
below a certain line count threshold.
The behavior of these checks is controlled by the `InputProperties` defined in
the `recipes.pigweed.static_checks.InputProperties` protocol buffer message.
The module interacts with Gerrit to post review comments, set labels, or
modify the change status (e.g., marking as WIP) based on the check results.
It also handles common scenarios like ignoring checks for revert changes,
merge commits, or changes made by allowlisted accounts.
"""
from __future__ import annotations
import functools
import re
from typing import Any
from recipe_engine import recipe_api
from PB.recipes.pigweed.static_checks import InputProperties
from PB.recipes.pigweed.static_checks import Readability as ReadabilityPb
from RECIPE_MODULES.pigweed.util import api as util_api
def nest_step(func: Any) -> Any:
@functools.wraps(func)
def wrapper(self: StaticChecksApi, *args: Any, **kwargs: Any) -> Any:
name = func.__name__.strip('_').replace('_', ' ')
with self.m.step.nest(name):
return func(self, *args, **kwargs)
return wrapper
class StaticChecksApi(recipe_api.RecipeApi):
"""API for performing static checks on Gerrit changes."""
InputProperties = InputProperties
Readability = ReadabilityPb
@nest_step
def _check_regexp(
self,
change: util_api.ChangeWithComments,
details: dict[str, Any],
regexp: re.Pattern | str,
failure_message: str,
) -> None:
_ = change # Unused.
if re.match(regexp, details['message']):
with self.m.step.nest('matches') as pres:
pres.step_summary_text = str(regexp)
return
with self.m.step.nest('does not match') as pres:
pres.step_summary_text = (
f'{details["message"]!r} does not match {regexp!r}'
)
if failure_message:
with self.m.step.nest('more details') as details_pres:
details_pres.step_summary_text = failure_message
details_pres.status = 'FAILURE'
raise self.m.step.StepFailure('commit message regexp match failed')
@nest_step
def _check_dns(
self,
details: dict[str, Any],
*,
dry_run: bool,
) -> None:
"""Check that the commit message doesn't contain "do not submit"."""
regexes = [
re.compile(r'do.not.(submit|com+it|merge)', re.IGNORECASE),
re.compile(r'\bWIP\b', re.IGNORECASE),
re.compile(r'\bwork.in.progres+\b', re.IGNORECASE),
]
match: re.Match | None = None
for regex in regexes:
if match := regex.search(details['message']):
break
else:
return
# We only get here if the current change has matched "work in progress",
# "do not commit", or similar.
if self.m.cv.active and self.m.cv.run_mode == self.m.cv.FULL_RUN:
raise self.m.step.StepFailure(
f'found "{match.group(0)}" in commit message'
)
if not details.get('work_in_progress', False):
if dry_run: # pragma: no cover
self.m.step.empty('would mark change as wip, but in dry run')
else:
try:
self.m.gerrit.wip('wip', details['_number'])
except self.m.step.StepFailure: # pragma: no cover
pass
@nest_step
def _check_tested(self, details: dict[str, Any]) -> None:
"""Check for a "Tested:" line in the commit message."""
for line in details['message'].split('\n'):
if line.lower().startswith('tested:'):
with self.m.step.nest(
'found tested line in commit message'
) as pres:
pres.step_summary_text = line
return
raise self.m.step.StepFailure(
'could not find "Tested:" line in commit message'
)
def _check_readability(
self,
details: dict[str, Any],
readability: ReadabilityPb,
) -> None:
"""Set the trivial readability label if the CL is trivial."""
extensions = tuple(str(x) for x in readability.file_extensions)
with self.m.step.nest(readability.trivial_label):
if readability.trivial_label not in details['labels']:
with self.m.step.nest('trivial label not present for repo'):
return
current_revision = details['revisions'][details['current_revision']]
changed_lines = 0
changed_files = 0
for filename, entry in current_revision['files'].items():
# TODO: b/240290899 - Maybe don't count deleted files?
if filename.endswith(extensions):
changed_files += 1
changed_lines += entry.get('lines_deleted', 0)
changed_lines += entry.get('lines_inserted', 0)
with self.m.step.nest(f'changed files {changed_files}'):
pass
with self.m.step.nest(f'changed lines {changed_lines}'):
pass
with self.m.step.nest(
f'threshold {readability.line_number_threshold}'
):
pass
if (
changed_files
and changed_lines < readability.line_number_threshold
):
self.m.gerrit.set_review(
'set label',
str(details['_number']),
labels={readability.trivial_label: 1},
notify='OWNER',
)
def __call__(self, props: InputProperties) -> None:
res = self.m.util.get_change_with_comments()
change = res.change
details = res.details
self.m.cv.set_do_not_retry_build()
# Make it just a little easier to retrieve current commit message later.
current_revision = details['revisions'][details['current_revision']]
details['message'] = current_revision['commit']['message']
with self.m.step.nest('checking if revert') as pres:
pres.step_summary_text = str(details['revert_of'])
if details['revert_of']:
with self.m.step.nest('ignored'): # pragma: no cover
return
with self.m.step.nest('checking if merge') as pres:
parents = [
x['commit'] for x in current_revision['commit']['parents']
]
pres.step_summary_text = str(parents)
if len(parents) > 1:
with self.m.step.nest('ignored'): # pragma: no cover
return
with self.m.step.nest('checking owner account') as pres:
owner = details['owner']['email']
pres.step_summary_text = owner
if owner in props.ignored_accounts:
with self.m.step.nest('ignored'):
return
if props.require_tested:
self._check_tested(details)
if props.commit_message_regexp:
self._check_regexp(
change,
details,
str(props.commit_message_regexp),
str(props.commit_message_regexp_failure_message),
)
self._check_dns(details, dry_run=props.dry_run)
for readability in props.readability:
self._check_readability(details, readability)