← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~ruinedyourlife/launchpad:add-craft-recipe-build-notifications into launchpad:master

 

Quentin Debhi has proposed merging ~ruinedyourlife/launchpad:add-craft-recipe-build-notifications into launchpad:master with ~ruinedyourlife/launchpad:implement-craftrecipe-requestbuild as a prerequisite.

Commit message:
Add craft recipe build notifications

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~ruinedyourlife/launchpad/+git/launchpad/+merge/473932
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~ruinedyourlife/launchpad:add-craft-recipe-build-notifications into launchpad:master.
diff --git a/lib/lp/crafts/emailtemplates/craftrecipebuild-notification.txt b/lib/lp/crafts/emailtemplates/craftrecipebuild-notification.txt
new file mode 100644
index 0000000..ff62b9d
--- /dev/null
+++ b/lib/lp/crafts/emailtemplates/craftrecipebuild-notification.txt
@@ -0,0 +1,9 @@
+ * Craft Recipe: %(recipe_name)s
+ * Project: %(project_name)s
+ * Distroseries: %(distroseries)s
+ * Architecture: %(architecturetag)s
+ * State: %(build_state)s
+ * Duration: %(build_duration)s
+ * Build Log: %(log_url)s
+ * Upload Log: %(upload_log_url)s
+ * Builder: %(builder_url)s
diff --git a/lib/lp/crafts/mail/__init__.py b/lib/lp/crafts/mail/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/lib/lp/crafts/mail/__init__.py
diff --git a/lib/lp/crafts/mail/craftrecipebuild.py b/lib/lp/crafts/mail/craftrecipebuild.py
new file mode 100644
index 0000000..e6e5d47
--- /dev/null
+++ b/lib/lp/crafts/mail/craftrecipebuild.py
@@ -0,0 +1,92 @@
+# Copyright 2024 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+__all__ = [
+    "CraftRecipeBuildMailer",
+]
+
+from lp.app.browser.tales import DurationFormatterAPI
+from lp.services.config import config
+from lp.services.mail.basemailer import BaseMailer, RecipientReason
+from lp.services.webapp import canonical_url
+
+
+class CraftRecipeBuildMailer(BaseMailer):
+
+    app = "crafts"
+
+    @classmethod
+    def forStatus(cls, build):
+        """Create a mailer for notifying about craft recipe build status.
+
+        :param build: The relevant build.
+        """
+        requester = build.requester
+        recipients = {requester: RecipientReason.forBuildRequester(requester)}
+        return cls(
+            "[Craft recipe build #%(build_id)d] %(build_title)s",
+            "craftrecipebuild-notification.txt",
+            recipients,
+            config.canonical.noreply_from_address,
+            "craft-recipe-build-status",
+            build,
+        )
+
+    def __init__(
+        self,
+        subject,
+        template_name,
+        recipients,
+        from_address,
+        notification_type,
+        build,
+    ):
+        super().__init__(
+            subject,
+            template_name,
+            recipients,
+            from_address,
+            notification_type=notification_type,
+        )
+        self.build = build
+
+    def _getHeaders(self, email, recipient):
+        """See `BaseMailer`."""
+        headers = super()._getHeaders(email, recipient)
+        headers["X-Launchpad-Build-State"] = self.build.status.name
+        return headers
+
+    def _getTemplateParams(self, email, recipient):
+        """See `BaseMailer`."""
+        build = self.build
+        params = super()._getTemplateParams(email, recipient)
+        params.update(
+            {
+                "architecturetag": build.distro_arch_series.architecturetag,
+                "build_duration": "",
+                "build_id": build.id,
+                "build_state": build.status.title,
+                "build_title": build.title,
+                "build_url": canonical_url(build),
+                "builder_url": "",
+                "distroseries": build.distro_series,
+                "log_url": "",
+                "project_name": build.recipe.project.name,
+                "recipe_name": build.recipe.name,
+                "upload_log_url": "",
+            }
+        )
+        if build.duration is not None:
+            duration_formatter = DurationFormatterAPI(build.duration)
+            params["build_duration"] = duration_formatter.approximateduration()
+        if build.log is not None:
+            params["log_url"] = build.log_url
+        if build.upload_log is not None:
+            params["upload_log_url"] = build.upload_log_url
+        if build.builder is not None:
+            params["builder_url"] = canonical_url(build.builder)
+        return params
+
+    def _getFooter(self, email, recipient, params):
+        """See `BaseMailer`."""
+        return "%(build_url)s\n" "%(reason)s\n" % params
diff --git a/lib/lp/crafts/model/craftrecipebuild.py b/lib/lp/crafts/model/craftrecipebuild.py
index 30d9450..6f989e9 100644
--- a/lib/lp/crafts/model/craftrecipebuild.py
+++ b/lib/lp/crafts/model/craftrecipebuild.py
@@ -32,6 +32,7 @@ from lp.crafts.interfaces.craftrecipebuild import (
     ICraftRecipeBuild,
     ICraftRecipeBuildSet,
 )
+from lp.crafts.mail.craftrecipebuild import CraftRecipeBuildMailer
 from lp.registry.interfaces.pocket import PackagePublishingPocket
 from lp.registry.interfaces.series import SeriesStatus
 from lp.registry.model.person import Person
@@ -347,7 +348,8 @@ class CraftRecipeBuild(PackageBuildMixin, StormBase):
             return
         if self.status == BuildStatus.FULLYBUILT:
             return
-        # XXX ruinedyourlife 2024-09-25: Send email notifications.
+        mailer = CraftRecipeBuildMailer.forStatus(self)
+        mailer.sendAll()
 
 
 @implementer(ICraftRecipeBuildSet)
diff --git a/lib/lp/crafts/tests/test_craftrecipebuild.py b/lib/lp/crafts/tests/test_craftrecipebuild.py
index 8b118b9..f7f5129 100644
--- a/lib/lp/crafts/tests/test_craftrecipebuild.py
+++ b/lib/lp/crafts/tests/test_craftrecipebuild.py
@@ -5,6 +5,7 @@
 
 from datetime import datetime, timedelta, timezone
 
+import six
 from testtools.matchers import Equals
 from zope.component import getUtility
 from zope.security.proxy import removeSecurityProxy
@@ -14,6 +15,7 @@ from lp.app.errors import NotFoundError
 from lp.buildmaster.enums import BuildStatus
 from lp.buildmaster.interfaces.buildqueue import IBuildQueue
 from lp.buildmaster.interfaces.packagebuild import IPackageBuild
+from lp.buildmaster.interfaces.processor import IProcessorSet
 from lp.crafts.interfaces.craftrecipe import (
     CRAFT_RECIPE_ALLOW_CREATE,
     CRAFT_RECIPE_PRIVATE_FEATURE_FLAG,
@@ -24,6 +26,7 @@ from lp.crafts.interfaces.craftrecipebuild import (
 )
 from lp.registry.enums import PersonVisibility, TeamMembershipPolicy
 from lp.registry.interfaces.series import SeriesStatus
+from lp.services.config import config
 from lp.services.features.testing import FeatureFixture
 from lp.services.propertycache import clear_property_cache
 from lp.testing import (
@@ -32,8 +35,21 @@ from lp.testing import (
     person_logged_in,
 )
 from lp.testing.layers import LaunchpadZopelessLayer
+from lp.testing.mail_helpers import pop_notifications
 from lp.testing.matchers import HasQueryCount
 
+expected_body = """\
+ * Craft Recipe: craft-1
+ * Project: craft-project
+ * Distroseries: distro unstable
+ * Architecture: i386
+ * State: Failed to build
+ * Duration: 10 minutes
+ * Build Log: %s
+ * Upload Log: %s
+ * Builder: http://launchpad.test/builders/bob
+"""
+
 
 class TestCraftRecipeBuild(TestCaseWithFactory):
 
@@ -257,10 +273,81 @@ class TestCraftRecipeBuild(TestCaseWithFactory):
         )
         self.assertEqual("dummy", self.build.revision_id)
 
+    def test_notify_fullybuilt(self):
+        # notify does not send mail when a recipe build completes normally.
+        build = self.factory.makeCraftRecipeBuild(
+            status=BuildStatus.FULLYBUILT
+        )
+        build.notify()
+        self.assertEqual(0, len(pop_notifications()))
+
+    def test_notify_packagefail(self):
+        # notify sends mail when a recipe build fails.
+        person = self.factory.makePerson(name="person")
+        project = self.factory.makeProduct(name="craft-project")
+        distribution = self.factory.makeDistribution(name="distro")
+        distroseries = self.factory.makeDistroSeries(
+            distribution=distribution, name="unstable"
+        )
+        processor = getUtility(IProcessorSet).getByName("386")
+        das = self.factory.makeDistroArchSeries(
+            distroseries=distroseries,
+            architecturetag="i386",
+            processor=processor,
+        )
+        build = self.factory.makeCraftRecipeBuild(
+            name="craft-1",
+            requester=person,
+            owner=person,
+            project=project,
+            distro_arch_series=das,
+            status=BuildStatus.FAILEDTOBUILD,
+            builder=self.factory.makeBuilder(name="bob"),
+            duration=timedelta(minutes=10),
+        )
+        build.setLog(self.factory.makeLibraryFileAlias())
+        build.notify()
+        [notification] = pop_notifications()
+        self.assertEqual(
+            config.canonical.noreply_from_address, notification["From"]
+        )
+        self.assertEqual(
+            "Person <%s>" % person.preferredemail.email, notification["To"]
+        )
+        subject = notification["Subject"].replace("\n ", " ")
+        self.assertEqual(
+            "[Craft recipe build #%d] i386 build of "
+            "/~person/craft-project/+craft/craft-1" % build.id,
+            subject,
+        )
+        self.assertEqual(
+            "Requester", notification["X-Launchpad-Message-Rationale"]
+        )
+        self.assertEqual(person.name, notification["X-Launchpad-Message-For"])
+        self.assertEqual(
+            "craft-recipe-build-status",
+            notification["X-Launchpad-Notification-Type"],
+        )
+        self.assertEqual(
+            "FAILEDTOBUILD", notification["X-Launchpad-Build-State"]
+        )
+        body, footer = six.ensure_text(
+            notification.get_payload(decode=True)
+        ).split("\n-- \n")
+        self.assertEqual(
+            expected_body.strip() % (build.log_url, ""), body.strip()
+        )
+        self.assertEqual(
+            "http://launchpad.test/~person/craft-project/+craft/craft-1/";
+            "+build/%d\n"
+            "You are the requester of the build.\n" % build.id,
+            footer,
+        )
+
     def addFakeBuildLog(self, build):
         build.setLog(self.factory.makeLibraryFileAlias("mybuildlog.txt"))
 
-    def test_log_url_123(self):
+    def test_log_url(self):
         # The log URL for a craft recipe build will use the recipe context.
         self.addFakeBuildLog(self.build)
         self.build.log_url