diff --git a/DEVGUIDE.md b/DEVGUIDE.md index b7558fd..a72d6e4 100644 --- a/DEVGUIDE.md +++ b/DEVGUIDE.md @@ -1023,6 +1023,20 @@ for commit in repo.get_commits(): # Paginated API calls! ### Testing Requirements +**Local development setup**: + +```bash +# Create and activate a virtual environment +python3 -m venv .venv +source .venv/bin/activate + +# Install package with dependencies +pip install . + +# Install dev tools +pip install pytest pytest-cov black flake8 mypy boto3 +``` + **Before submitting changes**: ```bash diff --git a/tagbot/action/changelog.py b/tagbot/action/changelog.py index 3eefaf1..6cac020 100644 --- a/tagbot/action/changelog.py +++ b/tagbot/action/changelog.py @@ -4,6 +4,7 @@ from datetime import datetime, timedelta, timezone from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union +from github import UnknownObjectException from github.GitRelease import GitRelease from github.Issue import Issue from github.NamedUser import NamedUser @@ -49,12 +50,13 @@ def _previous_release(self, version_tag: str) -> Optional[GitRelease]: cur_ver = VersionInfo.parse(version_tag[i_start:]) prev_ver = VersionInfo(0) prev_rel = None - tag_prefix = self._repo._tag_prefix() - for r in self._repo._repo.get_releases(): - if not r.tag_name.startswith(tag_prefix): + tags = self._repo.get_all_tags() + + for tag_name in tags: + if not tag_name.startswith(tag_prefix): continue try: - ver = VersionInfo.parse(r.tag_name[i_start:]) + ver = VersionInfo.parse(tag_name[i_start:]) except ValueError: continue if ver.prerelease or ver.build: @@ -63,7 +65,17 @@ def _previous_release(self, version_tag: str) -> Optional[GitRelease]: # That means if we're creating a backport v1.1, an already existing v2.0, # despite being newer than v1.0, will not be selected. if ver < cur_ver and ver > prev_ver: - prev_rel = r + # Get the GitHub release for this tag if it exists + try: + prev_rel = self._repo._repo.get_release(tag_name) + except UnknownObjectException: + # Release doesn't exist - get commit datetime from the tag + commit_time = self._repo._git.time_of_commit(tag_name) + prev_rel = type( + "obj", + (object,), + {"tag_name": tag_name, "created_at": commit_time}, + )() prev_ver = ver return prev_rel @@ -75,8 +87,8 @@ def _is_backport(self, version: str, tags: Optional[List[str]] = None) -> bool: ) if tags is None: - # Populate the tags list with tag names from the releases - tags = [r.tag_name for r in self._repo._repo.get_releases()] + # Use Git tags instead of GitHub releases + tags = self._repo.get_all_tags() # Extract any package name prefix and version number from the input match = version_pattern.match(version) @@ -266,7 +278,8 @@ def _collect_data(self, version_tag: str, sha: str) -> Dict[str, object]: prev_tag = None compare = None if previous: - start = previous.created_at + if previous.created_at: + start = previous.created_at prev_tag = previous.tag_name compare = f"{self._repo._repo.html_url}/compare/{prev_tag}...{version_tag}" # When the last commit is a PR merge, the commit happens a second or two before diff --git a/tagbot/action/repo.py b/tagbot/action/repo.py index c358001..2bfd08e 100644 --- a/tagbot/action/repo.py +++ b/tagbot/action/repo.py @@ -700,6 +700,15 @@ def _highest_existing_version(self) -> Optional[VersionInfo]: return highest + def get_all_tags(self) -> List[str]: + """Get all Git tag names in the repository. + + Returns a list of tag names (without 'refs/tags/' prefix). + Uses the tags cache to avoid repeated API calls. + """ + tags_cache = self._build_tags_cache() + return list(tags_cache.keys()) + def version_with_latest_commit(self, versions: Dict[str, str]) -> Optional[str]: """Find the version with the most recent commit datetime. diff --git a/test/action/test_changelog.py b/test/action/test_changelog.py index a86793b..f3fade9 100644 --- a/test/action/test_changelog.py +++ b/test/action/test_changelog.py @@ -42,7 +42,11 @@ def test_slug(): def test_previous_release(): c = _changelog() tags = ["ignore", "v1.2.4-ignore", "v1.2.3", "v1.2.2", "v1.0.2", "v1.0.10"] - c._repo._repo.get_releases = Mock(return_value=[Mock(tag_name=t) for t in tags]) + c._repo.get_all_tags = Mock(return_value=tags) + # Mock get_release to return a minimal release-like object + c._repo._repo.get_release = Mock( + side_effect=lambda tag: type("obj", (object,), {"tag_name": tag})() + ) assert c._previous_release("v1.0.0") is None assert c._previous_release("v1.0.2") is None rel = c._previous_release("v1.2.5") @@ -51,6 +55,29 @@ def test_previous_release(): assert rel and rel.tag_name == "v1.0.2" +def test_previous_release_no_github_release(): + """Test that _previous_release falls back to commit time when no GitHub release.""" + from datetime import datetime + from github import UnknownObjectException + + c = _changelog() + tags = ["v1.0.0", "v1.1.0"] + c._repo.get_all_tags = Mock(return_value=tags) + # Simulate no GitHub release existing for the tag + c._repo._repo.get_release = Mock( + side_effect=UnknownObjectException(404, "Not Found", {}) + ) + # Mock time_of_commit to return a datetime + mock_time = datetime(2025, 1, 1, 12, 0, 0) + c._repo._git.time_of_commit = Mock(return_value=mock_time) + + rel = c._previous_release("v1.1.1") + assert rel is not None + assert rel.tag_name == "v1.1.0" + assert rel.created_at == mock_time + c._repo._git.time_of_commit.assert_called_with("v1.1.0") + + def test_previous_release_subdir(): True c = _changelog(subdir="Foo") @@ -67,7 +94,11 @@ def test_previous_release_subdir(): "v2.0.1", "Foo-v2.0.0", ] - c._repo._repo.get_releases = Mock(return_value=[Mock(tag_name=t) for t in tags]) + c._repo.get_all_tags = Mock(return_value=tags) + # Mock get_release to return a minimal release-like object + c._repo._repo.get_release = Mock( + side_effect=lambda tag: type("obj", (object,), {"tag_name": tag})() + ) assert c._previous_release("Foo-v1.0.0") is None assert c._previous_release("Foo-v1.0.2") is None rel = c._previous_release("Foo-v1.2.5")