← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:ci-build-files into launchpad:master

 

Colin Watson has proposed merging ~cjwatson/launchpad:ci-build-files into launchpad:master.

Commit message:
Show built files on CIBuild:+index

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/437673

Most build types show links to their output files on their index page, and it was odd that CI builds didn't; it also made certain kinds of debugging more difficult.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:ci-build-files into launchpad:master.
diff --git a/lib/lp/code/browser/cibuild.py b/lib/lp/code/browser/cibuild.py
index 187f5ef..a1018e1 100644
--- a/lib/lp/code/browser/cibuild.py
+++ b/lib/lp/code/browser/cibuild.py
@@ -13,7 +13,11 @@ from zope.interface import Interface
 
 from lp.app.browser.launchpadform import LaunchpadFormView, action
 from lp.code.interfaces.cibuild import ICIBuild
-from lp.services.librarian.browser import FileNavigationMixin
+from lp.services.librarian.browser import (
+    FileNavigationMixin,
+    ProxiedLibraryFileAlias,
+)
+from lp.services.propertycache import cachedproperty
 from lp.services.webapp import (
     ContextMenu,
     Link,
@@ -77,6 +81,22 @@ class CIBuildView(LaunchpadFormView):
 
     page_title = label
 
+    @cachedproperty
+    def files(self):
+        """Return `LibraryFileAlias`es for files produced by this build."""
+        if not self.context.was_built:
+            return None
+
+        return [
+            ProxiedLibraryFileAlias(artifact.library_file, artifact)
+            for artifact in self.context.getArtifacts()
+            if not artifact.library_file.deleted
+        ]
+
+    @cachedproperty
+    def has_files(self):
+        return bool(self.files)
+
 
 class CIBuildRetryView(LaunchpadFormView):
     """View for retrying a CI build."""
diff --git a/lib/lp/code/browser/tests/test_cibuild.py b/lib/lp/code/browser/tests/test_cibuild.py
index 2390997..3c5d7bf 100644
--- a/lib/lp/code/browser/tests/test_cibuild.py
+++ b/lib/lp/code/browser/tests/test_cibuild.py
@@ -11,6 +11,7 @@ from fixtures import FakeLogger
 from storm.locals import Store
 from zope.component import getUtility
 from zope.security.interfaces import Unauthorized
+from zope.security.proxy import removeSecurityProxy
 from zope.testbrowser.browser import LinkNotFoundError
 
 from lp.app.interfaces.launchpad import ILaunchpadCelebrities
@@ -23,12 +24,13 @@ from lp.testing import (
     login,
     person_logged_in,
 )
-from lp.testing.layers import DatabaseFunctionalLayer
+from lp.testing.layers import DatabaseFunctionalLayer, LaunchpadFunctionalLayer
 from lp.testing.pages import (
     extract_text,
     find_main_content,
     find_tags_by_class,
 )
+from lp.testing.views import create_initialized_view
 
 
 class TestCanonicalUrlForCIBuild(TestCaseWithFactory):
@@ -224,3 +226,50 @@ class TestCIBuildOperations(BrowserTestCase):
         build = self.makeBuildingRecipe()
         browser = self.getViewBrowser(build.builder, no_login=True)
         self.assertIn("tail of the log", browser.contents)
+
+
+class TestCIBuildView(TestCaseWithFactory):
+
+    layer = LaunchpadFunctionalLayer
+
+    def test_files(self):
+        # CIBuildView.files returns all the associated files.
+        build = self.factory.makeCIBuild(status=BuildStatus.FULLYBUILT)
+        reports = [
+            build.getOrCreateRevisionStatusReport(job_id)
+            for job_id in ("build:0", "build:1")
+        ]
+        for report in reports:
+            # Deliberately use identical filenames for each artifact, since
+            # that's the hardest case.
+            removeSecurityProxy(report).attach(
+                "package.tar.gz", report.title.encode()
+            )
+        artifacts = build.getArtifacts()
+        self.assertContentEqual(
+            reports, {artifact.report for artifact in artifacts}
+        )
+        build_view = create_initialized_view(build, "+index")
+        self.assertEqual(
+            [
+                "%s/+files/%s"
+                % (canonical_url(artifact), artifact.library_file.filename)
+                for artifact in artifacts
+            ],
+            [lfa.http_url for lfa in build_view.files],
+        )
+        # Deleted files won't be included.
+        self.assertFalse(artifacts[1].library_file.deleted)
+        removeSecurityProxy(artifacts[1].library_file).content = None
+        self.assertTrue(artifacts[1].library_file.deleted)
+        build_view = create_initialized_view(build, "+index")
+        self.assertEqual(
+            [
+                "%s/+files/%s"
+                % (
+                    canonical_url(artifacts[0]),
+                    artifacts[0].library_file.filename,
+                )
+            ],
+            [lfa.http_url for lfa in build_view.files],
+        )
diff --git a/lib/lp/code/interfaces/cibuild.py b/lib/lp/code/interfaces/cibuild.py
index 5c7725f..9599b75 100644
--- a/lib/lp/code/interfaces/cibuild.py
+++ b/lib/lp/code/interfaces/cibuild.py
@@ -201,6 +201,14 @@ class ICIBuildView(IPackageBuildView, IPrivacy):
         :return: The corresponding `ILibraryFileAlias`.
         """
 
+    def getArtifacts():
+        """Return `IRevisionStatusArtifact`s produced by this build.
+
+        :return: A result set of `IRevisionStatusArtifact`s, ordered by
+            filename then artifact ID, with their associated
+            `ILibraryFileAlias`es preloaded.
+        """
+
     @export_read_operation()
     @operation_for_version("devel")
     def getFileUrls():
diff --git a/lib/lp/code/model/cibuild.py b/lib/lp/code/model/cibuild.py
index 0bfc773..c06e7a6 100644
--- a/lib/lp/code/model/cibuild.py
+++ b/lib/lp/code/model/cibuild.py
@@ -9,7 +9,7 @@ __all__ = [
 
 from copy import copy
 from datetime import timedelta
-from operator import attrgetter, itemgetter
+from operator import itemgetter
 
 import pytz
 from lazr.lifecycle.event import ObjectCreatedEvent
@@ -49,12 +49,13 @@ from lp.code.interfaces.cibuild import (
 )
 from lp.code.interfaces.githosting import IGitHostingClient
 from lp.code.interfaces.gitrepository import IGitRepository
-from lp.code.interfaces.revisionstatus import (
-    IRevisionStatusArtifactSet,
-    IRevisionStatusReportSet,
-)
+from lp.code.interfaces.revisionstatus import IRevisionStatusReportSet
 from lp.code.model.gitref import GitRef
 from lp.code.model.lpcraft import load_configuration
+from lp.code.model.revisionstatus import (
+    RevisionStatusArtifact,
+    RevisionStatusReport,
+)
 from lp.registry.interfaces.pocket import PackagePublishingPocket
 from lp.registry.interfaces.series import SeriesStatus
 from lp.registry.interfaces.sourcepackage import SourcePackageType
@@ -478,15 +479,25 @@ class CIBuild(PackageBuildMixin, StormBase):
 
         raise NotFoundError(filename)
 
-    def getFileUrls(self):
-        artifacts = getUtility(IRevisionStatusArtifactSet).findByCIBuild(self)
-        load_related(LibraryFileAlias, artifacts, ["library_file_id"])
-        artifacts = sorted(
-            artifacts, key=attrgetter("library_file.filename", "id")
+    def getArtifacts(self):
+        """See `ICIBuild`."""
+        artifacts = (
+            IStore(self)
+            .find(
+                (RevisionStatusArtifact, LibraryFileAlias),
+                RevisionStatusReport.ci_build == self,
+                RevisionStatusArtifact.report == RevisionStatusReport.id,
+                RevisionStatusArtifact.library_file == LibraryFileAlias.id,
+            )
+            .order_by(LibraryFileAlias.filename, RevisionStatusArtifact.id)
         )
+        return DecoratedResultSet(artifacts, result_decorator=itemgetter(0))
+
+    def getFileUrls(self):
+        """See `ICIBuild`."""
         return [
             ProxiedLibraryFileAlias(artifact.library_file, artifact).http_url
-            for artifact in artifacts
+            for artifact in self.getArtifacts()
         ]
 
     def verifySuccessfulUpload(self) -> bool:
diff --git a/lib/lp/code/templates/cibuild-index.pt b/lib/lp/code/templates/cibuild-index.pt
index 35ef0cf..790b651 100644
--- a/lib/lp/code/templates/cibuild-index.pt
+++ b/lib/lp/code/templates/cibuild-index.pt
@@ -33,6 +33,10 @@
 
       </div> <!-- yui-g -->
 
+      <div id="files" class="portlet" tal:condition="view/has_files">
+        <div metal:use-macro="template/macros/files"/>
+      </div>
+
       <div id="buildlog" class="portlet"
            tal:condition="context/status/enumvalue:BUILDING">
         <div metal:use-macro="template/macros/buildlog"/>
@@ -162,6 +166,22 @@
     </div>
   </metal:macro>
 
+  <metal:macro define-macro="files">
+    <tal:comment replace="nothing">
+      Files section.
+    </tal:comment>
+    <h2>Built files</h2>
+    <p>Files resulting from this build:</p>
+    <ul>
+      <li tal:repeat="file view/files">
+        <a class="sprite download"
+           tal:content="file/filename"
+           tal:attributes="href file/http_url"/>
+        (<span tal:replace="file/content/filesize/fmt:bytes"/>)
+      </li>
+    </ul>
+  </metal:macro>
+
   <metal:macro define-macro="buildlog">
     <tal:comment replace="nothing">
       Buildlog section.

Follow ups