Repository: MarketSquare/robotframework-robocop
Pull request #1318 (2025-03-13 14:33:30)

Add Gitlab report for Gitlab Code Quality reports
#1116  -> I will close it after we release 6.0beta1 and it could be tested by others.


Patch:
diff --git a/docs/releasenotes/6.0.0a2.rst b/docs/releasenotes/6.0.0a2.rst
index 4064681..0179e23 100644
--- a/docs/releasenotes/6.0.0a2.rst
+++ b/docs/releasenotes/6.0.0a2.rst
@@ -675,6 +675,20 @@ You can list all rules that refer to official style guide rule::
 
 It is ongoing process and there will be more updates in upcoming versions of Robocop.
 
+Integrations with other platforms
+---------------------------------
+
+Robocop could be already integrated with different CI/CD platforms. It wasn't fully documented and sometimes it could
+require parsing output file. That's why we have decided to focus on improving how Robocop linting results can be
+integrated with other tools.
+
+Documentation is available at X # TODO .
+
+We are now supporting following integrations:
+
+- Gitlab Code Quality (with ``gitlab`` report) (#1116)
+- ... and more - see the `Integrations` documentation
+
 Shell autocompletion
 ---------------------
 
diff --git a/docs/source/formatters/skip_formatting.rst b/docs/source/formatters/skip_formatting.rst
index e3907e7..41ae80e 100644
--- a/docs/source/formatters/skip_formatting.rst
+++ b/docs/source/formatters/skip_formatting.rst
@@ -33,7 +33,7 @@ Example usage:
 
 .. code:: shell
 
-    robocop format -c NormalizeSeparators:skip=documentation
+    robocop format -c NormalizeSeparators.skip=documentation
 
 It is possible to use global option to skip formatting for every formatter that supports it:
 
@@ -61,7 +61,7 @@ normalized before search (spaces and underscores removed, lowercase).
 
 With this configuration::
 
-    robotidy -c AlignTestCasesSection:skip_keyword_call=ExecuteJavascript,catenate
+    robotidy -c AlignTestCasesSection.skip_keyword_call=ExecuteJavascript,catenate
 
 All instances of ``Execute Javascript`` and ``Catenate`` keywords will not be formatted.
 
@@ -82,7 +82,7 @@ Both options are configurable using configuration file (:ref:`config-file`).
         "supports spaces too"
     ]
     configure = [
-        "AlignKeywordsSection : skip_keyword_call = Name,othername"
+        "AlignKeywordsSection.skip_keyword_call=Name,othername"
     ]
 
 .. _skip keyword call pattern:
@@ -96,7 +96,7 @@ recommended to use proper regex flags to match it properly.
 
 With this configuration::
 
-    robotidy -c AlignKeywordsSection:skip_keyword_call_pattern=^First,(i?)contains\s?words src
+    robotidy -c AlignKeywordsSection.skip_keyword_call_pattern=^First,(i?)contains\s?words src
 
 All instances of keywords that start with "First" or contain "contains words" (case insensitive, space optional) will
 not be formatted.
@@ -120,7 +120,7 @@ Both options are configurable using configuration file (:ref:`config-file`).
         "(i?)contains\s?words"
     ]
     configure = [
-        "AlignKeywordsSection : skip_keyword_call_pattern = first,secondname"
+        "AlignKeywordsSection.skip_keyword_call_pattern=first,secondname"
     ]
 
 .. _skip sections:
@@ -130,7 +130,7 @@ Skip sections
 
 Option that disables formatting of the selected sections. Example usage::
 
-    robotidy -c NormalizeSeparators:skip_sections=variables src
+    robotidy -c NormalizeSeparators.skip_sections=variables src
 
 It is possible to use global option to skip formatting for every transformer that supports it::
 
@@ -147,5 +147,5 @@ Both options are configurable using configuration file (:ref:`config-file`).
     [tool.robocop.format]
     skip-sections = "comments"
     configure = [
-        "NormalizeSeparators : skip_sections = tasks,keywords"
+        "NormalizeSeparators.skip_sections=tasks,keywords"
     ]
diff --git a/docs/source/index.rst b/docs/source/index.rst
index 3b5becc..3ceb8a4 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -33,6 +33,11 @@ Table of contents
     formatters/formatters_list/index.rst
     formatters/skip_formatting.rst
 
+.. toctree::
+    :caption: Integrations
+
+    integrations
+
 .. toctree::
     :maxdepth: 2
     :caption: Other features
diff --git a/docs/source/integrations.rst b/docs/source/integrations.rst
new file mode 100644
index 0000000..5c82bad
--- /dev/null
+++ b/docs/source/integrations.rst
@@ -0,0 +1,37 @@
+.. _integrations:
+
+************
+Integrations
+************
+
+Gitlab
+------
+
+You can integrate Robocop results with `Gitlab Code Quality <https://docs.gitlab.com/ci/testing/code_quality/#implement-a-custom-tool>`_ .
+
+For that purpose you need to generate report that supports Code Quality format::
+
+    robocop check --reports gitlab
+
+It's also available using ``--gitlab`` option::
+
+    robocop check --gitlab
+
+By default it will produce ``robocop-code-quality.json`` file in the directory where Robocop was executed.
+You will need to attach this file to Gitlab artifacts::
+
+    stages:
+      - lint
+
+    robocop:
+      stage: lint
+      image: python:3.12
+      before_script:
+        - pip install robotframework-robocop==6.0
+      script:
+        - robocop check --gitlab
+      artifacts:
+        reports:
+          codequality: robocop-code-quality.json
+
+See :ref:`gitlab` for more information about report and how to configure it.
diff --git a/docs/source/reports.rst b/docs/source/reports.rst
index 4bce600..a439e84 100644
--- a/docs/source/reports.rst
+++ b/docs/source/reports.rst
@@ -248,3 +248,13 @@ SARIF export
 ------------
 
 .. automodule:: robocop.linter.reports.sarif_report.SarifReport
+
+
+----
+
+.. _gitlab:
+
+Gitlab
+------
+
+.. automodule:: robocop.linter.reports.gitlab.GitlabReport
diff --git a/src/robocop/cli.py b/src/robocop/cli.py
index 1270775..efe4aed 100644
--- a/src/robocop/cli.py
+++ b/src/robocop/cli.py
@@ -222,6 +222,13 @@ def check_files(
             help="Compare reports results with previous results (saved with --persistent)", rich_help_panel="Reports"
         ),
     ] = None,
+    gitlab: Annotated[
+        bool,
+        typer.Option(
+            help="Generate Gitlab Code Quality report. Equivalent of --reports gitlab",
+            rich_help_panel="Reports",
+        ),
+    ] = False,
     exit_zero: Annotated[
         bool,
         typer.Option(
@@ -241,6 +248,10 @@ def check_files(
     verbose: verbose_option = None,
 ) -> list[Diagnostic]:
     """Lint Robot Framework files."""
+    if gitlab:
+        if not reports:
+            reports = []
+        reports.append("gitlab")
     linter_config = config.LinterConfig(
         configure=configure,
         select=select,
diff --git a/src/robocop/config.py b/src/robocop/config.py
index 75c7a3b..8d99c4e 100644
--- a/src/robocop/config.py
+++ b/src/robocop/config.py
@@ -589,9 +589,12 @@ class Config:
                     continue
                 value = getattr(overwrite_config.linter, config_field.name)
                 if value:
-                    setattr(self.linter, config_field.name, value)
-                    if config_field.name in {"select", "ignore"}:
-                        self.linter.config_source = "cli"
+                    if config_field.name == "reports":  # allows to combine cli and config for reports
+                        self.linter.reports.extend(value)
+                    else:
+                        setattr(self.linter, config_field.name, value)
+                        if config_field.name in {"select", "ignore"}:
+                            self.linter.config_source = "cli"
         if overwrite_config.formatter:
             for config_field in fields(overwrite_config.formatter):
                 if config_field.name in ("whitespace_config", "skip_config") or config_field.name.startswith("_"):
diff --git a/src/robocop/linter/reports/gitlab.py b/src/robocop/linter/reports/gitlab.py
new file mode 100644
index 0000000..765dfbc
--- /dev/null
+++ b/src/robocop/linter/reports/gitlab.py
@@ -0,0 +1,144 @@
+from __future__ import annotations
+
+import hashlib
+import json
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+import typer
+
+import robocop.linter.reports
+from robocop.formatter.utils.misc import StatementLinesCollector
+from robocop.linter.rules import RuleSeverity
+
+if TYPE_CHECKING:
+    from robot.parsing import File
+
+    from robocop.config import Config
+    from robocop.linter.diagnostics import Diagnostic
+
+
+class GitlabReport(robocop.linter.reports.Report):
+    """
+    **Report name**: ``gitlab``
+
+    Report that generates Gitlab Code Quality output file.
+
+    It allows to display issue information in the Gitlab, for example in the PR view.
+    More information at https://docs.gitlab.com/ci/testing/code_quality/#code-quality-report-format .
+
+    You can configure output path. It's relative path to file that will be produced by the report::
+
+        robocop check --configure gitlab.output_path=output/robocop_code_quality.json
+
+    Default path is ``robocop-code-quality.json`` .
+
+    """
+
+    NO_ALL = False
+
+    def __init__(self, config: Config):
+        self.name = "gitlab"
+        self.description = "Generate Gitlab Code Quality output file"
+        self.output_path = "robocop-code-quality.json"
+        self.diagn_by_source: dict[str, list[Diagnostic]] = {}
+        super().__init__(config)
+
+    def add_message(self, message: Diagnostic) -> None:
+        if message.source not in self.diagn_by_source:
+            self.diagn_by_source[message.source] = []
+        self.diagn_by_source[message.source].append(message)
+
+    def configure(self, name, value) -> None:
+        if name == "output_path":
+            self.output_path = value
+        else:
+            super().configure(name, value)
+
+    def get_report(self) -> str:
+        report = self.generate_gitlab_report()
+        output_path = Path(self.output_path)
+        try:
+            output_path.parent.mkdir(exist_ok=True, parents=True)
+            with open(output_path, "w") as fp:
+                json.dump(report, fp, indent=4)
+        except OSError as err:
+            print(f"Failed to write Gitlab report to {output_path}: {err}")
+            raise typer.Exit(code=1) from None
+        return f"Generated Gitlab Code Quality report at {self.output_path}"
+
+    def generate_gitlab_report(self) -> list[dict]:
+        report = []
+        cwd = Path.cwd()
+        for source, diagnostics in self.diagn_by_source.items():
+            diagnostics.sort()
+            source_rel = Path(source).relative_to(cwd).as_posix()
+            source_lines = None
+            fingerprints = set()
+            for diagnostic in diagnostics:
+                if not source_lines:  # TODO: model should be coming from source, not diagnostics
+                    source_lines = self._get_source_lines(diagnostic.model)
+                content = self._get_line_content(diagnostic, source_lines)
+                unique_id = 0
+                while True:
+                    fingerprint = self.get_fingerprint(diagnostic, str(source_rel), content, unique_id)
+                    if fingerprint not in fingerprints:
+                        fingerprints.add(fingerprint)
+                        break
+                    unique_id += 1
+                report.append(
+                    {
+                        "description": diagnostic.message,
+                        "check_name": diagnostic.rule.name,
+                        "fingerprint": fingerprint,
+                        "severity": self.get_severity(diagnostic),
+                        "location": {"path": str(source_rel), "lines": {"begin": diagnostic.range.start.line}},
+                    }
+                )
+        return report
+
+    @staticmethod
+    def _get_source_lines(model: File) -> list[str]:
+        return StatementLinesCollector(model).text.splitlines()
+
+    @staticmethod
+    def _get_line_content(diagnostic: Diagnostic, lines: list[str]) -> str:
+        if not lines:
+            return ""
+        line_pos = max(0, min(diagnostic.range.start.line - 1, len(lines) - 1))
+        return lines[line_pos]
+
+    @staticmethod
+    def get_fingerprint(diagnostic: Diagnostic, source_rel: str, content: str, unique_id: int) -> str:
+        """
+        Generate unique identifier of the issue based on the rule message, name and line content.
+
+        We are not using issue location to avoid marking issue as fixed and new issue raised when position of line
+        changes. Since there could be several instances of the same line content in the same file, we are using
+        unique_id to differentiate them.
+        """
+        issue = {
+            "description": diagnostic.message,
+            "check_name": diagnostic.rule.name,
+            "severity": diagnostic.severity,
+            "source": source_rel,
+            "content": content,
+            "unique_id": unique_id,
+        }
+        return hashlib.sha1(bytes(str(issue), "utf-8")).hexdigest()  # noqa: S324
+
+    @staticmethod
+    def get_severity(diagnostic: Diagnostic) -> str:
+        """
+        Map Robocop severity to Code Quality severity.
+
+        Robocop uses three severity levels: info, warning and error.
+        Code Quality uses: info, minor, major, critical, or blocker.
+        """
+        if diagnostic.severity == RuleSeverity.INFO:
+            return "info"
+        if diagnostic.severity == RuleSeverity.WARNING:
+            return "minor"
+        if diagnostic.rule.rule_id.startswith("ERR"):
+            return "blocker"
+        return "major"
diff --git a/src/robocop/linter/reports/return_status_report.py b/src/robocop/linter/reports/return_status_report.py
index f59ca14..175d52d 100644
--- a/src/robocop/linter/reports/return_status_report.py
+++ b/src/robocop/linter/reports/return_status_report.py
@@ -8,9 +8,8 @@ class ReturnStatusReport(robocop.linter.reports.Report):
     """
     **Report name**: ``return_status``
 
-    This report is always enabled.
     Report that checks if number of returned rules messages for given severity value does not exceed preset threshold.
-    That information is later used as a return status from Robocop.
+    If the report is enabled, it will be used as a return status from Robocop.
     """
 
     NO_ALL = False
diff --git a/src/robocop/linter/reports/text_file.py b/src/robocop/linter/reports/text_file.py
index c6067fa..76713f7 100644
--- a/src/robocop/linter/reports/text_file.py
+++ b/src/robocop/linter/reports/text_file.py
@@ -13,7 +13,7 @@ class TextFile(robocop.linter.reports.Report):
 
     You can configure output path::
 
-        robocop linter --configure text_file.output_path=output/robocop.txt
+        robocop check --configure text_file.output_path=output/robocop.txt
 
     ``text_file`` report supports only ``simple`` issue output format.
     """


Test patch:
diff --git a/tests/linter/reports/test_gitlab.py b/tests/linter/reports/test_gitlab.py
new file mode 100644
index 0000000..5165c27
--- /dev/null
+++ b/tests/linter/reports/test_gitlab.py
@@ -0,0 +1,76 @@
+import json
+from unittest import mock
+
+from robocop.linter.reports.gitlab import GitlabReport
+from tests.linter.reports import generate_issues
+
+
+class TestGitlabReport:
+    def test_gitlab_report(self, rule, rule2, config, tmp_path):
+        output_file = tmp_path / "reports" / "report.json"
+        issues = generate_issues(rule, rule2)
+        issues[0].range.start.line = 1
+        issues[0].range.end.line = 1
+        issues[1].range.start.line = 2
+        issues[1].range.end.line = 2
+        issues[2].range.start.line = 1
+        issues[2].range.end.line = 1
+        issues[3].range.start.line = 2
+        issues[3].range.end.line = 2
+        issues.append(issues[2])
+        issues[4].range.start.line = 2
+        issues[4].range.end.line = 2
+        expected_report = [
+            {
+                "check_name": "some-message",
+                "description": "Some description",
+                "fingerprint": "1be5653ab24aa7ff31a65acff80621fe09e5b31f",
+                "location": {"lines": {"begin": 1}, "path": "tests/atest/rules/comments/ignored-data/test.robot"},
+                "severity": "minor",
+            },
+            {
+                "check_name": "other-message",
+                "description": "Some description. Example",
+                "fingerprint": "9841ed60f7af5c1fff6727dae96af16ccf8bb152",
+                "location": {"lines": {"begin": 2}, "path": "tests/atest/rules/comments/ignored-data/test.robot"},
+                "severity": "major",
+            },
+            {
+                "check_name": "some-message",
+                "description": "Some description",
+                "fingerprint": "fdf047043356a7d73bc1f4a335c682930aca0e18",
+                "location": {"lines": {"begin": 2}, "path": "tests/atest/rules/misc/empty-return/test.robot"},
+                "severity": "minor",
+            },
+            {
+                "check_name": "some-message",
+                "description": "Some description",
+                "fingerprint": "3a2a89f126310a3559629c7056ced04141e6597c",
+                "location": {"lines": {"begin": 2}, "path": "tests/atest/rules/misc/empty-return/test.robot"},
+                "severity": "minor",
+            },
+            {
+                "check_name": "other-message",
+                "description": "Some description. Example",
+                "fingerprint": "01db54652f4a2f86dc6248dff586d62f66313b59",
+                "location": {"lines": {"begin": 2}, "path": "tests/atest/rules/misc/empty-return/test.robot"},
+                "severity": "major",
+            },
+        ]
+        report = GitlabReport(config)
+        report.configure("output_path", output_file)
+        for issue in issues:
+            report.add_message(issue)
+        # content of 1 and 2 file. 2 file use the same lines to check if fingerprint will be different
+        content = [["line1", "line2"], ["line1", "line1"]]
+        with mock.patch.object(report, "_get_source_lines", side_effect=content):
+            report.get_report()
+        with open(output_file) as fp:
+            json_report = json.load(fp)
+        assert json_report == expected_report
+
+    def test_configure_output_path(self, config):
+        output_path = "path/to/dir/file.json"
+        report = GitlabReport(config)
+        report.configure("output_path", output_path)
+        assert report.output_path == output_path