diff mbox series

[layerindex-web] layerindex: email maintainers who opt in when their layer fails

Message ID 20261114061038.18512-1-tim.orling@konsulko.com
State Under Review
Delegated to: Tim Orling
Headers show
Series [layerindex-web] layerindex: email maintainers who opt in when their layer fails | expand

Commit Message

Tim Orling Nov. 14, 2026, 6:10 a.m. UTC
Maintainers only found out that their layer failed to parse by looking
at its update history. Add "Email me when my layers fail to parse" to
the profile page, off by default. At the end of each update, email the
users who opted in and are an active maintainer of a layer branch that
has errors in this update but had none in its previous one, with a link
to the update's log. If more than half of the layer branches of a large
update newly fail, the problem is probably the update itself, so email
the site admins once instead of the maintainers.

[YOCTO #11196]

AI-Generated: Claude Opus 5.5
Signed-off-by: Tim Orling <tim.orling@konsulko.com>
---
This adds migration 0058, so it applies on top of "[PATCH 0/2] Let
maintainers change their email address safely" (bug 11195, migration
0057) and the 14348 series it builds on, and on master-next.

Applying the rule to the last two updates of a test instance loaded
with a copy of the layers.openembedded.org database: on 2026-11-13, 0
of 11 layer branches newly failed, so no emails; on 2026-11-14, 45 of
54 did, so one email to the admins and none to maintainers. That spike
came from "update: record errors in a layer's first parse against the
layer" recording dependency failures as layer updates for the first
time, which the admin rule is meant for.

 layerindex/forms.py                           |  3 +
 .../0058_userprofile_notify_parse_errors.py   | 16 ++++
 layerindex/models.py                          |  2 +
 layerindex/notify.py                          | 70 ++++++++++++++++
 layerindex/update.py                          |  3 +
 layerindex/views.py                           |  5 ++
 tests/test_parse_error_mail.py                | 82 +++++++++++++++++++
 tests/test_profile_email.py                   | 11 ++-
 8 files changed, 190 insertions(+), 2 deletions(-)
 create mode 100644 layerindex/migrations/0058_userprofile_notify_parse_errors.py
 create mode 100644 layerindex/notify.py
 create mode 100644 tests/test_parse_error_mail.py


base-commit: 08e58cdece30019a6f9e5d14af16ebe20afa6177
prerequisite-patch-id: 6c677e4190187d389840d2a94b4d857a69c0d647
prerequisite-patch-id: 282bc018dfe6528deb8363d3cbd1b31671c4b606
prerequisite-patch-id: 41ede4992addb0d7f364a079e773a77943b6341f
prerequisite-patch-id: 51420179b7abf0bd91736545eb4a692058cca8b4
diff mbox series

Patch

diff --git a/layerindex/forms.py b/layerindex/forms.py
index 7fcdbb0..bc9f212 100644
--- a/layerindex/forms.py
+++ b/layerindex/forms.py
@@ -211,6 +211,8 @@  class EditNoteForm(StyledModelForm):
 
 
 class EditProfileForm(StyledModelForm):
+    notify_parse_errors = forms.BooleanField(label='Email me when my layers fail to parse', required=False,
+                                             help_text='Sent when a layer branch you maintain starts to fail')
     current_password = forms.CharField(label='Current password', required=False, strip=False,
                                        widget=forms.PasswordInput(attrs={'autocomplete': 'current-password'}),
                                        help_text='Needed to change your email address')
@@ -233,6 +235,7 @@  class EditProfileForm(StyledModelForm):
                 'autocomplete': 'off'
             })
         user = kwargs.get("instance")
+        self.fields['notify_parse_errors'].initial = UserProfile.objects.filter(user=user, notify_parse_errors=True).exists()
         try:
             self.fields['security_question_1'].initial=user.userprofile.securityquestionanswer_set.all()[0].security_question
             self.fields['security_question_2'].initial=user.userprofile.securityquestionanswer_set.all()[1].security_question
diff --git a/layerindex/migrations/0058_userprofile_notify_parse_errors.py b/layerindex/migrations/0058_userprofile_notify_parse_errors.py
new file mode 100644
index 0000000..3739cb3
--- /dev/null
+++ b/layerindex/migrations/0058_userprofile_notify_parse_errors.py
@@ -0,0 +1,16 @@ 
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('layerindex', '0057_userprofile_previous_email'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='userprofile',
+            name='notify_parse_errors',
+            field=models.BooleanField(default=False, help_text='Sent when a layer branch you maintain starts to fail', verbose_name='Email me when my layers fail to parse'),
+        ),
+    ]
diff --git a/layerindex/models.py b/layerindex/models.py
index 883168c..4060adb 100644
--- a/layerindex/models.py
+++ b/layerindex/models.py
@@ -965,6 +965,8 @@  class UserProfile(models.Model):
     # The confirmed email address before a change of address that has not
     # been confirmed yet, to move the user's maintainer records once it is
     previous_email = models.CharField(max_length=255, blank=True)
+    notify_parse_errors = models.BooleanField('Email me when my layers fail to parse', default=False,
+                                              help_text='Sent when a layer branch you maintain starts to fail')
 
     def pending_maintainer_records(self):
         """Maintainer records still under the user's previous address"""
diff --git a/layerindex/notify.py b/layerindex/notify.py
new file mode 100644
index 0000000..37869bc
--- /dev/null
+++ b/layerindex/notify.py
@@ -0,0 +1,70 @@ 
+# Email notifications about layer updates
+#
+# Licensed under the MIT license, see COPYING.MIT for details
+#
+# SPDX-License-Identifier: MIT
+
+from django.conf import settings
+from django.contrib.sites.models import Site
+from django.core.mail import mail_admins, send_mail
+from django.urls import reverse
+
+from layerindex.models import LayerMaintainer, LayerUpdate, UserProfile
+
+# ponytail: fixed thresholds. If more than half of the layer branches in an
+# update of at least this many newly fail, something is probably wrong with
+# the update itself, not the layers.
+SYSTEMIC_MIN_LAYERBRANCHES = 10
+
+
+def newly_failed(update):
+    """Layer updates in this update with errors where the layer branch's
+    previous update had none"""
+    failed = []
+    for layerupdate in LayerUpdate.objects.filter(update=update, errors__gt=0).select_related('layer', 'branch'):
+        previous = LayerUpdate.objects.filter(layer=layerupdate.layer, branch=layerupdate.branch,
+                                              started__lt=layerupdate.started).order_by('-started').first()
+        if not (previous and previous.errors):
+            failed.append(layerupdate)
+    return failed
+
+
+def notify_parse_errors(update, logger):
+    """Email users who opted in when a layer branch they maintain starts to
+    fail, or the site admins instead if most layers did"""
+    failed = newly_failed(update)
+    if not failed:
+        return
+    site = Site.objects.get_current()
+
+    def url(layerupdate):
+        return 'https://%s%s' % (site.domain, reverse('layerupdate', args=(layerupdate.id,)))
+
+    total = LayerUpdate.objects.filter(update=update).count()
+    if total >= SYSTEMIC_MIN_LAYERBRANCHES and len(failed) * 2 > total:
+        logger.warning('%d of %d layer branches newly failed; telling the admins instead of the maintainers'
+                       % (len(failed), total))
+        mail_admins('Layer index update: %d of %d layer branches newly failed' % (len(failed), total),
+                    'This is probably a problem with the update rather than with the layers, so no\n'
+                    'maintainers were emailed. For example:\n\n%s\n'
+                    % '\n'.join('  %s (%s): %s' % (lu.layer.name, lu.branch.name, url(lu)) for lu in failed[:20]))
+        return
+
+    subscribers = UserProfile.objects.filter(notify_parse_errors=True, user__is_active=True).select_related('user')
+    by_email = {p.user.email.strip().lower(): p.user for p in subscribers if p.user.email}
+    for layerupdate in failed:
+        maintainers = LayerMaintainer.objects.filter(layerbranch__layer=layerupdate.layer,
+                                                     layerbranch__branch=layerupdate.branch, status='A')
+        to = sorted({by_email[m.email.strip().lower()].email for m in maintainers
+                     if m.email.strip().lower() in by_email})
+        if not to:
+            continue
+        try:
+            send_mail('%s (%s) failed to parse on %s' % (layerupdate.layer.name, layerupdate.branch.name, site.name),
+                      'The layer index update of %s on the %s branch found %d errors:\n\n  %s\n\n'
+                      'You get this email because you maintain this layer and asked to be told when it\n'
+                      'starts to fail; you can turn it off on your profile page.\n'
+                      % (layerupdate.layer.name, layerupdate.branch.name, layerupdate.errors, url(layerupdate)),
+                      settings.DEFAULT_FROM_EMAIL, to)
+        except Exception as e:
+            logger.warning('Failed to email %s about %s: %s' % (', '.join(to), layerupdate.layer.name, e))
diff --git a/layerindex/update.py b/layerindex/update.py
index ca68e3e..436ddb6 100755
--- a/layerindex/update.py
+++ b/layerindex/update.py
@@ -621,6 +621,9 @@  def main():
         update_purge_days = getattr(settings, 'UPDATE_PURGE_DAYS', 30)
         Update.objects.filter(started__lte=datetime.now()-timedelta(days=update_purge_days)).delete()
 
+        from layerindex.notify import notify_parse_errors
+        notify_parse_errors(update, logger)
+
     sys.exit(0)
 
 
diff --git a/layerindex/views.py b/layerindex/views.py
index 32f1c16..6e160c0 100644
--- a/layerindex/views.py
+++ b/layerindex/views.py
@@ -1056,6 +1056,11 @@  class EditProfileFormView(LoginRequiredMixin, SuccessMessageMixin, UpdateView):
     def form_valid(self, form):
         self.object = form.save()
 
+        if 'notify_parse_errors' in form.changed_data:
+            profile, _ = UserProfile.objects.get_or_create(user=self.user)
+            profile.notify_parse_errors = form.cleaned_data['notify_parse_errors']
+            profile.save(update_fields=['notify_parse_errors'])
+
         if'answer_1' in form.changed_data:
             # If one security answer has changed, they all have. Delete current questions and add new ones.
             # Don't throw an error if we are editing the super user and they don't have security questions yet.
diff --git a/tests/test_parse_error_mail.py b/tests/test_parse_error_mail.py
new file mode 100644
index 0000000..6b478fc
--- /dev/null
+++ b/tests/test_parse_error_mail.py
@@ -0,0 +1,82 @@ 
+# layerindex-web - tests for the parse error emails to maintainers
+#
+# Licensed under the MIT license, see COPYING.MIT for details
+#
+# SPDX-License-Identifier: MIT
+
+import logging
+from datetime import datetime, timedelta
+
+import pytest
+from django.contrib.auth.models import User
+from django.core import mail
+
+from layerindex.models import (Branch, LayerBranch, LayerItem, LayerMaintainer, LayerUpdate, Update,
+                               UserProfile)
+from layerindex.notify import notify_parse_errors
+
+LOG = logging.getLogger('test')
+START = datetime(2026, 11, 1)
+
+
+def make_layers(names):
+    master, _ = Branch.objects.get_or_create(name='master', defaults={'bitbake_branch': 'master'})
+    layers = {}
+    for name in names:
+        layer = LayerItem.objects.create(name=name, status='P', layer_type='A', summary=name,
+                                         description=name, vcs_url='git://example.com/%s.git' % name)
+        LayerBranch.objects.create(layer=layer, branch=master)
+        layers[name] = layer
+    return layers
+
+
+def run_update(layers, failing, day):
+    update = Update.objects.create(started=START + timedelta(days=day), finished=START + timedelta(days=day))
+    for name, layer in layers.items():
+        lu = LayerUpdate(update=update, layer=layer, branch=Branch.objects.get(name='master'),
+                         started=START + timedelta(days=day), log='ERROR: broken\n' if name in failing else '')
+        lu.save()
+    return update
+
+
+def subscribe(username, email, notify=True, active=True):
+    user = User.objects.create_user(username, email, 'pw', is_active=active)
+    UserProfile.objects.create(user=user, notify_parse_errors=notify)
+
+
+@pytest.mark.django_db
+def test_maintainers_who_opted_in_get_mail_when_a_layer_starts_failing():
+    # [YOCTO #11196]
+    layers = make_layers(['meta-a', 'meta-b'])
+    lb = layers['meta-a'].layerbranch_set.get()
+    for name, email, status in (('Yes', 'Yes@example.com', 'A'), ('No', 'no@example.com', 'A'),
+                                ('Gone', 'gone@example.com', 'I')):
+        LayerMaintainer.objects.create(layerbranch=lb, name=name, email=email, status=status)
+    subscribe('yes', 'yes@example.com')
+    subscribe('no', 'no@example.com', notify=False)
+    subscribe('gone', 'gone@example.com')
+
+    run_update(layers, [], day=0)
+    notify_parse_errors(run_update(layers, ['meta-a'], day=1), LOG)
+    assert [m.to for m in mail.outbox] == [['yes@example.com']]
+    assert 'meta-a (master) failed to parse' in mail.outbox[0].subject
+    assert '/layerindex/layerupdate/' in mail.outbox[0].body
+
+    # Still failing: no new mail
+    mail.outbox.clear()
+    notify_parse_errors(run_update(layers, ['meta-a'], day=2), LOG)
+    assert mail.outbox == []
+
+
+@pytest.mark.django_db
+def test_mass_failure_goes_to_the_admins(settings):
+    settings.ADMINS = [('Admin', 'admin@example.com')]
+    layers = make_layers(['meta-%d' % i for i in range(10)])
+    for layer in layers.values():
+        LayerMaintainer.objects.create(layerbranch=layer.layerbranch_set.get(), name='M', email='m@example.com')
+    subscribe('m', 'm@example.com')
+
+    run_update(layers, [], day=0)
+    notify_parse_errors(run_update(layers, list(layers)[:6], day=1), LOG)
+    assert [m.to for m in mail.outbox] == [['admin@example.com']]
+    assert '6 of 10 layer branches newly failed' in mail.outbox[0].subject
diff --git a/tests/test_profile_email.py b/tests/test_profile_email.py
index 85dcaa0..a35db68 100644
--- a/tests/test_profile_email.py
+++ b/tests/test_profile_email.py
@@ -34,9 +34,9 @@  class TestProfileEmailChange(TestCase):
             SecurityQuestionAnswer.objects.create(user=profile, security_question=question, answer='x')
         self.client.force_login(self.user)
 
-    def _post_profile(self, email, password='pw'):
+    def _post_profile(self, email, password='pw', **extra):
         hashkey = CaptchaStore.generate_key()
-        data = {
+        data = {**extra,
             'current_password': password,
             'username': 'someone',
             'first_name': '',
@@ -147,3 +147,10 @@  class TestProfileEmailChange(TestCase):
         self.assertTrue(layer.user_can_edit(self.user))
         self.assertEqual([m.to for m in mail.outbox], [['new@example.com']])
         self.assertEqual(UserProfile.objects.get(user=self.user).previous_email, '')
+
+    def test_opt_in_to_parse_error_mail(self):
+        # [YOCTO #11196]
+        self._post_profile('old@example.com', notify_parse_errors='on')
+        self.assertTrue(UserProfile.objects.get(user=self.user).notify_parse_errors)
+        self._post_profile('old@example.com')
+        self.assertFalse(UserProfile.objects.get(user=self.user).notify_parse_errors)