← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~andrey-fedoseev/launchpad:ci-build-email into launchpad:master

 

Andrey Fedoseev has proposed merging ~andrey-fedoseev/launchpad:ci-build-email into launchpad:master.

Commit message:
Add email notification for failed CI builds

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~andrey-fedoseev/launchpad/+git/launchpad/+merge/426866

The code is mostly based on email notifications for Snap builds
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~andrey-fedoseev/launchpad:ci-build-email into launchpad:master.
diff --git a/lib/lp/code/emailtemplates/cibuild-notification.txt b/lib/lp/code/emailtemplates/cibuild-notification.txt
new file mode 100644
index 0000000..a1fe129
--- /dev/null
+++ b/lib/lp/code/emailtemplates/cibuild-notification.txt
@@ -0,0 +1,9 @@
+ * Git Repository: %(git_repository)s
+ * Commit: %(commit_sha1)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/code/mail/cibuild.py b/lib/lp/code/mail/cibuild.py
new file mode 100644
index 0000000..1183db8
--- /dev/null
+++ b/lib/lp/code/mail/cibuild.py
@@ -0,0 +1,86 @@
+#  Copyright 2022 Canonical Ltd.  This software is licensed under the
+#  GNU Affero General Public License version 3 (see the file LICENSE).
+
+__all__ = [
+    "CIBuildMailer",
+]
+
+from lp.app.browser.tales import DurationFormatterAPI
+from lp.code.model.cibuild import CIBuild
+from lp.services.config import config
+from lp.services.mail.basemailer import (
+    BaseMailer,
+    RecipientReason,
+    )
+from lp.services.webapp import canonical_url
+
+
+class CIBuildMailer(BaseMailer):
+
+    app = "code"
+
+    @classmethod
+    def forStatus(cls, build: CIBuild):
+        """Create a mailer for notifying about snap package build status.
+
+        :param build: The relevant build.
+        """
+        repository_owner = build.git_repository.owner
+        recipients = {
+            repository_owner: RecipientReason.forBuildRequester(
+                repository_owner
+            )
+        }
+        return cls(
+            build,
+            "[CI build #%(build_id)d] %(build_title)s",
+            "cibuild-notification.txt",
+            recipients,
+            config.canonical.noreply_from_address,
+            notification_type="ci-build-status",
+        )
+
+    def __init__(self, build: CIBuild, *args, **kwargs):
+        self.build = build
+        super().__init__(*args, **kwargs)
+
+    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(
+            {
+                "build_id": build.id,
+                "build_title": build.title,
+                "git_repository": build.git_repository.unique_name,
+                "commit_sha1": build.commit_sha1,
+                "distroseries": build.distro_series,
+                "architecturetag": build.arch_tag,
+                "build_state": build.status.title,
+                "build_duration": "",
+                "log_url": "",
+                "upload_log_url": "",
+                "builder_url": "",
+                "build_url": canonical_url(build),
+            }
+        )
+        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}\n{reason}\n".format(**params)
diff --git a/lib/lp/code/model/cibuild.py b/lib/lp/code/model/cibuild.py
index 281c3a1..0ec5581 100644
--- a/lib/lp/code/model/cibuild.py
+++ b/lib/lp/code/model/cibuild.py
@@ -69,6 +69,7 @@ from lp.registry.interfaces.sourcepackage import SourcePackageType
 from lp.registry.model.distribution import Distribution
 from lp.registry.model.distroseries import DistroSeries
 from lp.registry.model.sourcepackagename import SourcePackageName
+from lp.services.config import config
 from lp.services.database.bulk import load_related
 from lp.services.database.constants import DEFAULT
 from lp.services.database.decoratedresultset import DecoratedResultSet
@@ -471,7 +472,14 @@ class CIBuild(PackageBuildMixin, StormBase):
 
     def notify(self, extra_info=None):
         """See `IPackageBuild`."""
-        # We don't currently send any notifications.
+        from lp.code.mail.cibuild import CIBuildMailer
+
+        if not config.builddmaster.send_build_notification:
+            return
+        if self.status == BuildStatus.FULLYBUILT:
+            return
+        mailer = CIBuildMailer.forStatus(self)
+        mailer.sendAll()
 
     @property
     def sourcepackages(self):
diff --git a/lib/lp/code/model/tests/test_cibuild.py b/lib/lp/code/model/tests/test_cibuild.py
index ca0192b..44ca7d0 100644
--- a/lib/lp/code/model/tests/test_cibuild.py
+++ b/lib/lp/code/model/tests/test_cibuild.py
@@ -40,6 +40,7 @@ from lp.buildmaster.enums import (
     )
 from lp.buildmaster.interfaces.buildqueue import IBuildQueue
 from lp.buildmaster.interfaces.packagebuild import IPackageBuild
+from lp.buildmaster.interfaces.processor import IProcessorSet
 from lp.buildmaster.model.buildqueue import BuildQueue
 from lp.code.errors import (
     GitRepositoryBlobNotFound,
@@ -78,6 +79,7 @@ from lp.testing import (
     login,
     logout,
     person_logged_in,
+    pop_notifications,
     StormStatementRecorder,
     TestCaseWithFactory,
     )
@@ -441,6 +443,78 @@ class TestCIBuild(TestCaseWithFactory):
             ))
         self.assertContentEqual([bpr], build.binarypackages)
 
+    def test_notify_fullybuilt(self):
+        # notify does not send mail when a CIBuild completes normally.
+        build = self.factory.makeCIBuild(status=BuildStatus.FULLYBUILT)
+        build.notify()
+        self.assertEqual(0, len(pop_notifications()))
+
+    def test_notify_packagefail(self):
+        # notify sends mail when a CIBuild fails.
+        person = self.factory.makePerson(name="person")
+        product = self.factory.makeProduct(name="product", owner=person)
+        git_repository = self.factory.makeGitRepository(
+            owner=person, target=product, name="repo"
+        )
+        distribution = self.factory.makeDistribution(name="distro")
+        distroseries = self.factory.makeDistroSeries(
+            distribution=distribution, name="unstable")
+        processor = getUtility(IProcessorSet).getByName("386")
+        distroarchseries = self.factory.makeDistroArchSeries(
+            distroseries=distroseries, architecturetag="i386",
+            processor=processor)
+        build = self.factory.makeCIBuild(
+            git_repository=git_repository,
+            commit_sha1="a39b604dcf9124d61cf94a1f9fffab638ee9a0cd",
+            distro_arch_series=distroarchseries,
+            date_created=datetime(2014, 4, 25, 10, 38, 0, tzinfo=pytz.UTC),
+            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 ", " ")
+        expected_subject = (
+            "[CI build #{:d}] i386 CI build of "
+            "~person/product/+git/repo:"
+            "a39b604dcf9124d61cf94a1f9fffab638ee9a0cd"
+        ).format(build.id)
+        self.assertEqual(expected_subject, subject)
+        self.assertEqual(
+            "Requester", notification["X-Launchpad-Message-Rationale"])
+        self.assertEqual(person.name, notification["X-Launchpad-Message-For"])
+        self.assertEqual(
+            "ci-build-status",
+            notification["X-Launchpad-Notification-Type"])
+        self.assertEqual(
+            "FAILEDTOBUILD", notification["X-Launchpad-Build-State"])
+
+        message = notification.get_payload(decode=True).decode()
+        body, footer = message.split("\n-- \n")
+
+        expected_body = (
+            " * Git Repository: ~person/product/+git/repo\n"
+            " * Commit: a39b604dcf9124d61cf94a1f9fffab638ee9a0cd\n"
+            " * Distroseries: distro unstable\n"
+            " * Architecture: i386\n"
+            " * State: Failed to build\n"
+            " * Duration: 10 minutes\n"
+            " * Build Log: {}\n"
+            " * Upload Log: \n"
+            " * Builder: http://launchpad.test/builders/bob\n";
+        ).format(build.log_url)
+
+        self.assertEqual(expected_body, body)
+        self.assertEqual(
+            "http://launchpad.test/~person/product/+git/repo/+build/%d\n";
+            "You are the requester of the build.\n" % build.id, footer)
+
+
 
 class TestCIBuildSet(TestCaseWithFactory):
 
diff --git a/lib/lp/services/mail/basemailer.py b/lib/lp/services/mail/basemailer.py
index 04d907e..5be3ce8 100644
--- a/lib/lp/services/mail/basemailer.py
+++ b/lib/lp/services/mail/basemailer.py
@@ -9,6 +9,11 @@ from collections import OrderedDict
 import logging
 from smtplib import SMTPException
 import sys
+from typing import (
+    Any,
+    Dict,
+    Optional,
+    )
 
 from zope.component import getUtility
 from zope.error.interfaces import IErrorReportingUtility
@@ -161,7 +166,7 @@ class BaseMailer:
         """Return the name of the template to use for this email body."""
         return self._template_name
 
-    def _getTemplateParams(self, email, recipient):
+    def _getTemplateParams(self, email, recipient) -> Dict[str, Any]:
         """Return a dict of values to use in the body and subject."""
         reason, rationale = self._recipients.getReason(email)
         params = {'reason': reason.getReason()}
@@ -174,7 +179,7 @@ class BaseMailer:
         return text_delta(self.delta, self.delta.delta_values,
             self.delta.new_values, self.delta.interface)
 
-    def _getBody(self, email, recipient):
+    def _getBody(self, email, recipient) -> str:
         """Return the complete body to use for this email."""
         template = get_email_template(
             self._getTemplateName(email, recipient), app=self.app)
@@ -188,7 +193,9 @@ class BaseMailer:
             body = append_footer(body, footer)
         return body
 
-    def _getFooter(self, email, recipient, params):
+    def _getFooter(
+        self, email, recipient, params: Dict[str, Any]
+    ) -> Optional[str]:
         """Provide a footer to attach to the body, or None."""
         return None