From c0c77cb6ea0dce1b289a4df4d111e0c13dc0d7a3 Mon Sep 17 00:00:00 2001
From: Emanuele Aina <emanuele.aina@collabora.com>
Date: Fri, 5 Nov 2021 13:39:14 +0100
Subject: [PATCH] Use more structured reports

Rather than using plaintext error messages, use readable codes and
structured metadata for errors and updates to make them easier
to process.

This will be particularly useful for filtering: for instance we
preserve the branch information rather than muddling it in the
error message.

Signed-off-by: Emanuele Aina <emanuele.aina@collabora.com>
---
 .gitlab-ci.yml                       |   2 +
 bin/classes.py                       |  34 ++++
 bin/dashboard                        |  14 +-
 bin/packaging-check-invariants       | 237 +++++++++++++++++++--------
 bin/packaging-updates                |   7 +-
 bin/packaging-updates-upstream-linux |  51 +++---
 templates/index.html.jinja2          | 216 +++++++++++++++++++-----
 7 files changed, 429 insertions(+), 132 deletions(-)

diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 6853b9f..15f8794 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -266,6 +266,7 @@ packaging-updates:
   before_script:
     - apt update && apt install -y --no-install-recommends
         python3-debian
+        python3-gitlab
         python3-yaml
   script:
     - ./bin/packaging-updates
@@ -287,6 +288,7 @@ packaging-updates-upstream-linux:
         ca-certificates
         git
         python3-debian
+        python3-gitlab
         python3-yaml
   script:
     - ./bin/packaging-updates-upstream-linux
diff --git a/bin/classes.py b/bin/classes.py
index 8820c92..da10349 100644
--- a/bin/classes.py
+++ b/bin/classes.py
@@ -1,4 +1,5 @@
 import dataclasses
+import enum
 import typing
 
 import debian.debian_support
@@ -15,6 +16,39 @@ RENAMED = {
 }
 
 
+class Report(enum.Enum):
+    def _generate_next_value_(name, start, count, last_values):
+        return name.lower().replace("_", "-")
+
+    APT_PACKAGE_BINARIES_AMBIGUOUS = enum.auto()
+    APT_PACKAGE_BINARIES_MISSING = enum.auto()
+    APT_PACKAGE_MISSING = enum.auto()
+    APT_PACKAGE_MISSING_BUT_ON_OBS = enum.auto()
+    APT_PACKAGE_SOURCE_AMBIGUOUS = enum.auto()
+    APT_PACKAGE_SOURCE_MISSING = enum.auto()
+    APT_PACKAGE_SOURCE_VERSION_MISMATCH_BINARIES = enum.auto()
+    APT_PACKAGE_VERSION_MISMATCH_OBS = enum.auto()
+    GIT_BRANCH_COMPONENT_MISSING = enum.auto()
+    GIT_BRANCH_FOLDED_BUT_NOT_REMOVED = enum.auto()
+    GIT_BRANCH_HAS_AMBIGUOUS_TAGS = enum.auto()
+    GIT_BRANCH_LICENSING_REPORT_MISSING = enum.auto()
+    GIT_BRANCH_MISSING_BUT_ON_OBS = enum.auto()
+    GIT_BRANCH_NOT_POINTING_TO_TAGGED_COMMIT = enum.auto()
+    GIT_BRANCH_PIPELINE_FAILED = enum.auto()
+    GIT_CHANNEL_LAGGING = enum.auto()
+    GIT_PROJECT_MISSING = enum.auto()
+    GIT_UPSTREAM_BRANCH_DROPPED = enum.auto()
+    GIT_UPSTREAM_BRANCH_NOT_MERGED = enum.auto()
+    OBS_PACKAGE_AMBIGUOUS = enum.auto()
+    OBS_PACKAGE_BUILD_FAILED = enum.auto()
+    OBS_PACKAGE_MISSING_BUT_IN_GIT = enum.auto()
+    OBS_PACKAGE_MISSING_BUT_ON_APT = enum.auto()
+    OBS_PACKAGE_MISSING_BUT_PUBLISHED = enum.auto()
+    OBS_PACKAGE_VERSION_MISMATCH = enum.auto()
+    UPDATE_AVAILABLE = enum.auto()
+    UPDATE_AVAILABLE_MAINLINE = enum.auto()
+
+
 @dataclasses.dataclass
 class UpstreamPackage:
     name: str
diff --git a/bin/dashboard b/bin/dashboard
index bd49c6e..78a7f9c 100755
--- a/bin/dashboard
+++ b/bin/dashboard
@@ -11,12 +11,22 @@ import jinja2
 import yaml
 
 
+def count_reports(package, func):
+    reports = package.get("reports", [])
+    return sum(1 for r in reports if func(r))
+
+
 def preprocess_packaging_data(data):
     packages = data["packages"].values()
     summary = {
         "total_downstream_packages": sum(int("git" in p) for p in packages),
-        "total_errors_count": sum(len(p.get("errors", [])) for p in packages),
-        "total_updates_count": sum(len(p.get("updates", [])) for p in packages),
+        "total_errors_count": sum(
+            count_reports(p, lambda r: r["severity"] == "error") for p in packages
+        ),
+        "total_updates_count": sum(
+            count_reports(p, lambda r: r["kind"].startswith("update-available"))
+            for p in packages
+        ),
     }
     data["summary"] = summary
 
diff --git a/bin/packaging-check-invariants b/bin/packaging-check-invariants
index 0c4992d..8d126e4 100755
--- a/bin/packaging-check-invariants
+++ b/bin/packaging-check-invariants
@@ -5,7 +5,7 @@ import logging
 
 import debian.debian_support
 import yaml
-from classes import GitBranch
+from classes import GitBranch, Report
 
 BINARY_VERSION_IGNORELIST = {
     "cross-toolchain-base": "generates the binary version from the gcc sources",
@@ -107,10 +107,15 @@ class InvariantChecker:
         self.data = yaml.load(yamlfile, Loader=yaml.CSafeLoader)
         self.packages = {}
 
-    def error(self, package, msg, **kwargs):
-        logging.error(msg)
-        errors = self.packages.setdefault(package, {}).setdefault("errors", [])
-        errors.append(dict(msg=msg, **kwargs))
+    def error(self, package, kind, **kwargs):
+        logging.error(
+            "%s: %s: %s",
+            package,
+            kind.value,
+            ", ".join(f"{k}={v}" for k, v in kwargs.items()),
+        )
+        reports = self.packages.setdefault(package, {}).setdefault("reports", [])
+        reports.append(dict(kind=kind.value, severity="error", **kwargs))
 
     def check_source_tags(self):
         sources = self.data.sources
@@ -128,11 +133,11 @@ class InvariantChecker:
                 branch_addon_merged = branch_base in branch.descendant_branches
                 if branch_is_channel and branch_is_addon:
                     if branch_addon_merged:
-                        msg = f"Branch {package.git.path_with_namespace}:{branch.name} has been folded into {branch_base} but has not been removed"
                         self.error(
                             packagename,
-                            msg,
+                            Report.GIT_BRANCH_FOLDED_BUT_NOT_REMOVED,
                             branch=branch.name,
+                            folded_in=branch_base,
                         )
                     continue
                 tags = [
@@ -143,14 +148,15 @@ class InvariantChecker:
                 if not tags and branch.name in sources:
                     self.error(
                         packagename,
-                        f"Branch {package.git.path_with_namespace}:{branch.name} does not point to a tagged commit",
+                        Report.GIT_BRANCH_NOT_POINTING_TO_TAGGED_COMMIT,
                         branch=branch.name,
                     )
                 if len(tags) > 1:
                     self.error(
                         packagename,
-                        f'Branch {package.git.path_with_namespace}:{branch.name} has ambiguous version tags: {", ".join(sorted(tags))}',
+                        Report.GIT_BRANCH_HAS_AMBIGUOUS_TAGS,
                         branch=branch.name,
+                        tags=tags,
                     )
                 source = sources.get(branch.name)
                 if source:
@@ -166,8 +172,9 @@ class InvariantChecker:
                     ):
                         self.error(
                             packagename,
-                            f"Package {packagename} not found in the upstreams for {branch.name} but it is still in development channels: {', '.join(development_channels)}",
+                            Report.GIT_UPSTREAM_BRANCH_DROPPED,
                             branch=branch.name,
+                            development_channels=development_channels,
                         )
 
                     channels = set(
@@ -180,12 +187,13 @@ class InvariantChecker:
                     descendant_channels = set(
                         branch_split_addon(b)[0] for b in branch.descendant_branches
                     )
-                    unmerged = channels - descendant_channels
+                    unmerged = list(sorted(channels - descendant_channels))
                     if unmerged:
                         self.error(
                             packagename,
-                            f"Branch {package.git.path_with_namespace}:{branch.name} has not been merged into {', '.join(sorted(unmerged))}",
+                            Report.GIT_UPSTREAM_BRANCH_NOT_MERGED,
                             branch=branch.name,
+                            downstreams=unmerged,
                         )
 
     def check_obs_duplicates(self):
@@ -201,8 +209,11 @@ class InvariantChecker:
             for (releasename, section), obsprojectnames in count.items():
                 if len(obsprojectnames) == 1:
                     continue
-                msg = f"Package {obspackagename} is ambiguous on OBS for {releasename}: {', '.join(sorted(obsprojectnames))}"
-                self.error(obspackagename, msg, projects=obsprojectnames)
+                self.error(
+                    obspackagename,
+                    Report.OBS_PACKAGE_AMBIGUOUS,
+                    projects=obsprojectnames,
+                )
 
     def check_git_and_obs_versions(self):
         """Compare versions in git and OBS to ensure they match
@@ -214,45 +225,55 @@ class InvariantChecker:
             if not set(package).intersection({"git", "obs", "published"}):
                 continue
             if "published" not in package:
-                msg = f"Package {packagename} is not published anywhere in the APT repositories"
-                self.error(packagename, msg)
+                self.error(packagename, Report.APT_PACKAGE_MISSING)
                 continue
             if "obs" not in package:
-                pub = (
-                    f"{entry.source.source}:{entry.source.component}"
-                    for channel in package.published.values()
-                    for entry in channel
-                    if "source" in entry
-                )
-                msg = f"Package {packagename} is not on OBS anywhere but it is published on {', '.join(pub)}"
-                self.error(packagename, msg)
+                # reported elsewhere, see OBS_PACKAGE_MISSING_BUT_ON_APT
                 continue
             if "git" not in package:
                 obsprojectnames = list(package.obs.keys())
-                msg = f"Package {packagename} has no git repository"
-                self.error(packagename, msg, projects=obsprojectnames)
+                self.error(
+                    packagename, Report.GIT_PROJECT_MISSING, obsprojects=obsprojectnames
+                )
                 continue
 
             for branchname, branch in package.git.branches.items():
-                if branchname not in self.data.channels:
+                channel = self.data.channels.get(branchname)
+                if not channel:
                     continue
                 # when a branch does not point to a tagged commit we can't easily detect the version
                 if not branch.version:
                     continue
                 branch_version_without_epoch = branch.version.split(":", 1)[-1]
-                prefix = branch_to_obs_project(branchname)
-                projects = {
-                    name.rsplit(":", 1)[0]: p for name, p in package.obs.items()
-                }
-                project = projects.get(prefix)
+                component = branch.component
+                if not component:
+                    if channel.release < "v2021":
+                        # prior to v2021 the component was not tracked in git
+                        continue
+                    self.error(
+                        packagename,
+                        Report.GIT_BRANCH_COMPONENT_MISSING,
+                        branch=branchname,
+                    )
+                    continue
+                projectname = branch_to_obs_project(branchname, component)
+                project = package.obs.get(projectname)
                 if not project:
-                    msg = f"Branch {package.git.path_with_namespace}:{branchname} has no matching package on OBS"
-                    self.error(packagename, msg, branch=branchname)
+                    self.error(
+                        packagename,
+                        Report.OBS_PACKAGE_MISSING_BUT_IN_GIT,
+                        branch=branchname,
+                        obsproject=projectname,
+                    )
                     continue
                 if branch_version_without_epoch != project.version_without_epoch:
-                    msg = f"Branch {package.git.path_with_namespace}:{branchname} has version {branch_version_without_epoch} which does not match version {project.version_without_epoch} in {project.project} on OBS"
                     self.error(
-                        packagename, msg, branch=branchname, projects=[project.project]
+                        packagename,
+                        Report.OBS_PACKAGE_VERSION_MISMATCH,
+                        branch=branchname,
+                        gitversion=branch_version_without_epoch,
+                        obsversion=project.version_without_epoch,
+                        obsproject=project.project,
                     )
             for obspackage in package.obs.values():
                 branch, component = obs_project_to_branch(obspackage.project)
@@ -261,8 +282,12 @@ class InvariantChecker:
                     f"Checking that {obspackage.project}:{package.name} has a branch {branch} in git"
                 )
                 if branch not in package.git.branches:
-                    msg = f"Package {packagename} from {obspackage.project} has no {branch} branch in git"
-                    self.error(packagename, msg, projects=[obspackage.project])
+                    self.error(
+                        packagename,
+                        Report.GIT_BRANCH_MISSING_BUT_ON_OBS,
+                        branch=branch,
+                        obsproject=obspackage.project,
+                    )
 
                 logging.debug(
                     f"Checking publishing status of {package.name} branch {branch} in {component} component"
@@ -272,8 +297,11 @@ class InvariantChecker:
                     lambda p: "source" in p and p.source.component == component,
                 )
                 if not published:
-                    msg = f"Package {packagename} from {obspackage.project} has not been published in the APT source repositories"
-                    self.error(packagename, msg, projects=[obspackage.project])
+                    self.error(
+                        packagename,
+                        Report.APT_PACKAGE_MISSING_BUT_ON_OBS,
+                        obsproject=obspackage.project,
+                    )
                     continue
                 version = published.source.version
                 published_version_without_epoch = version.split(":", 1)[-1]
@@ -281,8 +309,15 @@ class InvariantChecker:
                     f"Package {package.name} {published_version_without_epoch} published on {branch}/{component}"
                 )
                 if published_version_without_epoch != obspackage.version_without_epoch:
-                    msg = f"Package {packagename} from {obspackage.project} has version {obspackage.version_without_epoch} but version {published_version_without_epoch} has been published in the APT repositories"
-                    self.error(packagename, msg, projects=[obspackage.project])
+                    self.error(
+                        packagename,
+                        Report.APT_PACKAGE_VERSION_MISMATCH_OBS,
+                        branch=branch,
+                        component=component,
+                        aptversion=published_version_without_epoch,
+                        obsversion=obspackage.version_without_epoch,
+                        obsproject=obspackage.project,
+                    )
 
     def check_published_packages(self):
         """Compare source and binary published versions to ensure they match
@@ -300,68 +335,110 @@ class InvariantChecker:
                 )
                 sources = [p["source"] for p in entry if "source" in p]
                 if len(sources) < 1:
-                    msg = f"Package {packagename} branch {branch} has no source published in the APT repositories"
-                    self.error(packagename, msg)
+                    self.error(
+                        packagename, Report.APT_PACKAGE_SOURCE_MISSING, branch=branch
+                    )
                     continue
                 if len(sources) > 1:
-                    msg = f"Package {packagename} branch {branch} has multiple sources published"
-                    self.error(packagename, msg)
+                    srcs = [
+                        {"component": s["component"], "version": s["version"]}
+                        for s in sources
+                    ]
+                    self.error(
+                        packagename,
+                        Report.APT_PACKAGE_SOURCE_AMBIGUOUS,
+                        branch=branch,
+                        sources=srcs,
+                    )
                     continue
-                source = max(sources, key=lambda s: debian.debian_support.Version(s["version"]))
+                source = max(
+                    sources, key=lambda s: debian.debian_support.Version(s["version"])
+                )
 
                 obsproject = branch_to_obs_project(branch, source.component)
                 logging.debug(
                     f"Checking if {packagename} branch {branch}/{source.component} is in {obsproject}"
                 )
                 if obsproject not in package.get("obs", {}):
-                    msg = f"Package {packagename} branch {branch}/{source.component} not found in {obsproject} on OBS"
-                    self.error(packagename, msg)
+                    self.error(
+                        packagename,
+                        Report.OBS_PACKAGE_MISSING_BUT_ON_APT,
+                        branch=branch,
+                        source=dict(
+                            component=source.component,
+                            version=source.version,
+                        ),
+                        obsproject=obsproject,
+                    )
 
                 logging.debug(
                     f"Checking binaries are only published for one component for {package.name} branch {branch}"
                 )
                 binaries = list(filter(lambda p: "binaries" in p, entry))
                 if len(binaries) < 1:
-                    msg = f"Package {packagename} branch {branch} has no binary published in the APT repositories"
-                    self.error(packagename, msg)
+                    self.error(
+                        packagename, Report.APT_PACKAGE_BINARIES_MISSING, branch=branch
+                    )
                     continue
                 if len(binaries) > 1:
-                    pub = (f"{binary.component}" for binary in binaries)
-                    msg = f"Package {packagename} branch {branch} has binaries published from multiple components: {', '.join(pub)}"
-                    self.error(packagename, msg)
+                    pub = [binary.component for binary in binaries]
+                    self.error(
+                        packagename,
+                        Report.APT_PACKAGE_BINARIES_AMBIGUOUS,
+                        branch=branch,
+                        components=pub,
+                    )
                     continue
 
                 logging.debug(
                     f"Checking version mismatch between source and binaries for {package.name} branch {branch}"
                 )
-                for pkg in binaries[0].binaries:
+                pkgs = binaries[0].binaries
+                component = binaries[0].component
+                for pkg in pkgs:
                     for name, pkg_entry in pkg.items():
                         for version, archs in pkg_entry.items():
                             if not source_binary_versions_match(
                                 source.version, version
                             ):
-                                msg = f"Package {packagename} branch {branch} version mismatch between source ({source.version}) and binary {name} ({version}, {', '.join(archs)})"
                                 ignore_reason = BINARY_VERSION_IGNORELIST.get(
                                     packagename
                                 )
                                 if ignore_reason:
-                                    logging.info(f"{msg}: ignoring, {ignore_reason}")
+                                    logging.info(
+                                        f"{packagename}: Mismatch version source {source.version} and binary {name} {version} ignoring, {ignore_reason}"
+                                    )
                                 else:
-                                    self.error(packagename, msg)
+                                    self.error(
+                                        packagename,
+                                        Report.APT_PACKAGE_SOURCE_VERSION_MISMATCH_BINARIES,
+                                        branch=branch,
+                                        source=dict(
+                                            version=source.version,
+                                            component=source.component,
+                                        ),
+                                        binary=dict(
+                                            component=component,
+                                            name=name,
+                                            version=version,
+                                            architectures=archs,
+                                        ),
+                                    )
 
     def check_pipelines(self):
         for obspackagename, package in self.data.packages.items():
             for branch in package.get("git", {}).get("branches", {}).values():
-                msg = f"Pipeline failed on {package.git.path_with_namespace}:{branch.name}"
                 ignore_reason = FAILED_PIPELINE_BRANCH_IGNORELIST.get(branch.name)
                 if ignore_reason:
-                    logging.info(f"{msg}: ignoring, {ignore_reason}")
+                    logging.info(
+                        f"{obspackagename}: Pipeline failed on {package.git.path_with_namespace}:{branch.name}, ignoring, {ignore_reason}"
+                    )
                     continue
                 if branch.get("pipeline", {}).get("status") != "failed":
                     continue
                 self.error(
                     obspackagename,
-                    msg,
+                    Report.GIT_BRANCH_PIPELINE_FAILED,
                     branch=branch.name,
                     pipeline_web_url=branch.pipeline.web_url,
                 )
@@ -374,20 +451,28 @@ class InvariantChecker:
         if not branches:
             return
         # coalesce things like `apertis/v2020` and `apertis/v2020-security` as `apertis/v2020`
-        channels = list({b.name.split("-", 1)[0]: b.version for b in branches}.items())
-        latest_channel, latest_version = channels.pop()
-        for channel, version in channels:
+        channels = list(
+            {b.name.split("-", 1)[0]: (b.name, b.version) for b in branches}.items()
+        )
+        latest_channel, (latest_branch, latest_version) = channels.pop()
+        for channel, (branch, version) in channels:
             release = channel.split("/", 1)[-1]
             # ignore things like base-files where each branch targets a different release
             if release in version:
                 continue
             if version != latest_version:
-                msg = f"Channel {package.git.path_with_namespace}:{channel} has version {version} which lags behind {latest_channel} with version {latest_version}"
                 ignore_reason = LAGGING_VERSION_BRANCH_IGNORELIST.get(channel)
                 if ignore_reason:
-                    logging.info(f"{msg}: ignoring, {ignore_reason}")
+                    logging.info(
+                        f"{package.name}: {channel} lags behind with {branch}:{version} compared to {latest_branch}:{latest_version}: ignoring, {ignore_reason}"
+                    )
                     continue
-                self.error(package.name, msg)
+                self.error(
+                    package.name,
+                    Report.GIT_CHANNEL_LAGGING,
+                    branch=branch,
+                    latest_branch=latest_branch,
+                )
 
     def catch_missing_updates(self):
         """Compare versions in branches and ensure they are all aligned.
@@ -426,9 +511,14 @@ class InvariantChecker:
                     for repository, result in repositories.items():
                         if result.code in ok_codes:
                             continue
-                        details = result.get("details", "")
-                        msg = f"Package {obspackagename} failed to build on {projectname}/{arch}/{repository}: <{result.code}> {details}"
-                        self.error(obspackagename, msg, projects=[projectname])
+                        self.error(
+                            obspackagename,
+                            Report.OBS_PACKAGE_BUILD_FAILED,
+                            obsproject=projectname,
+                            architecture=arch,
+                            repository=repository,
+                            result=dict(result),
+                        )
 
     def check_missing_license_report(self):
         for package in self.data.packages.values():
@@ -441,8 +531,11 @@ class InvariantChecker:
                     f"Checking if license report is found for {package.name} branch {branch.name}"
                 )
                 if branch.component == "target" and not branch.license_report:
-                    msg = f"Package {package.name} does not have a license report on branch {branch.name}"
-                    self.error(package.name, msg)
+                    self.error(
+                        package.name,
+                        Report.GIT_BRANCH_LICENSING_REPORT_MISSING,
+                        branch=branch.name,
+                    )
 
 
 if __name__ == "__main__":
diff --git a/bin/packaging-updates b/bin/packaging-updates
index 5d3ba63..5af7b42 100755
--- a/bin/packaging-updates
+++ b/bin/packaging-updates
@@ -8,6 +8,7 @@ import types
 
 import debian.debian_support
 import yaml
+from classes import Report
 
 
 def base_for_source(data, sourcename):
@@ -36,14 +37,16 @@ def compute_updates(data):
         msg += f" can be updated to {upstream['version']}"
         logging.info(msg)
         update = {
-            "branch": {"name": branch.name, "version": branch.version},
+            "kind": Report.UPDATE_AVAILABLE.value,
+            "severity": "info",
+            "branch": branch.name,
             "upstream": upstream,
         }
         if base:
             update["base"] = {"name": base.name, "version": base.version}
         p = ret["packages"].setdefault(package.name, {})
         p["git"] = {"path_with_namespace": package.git["path_with_namespace"]}
-        p.setdefault("updates", []).append(update)
+        p.setdefault("reports", []).append(update)
 
     for package in data["packages"].values():
         if "git" not in package:
diff --git a/bin/packaging-updates-upstream-linux b/bin/packaging-updates-upstream-linux
index ef092fc..4445c82 100755
--- a/bin/packaging-updates-upstream-linux
+++ b/bin/packaging-updates-upstream-linux
@@ -10,6 +10,7 @@ from collections import defaultdict
 
 import debian.debian_support
 import yaml
+from classes import Report
 
 
 def base_latest_version(entries, package):
@@ -48,13 +49,23 @@ def compute_linux_updates(data):
         logging.debug("No entry for linux found in the packaging data, skipping")
         return ret
 
-    def error(package, err):
-        name = package["name"]
+    def report(severity, packagename, kind, **kwargs):
+        logfunc = getattr(logging, severity)
+        logfunc(
+            "%s: %s: %s",
+            packagename,
+            kind.value,
+            ", ".join(f"{k}={v}" for k, v in kwargs.items()),
+        )
         packages = ret["packages"]
-        p = packages.setdefault(name, {})
-        logging.error(f"{name}: {err['branch']}: {err['msg']}")
-        errors = p.setdefault("errors", [])
-        errors.append(err)
+        reports = packages.setdefault(packagename, {}).setdefault("reports", [])
+        reports.append(dict(kind=kind.value, severity=severity, **kwargs))
+
+    def update(packagename, kind, **kwargs):
+        report("info", packagename, kind, **kwargs)
+
+    def error(packagename, kind, **kwargs):
+        report("error", packagename, kind, **kwargs)
 
     branch_versions = {}
     url = "https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git"
@@ -87,11 +98,12 @@ def compute_linux_updates(data):
             )
             downstream_version = debian.debian_support.Version(version)
             if downstream_version < debian_version:
-                err = {
-                    "branch": debian_base,
-                    "msg": f"Branch {debian_base}={debian_version} has not been merged into {branch}={version}",
-                }
-                error(package, err)
+                error(
+                    package["name"],
+                    Report.GIT_UPSTREAM_BRANCH_NOT_MERGED,
+                    branch=debian_base,
+                    downstreams=[branch],
+                )
             continue
 
         # Fetch the versions available upstream for the downstream tag (for e.g. 5.4) using
@@ -128,15 +140,16 @@ def compute_linux_updates(data):
         logging.debug(f"{branch}: Downstream has {version}, git has {latest_tag}")
         downstream_version = debian.debian_support.Version(version)
         if downstream_version < latest_tag:
-            err = {
-                "branch": branch,
-                "msg": f"Branch {branch}={version} lags behind upstream {latest_tag}",
-                "git": {
-                    "url": url,
-                    "tag": f"v{latest_tag}",
-                },
+            mainline = {
+                "url": f"{url}/tag/?h=v{latest_tag}",
+                "tag": f"v{latest_tag}",
             }
-            error(package, err)
+            update(
+                package["name"],
+                Report.UPDATE_AVAILABLE_MAINLINE,
+                branch=branch,
+                mainline=mainline,
+            )
 
     return ret
 
diff --git a/templates/index.html.jinja2 b/templates/index.html.jinja2
index 810c9f9..dcc82e2 100644
--- a/templates/index.html.jinja2
+++ b/templates/index.html.jinja2
@@ -1,5 +1,69 @@
 {% extends "base.html.jinja2" %}
 
+{%- macro badge_class(severity) -%}
+  {%- if severity == "error" -%}
+     danger
+  {%- else -%}
+    {{ severity }}
+  {%- endif -%}
+{%- endmacro -%}
+
+{%- macro branch_link(package, branchname, show_version=False, classes="") -%}
+  {%- if "git" in package -%}
+    <a class="{{classes}}" href="{{ package.git.web_url }}/-/commits/{{ branchname }}">{{ branchname }}</a>
+    {%- if show_version and branchname in package.git.branches and package.git.branches[branchname].version -%}
+       /<code>{{ package.git.branches[branchname].version }}</code>
+    {%- endif -%}
+  {%- else -%}
+  <span class="{{classes}}">{{ branchname }}</span>
+  {%- endif -%}
+{%- endmacro -%}
+
+{%- macro branch(package, branchname) -%}
+  <span class="branch">{{ branch_link(package, branchname, show_version=True) }}</span>
+{%- endmacro -%}
+
+{%- macro obsproject(package, project, version=None) -%}
+  <span class="obsproject">
+    <a href="{{ obs[project].web_url }}">{{ project }}</a>/
+    {%- if "obs" in package and project in package.obs -%}
+      <a href="{{ package.obs[project].web_url }}">
+        {{- package.name -}}
+        {%- if version -%}
+        /<code>{{ version }}</code>
+        {%- endif -%}
+      </a>
+    {%- else -%}
+      {{ package.name }}
+    {%- endif -%}
+  </span>
+{%- endmacro -%}
+
+{%- macro obsprojects(package, projects) -%}
+  <span class="obsprojects">
+    {%- for project in projects -%}
+      {{ obsproject(package, project) }}
+    {%- endfor -%}
+  </span>
+{%- endmacro -%}
+
+{%- macro aptsources(package, sources) -%}
+  <span class="aptsources">
+    {%- for source in sources -%}
+      {{ source.component }}/<code>{{ source.version }}</code>
+      {%- if not loop.last %}, {% endif -%}
+    {%- endfor -%}
+  </span>
+{%- endmacro -%}
+
+{%- macro pipeline(package, pipeline_web_url) -%}
+  {%- if pipeline_web_url -%}
+  <span class="pipeline">
+    <a href="{{ pipeline_web_url }}">pipeline 🚀</a>
+  </span>
+  {%- endif -%}
+{%- endmacro -%}
+
 {% block title %} Packages {% endblock %}
 
 {% block summary %}
@@ -36,9 +100,11 @@
 
 {% block content %}
   <div class="list-group">
-  {% for package in packages.values() %}
-  {% if package.errors or package.updates %}
-    <div id="pkg-{{package.name}}" class="list-group-item list-group-item-action flex-column align-items-start">
+  {% for package in packages.values() if package.reports or package.updates %}
+    <div
+      id="pkg-{{package.name}}"
+      data-package="{{package.name}}"
+      class="package list-group-item list-group-item-action flex-column align-items-start">
       <div class="d-flex w-100 justify-content-between">
         <h5 class="mb-1">
           <strong>
@@ -52,43 +118,119 @@
         </h5>
       </div>
 
-      {% for error in package.errors %}
-      <p class="mb-1">
-      <big><span class="badge badge-danger">error</span></big>
-
-      {% if error.branch %}
-      Branch <a href="{{ package.git.web_url }}/-/commits/{{ error.branch }}">{{ error.branch }}</a>
-        {% if package.git.branches[error.branch].version %}
-        (<code>{{ package.git.branches[error.branch].version }}</code>)
-        {%- endif %}:
-      {% endif %}
-
-      {% if error.projects %}
-      Projects
-      {% for project in error.projects %}
-      <a href="{{ obs[project].web_url }}">{{ project }}</a>/<a href="{{ package.obs[project].web_url }}">{{ package.name }}</a>
-      {%- if not loop.last %},{% endif %}
-      {%- endfor %}:
-      {% endif %}
-
-      {{ error.msg }}
-
-      {%- if error.pipeline_web_url -%}
-      : <a href="{{ error.pipeline_web_url }}">pipeline 🚀</a>
-      {% endif %}
-      </p>
-      {% endfor %}
+      {% for report in package.reports %}
+        {%- set report_channels = [] -%}
+        {%- if report.kind == "git-upstream-branch-not-merged" -%}
+          {%- for downstream in report.downstreams -%}
+            {{- report_channels.append(channels[downstream].base) or "" -}}
+          {%- endfor -%}
+        {%- elif report.kind == "update-available" -%}
+          {%- for channel in channels.values() if channel.source.distribution+"/"+channel.source.release == sources[report.upstream.source].base -%}
+            {{- report_channels.append(channel.base) or "" -}}
+          {%- endfor -%}
+        {%- elif report.branch in sources -%}
+          {%- for channel in channels.values() if channel.source.distribution+"/"+channel.source.release == sources[report.branch].base -%}
+            {{- report_channels.append(channel.base) or "" -}}
+          {%- endfor -%}
+        {%- elif report.branch in channels -%}
+          {{- report_channels.append(channels[report.branch].base) or "" -}}
+        {%- endif -%}
+        <p
+           data-report="{{ report.kind }}"
+           data-severity="{{ report.severity }}"
+           data-channels="{{- report_channels|sort|unique|join(" ") -}}"
+           class="mb-1 report">
+          <big>
+            <span class="report-severity badge badge-{{ badge_class(report.severity) }} border border-{{ badge_class(report.severity) }}">
+            {% if report.kind.startswith("update-") -%}
+              update
+            {%- else -%}
+              {{ report.severity }}
+            {%- endif %}
+            </span>
+          </big>
+          {% for channel in (report_channels + [report.branch])|select|sort|unique -%}
+          <big>
+            {{ branch_link(package, channel, classes="report-branch badge badge-light border border-secondary text-muted") }}
+          </big>
+          {%- endfor %}
+          {% for obsproject in (report.get("obsprojects") or [report.get("obsproject")]) if obsproject -%}
+          <big>
+            <a class="report-obsproject badge badge-light border border-secondary text-muted" href="{{obs[obsproject].web_url}}">{{ obsproject }}</a>
+          </big>
+          {%- endfor %}
 
-      {% for u in package.updates %}
-      <p class="mb-1"><big><span class="badge badge-info">update</span></big>
-      Branch {{u.branch.name}} can be updated from
-        <a href="{{ package.git.web_url }}/pipelines/new?ref={{ (u.base or u.branch).name|urlencode }}">
-          <code>{{u.branch.version}}</code>
-          to <code>{{u.upstream.version}}</code></a> from {{u.upstream.source}} {{u.upstream.component}}
-      </p>
+          {% if report.kind == "apt-package-missing" -%}
+          Not published on APT
+          {%- elif report.kind == "apt-package-missing-but-on-obs" -%}
+          Not published on APT, found on OBS: {{ obsprojects(package, report.obsprojects) }}
+          {%- elif report.kind == "apt-package-version-mismatch-obs" -%}
+          Mismatch between APT {{ report.component }}/<code>{{ report.aptversion}}</code> and OBS {{ obsproject(package, report.obsproject, report.obsversion ) }} ({{ branch(package, report.branch) }})
+          {%- elif report.kind == "apt-package-source-version-mismatch-binaries" -%}
+          Mismatch between APT source {{ aptsources(package, [report.source]) }} and binary
+          {{report.binary.component}}/{{report.binary.name}}/<code>{{report.binary.version}}</code>
+          on {{ report.binary.architectures|join(", ") }} ({{ branch(package, report.branch) }})
+          {%- elif report.kind == "apt-package-source-missing" -%}
+          Sources not published on APT ({{ branch(package, report.branch) }})
+          {%- elif report.kind == "apt-package-source-ambiguous" -%}
+          Ambiguous sources on APT: {{ aptsources(package, report.sources) }} ({{ branch(package, report.branch) }})
+          {%- elif report.kind == "apt-package-binaries-missing" -%}
+          Binaries not published on APT ({{ branch(package, report.branch) }})
+          {%- elif report.kind == "apt-package-binaries-ambiguous" -%}
+          Ambiguous binaries on APT: {{report.components|join(", ")}} ({{ branch(package, report.branch) }})
+          {%- elif report.kind == "git-channel-lagging" -%}
+          Branch {{ branch(package, report.branch) }} lags behind {{ branch(package, report.latest_branch) }}
+          {%- elif report.kind == "git-branch-folded-but-not-removed" -%}
+          Branch {{ branch(package, report.branch) }} folded in {{ branch(package, report.folded_in) }} but not removed
+          {%- elif report.kind == "git-branch-has-ambiguous-tags" -%}
+          Branch {{ branch(package, report.branch) }} has ambiguous tags: {{report.tags|join(", ")}}
+          {%- elif report.kind == "git-branch-missing-but-on-obs" -%}
+          Branch {{ report.branch }} missing but found on OBS: {{ obsproject(package, report.obsproject) }}
+          {%- elif report.kind == "git-branch-not-pointing-to-tagged-commit" -%}
+          Branch {{branch(package, report.branch)}} not pointing to a tagged commit
+          {%- elif report.kind == "git-project-missing" -%}
+          GitLab project missing but found on OBS: {{ obsprojects(package, report.obsprojects) }}
+          {%- elif report.kind == "git-upstream-branch-not-merged" -%}
+          Upstream {{branch(package, report.branch)}} not merged in
+          {% for downstream in report.downstreams -%}
+            {{branch(package, downstream)}}
+            {%- if not loop.last %}, {% endif -%}
+          {%- endfor -%}
+          {%- elif report.kind == "git-upstream-branch-dropped" -%}
+          Upstream branch {{branch(package, report.branch)}} dropped
+          {%- elif report.kind == "git-branch-pipeline-failed" -%}
+          Pipeline failed on {{ branch(package, report.branch) }}: {{ pipeline(package, report.pipeline_web_url) }}
+          {%- elif report.kind == "git-branch-licensing-report-missing" -%}
+          Missing licensing report on {{branch(package, report.branch)}}
+          {%- elif report.kind == "git-branch-component-missing" -%}
+          Missing component on {{branch(package, report.branch)}}
+          {%- elif report.kind == "obs-package-ambiguous" -%}
+          Ambiguous package on OBS: {{ obsprojects(package, report.projects) }}
+          {%- elif report.kind == "obs-package-missing-but-on-apt" -%}
+          Missing on OBS {{ obsproject(package, report.obsproject) }}, found on APT in {{ aptsources(package, [report.source]) }} ({{ branch(package, report.branch) }})
+          {%- elif report.kind == "obs-package-missing-but-in-git" -%}
+          Missing on OBS {{ obsproject(package, report.obsproject) }}, found on Git {{ branch(package, report.branch) }}
+          {%- elif report.kind == "obs-package-version-mismatch" -%}
+          Mismatch between OBS {{ obsproject(package, report.obsproject, report.obsversion ) }} and Git {{ branch(package, report.branch) }}
+          {%- elif report.kind == "obs-package-build-failed" -%}
+          Failed to build on OBS {{ obsproject(package, report.obsproject) }} {{report.repository}}/{{report.architecture}}:
+          &lt;<code class="build-result-code">{{report.result.code}}</code>&gt;
+            {%- if "details" in report.result -%}
+            <span class="build-result-details">{{ report.result.details }}</span>
+            {%- endif -%}
+          {%- elif report.kind == "update-available" -%}
+          Branch {{branch(package, report.branch)}} can be updated
+            <a href="{{ package.git.web_url }}/pipelines/new?ref={{ (report.base or report.branch).name|urlencode }}">
+              to <code>{{report.upstream.version}}</code> from {{report.upstream.source}} {{report.upstream.component}} 🚀
+            </a>
+          {%- elif report.kind == "update-available-mainline" -%}
+          Branch {{branch(package, report.branch)}} lags behind mainline <a href="{{report.mainline.url}}">{{report.mainline.tag}}</a>
+          {%- else -%}
+          Unknown report: {{ report }}
+          {%- endif %}
+        </p>
       {% endfor %}
     </div>
-  {% endif %}
   {% endfor %}
   </div>
 {% endblock %}
-- 
GitLab