Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add codeclimate formatter (json) #1308

Merged
merged 5 commits into from Feb 7, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/usage.rst
Expand Up @@ -91,3 +91,10 @@ are also handled:
:cwd: ..
:returncode: 2
:nostderr:

A codeclimate report in JSON format can be generated with ansible-lint.

.. command-output:: ansible-lint -f codeclimate examples/playbooks/example.yml
:cwd: ..
:returncode: 2
:nostderr:
10 changes: 10 additions & 0 deletions src/ansiblelint/app.py
Expand Up @@ -28,6 +28,14 @@ def __init__(self, options: "Namespace"):

def render_matches(self, matches: List) -> None:
"""Display given matches."""
if isinstance(self.formatter, formatters.CodeclimateJSONFormatter):
# If formatter CodeclimateJSONFormatter is chosen,
# then print only the matches in JSON
console.print(
self.formatter.format_result(matches), markup=False, highlight=False
)
return None

ignored_matches = [match for match in matches if match.ignored]
fatal_matches = [match for match in matches if not match.ignored]
# Displayed ignored matches first
Expand Down Expand Up @@ -66,6 +74,8 @@ def choose_formatter_factory(
r = formatters.ParseableFormatter
elif options_list.parseable_severity:
r = formatters.ParseableSeverityFormatter
elif options_list.format == 'codeclimate':
r = formatters.CodeclimateJSONFormatter
return r


Expand Down
2 changes: 1 addition & 1 deletion src/ansiblelint/cli.py
Expand Up @@ -131,7 +131,7 @@ def get_cli_parser() -> argparse.ArgumentParser:
'-f',
dest='format',
default='rich',
choices=['rich', 'plain', 'rst'],
choices=['rich', 'plain', 'rst', 'codeclimate'],
help="Format used rules output, (default: %(default)s)",
)
parser.add_argument(
Expand Down
61 changes: 60 additions & 1 deletion src/ansiblelint/formatters/__init__.py
@@ -1,7 +1,9 @@
"""Output formatters."""
import hashlib
import json
import os
from pathlib import Path
from typing import TYPE_CHECKING, Generic, TypeVar, Union
from typing import TYPE_CHECKING, Any, Dict, Generic, List, TypeVar, Union

import rich

Expand Down Expand Up @@ -144,3 +146,60 @@ def format(self, match: "MatchError") -> str:
f"[filename]{filename}[/]:{position}: [[error_code]{rule_id}[/]] "
f"[[error_code]{severity}[/]] [dim]{message}[/]"
)


class CodeclimateJSONFormatter(BaseFormatter):
"""Formatter for emitting violations in Codeclimate JSON report format.

The formatter expects a list of MatchError objects and returns a JSON formatted string.
The spec for the codeclimate report can be found here:
https://github.com/codeclimate/platform/blob/master/spec/analyzers/SPEC.md#data-type
"""

def format_result(self, matches: List["MatchError"]) -> str:

if not isinstance(matches, list):
raise RuntimeError(
"The CodeclimatJSONFormatter was expecting a list of MatchError."
)

result = []
for match in matches:
issue: Dict[str, Any] = {}
issue['type'] = 'issue'
issue['check_name'] = f"[{match.rule.id}] {match.message}"
issue['categories'] = match.rule.tags
issue['severity'] = self._severity_to_level(match.rule.severity)
issue['description'] = self.escape(str(match.rule.description))
issue['fingerprint'] = hashlib.sha256(
repr(match).encode('utf-8')
).hexdigest()
issue['location'] = {}
issue['location']['path'] = self._format_path(match.filename or "")
issue['location']['lines'] = {}
if match.column:
issue['location']['lines']['begin'] = {}
issue['location']['lines']['begin']['line'] = match.linenumber
issue['location']['lines']['begin']['column'] = match.column
else:
issue['location']['lines']['begin'] = match.linenumber
if match.details:
issue['content'] = {}
issue['content']['body'] = match.details
# Append issue to result list
result.append(issue)

return json.dumps(result)

@staticmethod
def _severity_to_level(severity: str) -> str:
if severity in ['LOW']:
return 'minor'
if severity in ['MEDIUM']:
return 'major'
if severity in ['HIGH']:
return 'critical'
if severity in ['VERY_HIGH']:
return 'blocker'
# VERY_LOW, INFO or anything else
return 'info'
83 changes: 83 additions & 0 deletions test/TestCodeclimateJSONFormatter.py
@@ -0,0 +1,83 @@
"""Test the codeclimate JSON formatter."""
import json
import pathlib
from typing import List, Optional

import pytest

from ansiblelint.errors import MatchError
from ansiblelint.formatters import CodeclimateJSONFormatter
from ansiblelint.rules import AnsibleLintRule


class TestCodeclimateJSONFormatter:
"""Unit test for CodeclimateJSONFormatter."""

rule = AnsibleLintRule()
matches: List[MatchError] = []
formatter: Optional[CodeclimateJSONFormatter] = None

def setup_class(self):
"""Set up few MatchError objects."""
self.rule = AnsibleLintRule()
self.rule.id = "TCF0001"
self.rule.severity = "VERY_HIGH"
self.matches = []
self.matches.append(
MatchError(
message="message",
linenumber=1,
details="hello",
filename="filename.yml",
rule=self.rule,
)
)
self.matches.append(
MatchError(
message="message",
linenumber=2,
details="hello",
filename="filename.yml",
rule=self.rule,
)
)
self.formatter = CodeclimateJSONFormatter(
pathlib.Path.cwd(), display_relative_path=True
)

def test_format_list(self):
"""Test if the return value is a string."""
assert isinstance(self.formatter.format_result(self.matches), str)

def test_result_is_json(self):
"""Test if returned string value is a JSON."""
json.loads(self.formatter.format_result(self.matches))

def test_single_match(self):
"""Test negative case. Only lists are allowed. Otherwise a RuntimeError will be raised."""
with pytest.raises(RuntimeError):
self.formatter.format_result(self.matches[0])

def test_result_is_list(self):
"""Test if the return JSON contains a list with a length of 2."""
result = json.loads(self.formatter.format_result(self.matches))
assert len(result) == 2

def test_validate_codeclimate_schema(self):
"""Test if the returned JSON is a valid codeclimate report."""
result = json.loads(self.formatter.format_result(self.matches))
single_match = result[0]
assert 'type' in single_match
assert single_match['type'] == 'issue'
assert 'check_name' in single_match
assert 'categories' in single_match
assert isinstance(single_match['categories'], list)
assert 'severity' in single_match
assert single_match['severity'] == 'blocker'
assert 'description' in single_match
assert 'fingerprint' in single_match
assert 'location' in single_match
assert 'path' in single_match['location']
assert single_match['location']['path'] == self.matches[0].filename
assert 'lines' in single_match['location']
assert single_match['location']['lines']['begin'] == self.matches[0].linenumber