← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:revision-status-report-overall-icon into launchpad:master

 

Colin Watson has proposed merging ~cjwatson/launchpad:revision-status-report-overall-icon into launchpad:master.

Commit message:
Fix overall summary of revision status reports for a commit

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

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

The set of revision status reports for a commit aren't well summarized by a boolean succeeded/failed icon that only goes green when all jobs have succeeded: some or all of the jobs might be skipped, or they might still be in progress.  Replace this with a more nuanced set of rules as follows:

 * If all jobs were skipped, show the `yes-gray` icon.
 * Otherwise, if all jobs either succeeded or were skipped, show the `yes` icon.
 * Otherwise, if any jobs failed or were cancelled, show the `no` icon.
 * Otherwise, show the `processing` icon.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:revision-status-report-overall-icon into launchpad:master.
diff --git a/lib/lp/code/browser/branchmergeproposal.py b/lib/lp/code/browser/branchmergeproposal.py
index 064e37b..4e89019 100644
--- a/lib/lp/code/browser/branchmergeproposal.py
+++ b/lib/lp/code/browser/branchmergeproposal.py
@@ -75,6 +75,7 @@ from lp.app.browser.tales import DateTimeFormatterAPI
 from lp.code.adapters.branch import BranchMergeProposalNoPreviewDiffDelta
 from lp.code.browser.codereviewcomment import CodeReviewDisplayComment
 from lp.code.browser.decorations import DecoratedBranch
+from lp.code.browser.revisionstatus import HasRevisionStatusReportsMixin
 from lp.code.browser.widgets.gitref import GitRefWidget
 from lp.code.enums import (
     BranchMergeProposalStatus,
@@ -126,7 +127,6 @@ from lp.services.webapp import (
     stepthrough,
     )
 from lp.services.webapp.authorization import check_permission
-from lp.services.webapp.batching import BatchNavigator
 from lp.services.webapp.breadcrumb import Breadcrumb
 from lp.services.webapp.escaping import structured
 from lp.services.webapp.interfaces import ILaunchBag
@@ -634,7 +634,8 @@ class CodeReviewNewRevisions:
 class BranchMergeProposalView(LaunchpadFormView, UnmergedRevisionsMixin,
                               BranchMergeProposalRevisionIdMixin,
                               BranchMergeProposalStatusMixin,
-                              DiffRenderingMixin):
+                              DiffRenderingMixin,
+                              HasRevisionStatusReportsMixin):
     """A basic view used for the index page."""
 
     schema = ClaimButton
@@ -665,11 +666,6 @@ class BranchMergeProposalView(LaunchpadFormView, UnmergedRevisionsMixin,
                 self.request.response.addErrorNotification(str(e))
         self.next_url = canonical_url(self.context)
 
-    def getStatusReports(self, commit_sha1):
-        reports = self.context.getStatusReports(
-            commit_sha1)
-        return BatchNavigator(reports, self.request)
-
     @property
     def comment_location(self):
         """Location of page for commenting on this proposal."""
diff --git a/lib/lp/code/browser/gitref.py b/lib/lp/code/browser/gitref.py
index 4cb3871..fedff3e 100644
--- a/lib/lp/code/browser/gitref.py
+++ b/lib/lp/code/browser/gitref.py
@@ -40,6 +40,7 @@ from lp.charms.browser.hascharmrecipes import (
 from lp.code.browser.branchmergeproposal import (
     latest_proposals_for_each_branch,
     )
+from lp.code.browser.revisionstatus import HasRevisionStatusReportsMixin
 from lp.code.browser.sourcepackagerecipelisting import HasRecipesMenuMixin
 from lp.code.browser.widgets.gitref import GitRefWidget
 from lp.code.enums import GitRepositoryType
@@ -66,7 +67,6 @@ from lp.services.webapp import (
     Link,
     )
 from lp.services.webapp.authorization import check_permission
-from lp.services.webapp.batching import BatchNavigator
 from lp.services.webapp.escaping import structured
 from lp.snappy.browser.hassnaps import (
     HasSnapsMenuMixin,
@@ -118,7 +118,8 @@ class GitRefContextMenu(
         return Link("+new-recipe", text, enabled=enabled, icon="add")
 
 
-class GitRefView(LaunchpadView, HasSnapsViewMixin, HasCharmRecipesViewMixin):
+class GitRefView(LaunchpadView, HasSnapsViewMixin, HasCharmRecipesViewMixin,
+                 HasRevisionStatusReportsMixin):
 
     # This is set at self.commit_infos, and should be accessed by the view
     # as self.commit_info_message.
@@ -276,10 +277,6 @@ class GitRefView(LaunchpadView, HasSnapsViewMixin, HasCharmRecipesViewMixin):
                 '<a href="+recipes">%s recipes</a> using this branch.',
                 count).escapedtext
 
-    def getStatusReports(self, commit_sha1):
-        reports = self.context.getStatusReports(commit_sha1)
-        return BatchNavigator(reports, self.request)
-
 
 class GitRefRegisterMergeProposalSchema(Interface):
     """The schema to define the form for registering a new merge proposal."""
diff --git a/lib/lp/code/browser/revisionstatus.py b/lib/lp/code/browser/revisionstatus.py
index e93276c..39574eb 100644
--- a/lib/lp/code/browser/revisionstatus.py
+++ b/lib/lp/code/browser/revisionstatus.py
@@ -1,12 +1,51 @@
 # Copyright 2022 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
+from lp.code.enums import RevisionStatusResult
 from lp.code.interfaces.revisionstatus import IRevisionStatusArtifact
 from lp.services.librarian.browser import FileNavigationMixin
 from lp.services.webapp import Navigation
+from lp.services.webapp.batching import BatchNavigator
 
 
 class RevisionStatusArtifactNavigation(Navigation, FileNavigationMixin):
     """Traversal to +files/${filename}."""
 
     usedfor = IRevisionStatusArtifact
+
+
+class HasRevisionStatusReportsMixin:
+
+    def getStatusReports(self, commit_sha1):
+        reports = self.context.getStatusReports(commit_sha1)
+        return BatchNavigator(reports, self.request)
+
+    def getOverallIcon(self, repository, commit_sha1):
+        """Show an appropriate icon at the top of the report."""
+        icon_template = (
+            '<img width="14" height="14" alt="%(title)s" '
+            'title="%(title)s" src="%(src)s" />')
+        reports = repository.getStatusReports(commit_sha1)
+        if all(
+                report.result == RevisionStatusResult.SKIPPED
+                for report in reports):
+            title = "Skipped"
+            source = "/@@/yes-gray"
+        elif all(
+                report.result in (
+                    RevisionStatusResult.SUCCEEDED,
+                    RevisionStatusResult.SKIPPED)
+                for report in reports):
+            title = "Succeeded"
+            source = "/@@/yes"
+        elif any(
+                report.result in (
+                    RevisionStatusResult.FAILED,
+                    RevisionStatusResult.CANCELLED)
+                for report in reports):
+            title = "Failed"
+            source = "/@@/no"
+        else:
+            title = "In progress"
+            source = "/@@/processing"
+        return icon_template % {"title": title, "src": source}
diff --git a/lib/lp/code/browser/tests/test_gitref.py b/lib/lp/code/browser/tests/test_gitref.py
index 18a94f1..85864ea 100644
--- a/lib/lp/code/browser/tests/test_gitref.py
+++ b/lib/lp/code/browser/tests/test_gitref.py
@@ -15,6 +15,7 @@ import soupmatchers
 from storm.store import Store
 from testtools.matchers import (
     Equals,
+    MatchesListwise,
     Not,
     )
 from zope.component import getUtility
@@ -267,6 +268,184 @@ class TestGitRefView(BrowserTestCase):
             # on the page: reports_section[0] and reports_section[1]
             self.assertEqual(2, len(reports_section))
 
+    def test_revisionStatusReports_all_skipped(self):
+        self.useFixture(FakeLogger())
+        [ref] = self.factory.makeGitRefs()
+        log = self.makeCommitLog()
+        for _ in range(2):
+            self.factory.makeRevisionStatusReport(
+                user=ref.repository.owner, git_repository=ref.repository,
+                commit_sha1=log[0]["sha1"],
+                result=RevisionStatusResult.SKIPPED)
+        self.hosting_fixture.getLog.result = list(log)
+        self.scanRef(ref, log[-1], blobs={".launchpad.yaml": ""})
+        view = create_initialized_view(
+            ref, "+index", principal=ref.repository.owner)
+        with person_logged_in(ref.repository.owner):
+            contents = view.render()
+        reports_section = find_tags_by_class(contents, "status-reports-table")
+        with person_logged_in(ref.repository.owner):
+            self.assertThat(
+                reports_section[0].div,
+                soupmatchers.Tag(
+                    "overall icon", "img",
+                    attrs={
+                        "width": "14", "height": "14",
+                        "src": "/@@/yes-gray", "title": "Skipped",
+                        }))
+            self.assertThat(
+                reports_section[0].table.find_all("tr"),
+                MatchesListwise([
+                    soupmatchers.Tag(
+                        "icon", "img",
+                        attrs={"src": "/@@/yes-gray", "title": "Skipped"})
+                    for _ in range(2)]))
+
+    def test_revisionStatusReports_all_succeeded_or_skipped(self):
+        self.useFixture(FakeLogger())
+        [ref] = self.factory.makeGitRefs()
+        log = self.makeCommitLog()
+        for result in (
+                RevisionStatusResult.SUCCEEDED, RevisionStatusResult.SUCCEEDED,
+                RevisionStatusResult.SKIPPED):
+            self.factory.makeRevisionStatusReport(
+                user=ref.repository.owner, git_repository=ref.repository,
+                commit_sha1=log[0]["sha1"], result=result)
+        self.hosting_fixture.getLog.result = list(log)
+        self.scanRef(ref, log[-1], blobs={".launchpad.yaml": ""})
+        view = create_initialized_view(
+            ref, "+index", principal=ref.repository.owner)
+        with person_logged_in(ref.repository.owner):
+            contents = view.render()
+        reports_section = find_tags_by_class(contents, "status-reports-table")
+        with person_logged_in(ref.repository.owner):
+            self.assertThat(
+                reports_section[0].div,
+                soupmatchers.Tag(
+                    "overall icon", "img",
+                    attrs={
+                        "width": "14", "height": "14",
+                        "src": "/@@/yes", "title": "Succeeded",
+                        }))
+            self.assertThat(
+                reports_section[0].table.find_all("tr"),
+                MatchesListwise([
+                    soupmatchers.Tag(
+                        "icon", "img", attrs={"src": src, "title": title})
+                    for src, title in (
+                        ("/@@/yes", "Succeeded"),
+                        ("/@@/yes", "Succeeded"),
+                        ("/@@/yes-gray", "Skipped"),
+                        )]))
+
+    def test_revisionStatusReports_failed(self):
+        self.useFixture(FakeLogger())
+        [ref] = self.factory.makeGitRefs()
+        log = self.makeCommitLog()
+        for result in (
+                RevisionStatusResult.SUCCEEDED, RevisionStatusResult.FAILED):
+            self.factory.makeRevisionStatusReport(
+                user=ref.repository.owner, git_repository=ref.repository,
+                commit_sha1=log[0]["sha1"], result=result)
+        self.hosting_fixture.getLog.result = list(log)
+        self.scanRef(ref, log[-1], blobs={".launchpad.yaml": ""})
+        view = create_initialized_view(
+            ref, "+index", principal=ref.repository.owner)
+        with person_logged_in(ref.repository.owner):
+            contents = view.render()
+        reports_section = find_tags_by_class(contents, "status-reports-table")
+        with person_logged_in(ref.repository.owner):
+            self.assertThat(
+                reports_section[0].div,
+                soupmatchers.Tag(
+                    "overall icon", "img",
+                    attrs={
+                        "width": "14", "height": "14",
+                        "src": "/@@/no", "title": "Failed",
+                        }))
+            self.assertThat(
+                reports_section[0].table.find_all("tr"),
+                MatchesListwise([
+                    soupmatchers.Tag(
+                        "icon", "img", attrs={"src": src, "title": title})
+                    for src, title in (
+                        ("/@@/yes", "Succeeded"),
+                        ("/@@/no", "Failed"),
+                        )]))
+
+    def test_revisionStatusReports_cancelled(self):
+        self.useFixture(FakeLogger())
+        [ref] = self.factory.makeGitRefs()
+        log = self.makeCommitLog()
+        for result in (
+                RevisionStatusResult.SUCCEEDED,
+                RevisionStatusResult.CANCELLED):
+            self.factory.makeRevisionStatusReport(
+                user=ref.repository.owner, git_repository=ref.repository,
+                commit_sha1=log[0]["sha1"], result=result)
+        self.hosting_fixture.getLog.result = list(log)
+        self.scanRef(ref, log[-1], blobs={".launchpad.yaml": ""})
+        view = create_initialized_view(
+            ref, "+index", principal=ref.repository.owner)
+        with person_logged_in(ref.repository.owner):
+            contents = view.render()
+        reports_section = find_tags_by_class(contents, "status-reports-table")
+        with person_logged_in(ref.repository.owner):
+            self.assertThat(
+                reports_section[0].div,
+                soupmatchers.Tag(
+                    "overall icon", "img",
+                    attrs={
+                        "width": "14", "height": "14",
+                        "src": "/@@/no", "title": "Failed",
+                        }))
+            self.assertThat(
+                reports_section[0].table.find_all("tr"),
+                MatchesListwise([
+                    soupmatchers.Tag(
+                        "icon", "img",
+                        attrs={"src": src, "title": title})
+                    for src, title in (
+                        ("/@@/yes", "Succeeded"),
+                        ("/@@/build-failed", "Cancelled"),
+                        )]))
+
+    def test_revisionStatusReports_all_waiting_or_running(self):
+        self.useFixture(FakeLogger())
+        [ref] = self.factory.makeGitRefs()
+        log = self.makeCommitLog()
+        for result in (
+                RevisionStatusResult.WAITING, RevisionStatusResult.RUNNING):
+            self.factory.makeRevisionStatusReport(
+                user=ref.repository.owner, git_repository=ref.repository,
+                commit_sha1=log[0]["sha1"], result=result)
+        self.hosting_fixture.getLog.result = list(log)
+        self.scanRef(ref, log[-1], blobs={".launchpad.yaml": ""})
+        view = create_initialized_view(
+            ref, "+index", principal=ref.repository.owner)
+        with person_logged_in(ref.repository.owner):
+            contents = view.render()
+        reports_section = find_tags_by_class(contents, "status-reports-table")
+        with person_logged_in(ref.repository.owner):
+            self.assertThat(
+                reports_section[0].div,
+                soupmatchers.Tag(
+                    "overall icon", "img",
+                    attrs={
+                        "width": "14", "height": "14",
+                        "src": "/@@/processing", "title": "In progress",
+                        }))
+            self.assertThat(
+                reports_section[0].table.find_all("tr"),
+                MatchesListwise([
+                    soupmatchers.Tag(
+                        "icon", "img",
+                        attrs={"src": src, "title": title})
+                    for src, title in (
+                        ("/@@/build-needed", "Waiting"),
+                        ("/@@/processing", "Running"),
+                        )]))
+
     def test_clone_instructions(self):
         [ref] = self.factory.makeGitRefs(paths=["refs/heads/branch"])
         username = ref.owner.name
diff --git a/lib/lp/code/interfaces/gitref.py b/lib/lp/code/interfaces/gitref.py
index eb8066a..b8bf0ce 100644
--- a/lib/lp/code/interfaces/gitref.py
+++ b/lib/lp/code/interfaces/gitref.py
@@ -152,9 +152,6 @@ class IGitRefView(IHasMergeProposals, IHasRecipes, IPrivacy, IInformationType):
     def getStatusReports(commit_sha1):
         """Get status reports for this repository at the given commit"""
 
-    def getCommitStatus(commit_sha1):
-        """Help to show red or green icon at the top of the commit."""
-
     information_type = Attribute(
         "The type of information contained in the repository containing this "
         "reference.")
diff --git a/lib/lp/code/interfaces/gitrepository.py b/lib/lp/code/interfaces/gitrepository.py
index a541026..4edf4c9 100644
--- a/lib/lp/code/interfaces/gitrepository.py
+++ b/lib/lp/code/interfaces/gitrepository.py
@@ -741,6 +741,18 @@ class IGitRepositoryView(IHasRecipes):
             macaroon.
         """
 
+    @operation_parameters(
+        commit_sha1=copy_field(IRevisionStatusReport["commit_sha1"]))
+    @scoped(AccessTokenScope.REPOSITORY_BUILD_STATUS.title)
+    @operation_returns_collection_of(Interface)
+    @export_read_operation()
+    @operation_for_version("devel")
+    def getStatusReports(commit_sha1):
+        """Retrieves the list of reports that exist for a commit.
+
+        :param commit_sha1: The commit sha1 for the report.
+        """
+
 
 class IGitRepositoryModerateAttributes(Interface):
     """IGitRepository attributes that can be edited by more than one community.
@@ -1039,18 +1051,6 @@ class IGitRepositoryEdit(IWebhookTarget, IAccessTokenTarget):
         :param result: The result of the new report.
         """
 
-    @operation_parameters(
-        commit_sha1=copy_field(IRevisionStatusReport["commit_sha1"]))
-    @scoped(AccessTokenScope.REPOSITORY_BUILD_STATUS.title)
-    @operation_returns_collection_of(Interface)
-    @export_read_operation()
-    @operation_for_version("devel")
-    def getStatusReports(commit_sha1):
-        """Retrieves the list of reports that exist for a commit.
-
-        :param commit_sha1: The commit sha1 for the report.
-        """
-
 
 # XXX cjwatson 2015-01-19 bug=760849: "beta" is a lie to get WADL
 # generation working.  Individual attributes must set their version to
diff --git a/lib/lp/code/model/gitref.py b/lib/lp/code/model/gitref.py
index 08bb3bc..a3ef756 100644
--- a/lib/lp/code/model/gitref.py
+++ b/lib/lp/code/model/gitref.py
@@ -43,7 +43,6 @@ from lp.code.enums import (
     BranchMergeProposalStatus,
     GitObjectType,
     GitRepositoryType,
-    RevisionStatusResult,
     )
 from lp.code.errors import (
     BranchMergeProposalExists,
@@ -183,13 +182,6 @@ class GitRefMixin:
     def getStatusReports(self, commit_sha1):
         return self.repository.getStatusReports(commit_sha1)
 
-    def getCommitStatus(self, commit_sha1):
-        """Help to show red or green icon at the top of the commit."""
-        reports = self.repository.getStatusReports(commit_sha1)
-        return all(
-            report.result == RevisionStatusResult.SUCCEEDED
-            for report in reports)
-
     @property
     def information_type(self):
         """See `IGitRef`."""
diff --git a/lib/lp/code/templates/git-macros.pt b/lib/lp/code/templates/git-macros.pt
index 997f96a..ea6dd1d 100644
--- a/lib/lp/code/templates/git-macros.pt
+++ b/lib/lp/code/templates/git-macros.pt
@@ -224,12 +224,12 @@
     <dd class="subordinate collapsible status-reports-table"
         tal:define="
           commit_sha1 commit_info/sha1;
-          status_result python:ref.getCommitStatus(commit_sha1);
+          status_result python:view.getOverallIcon(ref.repository, commit_sha1);
           batchnav python:view.getStatusReports(commit_sha1);
           batch python:batchnav.currentBatch()"
         tal:condition="batch">
-        <div class="sprite treeCollapsed">
-          <span tal:replace="structure status_result/image:boolean" />
+        <div class="treeCollapsed">
+          <span class="icon" tal:replace="structure status_result" />
         </div>
         <table class="listing" tal:condition="batch">
           <tbody>