launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #31468
[Merge] ~jugmac00/launchpad:add-a-basic-rock-recipe-index-view into launchpad:master
Jürgen Gmach has proposed merging ~jugmac00/launchpad:add-a-basic-rock-recipe-index-view into launchpad:master with ~jugmac00/launchpad:delete-rock-recipe-builds-and-jobs-when-deleting-recipes as a prerequisite.
Commit message:
Add a basic rock recipe +index view
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~jugmac00/launchpad/+git/launchpad/+merge/473273
--
Your team Launchpad code reviewers is requested to review the proposed merge of ~jugmac00/launchpad:add-a-basic-rock-recipe-index-view into launchpad:master.
diff --git a/lib/lp/app/browser/configure.zcml b/lib/lp/app/browser/configure.zcml
index 70e44d9..57a699a 100644
--- a/lib/lp/app/browser/configure.zcml
+++ b/lib/lp/app/browser/configure.zcml
@@ -596,6 +596,13 @@
/>
<adapter
+ for="lp.rocks.interfaces.rockrecipebuild.IRockRecipeBuild"
+ provides="zope.traversing.interfaces.IPathAdapter"
+ factory="lp.app.browser.tales.BuildImageDisplayAPI"
+ name="image"
+ />
+
+ <adapter
for="lp.soyuz.interfaces.archive.IArchive"
provides="zope.traversing.interfaces.IPathAdapter"
factory="lp.app.browser.tales.ArchiveImageDisplayAPI"
diff --git a/lib/lp/rocks/browser/configure.zcml b/lib/lp/rocks/browser/configure.zcml
index b70835f..58cb182 100644
--- a/lib/lp/rocks/browser/configure.zcml
+++ b/lib/lp/rocks/browser/configure.zcml
@@ -12,9 +12,29 @@
<lp:url
for="lp.rocks.interfaces.rockrecipe.IRockRecipe"
urldata="lp.rocks.browser.rockrecipe.RockRecipeURL" />
+<<<<<<< lib/lp/rocks/browser/configure.zcml
<lp:navigation
module="lp.rocks.browser.rockrecipe"
classes="RockRecipeNavigation" />
+=======
+ <browser:defaultView
+ for="lp.rocks.interfaces.rockrecipe.IRockRecipe"
+ name="+index" />
+ <browser:page
+ for="lp.rocks.interfaces.rockrecipe.IRockRecipe"
+ class="lp.rocks.browser.rockrecipe.RockRecipeView"
+ permission="launchpad.View"
+ name="+index"
+ template="../templates/rockrecipe-index.pt" />
+ <lp:navigation
+ module="lp.rocks.browser.rockrecipe"
+ classes="RockRecipeNavigation" />
+ <adapter
+ provides="lp.services.webapp.interfaces.IBreadcrumb"
+ for="lp.rocks.interfaces.rockrecipe.IRockRecipe"
+ factory="lp.rocks.browser.rockrecipe.RockRecipeBreadcrumb"
+ permission="zope.Public" />
+>>>>>>> lib/lp/rocks/browser/configure.zcml
<lp:url
for="lp.rocks.interfaces.rockrecipe.IRockRecipeBuildRequest"
path_expression="string:+build-request/${id}"
diff --git a/lib/lp/rocks/browser/rockrecipe.py b/lib/lp/rocks/browser/rockrecipe.py
index 0e13dbe..64fa233 100644
--- a/lib/lp/rocks/browser/rockrecipe.py
+++ b/lib/lp/rocks/browser/rockrecipe.py
@@ -6,15 +6,30 @@
__all__ = [
"RockRecipeNavigation",
"RockRecipeURL",
+<<<<<<< lib/lp/rocks/browser/rockrecipe.py
+=======
+ "RockRecipeView",
+>>>>>>> lib/lp/rocks/browser/rockrecipe.py
]
from zope.component import getUtility
from zope.interface import implementer
+<<<<<<< lib/lp/rocks/browser/rockrecipe.py
+=======
+from zope.security.interfaces import Unauthorized
+>>>>>>> lib/lp/rocks/browser/rockrecipe.py
from lp.registry.interfaces.personproduct import IPersonProductFactory
from lp.rocks.interfaces.rockrecipe import IRockRecipe
from lp.rocks.interfaces.rockrecipebuild import IRockRecipeBuildSet
+<<<<<<< lib/lp/rocks/browser/rockrecipe.py
from lp.services.webapp import Navigation, stepthrough
+=======
+from lp.services.propertycache import cachedproperty
+from lp.services.utils import seconds_since_epoch
+from lp.services.webapp import LaunchpadView, Navigation, stepthrough
+from lp.services.webapp.breadcrumb import Breadcrumb, NameBreadcrumb
+>>>>>>> lib/lp/rocks/browser/rockrecipe.py
from lp.services.webapp.interfaces import ICanonicalUrlData
from lp.soyuz.browser.build import get_build_by_id_str
@@ -56,3 +71,100 @@ class RockRecipeNavigation(Navigation):
if build is None or build.recipe != self.context:
return None
return build
+<<<<<<< lib/lp/rocks/browser/rockrecipe.py
+=======
+
+
+class RockRecipeBreadcrumb(NameBreadcrumb):
+
+ @property
+ def inside(self):
+ # XXX cjwatson 2021-06-04: This should probably link to an
+ # appropriate listing view, but we don't have one of those yet.
+ return Breadcrumb(
+ self.context.project,
+ text=self.context.project.display_name,
+ inside=self.context.project,
+ )
+
+
+class RockRecipeView(LaunchpadView):
+ """Default view of a rock recipe."""
+
+ @cachedproperty
+ def builds_and_requests(self):
+ return builds_and_requests_for_recipe(self.context)
+
+ @property
+ def build_frequency(self):
+ if self.context.auto_build:
+ return "Built automatically"
+ else:
+ return "Built on request"
+
+ @property
+ def sorted_auto_build_channels_items(self):
+ if self.context.auto_build_channels is None:
+ return []
+ return sorted(self.context.auto_build_channels.items())
+
+ @property
+ def store_channels(self):
+ return ", ".join(self.context.store_channels)
+
+ @property
+ def user_can_see_source(self):
+ try:
+ return self.context.source.visibleByUser(self.user)
+ except Unauthorized:
+ return False
+
+
+def builds_and_requests_for_recipe(recipe):
+ """A list of interesting builds and build requests.
+
+ All pending builds and pending build requests are shown, as well as up
+ to 10 recent builds and recent failed build requests. Pending items are
+ ordered by the date they were created; recent items are ordered by the
+ date they finished (if available) or the date they started (if the date
+ they finished is not set due to an error). This allows started but
+ unfinished builds to show up in the view but be discarded as more recent
+ builds become available.
+
+ Builds that the user does not have permission to see are excluded (by
+ the model code).
+ """
+
+ # We need to interleave items of different types, so SQL can't do all
+ # the sorting for us.
+ def make_sort_key(*date_attrs):
+ def _sort_key(item):
+ for date_attr in date_attrs:
+ if getattr(item, date_attr, None) is not None:
+ return -seconds_since_epoch(getattr(item, date_attr))
+ return 0
+
+ return _sort_key
+
+ items = sorted(
+ list(recipe.pending_builds) + list(recipe.pending_build_requests),
+ key=make_sort_key("date_created", "date_requested"),
+ )
+ if len(items) < 10:
+ # We need to interleave two unbounded result sets, but we only need
+ # enough items from them to make the total count up to 10. It's
+ # simplest to just fetch the upper bound from each set and do our
+ # own sorting.
+ recent_items = sorted(
+ list(recipe.completed_builds[: 10 - len(items)])
+ + list(recipe.failed_build_requests[: 10 - len(items)]),
+ key=make_sort_key(
+ "date_finished",
+ "date_started",
+ "date_created",
+ "date_requested",
+ ),
+ )
+ items.extend(recent_items[: 10 - len(items)])
+ return items
+>>>>>>> lib/lp/rocks/browser/rockrecipe.py
diff --git a/lib/lp/rocks/browser/tests/test_rockrecipe.py b/lib/lp/rocks/browser/tests/test_rockrecipe.py
index 229824d..fe4a083 100644
--- a/lib/lp/rocks/browser/tests/test_rockrecipe.py
+++ b/lib/lp/rocks/browser/tests/test_rockrecipe.py
@@ -3,11 +3,42 @@
"""Test rock recipe views."""
+<<<<<<< lib/lp/rocks/browser/tests/test_rockrecipe.py
from lp.rocks.interfaces.rockrecipe import ROCK_RECIPE_ALLOW_CREATE
from lp.services.features.testing import FeatureFixture
from lp.services.webapp import canonical_url
from lp.testing import TestCaseWithFactory
from lp.testing.layers import DatabaseFunctionalLayer
+=======
+import re
+from datetime import datetime, timedelta, timezone
+
+import soupmatchers
+import transaction
+from fixtures import FakeLogger
+from testtools.matchers import Equals, MatchesListwise, MatchesStructure
+from zope.component import getUtility
+from zope.security.proxy import removeSecurityProxy
+
+from lp.app.interfaces.launchpad import ILaunchpadCelebrities
+from lp.buildmaster.enums import BuildStatus
+from lp.buildmaster.interfaces.processor import IProcessorSet
+from lp.rocks.browser.rockrecipe import RockRecipeView
+from lp.rocks.interfaces.rockrecipe import ROCK_RECIPE_ALLOW_CREATE
+from lp.services.features.testing import FeatureFixture
+from lp.services.job.interfaces.job import JobStatus
+from lp.services.propertycache import get_property_cache
+from lp.services.webapp import canonical_url
+from lp.testing import (
+ BrowserTestCase,
+ TestCaseWithFactory,
+ person_logged_in,
+ time_counter,
+)
+from lp.testing.layers import DatabaseFunctionalLayer, LaunchpadFunctionalLayer
+from lp.testing.publication import test_traverse
+from lp.testing.views import create_initialized_view, create_view
+>>>>>>> lib/lp/rocks/browser/tests/test_rockrecipe.py
class TestRockRecipeNavigation(TestCaseWithFactory):
@@ -28,3 +59,301 @@ class TestRockRecipeNavigation(TestCaseWithFactory):
"http://launchpad.test/~person/project/+rock/rock",
canonical_url(recipe),
)
+<<<<<<< lib/lp/rocks/browser/tests/test_rockrecipe.py
+=======
+
+ def test_rock_recipe(self):
+ recipe = self.factory.makeRockRecipe()
+ obj, _, _ = test_traverse(
+ "http://launchpad.test/~%s/%s/+rock/%s"
+ % (recipe.owner.name, recipe.project.name, recipe.name)
+ )
+ self.assertEqual(recipe, obj)
+
+
+class BaseTestRockRecipeView(BrowserTestCase):
+
+ layer = LaunchpadFunctionalLayer
+
+ def setUp(self):
+ super().setUp()
+ self.useFixture(FeatureFixture({ROCK_RECIPE_ALLOW_CREATE: "on"}))
+ self.useFixture(FakeLogger())
+ self.person = self.factory.makePerson(
+ name="test-person", displayname="Test Person"
+ )
+
+
+class TestRockRecipeView(BaseTestRockRecipeView):
+
+ def setUp(self):
+ super().setUp()
+ self.project = self.factory.makeProduct(
+ name="test-project", displayname="Test Project"
+ )
+ self.ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
+ self.distroseries = self.factory.makeDistroSeries(
+ distribution=self.ubuntu
+ )
+ processor = getUtility(IProcessorSet).getByName("386")
+ self.distroarchseries = self.factory.makeDistroArchSeries(
+ distroseries=self.distroseries,
+ architecturetag="i386",
+ processor=processor,
+ )
+ self.factory.makeBuilder(virtualized=True)
+
+ def makeRockRecipe(self, **kwargs):
+ if "project" not in kwargs:
+ kwargs["project"] = self.project
+ if "git_ref" not in kwargs:
+ kwargs["git_ref"] = self.factory.makeGitRefs()[0]
+ return self.factory.makeRockRecipe(
+ registrant=self.person,
+ owner=self.person,
+ name="rock-name",
+ **kwargs,
+ )
+
+ def makeBuild(self, recipe=None, date_created=None, **kwargs):
+ if recipe is None:
+ recipe = self.makeRockRecipe()
+ if date_created is None:
+ datetime.now(timezone.utc) - timedelta(hours=1)
+ build = self.factory.makeRockRecipeBuild(
+ requester=self.person,
+ recipe=recipe,
+ distro_arch_series=self.distroarchseries,
+ date_created=date_created,
+ **kwargs,
+ )
+ job = removeSecurityProxy(
+ removeSecurityProxy(build.build_request)._job
+ )
+ job.job._status = JobStatus.COMPLETED
+ return build
+
+ def test_breadcrumb(self):
+ recipe = self.makeRockRecipe()
+ view = create_view(recipe, "+index")
+ # To test the breadcrumbs we need a correct traversal stack.
+ view.request.traversed_objects = [
+ recipe.owner,
+ recipe.project,
+ recipe,
+ view,
+ ]
+ view.initialize()
+ breadcrumbs_tag = soupmatchers.Tag(
+ "breadcrumbs", "ol", attrs={"class": "breadcrumbs"}
+ )
+ self.assertThat(
+ view(),
+ soupmatchers.HTMLContains(
+ soupmatchers.Within(
+ breadcrumbs_tag,
+ soupmatchers.Tag(
+ "project breadcrumb",
+ "a",
+ text="Test Project",
+ attrs={"href": re.compile(r"/test-project$")},
+ ),
+ ),
+ soupmatchers.Within(
+ breadcrumbs_tag,
+ soupmatchers.Tag(
+ "rock breadcrumb",
+ "li",
+ text=re.compile(r"\srock-name\s"),
+ ),
+ ),
+ ),
+ )
+
+ def test_index_git(self):
+ [ref] = self.factory.makeGitRefs(
+ owner=self.person,
+ target=self.project,
+ name="rock-repository",
+ paths=["refs/heads/master"],
+ )
+ recipe = self.makeRockRecipe(git_ref=ref)
+ build = self.makeBuild(
+ recipe=recipe,
+ status=BuildStatus.FULLYBUILT,
+ duration=timedelta(minutes=30),
+ )
+ self.assertTextMatchesExpressionIgnoreWhitespace(
+ r"""\
+ Test Project
+ rock-name
+ .*
+ Rock recipe information
+ Owner: Test Person
+ Project: Test Project
+ Source: ~test-person/test-project/\+git/rock-repository:master
+ Build schedule: \(\?\)
+ Built on request
+ Builds of this rock recipe are not automatically uploaded to
+ the store.
+ Latest builds
+ Status When complete Architecture
+ Successfully built 30 minutes ago i386
+ """,
+ self.getMainText(build.recipe),
+ )
+
+ def test_index_success_with_buildlog(self):
+ # The build log is shown if it is there.
+ build = self.makeBuild(
+ status=BuildStatus.FULLYBUILT, duration=timedelta(minutes=30)
+ )
+ build.setLog(self.factory.makeLibraryFileAlias())
+ self.assertTextMatchesExpressionIgnoreWhitespace(
+ r"""\
+ Latest builds
+ Status When complete Architecture
+ Successfully built 30 minutes ago buildlog \(.*\) i386
+ """,
+ self.getMainText(build.recipe),
+ )
+
+ def test_index_no_builds(self):
+ # A message is shown when there are no builds.
+ recipe = self.makeRockRecipe()
+ self.assertIn(
+ "This rock recipe has not been built yet.",
+ self.getMainText(recipe),
+ )
+
+ def test_index_pending_build(self):
+ # A pending build is listed as such.
+ build = self.makeBuild()
+ build.queueBuild()
+ self.assertTextMatchesExpressionIgnoreWhitespace(
+ r"""\
+ Latest builds
+ Status When complete Architecture
+ Needs building in .* \(estimated\) i386
+ """,
+ self.getMainText(build.recipe),
+ )
+
+ def test_index_pending_build_request(self):
+ # A pending build request is listed as such.
+ recipe = self.makeRockRecipe()
+ with person_logged_in(recipe.owner):
+ recipe.requestBuilds(recipe.owner)
+ self.assertTextMatchesExpressionIgnoreWhitespace(
+ """\
+ Latest builds
+ Status When complete Architecture
+ Pending build request
+ """,
+ self.getMainText(recipe),
+ )
+
+ def test_index_failed_build_request(self):
+ # A failed build request is listed as such, with its error message.
+ recipe = self.makeRockRecipe()
+ with person_logged_in(recipe.owner):
+ request = recipe.requestBuilds(recipe.owner)
+ job = removeSecurityProxy(removeSecurityProxy(request)._job)
+ job.job._status = JobStatus.FAILED
+ job.job.date_finished = datetime.now(timezone.utc) - timedelta(hours=1)
+ job.error_message = "Boom"
+ self.assertTextMatchesExpressionIgnoreWhitespace(
+ r"""\
+ Latest builds
+ Status When complete Architecture
+ Failed build request 1 hour ago \(Boom\)
+ """,
+ self.getMainText(recipe),
+ )
+
+ def setStatus(self, build, status):
+ build.updateStatus(
+ BuildStatus.BUILDING, date_started=build.date_created
+ )
+ build.updateStatus(
+ status, date_finished=build.date_started + timedelta(minutes=30)
+ )
+
+ def test_builds_and_requests(self):
+ # RockRecipeView.builds_and_requests produces reasonable results,
+ # interleaving build requests with builds.
+ recipe = self.makeRockRecipe()
+ # Create oldest builds first so that they sort properly by id.
+ date_gen = time_counter(
+ datetime(2000, 1, 1, tzinfo=timezone.utc), timedelta(days=1)
+ )
+ builds = [
+ self.makeBuild(recipe=recipe, date_created=next(date_gen))
+ for i in range(3)
+ ]
+ self.setStatus(builds[2], BuildStatus.FULLYBUILT)
+ with person_logged_in(recipe.owner):
+ request = recipe.requestBuilds(recipe.owner)
+ job = removeSecurityProxy(removeSecurityProxy(request)._job)
+ job.job.date_created = next(date_gen)
+ view = RockRecipeView(recipe, None)
+ # The pending build request is interleaved in date order with
+ # pending builds, and these are followed by completed builds.
+ self.assertThat(
+ view.builds_and_requests,
+ MatchesListwise(
+ [
+ MatchesStructure.byEquality(id=request.id),
+ Equals(builds[1]),
+ Equals(builds[0]),
+ Equals(builds[2]),
+ ]
+ ),
+ )
+ transaction.commit()
+ builds.append(self.makeBuild(recipe=recipe))
+ del get_property_cache(view).builds_and_requests
+ self.assertThat(
+ view.builds_and_requests,
+ MatchesListwise(
+ [
+ Equals(builds[3]),
+ MatchesStructure.byEquality(id=request.id),
+ Equals(builds[1]),
+ Equals(builds[0]),
+ Equals(builds[2]),
+ ]
+ ),
+ )
+ # If we pretend that the job failed, it is still listed, but after
+ # any pending builds.
+ job.job._status = JobStatus.FAILED
+ job.job.date_finished = job.date_created + timedelta(minutes=30)
+ del get_property_cache(view).builds_and_requests
+ self.assertThat(
+ view.builds_and_requests,
+ MatchesListwise(
+ [
+ Equals(builds[3]),
+ Equals(builds[1]),
+ Equals(builds[0]),
+ MatchesStructure.byEquality(id=request.id),
+ Equals(builds[2]),
+ ]
+ ),
+ )
+
+ def test_store_channels_empty(self):
+ recipe = self.factory.makeRockRecipe()
+ view = create_initialized_view(recipe, "+index")
+ self.assertEqual("", view.store_channels)
+
+ def test_store_channels_display(self):
+ recipe = self.factory.makeRockRecipe(
+ store_channels=["track/stable/fix-123", "track/edge/fix-123"]
+ )
+ view = create_initialized_view(recipe, "+index")
+ self.assertEqual(
+ "track/stable/fix-123, track/edge/fix-123", view.store_channels
+ )
+>>>>>>> lib/lp/rocks/browser/tests/test_rockrecipe.py
diff --git a/lib/lp/rocks/configure.zcml b/lib/lp/rocks/configure.zcml
index bad9c80..5e63e08 100644
--- a/lib/lp/rocks/configure.zcml
+++ b/lib/lp/rocks/configure.zcml
@@ -13,6 +13,11 @@
<lp:authorizations module=".security" />
<include package=".browser" />
+<<<<<<< lib/lp/rocks/configure.zcml
+=======
+ <lp:help-folder folder="help" name="+help-rocks" />
+
+>>>>>>> lib/lp/rocks/configure.zcml
<!-- RockRecipe -->
<class class="lp.rocks.model.rockrecipe.RockRecipe">
<require
diff --git a/lib/lp/rocks/help/rock-recipe-build-frequency.html b/lib/lp/rocks/help/rock-recipe-build-frequency.html
new file mode 100644
index 0000000..54364b4
--- /dev/null
+++ b/lib/lp/rocks/help/rock-recipe-build-frequency.html
@@ -0,0 +1,40 @@
+<html>
+ <head>
+ <title>Rock recipe build schedule</title>
+ <link rel="stylesheet" type="text/css" href="/+icing/combo.css" />
+ <style type="text/css">
+ dt {
+ font-weight: bold
+ }
+
+ dd p {
+ margin-bottom: 0.5em
+ }
+
+ </style>
+ </head>
+ <body>
+ <div class="yui-d0">
+ <h2>Rock recipe build schedule</h2>
+
+ <p>There are two options for when rock recipes get built:</p>
+ <dl>
+ <dt>Built automatically</dt>
+ <dd>
+ <p>A build will be scheduled automatically once a change to the
+ top-level source branch for the rock recipe is detected.</p>
+ <p>If there has been a build of the rock recipe within the previous
+ hour, the build will not be scheduled until an hour since the last
+ build.</p>
+ <p>If you really want the build to happen before the one-hour period
+ is up, you can use the "Request builds" action.</p>
+ </dd>
+ <dt>Built on request</dt>
+ <dd>
+ <p>Builds of the rock recipe have to be manually requested using
+ the "Request builds" action.</p>
+ </dd>
+ </dl>
+ </div>
+ </body>
+</html>
diff --git a/lib/lp/rocks/interfaces/rockrecipe.py b/lib/lp/rocks/interfaces/rockrecipe.py
index c4a7085..2abc106 100644
--- a/lib/lp/rocks/interfaces/rockrecipe.py
+++ b/lib/lp/rocks/interfaces/rockrecipe.py
@@ -37,7 +37,11 @@ import http.client
from lazr.enum import EnumeratedType, Item
from lazr.restful.declarations import error_status, exported
from lazr.restful.fields import CollectionField, Reference, ReferenceChoice
+<<<<<<< lib/lp/rocks/interfaces/rockrecipe.py
from zope.interface import Interface
+=======
+from zope.interface import Attribute, Interface
+>>>>>>> lib/lp/rocks/interfaces/rockrecipe.py
from zope.schema import (
Bool,
Choice,
@@ -291,6 +295,11 @@ class IRockRecipeView(Interface):
description=_("The person who registered this rock recipe."),
)
+<<<<<<< lib/lp/rocks/interfaces/rockrecipe.py
+=======
+ source = Attribute("The source branch for this rock recipe.")
+
+>>>>>>> lib/lp/rocks/interfaces/rockrecipe.py
private = Bool(
title=_("Private"),
required=False,
@@ -372,6 +381,56 @@ class IRockRecipeView(Interface):
:return: `IRockRecipeBuildRequest`.
"""
+<<<<<<< lib/lp/rocks/interfaces/rockrecipe.py
+=======
+ pending_build_requests = CollectionField(
+ title=_("Pending build requests for this rock recipe."),
+ value_type=Reference(IRockRecipeBuildRequest),
+ required=True,
+ readonly=True,
+ )
+
+ failed_build_requests = CollectionField(
+ title=_("Failed build requests for this rock recipe."),
+ value_type=Reference(IRockRecipeBuildRequest),
+ required=True,
+ readonly=True,
+ )
+
+ builds = CollectionField(
+ title=_("All builds of this rock recipe."),
+ description=_(
+ "All builds of this rock recipe, sorted in descending order "
+ "of finishing (or starting if not completed successfully)."
+ ),
+ # Really IRockRecipeBuild.
+ value_type=Reference(schema=Interface),
+ readonly=True,
+ )
+
+ completed_builds = CollectionField(
+ title=_("Completed builds of this rock recipe."),
+ description=_(
+ "Completed builds of this rock recipe, sorted in descending "
+ "order of finishing."
+ ),
+ # Really IRockRecipeBuild.
+ value_type=Reference(schema=Interface),
+ readonly=True,
+ )
+
+ pending_builds = CollectionField(
+ title=_("Pending builds of this rock recipe."),
+ description=_(
+ "Pending builds of this rock recipe, sorted in descending "
+ "order of creation."
+ ),
+ # Really IRockRecipeBuild.
+ value_type=Reference(schema=Interface),
+ readonly=True,
+ )
+
+>>>>>>> lib/lp/rocks/interfaces/rockrecipe.py
class IRockRecipeEdit(Interface):
"""`IRockRecipe` methods that require launchpad.Edit permission."""
diff --git a/lib/lp/rocks/model/rockrecipe.py b/lib/lp/rocks/model/rockrecipe.py
index 2070951..bf575b6 100644
--- a/lib/lp/rocks/model/rockrecipe.py
+++ b/lib/lp/rocks/model/rockrecipe.py
@@ -14,6 +14,12 @@ from operator import itemgetter
from lazr.lifecycle.event import ObjectCreatedEvent
from storm.databases.postgres import JSON
from storm.locals import (
+ Bool,
+ DateTime,
+ Int,
+ Join,
+ Or,
+ Reference,
=======
from datetime import timezone
from operator import attrgetter, itemgetter
@@ -23,15 +29,14 @@ from lazr.lifecycle.event import ObjectCreatedEvent
from storm.databases.postgres import JSON
from storm.locals import (
And,
->>>>>>> lib/lp/rocks/model/rockrecipe.py
Bool,
DateTime,
+ Desc,
Int,
Join,
+ Not,
Or,
Reference,
-<<<<<<< lib/lp/rocks/model/rockrecipe.py
-=======
Select,
>>>>>>> lib/lp/rocks/model/rockrecipe.py
Store,
@@ -50,6 +55,8 @@ from lp.app.enums import (
from lp.buildmaster.enums import BuildStatus
<<<<<<< lib/lp/rocks/model/rockrecipe.py
=======
+from lp.buildmaster.interfaces.buildqueue import IBuildQueueSet
+from lp.buildmaster.model.builder import Builder
from lp.buildmaster.model.buildfarmjob import BuildFarmJob
from lp.buildmaster.model.buildqueue import BuildQueue
from lp.code.errors import GitRepositoryBlobNotFound, GitRepositoryScanFault
@@ -105,10 +112,13 @@ from lp.services.database.decoratedresultset import DecoratedResultSet
from lp.services.database.enumcol import DBEnum
from lp.services.database.interfaces import IPrimaryStore, IStore
from lp.services.database.stormbase import StormBase
+<<<<<<< lib/lp/rocks/model/rockrecipe.py
from lp.services.features import getFeatureFlag
from lp.services.job.interfaces.job import JobStatus
-<<<<<<< lib/lp/rocks/model/rockrecipe.py
=======
+from lp.services.database.stormexpr import Greatest, NullsLast
+from lp.services.features import getFeatureFlag
+from lp.services.job.interfaces.job import JobStatus
from lp.services.job.model.job import Job
>>>>>>> lib/lp/rocks/model/rockrecipe.py
from lp.services.librarian.model import LibraryFileAlias
@@ -360,6 +370,14 @@ class RockRecipe(StormBase):
self.git_path = None
@property
+<<<<<<< lib/lp/rocks/model/rockrecipe.py
+=======
+ def source(self):
+ """See `IRockRecipe`."""
+ return self.git_ref
+
+ @property
+>>>>>>> lib/lp/rocks/model/rockrecipe.py
def store_channels(self):
"""See `IRockRecipe`."""
return self._store_channels or []
@@ -575,11 +593,105 @@ class RockRecipe(StormBase):
"""See `IRockRecipe`."""
return RockRecipeBuildRequest(self, job_id)
+<<<<<<< lib/lp/rocks/model/rockrecipe.py
def destroySelf(self):
"""See `IRockRecipe`."""
-<<<<<<< lib/lp/rocks/model/rockrecipe.py
IStore(RockRecipe).remove(self)
=======
+ @property
+ def pending_build_requests(self):
+ """See `IRockRecipe`."""
+ job_source = getUtility(IRockRecipeRequestBuildsJobSource)
+ # The returned jobs are ordered by descending ID.
+ jobs = job_source.findByRecipe(
+ self, statuses=(JobStatus.WAITING, JobStatus.RUNNING)
+ )
+ return DecoratedResultSet(
+ jobs, result_decorator=RockRecipeBuildRequest.fromJob
+ )
+
+ @property
+ def failed_build_requests(self):
+ """See `IRockRecipe`."""
+ job_source = getUtility(IRockRecipeRequestBuildsJobSource)
+ # The returned jobs are ordered by descending ID.
+ jobs = job_source.findByRecipe(self, statuses=(JobStatus.FAILED,))
+ return DecoratedResultSet(
+ jobs, result_decorator=RockRecipeBuildRequest.fromJob
+ )
+
+ def _getBuilds(self, filter_term, order_by):
+ """The actual query to get the builds."""
+ query_args = [
+ RockRecipeBuild.recipe == self,
+ ]
+ if filter_term is not None:
+ query_args.append(filter_term)
+ result = Store.of(self).find(RockRecipeBuild, *query_args)
+ result.order_by(order_by)
+
+ def eager_load(rows):
+ getUtility(IRockRecipeBuildSet).preloadBuildsData(rows)
+ getUtility(IBuildQueueSet).preloadForBuildFarmJobs(rows)
+ load_related(Builder, rows, ["builder_id"])
+
+ return DecoratedResultSet(result, pre_iter_hook=eager_load)
+
+ @property
+ def builds(self):
+ """See `IRockRecipe`."""
+ order_by = (
+ NullsLast(
+ Desc(
+ Greatest(
+ RockRecipeBuild.date_started,
+ RockRecipeBuild.date_finished,
+ )
+ )
+ ),
+ Desc(RockRecipeBuild.date_created),
+ Desc(RockRecipeBuild.id),
+ )
+ return self._getBuilds(None, order_by)
+
+ @property
+ def _pending_states(self):
+ """All the build states we consider pending (non-final)."""
+ return [
+ BuildStatus.NEEDSBUILD,
+ BuildStatus.BUILDING,
+ BuildStatus.UPLOADING,
+ BuildStatus.CANCELLING,
+ ]
+
+ @property
+ def completed_builds(self):
+ """See `IRockRecipe`."""
+ filter_term = Not(RockRecipeBuild.status.is_in(self._pending_states))
+ order_by = (
+ NullsLast(
+ Desc(
+ Greatest(
+ RockRecipeBuild.date_started,
+ RockRecipeBuild.date_finished,
+ )
+ )
+ ),
+ Desc(RockRecipeBuild.id),
+ )
+ return self._getBuilds(filter_term, order_by)
+
+ @property
+ def pending_builds(self):
+ """See `IRockRecipe`."""
+ filter_term = RockRecipeBuild.status.is_in(self._pending_states)
+ # We want to order by date_created but this is the same as ordering
+ # by id (since id increases monotonically) and is less expensive.
+ order_by = Desc(RockRecipeBuild.id)
+ return self._getBuilds(filter_term, order_by)
+
+ def destroySelf(self):
+ """See `IRockRecipe`."""
store = IStore(self)
# Remove build jobs. There won't be many queued builds, so we can
# afford to do this the safe but slow way via BuildQueue.destroySelf
diff --git a/lib/lp/rocks/templates/rockrecipe-index.pt b/lib/lp/rocks/templates/rockrecipe-index.pt
new file mode 100644
index 0000000..ca29972
--- /dev/null
+++ b/lib/lp/rocks/templates/rockrecipe-index.pt
@@ -0,0 +1,166 @@
+<html
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:tal="http://xml.zope.org/namespaces/tal"
+ xmlns:metal="http://xml.zope.org/namespaces/metal"
+ xmlns:i18n="http://xml.zope.org/namespaces/i18n"
+ metal:use-macro="view/macro:page/main_side"
+ i18n:domain="launchpad"
+>
+
+<body>
+ <metal:registering fill-slot="registering">
+ Created by
+ <tal:registrant replace="structure context/registrant/fmt:link"/>
+ on
+ <tal:created-on replace="structure context/date_created/fmt:date"/>
+ and last modified on
+ <tal:last-modified replace="structure context/date_last_modified/fmt:date"/>
+ </metal:registering>
+
+ <metal:side fill-slot="side">
+ <div tal:replace="structure context/@@+global-actions"/>
+ </metal:side>
+
+ <metal:heading fill-slot="heading">
+ <h1 tal:content="context/name"/>
+ </metal:heading>
+
+ <div metal:fill-slot="main">
+ <h2>Rock recipe information</h2>
+ <div class="two-column-list">
+ <dl id="owner">
+ <dt>Owner:</dt>
+ <dd tal:content="structure context/owner/fmt:link"/>
+ </dl>
+ <dl id="project" tal:define="project context/project">
+ <dt>Project:</dt>
+ <dd>
+ <a tal:attributes="href context/project/fmt:url"
+ tal:content="context/project/display_name"/>
+ </dd>
+ </dl>
+ <dl id="source"
+ tal:define="source context/source" tal:condition="source">
+ <dt>Source:</dt>
+ <dd tal:condition="view/user_can_see_source">
+ <a tal:replace="structure source/fmt:link"/>
+ </dd>
+ <dd tal:condition="not: view/user_can_see_source">
+ <span class="sprite private"><redacted></span>
+ </dd>
+ </dl>
+
+ <dl id="auto_build">
+ <dt>Build schedule:
+ <a href="/+help-rocks/rock-recipe-build-frequency.html"
+ target="help" class="sprite maybe action-icon">(?)</a>
+ </dt>
+ <dd>
+ <span tal:replace="view/build_frequency"/>
+ </dd>
+ </dl>
+ <dl id="auto_build_channels" tal:condition="context/auto_build_channels">
+ <dt>
+ Source snap channels for automatic builds:
+ </dt>
+ <dd>
+ <table class="listing compressed">
+ <tbody>
+ <tr tal:repeat="pair view/sorted_auto_build_channels_items">
+ <td tal:repeat="value pair" tal:content="value"/>
+ </tr>
+ </tbody>
+ </table>
+ </dd>
+ </dl>
+ </div>
+
+ <div id="store_upload" class="two-column-list"
+ tal:condition="context/store_upload">
+ <dl id="store_name">
+ <dt>Registered store package name:</dt>
+ <dd>
+ <span tal:content="context/store_name"/>
+ </dd>
+ </dl>
+ <dl id="store_channels" tal:condition="view/store_channels">
+ <dt>Store channels:</dt>
+ <dd>
+ <span tal:content="view/store_channels"/>
+ </dd>
+ </dl>
+ <p id="store_channels" tal:condition="not: view/store_channels">
+ This rock recipe will not be released to any channels on the store.
+ </p>
+ </div>
+ <p id="store_upload" tal:condition="not: context/store_upload">
+ Builds of this rock recipe are not automatically uploaded to the store.
+ </p>
+
+ <h2>Latest builds</h2>
+ <table id="latest-builds-listing" class="listing"
+ style="margin-bottom: 1em;">
+ <thead>
+ <tr>
+ <th>Status</th>
+ <th>When complete</th>
+ <th>Architecture</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tal:rock-recipe-builds-and-requests repeat="item view/builds_and_requests">
+ <tal:rock-recipe-build-request condition="item/date_requested|nothing">
+ <tr tal:define="request item"
+ tal:attributes="id string:request-${request/id}">
+ <td tal:attributes="class string:request_status ${request/status/name}">
+ <span tal:replace="structure request/image:icon"/>
+ <tal:title replace="request/status/title"/> build request
+ </td>
+ <td>
+ <tal:date condition="request/date_finished"
+ replace="structure request/date_finished/fmt:displaydatetitle"/>
+ <tal:error-message condition="request/error_message">
+ (<span tal:replace="request/error_message"/>)
+ </tal:error-message>
+ </td>
+ <td/>
+ </tr>
+ </tal:rock-recipe-build-request>
+ <tal:rock-recipe-build condition="not: item/date_requested|nothing">
+ <tr tal:define="build item"
+ tal:attributes="id string:build-${build/id}">
+ <td tal:attributes="class string:build_status ${build/status/name}">
+ <span tal:replace="structure build/image:icon"/>
+ <a tal:content="build/status/title"
+ tal:attributes="href build/fmt:url"/>
+ </td>
+ <td class="datebuilt">
+ <tal:date replace="structure build/date/fmt:displaydatetitle"/>
+ <tal:estimate condition="build/estimate">
+ (estimated)
+ </tal:estimate>
+
+ <tal:build-log define="file build/log" tal:condition="file">
+ <a class="sprite download"
+ tal:attributes="href build/log_url">buildlog</a>
+ (<span tal:replace="file/content/filesize/fmt:bytes"/>)
+ </tal:build-log>
+ </td>
+ <td>
+ <a class="sprite distribution"
+ tal:define="archseries build/distro_arch_series"
+ tal:attributes="href archseries/fmt:url"
+ tal:content="archseries/architecturetag"/>
+ </td>
+ </tr>
+ </tal:rock-recipe-build>
+ </tal:rock-recipe-builds-and-requests>
+ </tbody>
+ </table>
+ <p tal:condition="not: view/builds_and_requests">
+ This rock recipe has not been built yet.
+ </p>
+ </div>
+
+</body>
+</html>
Follow ups