← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:charmhub-upload-ui into launchpad:master

 

Colin Watson has proposed merging ~cjwatson/launchpad:charmhub-upload-ui into launchpad:master.

Commit message:
Add UI for Charmhub uploads

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

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

In particular, this makes it possible to retry upload failures.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:charmhub-upload-ui into launchpad:master.
diff --git a/lib/lp/charms/browser/charmrecipebuild.py b/lib/lp/charms/browser/charmrecipebuild.py
index 5addc5b..518d8c3 100644
--- a/lib/lp/charms/browser/charmrecipebuild.py
+++ b/lib/lp/charms/browser/charmrecipebuild.py
@@ -16,7 +16,10 @@ from lp.app.browser.launchpadform import (
     action,
     LaunchpadFormView,
     )
-from lp.charms.interfaces.charmrecipebuild import ICharmRecipeBuild
+from lp.charms.interfaces.charmrecipebuild import (
+    CannotScheduleStoreUpload,
+    ICharmRecipeBuild,
+    )
 from lp.services.librarian.browser import (
     FileNavigationMixin,
     ProxiedLibraryFileAlias,
@@ -26,7 +29,6 @@ from lp.services.webapp import (
     canonical_url,
     ContextMenu,
     enabled_with_permission,
-    LaunchpadView,
     Link,
     Navigation,
     )
@@ -65,9 +67,12 @@ class CharmRecipeBuildContextMenu(ContextMenu):
             enabled=self.context.can_be_rescored)
 
 
-class CharmRecipeBuildView(LaunchpadView):
+class CharmRecipeBuildView(LaunchpadFormView):
     """Default view of a charm recipe build."""
 
+    class schema(Interface):
+        """Schema for uploading a build."""
+
     @property
     def label(self):
         return self.context.title
@@ -92,6 +97,18 @@ class CharmRecipeBuildView(LaunchpadView):
     def next_url(self):
         return canonical_url(self.context)
 
+    @action("Upload build to Charmhub", name="upload")
+    def upload_action(self, action, data):
+        """Schedule an upload of this build to Charmhub."""
+        try:
+            self.context.scheduleStoreUpload()
+        except CannotScheduleStoreUpload as e:
+            self.request.response.addWarningNotification(str(e))
+        else:
+            self.request.response.addInfoNotification(
+                "An upload has been scheduled and will run as soon as "
+                "possible.")
+
 
 class CharmRecipeBuildRetryView(LaunchpadFormView):
     """View for retrying a charm recipe build."""
diff --git a/lib/lp/charms/browser/tests/test_charmrecipebuild.py b/lib/lp/charms/browser/tests/test_charmrecipebuild.py
index 08c13a1..0abb77b 100644
--- a/lib/lp/charms/browser/tests/test_charmrecipebuild.py
+++ b/lib/lp/charms/browser/tests/test_charmrecipebuild.py
@@ -8,6 +8,7 @@ __metaclass__ = type
 import re
 
 from fixtures import FakeLogger
+from pymacaroons import Macaroon
 import soupmatchers
 from storm.locals import Store
 from testtools.matchers import StartsWith
@@ -21,7 +22,9 @@ from lp.app.enums import InformationType
 from lp.app.interfaces.launchpad import ILaunchpadCelebrities
 from lp.buildmaster.enums import BuildStatus
 from lp.charms.interfaces.charmrecipe import CHARM_RECIPE_ALLOW_CREATE
+from lp.charms.interfaces.charmrecipebuildjob import ICharmhubUploadJobSource
 from lp.services.features.testing import FeatureFixture
+from lp.services.job.interfaces.job import JobStatus
 from lp.services.webapp import canonical_url
 from lp.testing import (
     ANONYMOUS,
@@ -98,6 +101,63 @@ class TestCharmRecipeBuildView(TestCaseWithFactory):
                 "revision ID", "li", attrs={"id": "revision-id"},
                 text=re.compile(r"^\s*Revision: dummy\s*$"))))
 
+    def test_store_upload_status_in_progress(self):
+        build = self.factory.makeCharmRecipeBuild(
+            status=BuildStatus.FULLYBUILT)
+        getUtility(ICharmhubUploadJobSource).create(build)
+        build_view = create_initialized_view(build, "+index")
+        self.assertThat(build_view(), soupmatchers.HTMLContains(
+            soupmatchers.Tag(
+                "store upload status", "li",
+                attrs={"id": "store-upload-status"},
+                text=re.compile(r"^\s*Charmhub upload in progress\s*$"))))
+
+    def test_store_upload_status_completed(self):
+        build = self.factory.makeCharmRecipeBuild(
+            status=BuildStatus.FULLYBUILT)
+        job = getUtility(ICharmhubUploadJobSource).create(build)
+        naked_job = removeSecurityProxy(job)
+        naked_job.job._status = JobStatus.COMPLETED
+        build_view = create_initialized_view(build, "+index")
+        self.assertThat(build_view(), soupmatchers.HTMLContains(
+            soupmatchers.Tag(
+                "store upload status", "li",
+                attrs={"id": "store-upload-status"},
+                text=re.compile(r"^\s*Uploaded to Charmhub\s*$"))))
+
+    def test_store_upload_status_failed(self):
+        build = self.factory.makeCharmRecipeBuild(
+            status=BuildStatus.FULLYBUILT)
+        job = getUtility(ICharmhubUploadJobSource).create(build)
+        naked_job = removeSecurityProxy(job)
+        naked_job.job._status = JobStatus.FAILED
+        naked_job.error_message = "Review failed."
+        build_view = create_initialized_view(build, "+index")
+        self.assertThat(build_view(), soupmatchers.HTMLContains(
+            soupmatchers.Tag(
+                "store upload status", "li",
+                attrs={"id": "store-upload-status"},
+                text=re.compile(
+                    r"^\s*Charmhub upload failed:\s+"
+                    r"Review failed.\s*$"))))
+
+    def test_store_upload_status_release_failed(self):
+        build = self.factory.makeCharmRecipeBuild(
+            status=BuildStatus.FULLYBUILT)
+        job = getUtility(ICharmhubUploadJobSource).create(build)
+        naked_job = removeSecurityProxy(job)
+        naked_job.job._status = JobStatus.FAILED
+        naked_job.store_revision = 1
+        naked_job.error_message = "Failed to publish"
+        build_view = create_initialized_view(build, "+index")
+        self.assertThat(build_view(), soupmatchers.HTMLContains(
+            soupmatchers.Tag(
+                "store upload status", "li",
+                attrs={"id": "store-upload-status"},
+                text=re.compile(
+                    r"^\s*Charmhub release failed:\s+"
+                    r"Failed to publish\s*$"))))
+
 
 class TestCharmRecipeBuildOperations(BrowserTestCase):
 
@@ -232,6 +292,71 @@ class TestCharmRecipeBuildOperations(BrowserTestCase):
                 "notification", "div", attrs={"class": "warning message"},
                 text="Cannot rescore this build because it is not queued.")))
 
+    def setUpStoreUpload(self):
+        self.pushConfig("charms", charmhub_url="http://charmhub.example/";)
+        with person_logged_in(self.requester):
+            self.build.recipe.store_name = self.factory.getUniqueUnicode()
+            # CharmRecipe.can_upload_to_store only checks whether
+            # "exchanged_encrypted" is present, so don't bother setting up
+            # encryption keys here.
+            self.build.recipe.store_secrets = {
+                "exchanged_encrypted": Macaroon().serialize()}
+
+    def test_store_upload(self):
+        # A build not previously uploaded to Charmhub can be uploaded
+        # manually.
+        self.setUpStoreUpload()
+        self.build.updateStatus(BuildStatus.FULLYBUILT)
+        self.factory.makeCharmFile(
+            build=self.build,
+            library_file=self.factory.makeLibraryFileAlias(db_only=True))
+        browser = self.getViewBrowser(self.build, user=self.requester)
+        browser.getControl("Upload this charm to Charmhub").click()
+        self.assertEqual(self.build_url, browser.url)
+        login(ANONYMOUS)
+        [job] = getUtility(ICharmhubUploadJobSource).iterReady()
+        self.assertEqual(JobStatus.WAITING, job.job.status)
+        self.assertEqual(self.build, job.build)
+        self.assertEqual(
+            "An upload has been scheduled and will run as soon as possible.",
+            extract_text(find_tags_by_class(browser.contents, "message")[0]))
+
+    def test_store_upload_retry(self):
+        # A build with a previously-failed Charmhub upload can have the
+        # upload retried.
+        self.setUpStoreUpload()
+        self.build.updateStatus(BuildStatus.FULLYBUILT)
+        self.factory.makeCharmFile(
+            build=self.build,
+            library_file=self.factory.makeLibraryFileAlias(db_only=True))
+        old_job = getUtility(ICharmhubUploadJobSource).create(self.build)
+        removeSecurityProxy(old_job).job._status = JobStatus.FAILED
+        browser = self.getViewBrowser(self.build, user=self.requester)
+        browser.getControl("Retry").click()
+        self.assertEqual(self.build_url, browser.url)
+        login(ANONYMOUS)
+        [job] = getUtility(ICharmhubUploadJobSource).iterReady()
+        self.assertEqual(JobStatus.WAITING, job.job.status)
+        self.assertEqual(self.build, job.build)
+        self.assertEqual(
+            "An upload has been scheduled and will run as soon as possible.",
+            extract_text(find_tags_by_class(browser.contents, "message")[0]))
+
+    def test_store_upload_error_notifies(self):
+        # If a build cannot be scheduled for uploading to Charmhub, we issue
+        # a notification.
+        self.setUpStoreUpload()
+        self.build.updateStatus(BuildStatus.FULLYBUILT)
+        browser = self.getViewBrowser(self.build, user=self.requester)
+        browser.getControl("Upload this charm to Charmhub").click()
+        self.assertEqual(self.build_url, browser.url)
+        login(ANONYMOUS)
+        self.assertEqual(
+            [], list(getUtility(ICharmhubUploadJobSource).iterReady()))
+        self.assertEqual(
+            "Cannot upload this charm because it has no files.",
+            extract_text(find_tags_by_class(browser.contents, "message")[0]))
+
     def test_builder_history(self):
         Store.of(self.build).flush()
         self.build.updateStatus(
diff --git a/lib/lp/charms/templates/charmrecipebuild-index.pt b/lib/lp/charms/templates/charmrecipebuild-index.pt
index 1d0e4f0..95115bf 100644
--- a/lib/lp/charms/templates/charmrecipebuild-index.pt
+++ b/lib/lp/charms/templates/charmrecipebuild-index.pt
@@ -155,6 +155,44 @@
            tal:attributes="href context/upload_log_url">uploadlog</a>
         (<span tal:replace="file/content/filesize/fmt:bytes" />)
       </li>
+      <li id="store-upload-status"
+          tal:define="job context/last_store_upload_job"
+          tal:condition="job">
+        <tal:pending
+            condition="context/store_upload_status/enumvalue:PENDING">
+          Charmhub upload in progress
+        </tal:pending>
+        <tal:failed-upload
+            condition="context/store_upload_status/enumvalue:FAILEDTOUPLOAD">
+          Charmhub upload failed:
+          <tal:error-message replace="context/store_upload_error_message" />
+          <form action="" method="POST">
+            <input type="submit" name="field.actions.upload" value="Retry" />
+          </form>
+        </tal:failed-upload>
+        <tal:failed-release
+            condition="context/store_upload_status/enumvalue:FAILEDTORELEASE">
+          Charmhub release failed:
+          <tal:error-message replace="context/store_upload_error_message" />
+          <form action="" method="POST">
+            <input type="submit" name="field.actions.upload" value="Retry" />
+          </form>
+        </tal:failed-release>
+        <tal:uploaded
+            condition="context/store_upload_status/enumvalue:UPLOADED">
+          Uploaded to Charmhub
+        </tal:uploaded>
+      </li>
+      <li id="store-upload-status"
+          tal:condition="python:
+            context.status.title == 'Successfully built' and
+            context.recipe.can_upload_to_store and
+            context.last_store_upload_job is None">
+        <form action="" method="POST">
+          <input type="submit" name="field.actions.upload"
+                 value="Upload this charm to Charmhub" />
+        </form>
+      </li>
     </ul>
 
     <div