gitlab CI: add a JUnit XML report for scan-build
authorPeter Hutterer <peter.hutterer@who-t.net>
Tue, 11 May 2021 04:26:27 +0000 (14:26 +1000)
committerPeter Hutterer <peter.hutterer@who-t.net>
Wed, 12 May 2021 03:31:42 +0000 (13:31 +1000)
Use a scan-build wrapper to generate plist files, then parse those into a
JUnit xml format. This makes the errors appear on the main MR page as opposed
to being hidden in the artifacts somewhere.

Signed-off-by: Peter Hutterer <peter.hutterer@who-t.net>
.gitlab-ci.yml
.gitlab-ci/ci.template
.gitlab-ci/scanbuild-plist-to-junit.py [new file with mode: 0755]
.gitlab-ci/scanbuild-wrapper.sh [new file with mode: 0755]

index b29a8ab..7665a01 100644 (file)
@@ -724,16 +724,16 @@ scan-build@fedora:34:
   extends:
     - .fedora-build@template
   variables:
-    NINJA_ARGS: scan-build
+    NINJA_ARGS: ''
     MESON_TEST_ARGS: ''
   before_script:
-    - dnf install -y clang-analyzer findutils
+    - dnf install -y clang-analyzer
   script:
     - .gitlab-ci/meson-build.sh
-    - test ! -d "$MESON_BUILDDIR"/meson-logs/scanbuild && exit 0
-    - test $(find "$MESON_BUILDDIR"/meson-logs/scanbuild -maxdepth 0 ! -empty -exec echo "not empty" \; | wc -l) -eq 0 && exit 0
-    - echo "Check scan-build results"
-    - /bin/false
+    - export SCANBUILD="$PWD/.gitlab-ci/scanbuild-wrapper.sh"
+    - ninja -C "$MESON_BUILDDIR" scan-build
+  after_script:
+    - .gitlab-ci/scanbuild-plist-to-junit.py "$MESON_BUILDDIR"/meson-logs/scanbuild/ > "$MESON_BUILDDIR"/junit-scan-build.xml
 
 # Below jobs are build option combinations. We only
 # run them on one image, they shouldn't fail on one distro
index 9970c02..8c2fe1d 100644 (file)
@@ -454,16 +454,16 @@ scan-build@{{distro.name}}:{{version}}:
   extends:
     - .{{distro.name}}-build@template
   variables:
-    NINJA_ARGS: scan-build
+    NINJA_ARGS: ''
     MESON_TEST_ARGS: ''
   before_script:
-    - dnf install -y clang-analyzer findutils
+    - dnf install -y clang-analyzer
   script:
     - .gitlab-ci/meson-build.sh
-    - test ! -d "$MESON_BUILDDIR"/meson-logs/scanbuild && exit 0
-    - test $(find "$MESON_BUILDDIR"/meson-logs/scanbuild -maxdepth 0 ! -empty -exec echo "not empty" \; | wc -l) -eq 0 && exit 0
-    - echo "Check scan-build results"
-    - /bin/false
+    - export SCANBUILD="$PWD/.gitlab-ci/scanbuild-wrapper.sh"
+    - ninja -C "$MESON_BUILDDIR" scan-build
+  after_script:
+    - .gitlab-ci/scanbuild-plist-to-junit.py "$MESON_BUILDDIR"/meson-logs/scanbuild/ > "$MESON_BUILDDIR"/junit-scan-build.xml
 
 # Below jobs are build option combinations. We only
 # run them on one image, they shouldn't fail on one distro
diff --git a/.gitlab-ci/scanbuild-plist-to-junit.py b/.gitlab-ci/scanbuild-plist-to-junit.py
new file mode 100755 (executable)
index 0000000..987148a
--- /dev/null
@@ -0,0 +1,131 @@
+#!/usr/bin/env python3
+#
+# SPDX-License-Identifier: MIT
+#
+# Usage:
+#   $ scanbuild-plist-to-junit.py /path/to/meson-logs/scanbuild/ > junit-report.xml
+#
+# Converts the plist output from scan-build into a JUnit-compatible XML.
+#
+# For use with meson, use a wrapper script with this content:
+#   scan-build -v --status-bugs -plist-html "$@"
+# then build with
+#  SCANBUILD="/abs/path/to/wrapper.sh" ninja -C builddir scan-build
+#
+# For file context, $PWD has to be the root source directory.
+#
+# Note that the XML format is tailored towards being useful in the gitlab
+# CI, the JUnit format supports more features.
+#
+# This file is formatted with Python Black
+
+import argparse
+import plistlib
+import re
+import sys
+from pathlib import Path
+
+errors = []
+
+
+class Error(object):
+    pass
+
+
+parser = argparse.ArgumentParser(
+    description="This tool convers scan-build's plist format to JUnit XML"
+)
+parser.add_argument(
+    "directory", help="Path to a scan-build output directory", type=Path
+)
+args = parser.parse_args()
+
+if not args.directory.exists():
+    print(f"Invalid directory: {args.directory}", file=sys.stderr)
+    sys.exit(1)
+
+# Meson places scan-build runs into a timestamped directory. To make it
+# easier to invoke this script, we just glob everything on the assumption
+# that there's only one scanbuild/$timestamp/ directory anyway.
+for file in Path(args.directory).glob("**/*.plist"):
+    with open(file, "rb") as fd:
+        plist = plistlib.load(fd, fmt=plistlib.FMT_XML)
+        try:
+            sources = plist["files"]
+            for elem in plist["diagnostics"]:
+                e = Error()
+                e.type = elem["type"]  # Human-readable error type
+                e.description = elem["description"]  # Longer description
+                e.func = elem["issue_context"]  # function name
+                e.lineno = elem["location"]["line"]
+                filename = sources[elem["location"]["file"]]
+                # Remove the ../../../ prefix from the file
+                e.file = re.sub(r"^(\.\./)*", "", filename)
+                errors.append(e)
+        except KeyError:
+            print(
+                "Failed to access plist content, incompatible format?", file=sys.stderr
+            )
+            sys.exit(1)
+
+
+# Add a few lines of context for each error that we can print in the xml
+# output. Note that e.lineno is 1-indexed.
+#
+# If one of the files fail, we stop doing this, we're probably in the wrong
+# directory.
+try:
+    current_file = None
+    lines = []
+    for e in sorted(errors, key=lambda x: x.file):
+        if current_file != e.file:
+            current_file = e.file
+            lines = open(current_file).readlines()
+
+        # e.lineno is 1-indexed, lineno is our 0-indexed line number
+        lineno = e.lineno - 1
+        start = max(0, lineno - 4)
+        end = min(len(lines), lineno + 5)  # end is exclusive
+        e.context = [
+            f"{'>' if line == e.lineno else ' '} {line}: {content}"
+            for line, content in zip(range(start + 1, end), lines[start:end])
+        ]
+except FileNotFoundError:
+    pass
+
+print('<?xml version="1.0" encoding="utf-8"?>')
+print("<testsuites>")
+if errors:
+    suites = sorted(set([s.type for s in errors]))
+    # Use a counter to ensure test names are unique, otherwise the CI
+    # display ignores duplicates.
+    counter = 0
+    for suite in suites:
+        errs = [e for e in errors if e.type == suite]
+        # Note: the grouping by suites doesn't actually do anything in gitlab. Oh well
+        print(f'<testsuite name="{suite}" failures="{len(errs)}" tests="{len(errs)}">')
+        for error in errs:
+            print(
+                f"""\
+<testcase name="{counter}. {error.type} - {error.file}:{error.lineno}" classname="{error.file}">
+<failure message="{error.description}">
+<![CDATA[
+In function {error.func}(),
+{error.description}
+
+{error.file}:{error.lineno}
+---
+{"".join(error.context)}
+]]>
+</failure>
+</testcase>"""
+            )
+            counter += 1
+        print("</testsuite>")
+else:
+    # In case of success, add one test case so that registers in the UI
+    # properly
+    print('<testsuite name="scanbuild" failures="0" tests="1">')
+    print('<testcase name="scanbuild" classname="scanbuild"/>')
+    print("</testsuite>")
+print("</testsuites>")
diff --git a/.gitlab-ci/scanbuild-wrapper.sh b/.gitlab-ci/scanbuild-wrapper.sh
new file mode 100755 (executable)
index 0000000..58243b0
--- /dev/null
@@ -0,0 +1,2 @@
+#!/bin/sh
+scan-build -v --status-bugs -plist-html "$@"