launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #28813
[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