Repository: ericsson/codechecker
Issue: CodeChecker cmd diff command doesn't filter reports by source code comments

Steps to reproduce the problem:
1. Create a main.cpp file and analyze it:
```cpp
int main() {}
```
2. Modify the source file, insert a bug, suppress it, and analyze it again.
```cpp
int main() {
  // codechecker_intentional [all] My comment.
  sizeof(42);
  return 0;
}
```
3. Run `CodeChecker cmd diff --new` command on these report directories and it will show the bug as new even I suppressed it.

Changed files:
web/tests/libtest/codechecker.py, web/client/codechecker_client/cmd_line_client.py, web/tests/functional/diff_local/test_diff_local.py, web/tests/functional/diff_remote/test_diff_remote.py, codechecker_common/report.py, web/tests/functional/diff_local_remote/test_diff_local_remote.py

diff --git a/codechecker_common/report.py b/codechecker_common/report.py
index 7dde9a10..3231c569 100644
--- a/codechecker_common/report.py
+++ b/codechecker_common/report.py
@@ -12,8 +12,11 @@ type of Report which will be printed or stored.
 
 from typing import Dict, List
 import json
+import os
 
 from codechecker_common.logger import get_logger
+from codechecker_common.source_code_comment_handler import \\
+    SourceCodeCommentHandler, SpellException
 from codechecker_common import util
 
 LOG = get_logger('report')
@@ -44,12 +47,15 @@ class Report(object):
         # Dictionary fileid to filepath that bugpath events refer to
         self.__files = files
 
-        # Can contain the soruce line where the main section was reported.
+        # Can contain the source line where the main section was reported.
         self.__source_line = ""
 
         # Dictionary containing metadata information (analyzer name, version).
         self.__metadata = metadata
 
+        self.__source_code_comments = None
+        self.__sc_handler = SourceCodeCommentHandler()
+
     @property
     def line(self) -> int:
         return self.__main['location']['line']
@@ -115,6 +121,52 @@ class Report(object):
     def metadata(self) -> Dict:
         return self.__metadata
 
+    @property
+    def source_code_comments(self):
+        """
+        Get source code comments for the report.
+        It will read the source file only once.
+        """
+        if self.__source_code_comments is not None:
+            return self.__source_code_comments
+
+        self.__source_code_comments = []
+
+        if not os.path.exists(self.file_path):
+            return self.__source_code_comments
+
+        with open(self.file_path, encoding='utf-8', errors='ignore') as sf:
+            try:
+                self.__source_code_comments = \\
+                    self.__sc_handler.filter_source_line_comments(
+                        sf, self.line, self.check_name)
+            except SpellException as ex:
+                LOG.warning("%s contains %s", os.path.basename(self.file_path),
+                            str(ex))
+
+        if len(self.__source_code_comments) == 1:
+            LOG.debug("Report %s is suppressed in code. file: %s Line %s",
+                      self.report_hash, self.file_path, self.line)
+        elif len(self.__source_code_comments) > 1:
+            LOG.warning(
+                "Multiple source code comment can be found "
+                "for '%s' checker in '%s' at line %s. "
+                "This bug will not be suppressed!",
+                self.check_name, self.file_path, self.line)
+
+        return self.__source_code_comments
+
+    def check_source_code_comments(self, comment_types: List[str]):
+        """
+        True if it doesn't have a source code comment or if every comments have
+        specified comment types.
+        """
+        if not self.source_code_comments:
+            return True
+
+        return all(c['status'] in comment_types
+                   for c in self.source_code_comments)
+
     def __str__(self):
         msg = json.dumps(self.__main, sort_keys=True, indent=2)
         msg += str(self.__files)
diff --git a/web/client/codechecker_client/cmd_line_client.py b/web/client/codechecker_client/cmd_line_client.py
index 8c233c24..930ecf92 100644
--- a/web/client/codechecker_client/cmd_line_client.py
+++ b/web/client/codechecker_client/cmd_line_client.py
@@ -30,8 +30,6 @@ from codechecker_api_shared.ttypes import RequestFailed
 from codechecker_common import logger, plist_parser, util
 from codechecker_common.report import Report
 from codechecker_common.output import twodim, gerrit, codeclimate
-from codechecker_common.source_code_comment_handler import \\
-    SourceCodeCommentHandler, SpellException
 from codechecker_report_hash.hash import get_report_path_hash
 
 from codechecker_web.shared import webserver_context
@@ -218,39 +216,11 @@ def process_run_args(client, run_args_with_tag):
     return run_ids, run_names, run_tags
 
 
-def get_suppressed_reports(reports: List[Report]) -> List[str]:
+def get_suppressed_reports(reports: List[Report],
+                           args: List[str]) -> List[str]:
     """Returns a list of suppressed report hashes."""
-    suppressed_in_code = []
-    sc_handler = SourceCodeCommentHandler()
-
-    for rep in reports:
-        bughash = rep.report_hash
-        source_file = rep.file_path
-        bug_line = rep.main['location']['line']
-        checker_name = rep.main['check_name']
-        src_comment_data = []
-        with open(source_file, encoding='utf-8', errors='ignore') as sf:
-            try:
-                src_comment_data = sc_handler.filter_source_line_comments(
-                    sf,
-                    bug_line,
-                    checker_name)
-            except SpellException as ex:
-                LOG.warning("%s contains %s",
-                            os.path.basename(source_file),
-                            str(ex))
-
-        if len(src_comment_data) == 1:
-            suppressed_in_code.append(bughash)
-            LOG.debug("Bug %s is suppressed in code. file: %s Line %s",
-                      bughash, source_file, bug_line)
-        elif len(src_comment_data) > 1:
-            LOG.warning(
-                "Multiple source code comment can be found "
-                "for '%s' checker in '%s' at line %s. "
-                "This bug will not be suppressed!",
-                checker_name, source_file, bug_line)
-    return suppressed_in_code
+    return [rep.report_hash for rep in reports
+            if not rep.check_source_code_comments(args.review_status)]
 
 
 def get_report_dir_results(report_dirs: List[str],
@@ -858,7 +828,7 @@ def handle_diff_results(args):
         report_dir_results = get_report_dir_results(report_dirs,
                                                     args,
                                                     context.severity_map)
-        suppressed_in_code = get_suppressed_reports(report_dir_results)
+        suppressed_in_code = get_suppressed_reports(report_dir_results, args)
 
         diff_type = get_diff_type(args)
         run_ids, run_names, tag_ids = \\
@@ -914,7 +884,7 @@ def handle_diff_results(args):
         report_dir_results = get_report_dir_results(report_dirs,
                                                     args,
                                                     context.severity_map)
-        suppressed_in_code = get_suppressed_reports(report_dir_results)
+        suppressed_in_code = get_suppressed_reports(report_dir_results, args)
 
         diff_type = get_diff_type(args)
         run_ids, run_names, tag_ids = \\
@@ -1005,6 +975,9 @@ def handle_diff_results(args):
                                              args,
                                              context.severity_map)
 
+        new_results = [res for res in new_results
+                       if res.check_source_code_comments(args.review_status)]
+
         base_hashes = set([res.report_hash for res in base_results])
         new_hashes = set([res.report_hash for res in new_results])
 
diff --git a/web/tests/functional/diff_local/test_diff_local.py b/web/tests/functional/diff_local/test_diff_local.py
index 1bef8cb4..67ab1ae7 100644
--- a/web/tests/functional/diff_local/test_diff_local.py
+++ b/web/tests/functional/diff_local/test_diff_local.py
@@ -20,7 +20,8 @@ import re
 import subprocess
 import unittest
 
-from libtest import env
+from libtest import env, codechecker
+from libtest.codechecker import get_diff_results
 
 
 class DiffLocal(unittest.TestCase):
@@ -35,16 +36,23 @@ class DiffLocal(unittest.TestCase):
 
         # Get the test configuration from the prepared int the test workspace.
         self._test_cfg = env.import_test_cfg(test_workspace)
-
+        self._codechecker_cfg = self._test_cfg['codechecker_cfg']
         # Get the test project configuration from the prepared test workspace.
         self._testproject_data = env.setup_test_proj_cfg(test_workspace)
         self.assertIsNotNone(self._testproject_data)
 
+        self._test_dir = os.path.join(test_workspace, 'test_files')
+        try:
+            os.makedirs(self._test_dir)
+        except os.error:
+            # Directory already exists.
+            pass
+
         # Get the CodeChecker cmd if needed for the tests.
         self._codechecker_cmd = env.codechecker_cmd()
 
-        self.base_reports = self._test_cfg['codechecker_cfg']['reportdir_base']
-        self.new_reports = self._test_cfg['codechecker_cfg']['reportdir_new']
+        self.base_reports = self._codechecker_cfg['reportdir_base']
+        self.new_reports = self._codechecker_cfg['reportdir_new']
 
     def test_resolved_json(self):
         """Get the resolved reports.
@@ -52,14 +60,8 @@ class DiffLocal(unittest.TestCase):
         core.CallAndMessage checker was disabled in the new run, those
         reports should be listed as resolved.
         """
-        unresolved_diff_cmd = [self._codechecker_cmd, 'cmd', 'diff',
-                               '-b', self.base_reports,
-                               '-n', self.new_reports,
-                               '--resolved', '-o', 'json']
-        print(unresolved_diff_cmd)
-        out_json = subprocess.check_output(
-            unresolved_diff_cmd, encoding="utf-8", errors="ignore")
-        resolved_results = json.loads(out_json)
+        resolved_results = get_diff_results(
+            [self.base_reports], [self.new_reports], '--resolved', 'json')
         print(resolved_results)
 
         for resolved in resolved_results:
@@ -71,18 +73,12 @@ class DiffLocal(unittest.TestCase):
         core.StackAddressEscape checker was enabled in the new run, those
         reports should be listed as new.
         """
-        new_diff_cmd = [self._codechecker_cmd, 'cmd', 'diff',
-                        '-b', self.base_reports,
-                        '-n', self.new_reports,
-                        '--new', '-o', 'json']
-        print(new_diff_cmd)
-        out_json = subprocess.check_output(
-            new_diff_cmd, encoding="utf-8", errors="ignore")
-        resolved_results = json.loads(out_json)
-        print(resolved_results)
+        new_results = get_diff_results(
+            [self.base_reports], [self.new_reports], '--new', 'json')
+        print(new_results)
 
-        for resolved in resolved_results:
-            self.assertEqual(resolved['checkerId'], "core.NullDereference")
+        for new_result in new_results:
+            self.assertEqual(new_result['checkerId'], "core.NullDereference")
 
     @unittest.skip("should return a valid empty json")
     def test_filter_severity_low_json(self):
@@ -91,37 +87,22 @@ class DiffLocal(unittest.TestCase):
         core.StackAddressEscape checker was enabled in the new run, those
         reports should be listed as new.
         """
-        low_severity_cmd = [self._codechecker_cmd, 'cmd', 'diff',
-                            '-b', self.base_reports,
-                            '-n', self.new_reports,
-                            '--severity', 'low',
-                            '--new', '-o', 'json']
-        print(low_severity_cmd)
-        out_json = subprocess.check_output(
-            low_severity_cmd, encoding="utf-8", errors="ignore")
-        print(out_json)
-        low_severity_res = json.loads(out_json)
-        self.assertEqual((len(low_severity_res)), 0)
+        low_severity_res = get_diff_results(
+            [self.base_reports], [self.new_reports], '--new', 'json',
+            ['--severity', 'low'])
         print(low_severity_res)
 
+        self.assertEqual((len(low_severity_res)), 0)
+
     def test_filter_severity_high_json(self):
         """Get the high severity new reports.
 
         core.StackAddressEscape checker (high severity) was enabled
         in the new run, those reports should be listed.
         """
-        high_severity_cmd = [self._codechecker_cmd, 'cmd', 'diff',
-                             '-b', self.base_reports,
-                             '-n', self.new_reports,
-                             '--severity', 'high',
-                             '--new', '-o', 'json']
-        print(high_severity_cmd)
-        try:
-            out_json = subprocess.check_output(
-                high_severity_cmd, encoding="utf-8", errors="ignore")
-        except subprocess.CalledProcessError as cerr:
-            print(cerr.output)
-        high_severity_res = json.loads(out_json)
+        high_severity_res = get_diff_results(
+            [self.base_reports], [self.new_reports], '--new', 'json',
+            ['--severity', 'high'])
         self.assertEqual((len(high_severity_res)), 4)
 
     def test_filter_severity_high_text(self):
@@ -130,36 +111,18 @@ class DiffLocal(unittest.TestCase):
         core.StackAddressEscape checker (high severity) was enabled
         in the new run, those reports should be listed.
         """
-        high_severity_cmd = [self._codechecker_cmd, 'cmd', 'diff',
-                             '-b', self.base_reports,
-                             '-n', self.new_reports,
-                             '--severity', 'high',
-                             '--new']
-        print(high_severity_cmd)
-        out = subprocess.check_output(
-            high_severity_cmd,
-            encoding="utf-8",
-            errors="ignore")
+        out = get_diff_results(
+            [self.base_reports], [self.new_reports], '--new', None,
+            ['--severity', 'high'])
         print(out)
         self.assertEqual(len(re.findall(r'\\[HIGH\\]', out)), 4)
         self.assertEqual(len(re.findall(r'\\[LOW\\]', out)), 0)
 
     def test_filter_severity_high_low_text(self):
         """Get the high and low severity unresolved reports."""
-        high_severity_cmd = [self._codechecker_cmd, 'cmd', 'diff',
-                             '-b', self.base_reports,
-                             '-n', self.new_reports,
-                             '--severity', 'high', 'low',
-                             '--unresolved']
-        print(high_severity_cmd)
-        try:
-            out = subprocess.check_output(
-                high_severity_cmd,
-                encoding="utf-8",
-                errors="ignore")
-        except subprocess.CalledProcessError as cerr:
-            print(cerr.stdout)
-            print(cerr.stderr)
+        out = get_diff_results(
+            [self.base_reports], [self.new_reports], '--unresolved', None,
+            ['--severity', 'high', 'low'])
         self.assertEqual(len(re.findall(r'\\[HIGH\\]', out)), 18)
         self.assertEqual(len(re.findall(r'\\[LOW\\]', out)), 6)
 
@@ -167,31 +130,20 @@ class DiffLocal(unittest.TestCase):
                    "available in the json output.")
     def test_filter_severity_high_low_json(self):
         """Get the high and low severity unresolved reports in json."""
-        high_severity_cmd = [self._codechecker_cmd, 'cmd', 'diff',
-                             '-b', self.base_reports,
-                             '-n', self.new_reports,
-                             '--severity', 'high', 'low',
-                             '--unresolved', '-o', 'json']
-        print(high_severity_cmd)
-        out_json = subprocess.check_output(
-            high_severity_cmd, encoding="utf-8", errors="ignore")
-        print(out_json)
-        high_low_unresolved_results = json.loads(out_json)
+        high_low_unresolved_results = get_diff_results(
+            [self.base_reports], [self.new_reports], '--unresolved', 'json',
+            ['--severity', 'high', 'low'])
         print(high_low_unresolved_results)
 
         # FIXME check json output for the returned severity levels.
 
     def test_multiple_dir(self):
         """ Get unresolved reports from muliple local directories. """
-        unresolved_diff_cmd = [self._codechecker_cmd, 'cmd', 'diff',
-                               '-b', self.base_reports, self.new_reports,
-                               '-n', self.new_reports, self.base_reports,
-                               '--unresolved', '-o', 'json']
-        print(unresolved_diff_cmd)
-        out_json = subprocess.check_output(
-            unresolved_diff_cmd, encoding="utf-8", errors="ignore")
-        print(out_json)
-        unresolved_results = json.loads(out_json)
+        unresolved_results = get_diff_results(
+            [self.base_reports, self.new_reports],
+            [self.new_reports, self.base_reports],
+            '--unresolved', 'json',
+            ['--severity', 'high', 'low'])
         self.assertNotEqual(len(unresolved_results), 0)
 
     def test_missing_source_file(self):
@@ -221,3 +173,83 @@ class DiffLocal(unittest.TestCase):
 
         # Rename the file back.
         os.rename(new_file_path, old_file_path)
+
+    def test_suppress_reports(self):
+        """
+        Check diff command when analysing the same source file which contains
+        source code comments.
+        """
+        cfg = dict(self._codechecker_cfg)
+        cfg['analyzers'] = ['clang-tidy']
+
+        makefile = f"all:\\n\\t$(CXX) -c main.cpp -Wno-all -Wno-extra " \\
+                   f"-o /dev/null\\n"
+        with open(os.path.join(self._test_dir, 'Makefile'), 'w',
+                  encoding="utf-8", errors="ignore") as f:
+            f.write(makefile)
+
+        project_info = {
+            "name": "suppress",
+            "clean_cmd": "",
+            "build_cmd": "make"
+        }
+        with open(os.path.join(self._test_dir, 'project_info.json'), 'w',
+                  encoding="utf-8", errors="ignore") as f:
+            json.dump(project_info, f)
+
+        # 1st phase.
+        content = """
+int main()
+{
+  sizeof(41);
+
+  sizeof(42);
+
+  sizeof(43);
+}"""
+
+        with open(os.path.join(self._test_dir, "main.cpp"), 'w',
+                  encoding="utf-8", errors="ignore") as f:
+            f.write(content)
+
+        report_dir_base = os.path.join(self._test_dir, "reports1")
+        cfg['reportdir'] = report_dir_base
+
+        codechecker.log_and_analyze(cfg, self._test_dir)
+
+        # 2nd phase.
+        content = """
+int main()
+{
+  // codechecker_intentional [all] This bug is suppressed in this change.
+  sizeof(41);
+
+  sizeof(42);
+
+  // codechecker_confirmed [all] This bug is a real bug
+  sizeof(44);
+
+  sizeof(45);
+}"""
+        with open(os.path.join(self._test_dir, "main.cpp"), 'w',
+                  encoding="utf-8", errors="ignore") as f:
+            f.write(content)
+
+        report_dir_new = os.path.join(self._test_dir, "reports2")
+        cfg['reportdir'] = report_dir_new
+
+        codechecker.log_and_analyze(cfg, self._test_dir)
+
+        # Run the diff command and check the results.
+        res = get_diff_results(
+            [report_dir_base], [report_dir_new], '--new', 'json')
+        print(res)
+        self.assertEqual(len(res), 2)
+
+        res = get_diff_results(
+            [report_dir_base], [report_dir_new], '--unresolved', 'json')
+        self.assertEqual(len(res), 1)
+
+        res = get_diff_results(
+            [report_dir_base], [report_dir_new], '--resolved', 'json')
+        self.assertEqual(len(res), 2)
diff --git a/web/tests/functional/diff_local_remote/test_diff_local_remote.py b/web/tests/functional/diff_local_remote/test_diff_local_remote.py
index a67a7097..7f17ec29 100644
--- a/web/tests/functional/diff_local_remote/test_diff_local_remote.py
+++ b/web/tests/functional/diff_local_remote/test_diff_local_remote.py
@@ -22,6 +22,7 @@ import subprocess
 import unittest
 
 from libtest import env
+from libtest.codechecker import get_diff_results
 
 
 class LocalRemote(unittest.TestCase):
@@ -64,25 +65,6 @@ class LocalRemote(unittest.TestCase):
 
         self._env = self._test_cfg['codechecker_cfg']['check_env']
 
-    def run_cmd(self, diff_cmd, r_env=None):
-        """ Runs the given command and returns the output. """
-        if not r_env:
-            r_env = self._env
-
-        print(diff_cmd)
-        try:
-            out = subprocess.check_output(diff_cmd,
-                                          env=r_env,
-                                          cwd=os.environ['TEST_WORKSPACE'],
-                                          encoding="utf-8",
-                                          errors="ignore")
-        except subprocess.CalledProcessError as e:
-            print(e.output)
-            raise e
-
-        print(out)
-        return out
-
     def get_local_remote_diff(self, extra_args=None):
         """Return the unresolved results comparing local to a remote.
 
@@ -93,58 +75,25 @@ class LocalRemote(unittest.TestCase):
                     arguments to the diff command.
                     Like filter arguments or to change output format.
         """
-        remote_run_name = self._run_names[0]
-
-        diff_cmd = [self._codechecker_cmd, "cmd", "diff",
-                    "--unresolved",
-                    "--url", self._url,
-                    "-b", self._local_reports,
-                    "-n", remote_run_name
-                    ]
-        if extra_args:
-            diff_cmd.extend(extra_args)
-
-        print(diff_cmd)
-        try:
-            out = subprocess.check_output(
-                diff_cmd,
-                env=self._env,
-                cwd=os.environ['TEST_WORKSPACE'],
-                encoding="utf-8",
-                errors="ignore")
-        except subprocess.CalledProcessError as cerr:
-            print(cerr.output)
+        if not extra_args:
+            extra_args = []
 
-        out = self.run_cmd(diff_cmd)
-        return out
+        return get_diff_results([self._local_reports], [self._run_names[0]],
+                                '--unresolved', None,
+                                ['--url', self._url, *extra_args])
 
     def test_local_to_remote_compare_count_new(self):
         """Count the new results with no filter in local compare mode."""
-        base_run_name = self._run_names[0]
-        diff_cmd = [self._codechecker_cmd, "cmd", "diff",
-                    "--new",
-                    "--url", self._url,
-                    "-b", self._local_reports,
-                    "-n", base_run_name
-                    ]
-
-        out = self.run_cmd(diff_cmd)
+        out = get_diff_results([self._local_reports], [self._run_names[0]],
+                               '--new', None, ["--url", self._url])
 
         count = len(re.findall(r'\\[core\\.NullDereference\\]', out))
-
         self.assertEqual(count, 4)
 
     def test_remote_to_local_compare_count_new(self):
         """Count the new results with no filter."""
-        base_run_name = self._run_names[0]
-        diff_cmd = [self._codechecker_cmd, "cmd", "diff",
-                    "--new",
-                    "--url", self._url,
-                    "-b", base_run_name,
-                    "-n", self._local_reports
-                    ]
-
-        out = self.run_cmd(diff_cmd)
+        out = get_diff_results([self._run_names[0]], [self._local_reports],
+                               '--new', None, ["--url", self._url])
 
         # 5 new core.CallAndMessage issues.
         # 1 is suppressed in code
@@ -158,16 +107,8 @@ class LocalRemote(unittest.TestCase):
 
     def test_local_compare_count_unres(self):
         """Count the unresolved results with no filter."""
-        base_run_name = self._run_names[0]
-        diff_cmd = [self._codechecker_cmd, "cmd", "diff",
-                    "--unresolved",
-                    "--url", self._url,
-                    "-b", self._local_reports,
-                    "-n", base_run_name
-                    ]
-
-        out = self.run_cmd(diff_cmd)
-
+        out = get_diff_results([self._local_reports], [self._run_names[0]],
+                               '--unresolved', None, ["--url", self._url])
         print(out)
 
         count = len(re.findall(r'\\[core\\.CallAndMessage\\]', out))
@@ -183,20 +124,8 @@ class LocalRemote(unittest.TestCase):
 
     def test_local_compare_count_unres_rgx(self):
         """Count the unresolved results with no filter and run name regex."""
-        base_run_name = self._run_names[0]
-
-        # Change test_files_blablabla to test_*_blablabla
-        base_run_name = base_run_name.replace('files', '*')
-
-        diff_cmd = [self._codechecker_cmd, "cmd", "diff",
-                    "--unresolved",
-                    "--url", self._url,
-                    "-b", self._local_reports,
-                    "-n", base_run_name
-                    ]
-
-        out = self.run_cmd(diff_cmd)
-
+        out = get_diff_results([self._local_reports], [self._run_names[0]],
+                               '--unresolved', None, ["--url", self._url])
         print(out)
 
         count = len(re.findall(r'\\[core\\.CallAndMessage\\]', out))
@@ -285,21 +214,12 @@ class LocalRemote(unittest.TestCase):
 
     def test_local_compare_res_html_output_unresolved(self):
         """Check that html files will be generated by using diff command."""
-        base_run_name = self._run_names[0]
-
         html_reports = os.path.join(self._local_reports, "html_reports")
 
-        diff_cmd = [self._codechecker_cmd, "cmd", "diff",
-                    "--unresolved",
-                    "--url", self._url,
-                    "-b", base_run_name,
-                    "-n", self._local_reports,
-                    "-o", "html",
-                    "-e", html_reports,
-                    "--verbose", "debug"
-                    ]
-
-        self.run_cmd(diff_cmd)
+        get_diff_results([self._run_names[0]], [self._local_reports],
+                         '--unresolved', 'html',
+                         ["--url", self._url, '-e', html_reports,
+                          "--verbose", "debug"])
 
         diff_res = json.loads(self.get_local_remote_diff(['-o', 'json']))
 
@@ -372,23 +292,18 @@ class LocalRemote(unittest.TestCase):
 
         Every report should be in the gerrit review json.
         """
-        base_run_name = self._run_names[0]
-
         export_dir = os.path.join(self._local_reports, "export_dir1")
 
         env = self._env.copy()
         env["CC_REPO_DIR"] = ''
         env["CC_CHANGED_FILES"] = ''
 
-        diff_cmd = [self._codechecker_cmd, "cmd", "diff",
-                    "--new",
-                    "--url", self._url,
-                    "-b", base_run_name,
-                    "-n", self._local_reports,
-                    "-o", "gerrit",
-                    "-e", export_dir]
+        get_diff_results(
+            [self._run_names[0]], [self._local_reports],
+            '--new', 'gerrit',
+            ["--url", self._url, "-e", export_dir],
+            env)
 
-        self.run_cmd(diff_cmd, env)
         gerrit_review_file = os.path.join(export_dir, 'gerrit_review.json')
         self.assertTrue(os.path.exists(gerrit_review_file))
 
@@ -427,20 +342,16 @@ class LocalRemote(unittest.TestCase):
         Only one output format was selected
         the gerrit review json should be printed to stdout.
         """
-        base_run_name = self._run_names[0]
-
         env = self._env.copy()
         env["CC_REPO_DIR"] = ''
         env["CC_CHANGED_FILES"] = ''
 
-        diff_cmd = [self._codechecker_cmd, "cmd", "diff",
-                    "--new",
-                    "--url", self._url,
-                    "-b", base_run_name,
-                    "-n", self._local_reports,
-                    "-o", "gerrit"]
+        review_data = get_diff_results(
+            [self._run_names[0]], [self._local_reports],
+            '--new', 'gerrit',
+            ["--url", self._url],
+            env)
 
-        review_data = self.run_cmd(diff_cmd, env)
         print(review_data)
         review_data = json.loads(review_data)
         lbls = review_data["labels"]
@@ -472,18 +383,8 @@ class LocalRemote(unittest.TestCase):
         Only the reports which belong to the changed files should
         be in the gerrit review json.
         """
-        base_run_name = self._run_names[0]
-
         export_dir = os.path.join(self._local_reports, "export_dir2")
 
-        diff_cmd = [self._codechecker_cmd, "cmd", "diff",
-                    "--unresolved",
-                    "--url", self._url,
-                    "-b", base_run_name,
-                    "-n", self._local_reports,
-                    "-o", "gerrit",
-                    "-e", export_dir]
-
         env = self._env.copy()
         env["CC_REPO_DIR"] = self._local_test_project
 
@@ -505,12 +406,19 @@ class LocalRemote(unittest.TestCase):
         env["CC_CHANGED_FILES"] = changed_file_path
 
         with self.assertRaises(subprocess.CalledProcessError):
-            out = self.run_cmd(diff_cmd)
+            out = get_diff_results([self._run_names[0]], [self._local_reports],
+                                   '--unresolved', 'gerrit',
+                                   ["--url", self._url, "-e", export_dir])
+
             self.assertIn("'CC_REPO_DIR'", out)
             self.assertIn("'CC_CHANGED_FILES'", out)
             self.assertIn("needs to be set", out)
 
-        self.run_cmd(diff_cmd, env)
+        get_diff_results([self._run_names[0]], [self._local_reports],
+                         '--unresolved', 'gerrit',
+                         ["--url", self._url, "-e", export_dir],
+                         env)
+
         gerrit_review_file = os.path.join(export_dir, 'gerrit_review.json')
         self.assertTrue(os.path.exists(gerrit_review_file))
 
@@ -538,22 +446,16 @@ class LocalRemote(unittest.TestCase):
 
     def test_diff_codeclimate_output(self):
         """ Test codeclimate output when using diff and set env vars. """
-        base_run_name = self._run_names[0]
-
         export_dir = os.path.join(self._local_reports, "export_dir")
 
-        diff_cmd = [self._codechecker_cmd, "cmd", "diff",
-                    "--unresolved",
-                    "--url", self._url,
-                    "-b", base_run_name,
-                    "-n", self._local_reports,
-                    "-o", "codeclimate",
-                    "-e", export_dir]
-
         env = self._env.copy()
         env["CC_REPO_DIR"] = self._local_test_project
 
-        self.run_cmd(diff_cmd, env)
+        get_diff_results([self._run_names[0]], [self._local_reports],
+                         '--unresolved', 'codeclimate',
+                         ["--url", self._url, "-e", export_dir],
+                         env)
+
         codeclimate_issues_file = os.path.join(export_dir,
                                                'codeclimate_issues.json')
         self.assertTrue(os.path.exists(codeclimate_issues_file))
@@ -591,19 +493,13 @@ class LocalRemote(unittest.TestCase):
 
     def test_diff_no_trim_codeclimate_output(self):
         """ Test codeclimate output when using diff and don't set env vars. """
-        base_run_name = self._run_names[0]
-
         export_dir_path = os.path.join(self._local_reports, "export_dir")
 
-        diff_cmd = [self._codechecker_cmd, "cmd", "diff",
-                    "--unresolved",
-                    "--url", self._url,
-                    "-b", base_run_name,
-                    "-n", self._local_reports,
-                    "-o", "codeclimate",
-                    "-e", export_dir_path]
+        get_diff_results([self._run_names[0]], [self._local_reports],
+                         '--unresolved', "codeclimate",
+                         ["-e", export_dir_path, "--url", self._url],
+                         self._env)
 
-        self.run_cmd(diff_cmd, self._env)
         issues_file_path = os.path.join(export_dir_path,
                                         'codeclimate_issues.json')
         self.assertTrue(os.path.exists(issues_file_path))
@@ -623,23 +519,18 @@ class LocalRemote(unittest.TestCase):
 
     def test_diff_multiple_output(self):
         """ Test multiple output type for diff command. """
-        base_run_name = self._run_names[0]
-
         export_dir = os.path.join(self._local_reports, "export_dir3")
 
         env = self._env.copy()
         env["CC_REPO_DIR"] = ''
         env["CC_CHANGED_FILES"] = ''
 
-        diff_cmd = [self._codechecker_cmd, "cmd", "diff",
-                    "--resolved",
-                    "--url", self._url,
-                    "-b", base_run_name,
-                    "-n", self._local_reports,
-                    "-o", "html", "gerrit", "plaintext",
-                    "-e", export_dir]
-
-        out = self.run_cmd(diff_cmd, env)
+        out = get_diff_results([self._run_names[0]], [self._local_reports],
+                               '--resolved', None,
+                               ["-o", "html", "gerrit", "plaintext",
+                                "-e", export_dir,
+                                "--url", self._url],
+                               env)
 
         # Check the plaintext output.
         count = len(re.findall(r'\\[core\\.NullDereference\\]', out))
@@ -657,12 +548,6 @@ class LocalRemote(unittest.TestCase):
 
     def test_diff_remote_local_resolved_same(self):
         """ Test for resolved reports on same list remotely and locally. """
-        diff_cmd = [self._codechecker_cmd, "cmd", "diff",
-                    "--resolved",
-                    "--url", self._url,
-                    "-b", self._run_names[0],
-                    "-n", self._remote_reports,
-                    "-o", "json"]
-
-        out = self.run_cmd(diff_cmd)
-        self.assertEqual(json.loads(out), [])
+        out = get_diff_results([self._run_names[0]], [self._remote_reports],
+                               '--resolved', 'json', ["--url", self._url])
+        self.assertEqual(out, [])
diff --git a/web/tests/functional/diff_remote/test_diff_remote.py b/web/tests/functional/diff_remote/test_diff_remote.py
index c04a94c0..ed34fd3f 100644
--- a/web/tests/functional/diff_remote/test_diff_remote.py
+++ b/web/tests/functional/diff_remote/test_diff_remote.py
@@ -12,11 +12,8 @@
 Test the compraison of two remote (in the database) runs.
 """
 
-
-import json
 import os
 import re
-import subprocess
 import unittest
 from datetime import datetime, timedelta
 
@@ -25,6 +22,7 @@ from codechecker_api.codeCheckerDBAccess_v6.ttypes import CompareData, \\
     RunSortMode, RunSortType, Severity
 
 from libtest import env
+from libtest.codechecker import get_diff_results
 from libtest.debug_printer import print_run_results
 from libtest.thrift_client_to_db import get_all_run_results
 
@@ -503,19 +501,8 @@ class DiffRemote(unittest.TestCase):
         # Change test_files_blablabla to test_*_blablabla
         new_run_name = new_run_name.replace('files', '*')
 
-        diff_cmd = [self._codechecker_cmd, "cmd", "diff",
-                    "--resolved",
-                    "--url", self._url,
-                    "-b", base_run_name,
-                    "-n", new_run_name
-                    ]
-        print(diff_cmd)
-        out = subprocess.check_output(
-            diff_cmd,
-            env=self._env,
-            cwd=os.environ['TEST_WORKSPACE'],
-            encoding="utf-8",
-            errors="ignore")
+        out = get_diff_results([base_run_name], [new_run_name], '--resolved',
+                               None, ["--url", self._url], self._env)
 
         # 4 disappeared core.CallAndMessage issues
         count = len(re.findall(r'\\[core\\.CallAndMessage\\]', out))
@@ -528,38 +515,29 @@ class DiffRemote(unittest.TestCase):
             'reports')
         run_name = self._test_runs[2].name
 
-        def diff_cmd(base_name, diff_type):
-            return [
-                self._codechecker_cmd, 'cmd', 'diff',
-                '--url', self._url,
-                '-o', 'json',
-                '-b', base_name,
-                '-n', report_dir,
-                diff_type]
-
-        out = subprocess.check_output(
-            diff_cmd(run_name + ':t1', '--new'))
-        self.assertEqual(len(json.loads(out)), 5)
+        out = get_diff_results([f'{run_name}:t1'], [report_dir],
+                               '--new', 'json', ["--url", self._url])
+        self.assertEqual(len(out), 5)
 
-        out = subprocess.check_output(diff_cmd(
-            run_name + ':t2', '--new'))
-        self.assertEqual(len(json.loads(out)), 0)
+        out = get_diff_results([f'{run_name}:t2'], [report_dir],
+                               '--new', 'json', ["--url", self._url])
+        self.assertEqual(len(out), 0)
 
-        out = subprocess.check_output(diff_cmd(
-            run_name + ':t1', '--unresolved'))
-        self.assertEqual(len(json.loads(out)), 26)
+        out = get_diff_results([f'{run_name}:t1'], [report_dir],
+                               '--unresolved', 'json', ["--url", self._url])
+        self.assertEqual(len(out), 26)
 
-        out = subprocess.check_output(diff_cmd(
-            run_name + ':t2', '--unresolved'))
-        self.assertEqual(len(json.loads(out)), 31)
+        out = get_diff_results([f'{run_name}:t2'], [report_dir],
+                               '--unresolved', 'json', ["--url", self._url])
+        self.assertEqual(len(out), 31)
 
-        out = subprocess.check_output(diff_cmd(
-            run_name + ':t1', '--resolved'))
-        self.assertEqual(len(json.loads(out)), 0)
+        out = get_diff_results([f'{run_name}:t1'], [report_dir],
+                               '--resolved', 'json', ["--url", self._url])
+        self.assertEqual(len(out), 0)
 
-        out = subprocess.check_output(diff_cmd(
-            run_name + ':t2', '--resolved'))
-        self.assertEqual(len(json.loads(out)), 0)
+        out = get_diff_results([f'{run_name}:t2'], [report_dir],
+                               '--resolved', 'json', ["--url", self._url])
+        self.assertEqual(len(out), 0)
 
     def test_max_compound_select(self):
         """Test the maximum number of compound select query."""
@@ -703,19 +681,9 @@ class DiffRemote(unittest.TestCase):
         base_run_name = self._test_runs[0].name
         new_run_name = self._test_runs[1].name
 
-        diff_cmd = [self._codechecker_cmd, "cmd", "diff",
-                    "-b", base_run_name, new_run_name,
-                    "-n", new_run_name, base_run_name,
-                    "--unresolved",
-                    "-o", "json",
-                    "--url", self._url]
-        print(diff_cmd)
-        out_json = subprocess.check_output(
-            diff_cmd,
-            env=self._env,
-            cwd=os.environ['TEST_WORKSPACE'],
-            encoding="utf-8",
-            errors="ignore")
-
-        unresolved_results = json.loads(out_json)
+        unresolved_results = \\
+            get_diff_results([base_run_name, new_run_name],
+                             [new_run_name, base_run_name],
+                             '--unresolved', 'json', ["--url", self._url])
+
         self.assertNotEqual(len(unresolved_results), 0)
diff --git a/web/tests/libtest/codechecker.py b/web/tests/libtest/codechecker.py
index 4e59a13c..b9eab5f8 100644
--- a/web/tests/libtest/codechecker.py
+++ b/web/tests/libtest/codechecker.py
@@ -82,6 +82,29 @@ def wait_for_postgres_shutdown(workspace):
             break
 
 
+def get_diff_results(basenames, newnames, diff_type, format_type=None,
+                     extra_args=None, cc_env=None):
+    """ Run diff command and get results in given format. """
+    diff_cmd = [env.codechecker_cmd(), 'cmd', 'diff', diff_type]
+
+    if format_type:
+        diff_cmd.extend(['-o', format_type])
+
+    diff_cmd.extend(['--basename'] + basenames)
+    diff_cmd.extend(['--newname'] + newnames)
+
+    if extra_args:
+        diff_cmd.extend(extra_args)
+
+    out = subprocess.check_output(
+        diff_cmd, encoding="utf-8", errors="ignore", env=cc_env)
+
+    if format_type == "json":
+        return json.loads(out)
+
+    return out
+
+
 def login(codechecker_cfg, test_project_path, username, password,
           protocol='http'):
     """
@@ -371,10 +394,11 @@ def log_and_analyze(codechecker_cfg, test_project_path, clean_project=True):
                '-b', "'" + build_cmd + "'",
                ]
 
+    analyzers = codechecker_cfg.get('analyzers', ['clangsa'])
     analyze_cmd = ['CodeChecker', 'analyze',
                    build_json,
                    '-o', codechecker_cfg['reportdir'],
-                   '--analyzers', 'clangsa'
+                   '--analyzers', *analyzers
                    ]
 
     suppress_file = codechecker_cfg.get('suppress_file')