Repository: SFDO-Tooling/CumulusCI
Pull request #3164 (2022-04-18 23:27:17)

Don't rely on github tag sorting to find latest release
# Critical Changes

# Changes

# Issues Closed



Patch:
diff --git a/cumulusci/core/github.py b/cumulusci/core/github.py
index b6e5d0539..7b329f1fe 100644
--- a/cumulusci/core/github.py
+++ b/cumulusci/core/github.py
@@ -4,6 +4,7 @@ import os
 import re
 import time
 import webbrowser
+from string import Template
 from typing import Callable, Optional, Union
 from urllib.parse import urlparse
 
@@ -13,6 +14,7 @@ from github3.exceptions import AuthenticationFailed, ResponseError, TransportErr
 from github3.git import Reference, Tag
 from github3.pulls import ShortPullRequest
 from github3.repos.commit import RepoCommit
+from github3.repos.release import Release
 from github3.repos.repo import Repository
 from github3.session import GitHubSession
 from requests.adapters import HTTPAdapter
@@ -216,16 +218,43 @@ def markdown_link_to_pr(change_note):
     return f"{change_note.title} [[PR{change_note.number}]({change_note.html_url})]"
 
 
-def find_latest_release(repo, include_beta=None):
+def find_latest_release(repo, include_beta=None) -> Optional[Release]:
     try:
         if include_beta:
-            return next(repo.releases())
+            return get_latest_prerelease(repo)
         else:
             return repo.latest_release()
     except (github3.exceptions.NotFoundError, StopIteration):
         pass
 
 
+def get_latest_prerelease(repo: Repository) -> Optional[Release]:
+    """Calls GraphQL to retrieve the latest release, ordered chronologically."""
+    QUERY = Template(
+        """
+          query {
+            repository(owner: "$owner", name: "$name") {
+              releases(last: 1, orderBy: {field: CREATED_AT, direction: ASC}) {
+                nodes {
+                  tagName
+                }
+              }
+            }
+          }
+        """
+    ).substitute(dict(owner=repo.owner, name=repo.name))
+
+    session: GitHubSession = repo.session
+    url: str = session.build_url("graphql")
+    response: Response = session.request("POST", url, json={"query": QUERY})
+    response_dict: dict = response.json()
+
+    release_tags = response_dict["data"]["repository"]["releases"]["nodes"]
+    if release_tags:
+        release = repo.release_from_tag(release_tags[0]["tagName"])
+        return release
+
+
 def find_previous_release(repo, prefix=None):
     most_recent = None
     for release in repo.releases():


Test patch:
diff --git a/cumulusci/core/config/tests/test_config.py b/cumulusci/core/config/tests/test_config.py
index 95dc6e17d..c70d7c80b 100644
--- a/cumulusci/core/config/tests/test_config.py
+++ b/cumulusci/core/config/tests/test_config.py
@@ -125,12 +125,14 @@ class DummyRepository(mock.Mock):
     default_branch = "main"
     _api = "http://"
 
-    def __init__(self, owner, name, contents, releases=None, commits=None):
+    def __init__(self, owner, name, contents, releases=None, commits=None, **kwargs):
+        """Passing kwargs to workaround python/cpython#83759"""
         super().__init__()
         self.owner = owner
         self.name = name
         self.html_url = f"https://github.com/{owner}/{name}"
         self.clone_url = self.html_url
+        self.session = mock.MagicMock()
         self._contents = contents
         self._releases = releases or []
         self._commits = commits or []
diff --git a/cumulusci/core/dependencies/tests/test_resolvers.py b/cumulusci/core/dependencies/tests/test_resolvers.py
index fb529e974..d8eed1257 100644
--- a/cumulusci/core/dependencies/tests/test_resolvers.py
+++ b/cumulusci/core/dependencies/tests/test_resolvers.py
@@ -6,6 +6,7 @@ from github3.exceptions import NotFoundError
 
 from cumulusci.core.config import UniversalConfig
 from cumulusci.core.config.project_config import BaseProjectConfig
+from cumulusci.core.config.tests.test_config import DummyRelease
 from cumulusci.core.dependencies.dependencies import (
     DynamicDependency,
     GitHubDynamicDependency,
@@ -270,14 +271,17 @@ version_id: 04t000000000000"""
             ),
         )
 
-    def test_beta_release_tag(self, project_config):
+    @mock.patch("cumulusci.core.dependencies.resolvers.find_latest_release")
+    def test_beta_release_tag(self, get_latest, project_config):
+        get_latest.return_value = DummyRelease("beta/2.1_Beta_1", "2.1 Beta 1")
         dep = GitHubDynamicDependency(
             github="https://github.com/SFDO-Tooling/ReleasesRepo"
         )
         resolver = GitHubBetaReleaseTagResolver()
-
         assert resolver.can_resolve(dep, project_config)
-        assert resolver.resolve(dep, project_config) == (
+        resolved = resolver.resolve(dep, project_config)
+
+        assert resolved == (
             "tag_sha",
             PackageNamespaceVersionDependency(
                 namespace="ccitestdep",
diff --git a/cumulusci/core/tests/test_github.py b/cumulusci/core/tests/test_github.py
index c756a70bb..b592888ea 100644
--- a/cumulusci/core/tests/test_github.py
+++ b/cumulusci/core/tests/test_github.py
@@ -4,6 +4,7 @@ from datetime import datetime
 from unittest import mock
 
 import pytest
+import requests
 import responses
 from github3.exceptions import (
     AuthenticationFailed,
@@ -37,6 +38,7 @@ from cumulusci.core.github import (
     get_commit,
     get_github_api,
     get_github_api_for_repo,
+    get_latest_prerelease,
     get_oauth_device_flow_token,
     get_oauth_scopes,
     get_pull_requests_by_commit,
@@ -662,3 +664,24 @@ class TestGithub(GithubApiTestMixin):
         get_token.assert_called_once()
         get_code.assert_called_once()
         browser_open.assert_called_with("https://github.com/login/device")
+
+    @responses.activate
+    def test_get_latest_prerelease(self):
+        expected_tag = "beta/1.0-Beta_1"
+        query_result = self._get_expected_prerelease_tag_gql(expected_tag)
+        endpoint = "https://api.github.com/graphql"
+
+        responses.add(
+            "POST",
+            endpoint,
+            json=query_result,
+        )
+
+        repo: Repository = mock.MagicMock()
+        repo.session = mock.MagicMock()
+        repo.session.build_url.return_value = endpoint
+        repo.session.request = requests.request
+
+        get_latest_prerelease(repo=repo)
+        assert responses.assert_call_count(endpoint, 1)
+        repo.release_from_tag.assert_called_once_with(expected_tag)
diff --git a/cumulusci/core/tests/test_source.py b/cumulusci/core/tests/test_source.py
index 144739e97..7067de6b8 100644
--- a/cumulusci/core/tests/test_source.py
+++ b/cumulusci/core/tests/test_source.py
@@ -233,10 +233,15 @@ class TestGitHubSource(MockUtilBase):
             url=self.repo_api_url,
             json=self._get_expected_repo(owner="TestOwner", name="TestRepo"),
         )
+        responses.add(
+            "POST",
+            "https://api.github.com/graphql",
+            json=self._get_expected_prerelease_tag_gql("beta/1.0-Beta_1"),
+        )
         responses.add(
             "GET",
-            "https://api.github.com/repos/TestOwner/TestRepo/releases",
-            json=[self._get_expected_release("beta/1.0-Beta_1")],
+            "https://api.github.com/repos/TestOwner/TestRepo/releases/tags/beta/1.0-Beta_1",
+            json=self._get_expected_release("beta/1.0-Beta_1"),
         )
         responses.add(
             "GET",
diff --git a/cumulusci/tasks/github/tests/util_github_api.py b/cumulusci/tasks/github/tests/util_github_api.py
index 103cb8f6d..7dfeafb47 100644
--- a/cumulusci/tasks/github/tests/util_github_api.py
+++ b/cumulusci/tasks/github/tests/util_github_api.py
@@ -547,6 +547,11 @@ class GithubApiTestMixin:
         release.update(kw)
         return release
 
+    def _get_expected_prerelease_tag_gql(self, tag_name):
+        return {
+            "data": {"repository": {"releases": {"nodes": [{"tagName": tag_name}]}}}
+        }
+
     def _random_sha(self):
         return random_sha()