Skip to content

Commit a4e6409

Browse files
authored
Enable progressive mode (#1065)
Adds a new option --progressive that ease adoption of the linter as it will only report failure if it detects an increase number of violations since previous commit.
1 parent 295ae7f commit a4e6409

File tree

6 files changed

+137
-19
lines changed

6 files changed

+137
-19
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,6 @@ pip-wheel-metadata
4141

4242
# mypy
4343
.mypy_cache
44+
45+
# .cache is used by progressive mode
46+
.cache

README.rst

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ The following is the output from ``ansible-lint --help``, providing an overview
9494
-q quieter, although not silent output
9595
-p parseable output in the format of pep8
9696
--parseable-severity parseable output including severity of rule
97+
--progressive Return success if it detects a reduction in number of violations compared with
98+
previous git commit. This feature works only on git repository clones.
9799
-r RULESDIR Specify custom rule directories. Add -R to keep using embedded rules from
98100
/usr/local/lib/python3.8/site-packages/ansiblelint/rules
99101
-R Keep default rules when using -r
@@ -111,6 +113,23 @@ The following is the output from ``ansible-lint --help``, providing an overview
111113
-c CONFIG_FILE Specify configuration file to use. Defaults to ".ansible-lint"
112114
--version show program's version number and exit
113115
116+
Progressive mode
117+
----------------
118+
119+
In order to ease tool adoption, git users can enable the progressive mode using
120+
``--progressive`` option. This makes the linter return a success even if
121+
some failures are found, as long the total number of violations did not
122+
increase since the previous commit.
123+
124+
As expected, this mode makes the linter run twice if it finds any violations.
125+
The second run is performed against a temporary git working copy that contains
126+
the previous commit. All the violations that were already present are removed
127+
from the list and the final result is displayed.
128+
129+
The most notable benefit introduced by this mode it does not prevent merging
130+
new code while allowing developer to address historical violation at his own
131+
speed.
132+
114133
CI/CD
115134
-----
116135

lib/ansiblelint/__main__.py

Lines changed: 96 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,16 @@
2424
import logging
2525
import os
2626
import pathlib
27+
import subprocess
2728
import sys
28-
from typing import TYPE_CHECKING, List, Set, Type
29+
from contextlib import contextmanager
30+
from typing import TYPE_CHECKING, Any, List, Set, Type, Union
2931

3032
from rich.markdown import Markdown
3133

3234
from ansiblelint import cli, formatters
3335
from ansiblelint.color import console, console_stderr
36+
from ansiblelint.file_utils import cwd
3437
from ansiblelint.generate_docs import rules_as_rich, rules_as_rst
3538
from ansiblelint.rules import RulesCollection
3639
from ansiblelint.runner import Runner
@@ -96,8 +99,9 @@ def report_outcome(matches: List["MatchError"], options) -> int:
9699
# .ansible-lint
97100
warn_list: # or 'skip_list' to silence them completely
98101
"""
102+
matches_unignored = [match for match in matches if not match.ignored]
99103

100-
matched_rules = {match.rule.id: match.rule for match in matches}
104+
matched_rules = {match.rule.id: match.rule for match in matches_unignored}
101105
for id in sorted(matched_rules.keys()):
102106
if {id, *matched_rules[id].tags}.isdisjoint(options.warn_list):
103107
msg += f" - '{id}' # {matched_rules[id].shortdesc}\n"
@@ -151,6 +155,76 @@ def main() -> int:
151155
skip.update(str(s).split(','))
152156
options.skip_list = frozenset(skip)
153157

158+
matches = _get_matches(rules, options)
159+
160+
# Assure we do not print duplicates and the order is consistent
161+
matches = sorted(set(matches))
162+
163+
mark_as_success = False
164+
if matches and options.progressive:
165+
_logger.info(
166+
"Matches found, running again on previous revision in order to detect regressions")
167+
with _previous_revision():
168+
old_matches = _get_matches(rules, options)
169+
# remove old matches from current list
170+
matches_delta = list(set(matches) - set(old_matches))
171+
if len(matches_delta) == 0:
172+
_logger.warning(
173+
"Total violations not increased since previous "
174+
"commit, will mark result as success. (%s -> %s)",
175+
len(old_matches), len(matches_delta))
176+
mark_as_success = True
177+
178+
ignored = 0
179+
for match in matches:
180+
# if match is not new, mark is as ignored
181+
if match not in matches_delta:
182+
match.ignored = True
183+
ignored += 1
184+
if ignored:
185+
_logger.warning(
186+
"Marked %s previously known violation(s) as ignored due to"
187+
" progressive mode.", ignored)
188+
189+
_render_matches(matches, options, formatter, cwd)
190+
191+
if matches and not mark_as_success:
192+
return report_outcome(matches, options=options)
193+
else:
194+
return 0
195+
196+
197+
def _render_matches(
198+
matches: List,
199+
options: "Namespace",
200+
formatter: Any,
201+
cwd: Union[str, pathlib.Path]):
202+
203+
ignored_matches = [match for match in matches if match.ignored]
204+
fatal_matches = [match for match in matches if not match.ignored]
205+
# Displayed ignored matches first
206+
if ignored_matches:
207+
_logger.warning(
208+
"Listing %s violation(s) marked as ignored, likely already known",
209+
len(ignored_matches))
210+
for match in ignored_matches:
211+
if match.ignored:
212+
print(formatter.format(match, options.colored))
213+
if fatal_matches:
214+
_logger.warning("Listing %s violation(s) that are fatal", len(fatal_matches))
215+
for match in fatal_matches:
216+
if not match.ignored:
217+
print(formatter.format(match, options.colored))
218+
219+
# If run under GitHub Actions we also want to emit output recognized by it.
220+
if os.getenv('GITHUB_ACTIONS') == 'true' and os.getenv('GITHUB_WORKFLOW'):
221+
formatter = formatters.AnnotationsFormatter(cwd, True)
222+
for match in matches:
223+
print(formatter.format(match))
224+
225+
226+
def _get_matches(rules: RulesCollection, options: "Namespace") -> list:
227+
154228
if not options.playbook:
155229
# no args triggers auto-detection mode
156230
playbooks = get_playbooks_and_roles(options=options)
@@ -164,23 +238,26 @@ def main() -> int:
164238
options.skip_list, options.exclude_paths,
165239
options.verbosity, checked_files)
166240
matches.extend(runner.run())
167-
168-
# Assure we do not print duplicates and the order is consistent
169-
matches = sorted(set(matches))
170-
171-
for match in matches:
172-
print(formatter.format(match, options.colored))
173-
174-
# If run under GitHub Actions we also want to emit output recognized by it.
175-
if os.getenv('GITHUB_ACTIONS') == 'true' and os.getenv('GITHUB_WORKFLOW'):
176-
formatter = formatters.AnnotationsFormatter(cwd, True)
177-
for match in matches:
178-
print(formatter.format(match))
179-
180-
if matches:
181-
return report_outcome(matches, options=options)
182-
else:
183-
return 0
241+
return matches
242+
243+
244+
@contextmanager
245+
def _previous_revision():
246+
"""Create or update a temporary workdir containing the previous revision."""
247+
worktree_dir = ".cache/old-rev"
248+
revision = subprocess.run(
249+
["git", "rev-parse", "HEAD^1"],
250+
check=True,
251+
universal_newlines=True,
252+
stdout=subprocess.PIPE,
253+
stderr=subprocess.DEVNULL,
254+
).stdout
255+
p = pathlib.Path(worktree_dir)
256+
p.mkdir(parents=True, exist_ok=True)
257+
os.system(f"git worktree add -f {worktree_dir} 2>/dev/null")
258+
with cwd(worktree_dir):
259+
os.system(f"git checkout {revision}")
260+
yield
184261

185262

186263
if __name__ == "__main__":

lib/ansiblelint/cli.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,12 @@ def get_cli_parser() -> argparse.ArgumentParser:
111111
default=False,
112112
action='store_true',
113113
help="parseable output including severity of rule")
114+
parser.add_argument('--progressive', dest='progressive',
115+
default=False,
116+
action='store_true',
117+
help="Return success if it detects a reduction in number"
118+
" of violations compared with previous git commit. This "
119+
"feature works only in git repositories.")
114120
parser.add_argument('-r', action=AbspathArgAction, dest='rulesdir',
115121
default=[], type=Path,
116122
help="Specify custom rule directories. Add -R "

lib/ansiblelint/errors.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ def __init__(
4141
self.details = details
4242
self.filename = normpath(filename) if filename else None
4343
self.rule = rule
44+
self.ignored = False # If set it will be displayed but not counted as failure
4445

4546
def __repr__(self):
4647
"""Return a MatchError instance representation."""

lib/ansiblelint/file_utils.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Utility functions related to file operations."""
22
import os
3+
from contextlib import contextmanager
34

45

56
def normpath(path) -> str:
@@ -11,3 +12,14 @@ def normpath(path) -> str:
1112
"""
1213
# convertion to string in order to allow receiving non string objects
1314
return os.path.relpath(str(path))
15+
16+
17+
@contextmanager
18+
def cwd(path):
19+
"""Context manager for temporary changing current working directory."""
20+
old_pwd = os.getcwd()
21+
os.chdir(path)
22+
try:
23+
yield
24+
finally:
25+
os.chdir(old_pwd)

0 commit comments

Comments
 (0)