[Date Prev][Date Next] [Thread Prev][Thread Next] [Date Index] [Thread Index]

Bug#806740: tracker.debian.org: Please add AppStream hints panel



Hi!
I created a preliminary patch to solve this issue.
(See the attachment)

Cheers,
    Matthias

-- 
I welcome VSRE emails. See http://vsre.info/
From b89dc9c6980b60be139bf82da36c98ce29e9d3f1 Mon Sep 17 00:00:00 2001
From: Matthias Klumpp <matthias@tenstral.net>
Date: Fri, 6 May 2016 21:31:26 +0200
Subject: [PATCH] Aggregate and display AppStream issue hint stats

This is WIP.
---
 .../debian/migrations/0002_appstreamstats.py       |  24 +
 distro_tracker/vendor/debian/models.py             |  45 ++
 .../templates/debian/appstream-action-item.html    |  14 +
 .../debian/templates/debian/appstream-link.html    |  10 +
 distro_tracker/vendor/debian/tests.py              | 674 +++++++++++++++++++++
 distro_tracker/vendor/debian/tracker_panels.py     |  30 +
 distro_tracker/vendor/debian/tracker_tasks.py      | 174 ++++++
 7 files changed, 971 insertions(+)
 create mode 100644 distro_tracker/vendor/debian/migrations/0002_appstreamstats.py
 create mode 100644 distro_tracker/vendor/debian/templates/debian/appstream-action-item.html
 create mode 100644 distro_tracker/vendor/debian/templates/debian/appstream-link.html

diff --git a/distro_tracker/vendor/debian/migrations/0002_appstreamstats.py b/distro_tracker/vendor/debian/migrations/0002_appstreamstats.py
new file mode 100644
index 0000000..341c520
--- /dev/null
+++ b/distro_tracker/vendor/debian/migrations/0002_appstreamstats.py
@@ -0,0 +1,24 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import jsonfield.fields
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('core', '0007_keywords_descriptions'),
+        ('debian', '0001_initial'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='AppStreamStats',
+            fields=[
+                ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
+                ('stats', jsonfield.fields.JSONField(default=dict)),
+                ('package', models.OneToOneField(related_name='appstream_stats', to='core.PackageName')),
+            ],
+        ),
+    ]
diff --git a/distro_tracker/vendor/debian/models.py b/distro_tracker/vendor/debian/models.py
index 9287a41..3634026 100644
--- a/distro_tracker/vendor/debian/models.py
+++ b/distro_tracker/vendor/debian/models.py
@@ -89,6 +89,51 @@ def get_lintian_url(self, full=False):
 
 
 @python_2_unicode_compatible
+class AppStreamStats(models.Model):
+    """
+    Model for AppStream hint stats of packages.
+    """
+    package = models.OneToOneField(PackageName, related_name='appstream_stats')
+    stats = JSONField()
+
+    def __str__(self):
+        return 'AppStream hints for package {package}'.format(
+            package=self.package)
+
+    def get_appstream_url(self):
+        """
+        Returns the AppStream URL for the package matching the
+        :class:`AppStreamStats
+        <distro_tracker.vendor.debian.models.AppStreamHints>`.
+        """
+        package = get_or_none(SourcePackageName, pk=self.package.pk)
+        if not package:
+            return ''
+        maintainer_email = ''
+        if package.main_version:
+            maintainer = package.main_version.maintainer
+            if maintainer:
+                maintainer_email = maintainer.email
+        # Adapt the maintainer URL to the form expected by appstream.d.o
+        pkg_maintainer_email = re.sub(
+            r"""[àáèéëêòöøîìùñ~/\(\)" ']""",
+            '_',
+            maintainer_email)
+
+        if not package.main_version:
+            return ''
+
+        # TODO: What is the proper way to get (guess?) the archive-component vai the source-pkg here?
+        section = "main"
+
+        return (
+            'https://appstream.debian.org/sid/{section}/issues/index.html#{maintainer}'.format(
+                section=section,
+                maintainer=pkg_maintainer_email)
+        )
+
+
+@python_2_unicode_compatible
 class PackageTransition(models.Model):
     package = models.ForeignKey(PackageName, related_name='package_transitions')
     transition_name = models.CharField(max_length=50)
diff --git a/distro_tracker/vendor/debian/templates/debian/appstream-action-item.html b/distro_tracker/vendor/debian/templates/debian/appstream-action-item.html
new file mode 100644
index 0000000..d9298ad
--- /dev/null
+++ b/distro_tracker/vendor/debian/templates/debian/appstream-action-item.html
@@ -0,0 +1,14 @@
+{% with warnings=item.extra_data.warnings %}
+{% with errors=item.extra_data.errors %}
+<a href="https://wiki.debian.org/AppStream";>AppStream</a> found
+<a href="{{ item.extra_data.appstream_url }}">
+{% if errors %}
+<span>{{ errors }} error{% if errors > 1 %}s{% endif %}</span>
+{% if warnings %}and{% endif %}
+{% endif %}
+{% if warnings %}
+<span>{{ warnings }} warning{% if warnings > 1 %}s{% endif %}</span>
+{% endif %}
+</a>
+for this package. You should get rid of them to provide more metadata about this software.
+{% endwith %}{% endwith %}
diff --git a/distro_tracker/vendor/debian/templates/debian/appstream-link.html b/distro_tracker/vendor/debian/templates/debian/appstream-link.html
new file mode 100644
index 0000000..5649bc8
--- /dev/null
+++ b/distro_tracker/vendor/debian/templates/debian/appstream-link.html
@@ -0,0 +1,10 @@
+{% with appstream_hints=item.context.appstream_hints %}
+<a href="{{ item.context.appstream_url }}" title="report about metadata issues spotted by AppStream">appstream</a>
+{% if appstream_hints.errors or appstream_hints.warnings %}
+{% with warnings=appstream_hints.warnings|default:"0" %}
+{% with errors=appstream_hints.errors|default:"0" %}
+<small>(<span title="errors">{{ errors }}</span>, <span title="warnings">{{ warnings }}</span>)</small>
+{% endwith %}
+{% endwith %}
+{% endif %}
+{% endwith %}
diff --git a/distro_tracker/vendor/debian/tests.py b/distro_tracker/vendor/debian/tests.py
index 538debf..d8f826f 100644
--- a/distro_tracker/vendor/debian/tests.py
+++ b/distro_tracker/vendor/debian/tests.py
@@ -41,6 +41,7 @@
 from distro_tracker.core.models import SourcePackage
 from distro_tracker.core.models import PseudoPackageName
 from distro_tracker.core.models import SourcePackageName
+from distro_tracker.core.models import BinaryPackageName
 from distro_tracker.core.models import Repository
 from distro_tracker.core.tasks import run_task
 from distro_tracker.core.retrieve_data import UpdateRepositoriesTask
@@ -76,6 +77,8 @@
 from distro_tracker.vendor.debian.models import UbuntuPackage
 from distro_tracker.vendor.debian.tracker_tasks import UpdateLintianStatsTask
 from distro_tracker.vendor.debian.models import LintianStats
+from distro_tracker.vendor.debian.tracker_tasks import UpdateAppStreamStatsTask
+from distro_tracker.vendor.debian.models import AppStreamStats
 from distro_tracker.vendor.debian.management.commands\
     .tracker_import_old_subscriber_dump \
     import Command as ImportOldSubscribersCommand
@@ -93,6 +96,7 @@
 import yaml
 import json
 import logging
+import zlib
 
 logging.disable(logging.CRITICAL)
 
@@ -1404,6 +1408,676 @@ def test_update_does_not_affect_other_item_types(self, mock_requests):
         self.assertEqual(2, self.package_name.action_items.count())
 
 
+class UpdateAppStreamStatsTaskTest(TestCase):
+
+    """
+    Tests for the
+    :class:`distro_tracker.vendor.debian.tracker_tasks.UpdateAppStreamStatsTask`
+    task.
+    """
+
+    def setUp(self):
+        self.package_name = SourcePackageName.objects.create(
+            name='dummy-package')
+        self.package = SourcePackage(
+            source_package_name=self.package_name, version='1.0.0')
+
+        self._tagdef_url = u'https://appstream.debian.org/hints/asgen-hints.json'
+        self._hints_url_template = u'https://appstream.debian.org/hints/sid/{section}/Hints-{arch}.json.gz'
+        self._tag_definitions = """{
+                "tag-mock-error": {
+                  "text": "Mocking an error tag.",
+                  "severity": "error"
+                },
+                "tag-mock-warning": {
+                  "text": "Mocking a warning tag.",
+                  "severity": "warning"
+                },
+                "tag-mock-info": {
+                  "text": "Mocking an info tag.",
+                  "severity": "info"
+                }
+            }"""
+
+    def run_task(self):
+        """
+        Runs the AppStream hints update task.
+        """
+        task = UpdateAppStreamStatsTask()
+        task.execute()
+
+    def _set_mock_response(self, mock_requests, text="", status_code=200):
+        """
+        Helper method which sets a mock response to the given mock requests
+        module.
+        """
+
+        mock_response = mock_requests.models.Response()
+        mock_response.status_code = status_code
+        mock_response.ok = status_code < 400
+
+        def compress_text(s):
+            """
+            Helper to GZip-compress a string.
+            """
+            compressor = zlib.compressobj(9,
+                                          zlib.DEFLATED,
+                                          16 + zlib.MAX_WBITS,
+                                          zlib.DEF_MEM_LEVEL,
+                                          0)
+            data = compressor.compress(s)
+            data += compressor.flush()
+            return data
+
+        def build_response(*args, **kwargs):
+            if args[0] == self._tagdef_url:
+                # the tag definitions are requested
+                mock_response.content = self._tag_definitions.encode('utf-8')
+                mock_response.json.return_value = json.loads(self._tag_definitions)
+            elif args[0] == self._hints_url_template.format(section='main', arch='amd64'):
+                # hint data was requested
+                data = compress_text(text)
+                mock_response.text = data
+                mock_response.content = data
+            else:
+                # return a compressed, but empty hints document as default
+                data = compress_text('[]')
+                mock_response.text = data
+                mock_response.content = data
+
+            return mock_response
+
+        mock_requests.get.side_effect = build_response
+
+    def get_action_item_type(self):
+        return ActionItemType.objects.get_or_create(
+            type_name=UpdateAppStreamStatsTask.ACTION_ITEM_TYPE_NAME)[0]
+
+    def assert_correct_severity_stats(self, hints, expected_hints):
+        """
+        Helper method which asserts that the given hint stats match the expected
+        stats.
+        """
+        for severity in ['errors', 'warnings', 'infos']:
+            count = hints[severity] if severity in hints else 0
+            expected_count = expected_hints[severity] if severity in expected_hints else 0
+            self.assertEqual(count, expected_count)
+
+    def assert_action_item_error_and_warning_count(self, item, errors=0, warnings=0):
+        """
+        Helper method which checks if an instance of
+        :class:`distro_tracker.core.ActionItem` contains the given error and
+        warning count in its extra_data.
+        """
+        self.assertEqual(item.extra_data['errors'], errors)
+        self.assertEqual(item.extra_data['warnings'], warnings)
+
+    @mock.patch('distro_tracker.core.utils.http.requests')
+    def test_hint_stats_created(self, mock_requests):
+        """
+        Tests that stats are created for a package that previously did not have
+        any AppStream stats.
+        """
+
+        testdata = """[{
+          "package": "dummy-package\/1.0\/amd64",
+          "hints": {
+            "org.example.test1.desktop": [
+              {
+                  "vars": {
+                      "icon_fname": "dummy.xpm"
+                  },
+                  "tag": "tag-mock-error"
+              }
+            ],
+            "org.example.test2.desktop": [
+              {
+                  "vars": {
+                      "icon_fname": "dummy.xpm"
+                  },
+                  "tag": "tag-mock-error"
+              },
+              {
+                  "vars": { },
+                  "tag": "tag-mock-warning"
+              }
+            ]
+          }
+        }]"""
+
+        self._set_mock_response(mock_requests, text=testdata)
+
+        self.run_task()
+
+        # The stats have been created
+        self.assertEqual(1, AppStreamStats.objects.count())
+        # They are associated with the correct package.
+        stats = AppStreamStats.objects.all()[0]
+        self.assertEqual(stats.package.name, 'dummy-package')
+        # The category counts themselves are correct
+        self.assert_correct_severity_stats(stats.stats, {'errors': 2, 'warnings': 1, 'infos': 0})
+
+    @mock.patch('distro_tracker.core.utils.http.requests')
+    def test_hint_stats_updated(self, mock_requests):
+        """
+        Tests that when a package already had associated AppStream stats, they are
+        correctly updated after running the task.
+        """
+
+        # Create the pre-existing stats for the package
+        AppStreamStats.objects.create(
+            package=self.package_name, stats={'errors': 1, 'warnings': 3})
+
+        as_hints_data = """[{
+          "package": "dummy-package\/1.0\/amd64",
+          "hints": {
+            "org.example.test.desktop": [
+              {
+                  "vars": {},
+                  "tag": "tag-mock-error"
+              },
+              {
+                  "vars": {},
+                  "tag": "tag-mock-error"
+              }
+            ]
+          }
+        }]"""
+
+        self._set_mock_response(mock_requests, text=as_hints_data)
+
+        self.run_task()
+
+        # Still only one AppStream stats object
+        self.assertEqual(1, AppStreamStats.objects.count())
+        # The package is still correct
+        stats = AppStreamStats.objects.all()[0]
+        self.assertEqual(stats.package.name, 'dummy-package')
+        # The stats have been updated
+        self.assert_correct_severity_stats(stats.stats, {'errors': 2, 'warnings': 0, 'infos': 0})
+
+    @mock.patch('distro_tracker.core.utils.http.requests')
+    def test_stats_created_multiple_packages(self, mock_requests):
+        """
+        Tests that stats are correctly creatd when there are stats for
+        multiple packages in the response.
+        """
+        # Create a second package.
+        SourcePackageName.objects.create(name='other-package')
+
+        as_hints_data = """[
+        {
+          "package": "dummy-package\/1.0\/amd64",
+          "hints": {
+            "org.example.test1.desktop": [
+              {
+                  "vars": {},
+                  "tag": "tag-mock-error"
+              },
+              {
+                  "vars": {},
+                  "tag": "tag-mock-error"
+              }
+            ]
+          }
+        },
+        {
+          "package": "other-package\/1.2\/amd64",
+          "hints": {
+            "org.example.test2.desktop": [
+              {
+                  "vars": {},
+                  "tag": "tag-mock-error"
+              },
+              {
+                  "vars": {},
+                  "tag": "tag-mock-warning"
+              }
+            ]
+          }
+        }
+        ]"""
+
+        self._set_mock_response(mock_requests, text=as_hints_data)
+        self.run_task()
+
+        # Stats created for both packages
+        self.assertEqual(2, AppStreamStats.objects.count())
+        all_names = [stats.package.name
+                     for stats in AppStreamStats.objects.all()]
+        self.assertIn('dummy-package', all_names)
+        self.assertIn('other-package', all_names)
+
+    @mock.patch('distro_tracker.core.utils.http.requests')
+    def test_stats_associated_with_source(self, mock_requests):
+        """
+        Tests that we correctly map the binary packages to source packages,
+        and the stats are accurate.
+        """
+
+        # Create source packages and connected binary packages
+        bin1 = BinaryPackageName.objects.create(name="alpha-package-bin")
+        bin2 = BinaryPackageName.objects.create(name="alpha-package-data")
+
+        src_name1 = SourcePackageName.objects.create(name='alpha-package')
+        src_pkg1, _ = SourcePackage.objects.get_or_create(
+            source_package_name=src_name1, version='1.0.0')
+        src_pkg1.binary_packages = [bin1, bin2]
+        src_pkg1.save()
+
+        bin3 = BinaryPackageName.objects.create(name="beta-common")
+        src_name2 = SourcePackageName.objects.create(name='beta-package')
+        src_pkg2, _ = SourcePackage.objects.get_or_create(
+            source_package_name=src_name2, version='1.2.0')
+        src_pkg2.binary_packages = [bin3]
+        src_pkg2.save()
+
+        # Set mock data
+        as_hints_data = """[
+        {
+          "package": "alpha-package-bin\/1.0\/amd64",
+          "hints": {
+            "org.example.AlphaTest1.desktop": [
+              { "tag": "tag-mock-error", "vars": {} },
+              { "tag": "tag-mock-warning", "vars": {} }
+            ]
+          }
+        },
+        {
+          "package": "alpha-package-data\/1.0\/amd64",
+          "hints": {
+            "org.example.AlphaTest2.desktop": [
+              { "tag": "tag-mock-warning", "vars": {} }
+            ]
+          }
+        },
+        {
+          "package": "beta-common\/1.2\/amd64",
+          "hints": {
+            "org.example.BetaTest1.desktop": [
+              { "tag": "tag-mock-error", "vars": {} },
+              { "tag": "tag-mock-error", "vars": {} }
+            ]
+          }
+        }
+        ]"""
+
+        self._set_mock_response(mock_requests, text=as_hints_data)
+        self.run_task()
+
+        # Stats created for two source packages
+        self.assertEqual(2, AppStreamStats.objects.count())
+        all_names = [stats.package.name
+                     for stats in AppStreamStats.objects.all()]
+
+        # source packages should be in the result
+        self.assertIn('alpha-package', all_names)
+        self.assertIn('beta-package', all_names)
+
+        # binary packages should not be there
+        self.assertNotIn('alpha-package-bin', all_names)
+        self.assertNotIn('alpha-package-data', all_names)
+        self.assertNotIn('beta-common', all_names)
+
+        # check if the stats are correct
+        stats = AppStreamStats.objects.get(package__name='alpha-package')
+        self.assert_correct_severity_stats(stats.stats, {'errors': 1, 'warnings': 2, 'infos': 0})
+
+        stats = AppStreamStats.objects.get(package__name='beta-package')
+        self.assert_correct_severity_stats(stats.stats, {'errors': 2, 'warnings': 0, 'infos': 0})
+
+    @mock.patch('distro_tracker.core.utils.http.requests')
+    def test_unknown_package(self, mock_requests):
+        """
+        Tests that when an unknown package is encountered, no stats are created.
+        """
+
+        as_hints_data = """[{
+          "package": "nonexistant\/1.0\/amd64",
+          "hints": {
+            "org.example.test.desktop": [
+              {
+                  "vars": {},
+                  "tag": "tag-mock-error"
+              }
+            ]
+          }
+        }]"""
+
+        self._set_mock_response(mock_requests, text=as_hints_data)
+        self.run_task()
+
+        # There are no stats
+        self.assertEqual(0, AppStreamStats.objects.count())
+
+    @mock.patch('distro_tracker.core.utils.http.requests')
+    def test_action_item_updated(self, mock_requests):
+        """
+        Tests that an existing action item is updated with new data.
+        """
+        # Create an existing action item
+        old_item = ActionItem.objects.create(
+            package=self.package_name,
+            item_type=self.get_action_item_type(),
+            short_description="Short description...",
+            extra_data={'errors': 1, 'warnings': 2})
+        old_timestamp = old_item.last_updated_timestamp
+
+        as_hints_data = """[{
+          "package": "dummy-package\/1.0\/amd64",
+          "hints": {
+            "org.example.test.desktop": [
+              {
+                  "vars": {},
+                  "tag": "tag-mock-error"
+              },
+              {
+                  "vars": {},
+                  "tag": "tag-mock-error"
+              }
+            ]
+          }
+        }]"""
+
+        self._set_mock_response(mock_requests, text=as_hints_data)
+
+        self.run_task()
+
+        # An action item is created.
+        self.assertEqual(1, ActionItem.objects.count())
+        # Extra data updated?
+        item = ActionItem.objects.all()[0]
+        self.assert_action_item_error_and_warning_count(item, 2, 0)
+
+        # The timestamp is updated
+        self.assertNotEqual(old_timestamp, item.last_updated_timestamp)
+
+    @mock.patch('distro_tracker.core.utils.http.requests')
+    def test_action_item_not_updated(self, mock_requests):
+        """
+        Tests that an existing action item is left unchanged when the update
+        shows unchanged stats.
+        """
+        errors, warnings = 2, 0
+        # Create an existing action item
+        old_item = ActionItem.objects.create(
+            package=self.package_name,
+            item_type=self.get_action_item_type(),
+            short_description="Short description...",
+            extra_data={'appstream_url': u'https://appstream.debian.org/sid/main/issues/index.html#',
+                        'errors': errors,
+                        'warnings': warnings})
+        old_timestamp = old_item.last_updated_timestamp
+
+        as_hints_data = """[{
+          "package": "dummy-package\/1.0\/amd64",
+          "hints": {
+            "org.example.test.desktop": [
+              {
+                  "vars": {},
+                  "tag": "tag-mock-error"
+              },
+              {
+                  "vars": {},
+                  "tag": "tag-mock-error"
+              }
+            ]
+          }
+        }]"""
+
+        self._set_mock_response(mock_requests, text=as_hints_data)
+        self.run_task()
+
+        # An action item is created.
+        self.assertEqual(1, ActionItem.objects.count())
+        # Item unchanged?
+        item = ActionItem.objects.all()[0]
+        self.assertEqual(old_timestamp, item.last_updated_timestamp)
+
+    @mock.patch('distro_tracker.core.utils.http.requests')
+    def test_action_item_created(self, mock_requests):
+        """
+        Tests that an action item is created when the package has errors and
+        warnings.
+        """
+
+        # Sanity check: there were no action items in the beginning
+        self.assertEqual(0, ActionItem.objects.count())
+
+        as_hints_data = """[{
+          "package": "dummy-package\/1.0\/amd64",
+          "hints": {
+            "org.example.test.desktop": [
+              {
+                  "vars": {},
+                  "tag": "tag-mock-error"
+              },
+              {
+                  "vars": {},
+                  "tag": "tag-mock-warning"
+              }
+            ]
+          }
+        }]"""
+
+        self._set_mock_response(mock_requests, text=as_hints_data)
+        self.run_task()
+
+        # An action item is created.
+        self.assertEqual(1, ActionItem.objects.count())
+        # The item is linked to the correct package
+        item = ActionItem.objects.all()[0]
+        self.assertEqual(item.package.name, self.package_name.name)
+        # The correct number of errors and warnings is stored in the item
+        self.assert_action_item_error_and_warning_count(item, errors=1, warnings=1)
+        # It is a high severity issue
+        self.assertEqual('high', item.get_severity_display())
+
+    @mock.patch('distro_tracker.core.utils.http.requests')
+    def test_action_item_not_created(self, mock_requests):
+        """
+        Tests that no action item is created when the package has no errors or
+        warnings.
+        """
+
+        # Sanity check: there were no action items in the beginning
+        self.assertEqual(0, ActionItem.objects.count())
+
+        as_hints_data = """[{
+          "package": "dummy-package\/1.0\/amd64",
+          "hints": {
+            "org.example.test.desktop": [
+              {
+                  "vars": {},
+                  "tag": "tag-mock-info"
+              }
+            ]
+          }
+        }]"""
+
+        self._set_mock_response(mock_requests, text=as_hints_data)
+        self.run_task()
+
+        # Still no action items.
+        self.assertEqual(0, ActionItem.objects.count())
+
+    @mock.patch('distro_tracker.core.utils.http.requests')
+    def test_action_item_created_errors(self, mock_requests):
+        """
+        Tests that an action item is created when the package has errors.
+        """
+
+        # Sanity check: there were no action items in the beginning
+        self.assertEqual(0, ActionItem.objects.count())
+
+        as_hints_data = """[{
+          "package": "dummy-package\/1.0\/amd64",
+          "hints": {
+            "org.example.test.desktop": [
+              { "tag": "tag-mock-error", "vars": {} },
+              { "tag": "tag-mock-error", "vars": {} }
+            ]
+          }
+        }]"""
+
+        self._set_mock_response(mock_requests, text=as_hints_data)
+        self.run_task()
+
+        # An action item is created.
+        self.assertEqual(1, ActionItem.objects.count())
+        # The correct number of errors and warnings is stored in the item
+        item = ActionItem.objects.all()[0]
+        self.assert_action_item_error_and_warning_count(item, errors=2, warnings=0)
+        # It has the correct type
+        self.assertEqual(
+            item.item_type.type_name,
+            UpdateAppStreamStatsTask.ACTION_ITEM_TYPE_NAME)
+        # It is a high severity issue
+        self.assertEqual('high', item.get_severity_display())
+        # Correct full description template
+        self.assertEqual(
+            item.full_description_template,
+            UpdateAppStreamStatsTask.ITEM_FULL_DESCRIPTION_TEMPLATE)
+
+    @mock.patch('distro_tracker.core.utils.http.requests')
+    def test_action_item_created_errors(self, mock_requests):
+        """
+        Tests that an action item is created when the package has warnings.
+        """
+
+        # Sanity check: there were no action items in the beginning
+        self.assertEqual(0, ActionItem.objects.count())
+
+        as_hints_data = """[{
+          "package": "dummy-package\/1.0\/amd64",
+          "hints": {
+            "org.example.test.desktop": [
+              { "tag": "tag-mock-warning", "vars": {} },
+              { "tag": "tag-mock-warning", "vars": {} }
+            ]
+          }
+        }]"""
+
+        self._set_mock_response(mock_requests, text=as_hints_data)
+        self.run_task()
+
+        # An action item is created.
+        self.assertEqual(1, ActionItem.objects.count())
+        # The correct number of errors and warnings is stored in the item
+        item = ActionItem.objects.all()[0]
+        self.assert_action_item_error_and_warning_count(item, errors=0, warnings=2)
+        # It should be a normal severity issue
+        self.assertEqual('normal', item.get_severity_display())
+
+    @mock.patch('distro_tracker.core.utils.http.requests')
+    def test_action_item_removed(self, mock_requests):
+        """
+        Tests that a previously existing action item is removed if the updated
+        hints no longer contain errors or warnings.
+        """
+        # Make sure an item exists for the package
+        ActionItem.objects.create(
+            package=self.package_name,
+            item_type=self.get_action_item_type(),
+            short_description="Short description...",
+            extra_data={'errors': 1, 'warnings': 2})
+
+        as_hints_data = """[{
+          "package": "dummy-package\/1.0\/amd64",
+          "hints": {
+            "org.example.test.desktop": [
+              { "tag": "tag-mock-info", "vars": {} }
+            ]
+          }
+        }]"""
+
+        self._set_mock_response(mock_requests, text=as_hints_data)
+        self.run_task()
+
+        # There are no action items any longer.
+        self.assertEqual(0, self.package_name.action_items.count())
+
+    @mock.patch('distro_tracker.core.utils.http.requests')
+    def test_action_item_removed_no_data(self, mock_requests):
+        """
+        Tests that a previously existing action item is removed when the
+        updated hints no longer contain any information for the package.
+        """
+        ActionItem.objects.create(
+            package=self.package_name,
+            item_type=self.get_action_item_type(),
+            short_description="Short description...",
+            extra_data={'errors': 1, 'warnings': 2})
+
+        as_hints_data = """[{
+          "package": "some-unrelated-package\/1.0\/amd64",
+          "hints": {
+            "org.example.test.desktop": [
+              { "tag": "tag-mock-error", "vars": {} }
+            ]
+          }
+        }]"""
+
+        self._set_mock_response(mock_requests, text=as_hints_data)
+        self.run_task()
+
+        # There are no action items any longer.
+        self.assertEqual(0, self.package_name.action_items.count())
+
+    @mock.patch('distro_tracker.core.utils.http.requests')
+    def test_action_item_created_multiple_packages(self, mock_requests):
+        """
+        Tests that action items are created correctly when there are stats
+        for multiple different packages in the response.
+        """
+
+        other_package = PackageName.objects.create(
+            name='other-package',
+            source=True)
+        # Sanity check: there were no action items in the beginning
+        self.assertEqual(0, ActionItem.objects.count())
+
+        as_hints_data = """[{
+          "package": "dummy-package\/1.0\/amd64",
+          "hints": {
+            "org.example.test1.desktop": [
+              { "tag": "tag-mock-error", "vars": {} },
+              { "tag": "tag-mock-error", "vars": {} }
+            ]
+          }
+        },
+        {
+          "package": "other-package\/1.4\/amd64",
+          "hints": {
+            "org.example.test2.desktop": [
+              { "tag": "tag-mock-warning", "vars": {} },
+              { "tag": "tag-mock-warning", "vars": {} }
+            ]
+          }
+        },
+        {
+          "package": "some-package\/1.0\/amd64",
+          "hints": {
+            "org.example.test3.desktop": [
+              { "tag": "tag-mock-error", "vars": {} }
+            ]
+          }
+        }]"""
+
+        self._set_mock_response(mock_requests, text=as_hints_data)
+        self.run_task()
+
+        # Action items are created for two packages.
+        self.assertEqual(1, self.package_name.action_items.count())
+        self.assertEqual(1, other_package.action_items.count())
+        # The items contain correct data.
+        item = self.package_name.action_items.all()[0]
+        self.assert_action_item_error_and_warning_count(item, errors=2, warnings=0)
+
+        item = other_package.action_items.all()[0]
+        self.assert_action_item_error_and_warning_count(item, errors=0, warnings=2)
+
+
 class DebianBugActionItemsTests(TestCase):
 
     """
diff --git a/distro_tracker/vendor/debian/tracker_panels.py b/distro_tracker/vendor/debian/tracker_panels.py
index c54c283..c835052 100644
--- a/distro_tracker/vendor/debian/tracker_panels.py
+++ b/distro_tracker/vendor/debian/tracker_panels.py
@@ -28,6 +28,7 @@
 from distro_tracker.vendor.debian.models import LintianStats
 from distro_tracker.vendor.debian.models import PackageExcuses
 from distro_tracker.vendor.debian.models import UbuntuPackage
+from distro_tracker.vendor.debian.models import AppStreamHints
 
 
 class LintianLink(LinksPanel.ItemProvider):
@@ -59,6 +60,35 @@ def get_panel_items(self):
         return []
 
 
+class AppStreamLink(LinksPanel.ItemProvider):
+    """
+    If there are any known AppStream hints for the package, provides a link to
+    the AppStream hints page.
+    """
+    def get_panel_items(self):
+        try:
+            appstream_hints = self.package.appstream_hints
+        except AppStreamHints.DoesNotExist:
+            return []
+
+        if sum(appstream_hints.hints.values()):
+            warnings, errors = (
+                appstream_hints.hints.get('warnings', 0),
+                appstream_hints.hints.get('errors', 0))
+            has_errors_or_warnings = warnings or errors
+            # Get the full URL only if the package does not have any errors or
+            # warnings
+            url = appstream_hints.get_appstream_url()
+            return [
+                TemplatePanelItem('debian/appstream-link.html', {
+                    'appstream_hints': appstream_hints.hints,
+                    'appstream_url': url,
+                })
+            ]
+
+        return []
+
+
 class BuildLogCheckLinks(LinksPanel.ItemProvider):
     def get_panel_items(self):
         if not isinstance(self.package, SourcePackageName):
diff --git a/distro_tracker/vendor/debian/tracker_tasks.py b/distro_tracker/vendor/debian/tracker_tasks.py
index b523890..e09a4f8 100644
--- a/distro_tracker/vendor/debian/tracker_tasks.py
+++ b/distro_tracker/vendor/debian/tracker_tasks.py
@@ -35,6 +35,7 @@
 from distro_tracker.vendor.debian.models import PackageTransition
 from distro_tracker.vendor.debian.models import PackageExcuses
 from distro_tracker.vendor.debian.models import UbuntuPackage
+from distro_tracker.vendor.debian.models import AppStreamStats
 from distro_tracker.core.utils.http import HttpCache
 from distro_tracker.core.utils.http import get_resource_content
 from distro_tracker.core.utils.packages import package_hashdir
@@ -45,6 +46,7 @@
 import os
 import re
 import json
+import zlib
 import hashlib
 import itertools
 
@@ -698,6 +700,178 @@ def execute(self):
         LintianStats.objects.bulk_create(stats)
 
 
+class UpdateAppStreamStatsTask(BaseTask):
+    """
+    Updates packages' AppStream issue hints data.
+    """
+    ACTION_ITEM_TYPE_NAME = 'appstream-issue-hints'
+    ITEM_DESCRIPTION = 'AppStream hints: <a href="{url}">{report}</a>'
+    ITEM_FULL_DESCRIPTION_TEMPLATE = 'debian/appstream-action-item.html'
+
+    def __init__(self, force_update=False, *args, **kwargs):
+        super(UpdateAppStreamStatsTask, self).__init__(*args, **kwargs)
+        self.force_update = force_update
+        self.appstream_action_item_type = ActionItemType.objects.create_or_update(
+            type_name=self.ACTION_ITEM_TYPE_NAME,
+            full_description_template=self.ITEM_FULL_DESCRIPTION_TEMPLATE)
+        self._tag_severities = {}
+
+    def set_parameters(self, parameters):
+        if 'force_update' in parameters:
+            self.force_update = parameters['force_update']
+
+    def _load_tag_severities(self):
+        url = 'https://appstream.debian.org/hints/asgen-hints.json'
+        cache = HttpCache(settings.DISTRO_TRACKER_CACHE_DIRECTORY)
+        response, updated = cache.update(url, force=True)
+        response.raise_for_status()
+
+        data = response.json()
+        for tag, info in data.items():
+            self._tag_severities[tag] = info['severity']
+
+    def _load_appstream_hint_stats(self, section, arch, all_stats={}):
+        url = 'https://appstream.debian.org/hints/sid/{section}/Hints-{arch}.json.gz'.format(section=section, arch=arch)
+        cache = HttpCache(settings.DISTRO_TRACKER_CACHE_DIRECTORY)
+        response, _ = cache.update(url, force=self.force_update)
+        response.raise_for_status()
+
+        jdata = zlib.decompress(response.content, 16 + zlib.MAX_WBITS)
+        hints = json.loads(jdata)
+        for hint in hints:
+            pkid = hint['package']
+            parts = pkid.split('/')
+            package_name = parts[0]
+
+            # get the source package for this binary package name
+            src_pkgname = None
+            if SourcePackageName.objects.exists_with_name(package_name):
+                package = SourcePackageName.objects.get(name=package_name)
+                src_pkgname = package.name
+            elif BinaryPackageName.objects.exists_with_name(package_name):
+                bin_package = BinaryPackageName.objects.get(name=package_name)
+                package = bin_package.main_source_package_name
+                src_pkgname = package.name
+            else:
+                src_pkgname = package_name
+
+            if not src_pkgname in all_stats:
+                all_stats[src_pkgname] = {}
+            for cid, h in hint['hints'].items():
+                for e in h:
+                    severity = self._tag_severities[e['tag']]
+                    sevkey = "errors"
+                    if severity == "warning":
+                        sevkey = "warnings"
+                    elif severity == "info":
+                        sevkey = "infos"
+                    if not sevkey in all_stats[src_pkgname]:
+                        all_stats[src_pkgname][sevkey] = 1
+                    else:
+                        all_stats[src_pkgname][sevkey] += 1
+
+        return all_stats
+
+    def update_action_item(self, package, as_stats):
+        """
+        Updates the :class:`ActionItem` for the given package based on the
+        :class:`AppStreamStats <distro_tracker.vendor.debian.models.AppStreamStats`
+        given in ``as_stats``. If the package has errors or warnings an
+        :class:`ActionItem` is created.
+        """
+        package_stats = as_stats.stats
+        stats_warnings = package_stats.get('warnings')
+        stats_errors = package_stats.get('errors', 0)
+        warnings, errors = (stats_warnings if stats_warnings else 0,
+                            stats_errors if stats_errors else 0)
+
+        # Get the old action item for this warning, if it exists.
+        appstream_action_item = package.get_action_item_for_type(
+            self.appstream_action_item_type.type_name)
+        if not warnings and not errors:
+            if appstream_action_item:
+                # If the item previously existed, delete it now since there
+                # are no longer any warnings/errors.
+                appstream_action_item.delete()
+            return
+
+        # The item didn't previously have an action item: create it now
+        if appstream_action_item is None:
+            appstream_action_item = ActionItem(
+                package=package,
+                item_type=self.appstream_action_item_type)
+
+        appstream_url = as_stats.get_appstream_url()
+        new_extra_data = {
+            'warnings': warnings,
+            'errors': errors,
+            'appstream_url': appstream_url,
+        }
+        if appstream_action_item.extra_data:
+            old_extra_data = appstream_action_item.extra_data
+            if (old_extra_data['warnings'] == warnings and
+                    old_extra_data['errors'] == errors):
+                # No need to update
+                return
+
+        appstream_action_item.extra_data = new_extra_data
+
+        if errors and warnings:
+            report = '{} error{} and {} warning{}'.format(
+                errors,
+                's' if errors > 1 else '',
+                warnings,
+                's' if warnings > 1 else '')
+        elif errors:
+            report = '{} error{}'.format(
+                errors,
+                's' if errors > 1 else '')
+        elif warnings:
+            report = '{} warning{}'.format(
+                warnings,
+                's' if warnings > 1 else '')
+
+        appstream_action_item.short_description = self.ITEM_DESCRIPTION.format(
+            url=appstream_url,
+            report=report)
+
+        # If there are errors make the item a high severity issue
+        if errors:
+            appstream_action_item.severity = ActionItem.SEVERITY_HIGH
+
+        appstream_action_item.save()
+
+    def execute(self):
+        self._load_tag_severities()
+        all_stats = {}
+        self._load_appstream_hint_stats("non-free", "amd64", all_stats)
+        self._load_appstream_hint_stats("contrib", "amd64", all_stats)
+        self._load_appstream_hint_stats("main", "amd64", all_stats)
+        if not all_stats:
+            return
+
+        # Discard all old hints
+        AppStreamStats.objects.all().delete()
+
+        packages = PackageName.objects.filter(name__in=all_stats.keys())
+        packages.prefetch_related('action_items')
+        # Remove action items for packages which no longer have associated
+        # AppStream hints.
+        ActionItem.objects.delete_obsolete_items(
+            [self.appstream_action_item_type], all_stats.keys())
+
+        stats = []
+        for package in packages:
+            package_stats = all_stats[package.name]
+            # Save the raw AppStream hints
+            as_stats = AppStreamStats(package=package, stats=package_stats)
+            stats.append(as_stats)
+            # Create an ActionItem if there are errors or warnings
+            self.update_action_item(package, as_stats)
+
+        AppStreamStats.objects.bulk_create(stats)
+
+
 class UpdateTransitionsTask(BaseTask):
     REJECT_LIST_URL = 'https://ftp-master.debian.org/transitions.yaml'
     PACKAGE_TRANSITION_LIST_URL = (

Reply to: