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 <matth...@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 = (