Add GitLab support for CHANGELOG.md

This commit is contained in:
César Pérez
2025-06-07 20:35:47 +02:00
parent c635887949
commit 122248ef9c
4 changed files with 442 additions and 6 deletions

View File

@ -0,0 +1,147 @@
import pytest
from unittest.mock import MagicMock, patch
from pr_agent.git_providers.gitlab_provider import GitLabProvider
from gitlab import Gitlab
from gitlab.v4.objects import Project, ProjectFile
from gitlab.exceptions import GitlabGetError
class TestGitLabProvider:
"""Test suite for GitLab provider functionality."""
@pytest.fixture
def mock_gitlab_client(self):
client = MagicMock()
return client
@pytest.fixture
def mock_project(self):
project = MagicMock()
return project
@pytest.fixture
def gitlab_provider(self, mock_gitlab_client, mock_project):
with patch('pr_agent.git_providers.gitlab_provider.gitlab.Gitlab', return_value=mock_gitlab_client), \
patch('pr_agent.git_providers.gitlab_provider.get_settings') as mock_settings:
mock_settings.return_value.get.side_effect = lambda key, default=None: {
"GITLAB.URL": "https://gitlab.com",
"GITLAB.PERSONAL_ACCESS_TOKEN": "fake_token"
}.get(key, default)
mock_gitlab_client.projects.get.return_value = mock_project
provider = GitLabProvider("https://gitlab.com/test/repo/-/merge_requests/1")
provider.gl = mock_gitlab_client
provider.id_project = "test/repo"
return provider
def test_get_pr_file_content_success(self, gitlab_provider, mock_project):
mock_file = MagicMock(ProjectFile)
mock_file.decode.return_value = "# Changelog\n\n## v1.0.0\n- Initial release"
mock_project.files.get.return_value = mock_file
content = gitlab_provider.get_pr_file_content("CHANGELOG.md", "main")
assert content == "# Changelog\n\n## v1.0.0\n- Initial release"
mock_project.files.get.assert_called_once_with("CHANGELOG.md", "main")
mock_file.decode.assert_called_once()
def test_get_pr_file_content_with_bytes(self, gitlab_provider, mock_project):
mock_file = MagicMock(ProjectFile)
mock_file.decode.return_value = b"# Changelog\n\n## v1.0.0\n- Initial release"
mock_project.files.get.return_value = mock_file
content = gitlab_provider.get_pr_file_content("CHANGELOG.md", "main")
assert content == "# Changelog\n\n## v1.0.0\n- Initial release"
mock_project.files.get.assert_called_once_with("CHANGELOG.md", "main")
def test_get_pr_file_content_file_not_found(self, gitlab_provider, mock_project):
mock_project.files.get.side_effect = GitlabGetError("404 Not Found")
content = gitlab_provider.get_pr_file_content("CHANGELOG.md", "main")
assert content == ""
mock_project.files.get.assert_called_once_with("CHANGELOG.md", "main")
def test_get_pr_file_content_other_exception(self, gitlab_provider, mock_project):
mock_project.files.get.side_effect = Exception("Network error")
content = gitlab_provider.get_pr_file_content("CHANGELOG.md", "main")
assert content == ""
def test_create_or_update_pr_file_create_new(self, gitlab_provider, mock_project):
mock_project.files.get.side_effect = GitlabGetError("404 Not Found")
mock_file = MagicMock()
mock_project.files.create.return_value = mock_file
new_content = "# Changelog\n\n## v1.1.0\n- New feature"
commit_message = "Add CHANGELOG.md"
gitlab_provider.create_or_update_pr_file(
"CHANGELOG.md", "feature-branch", new_content, commit_message
)
mock_project.files.get.assert_called_once_with("CHANGELOG.md", "feature-branch")
mock_project.files.create.assert_called_once_with({
'file_path': 'CHANGELOG.md',
'branch': 'feature-branch',
'content': new_content,
'commit_message': commit_message,
})
def test_create_or_update_pr_file_update_existing(self, gitlab_provider, mock_project):
mock_file = MagicMock(ProjectFile)
mock_file.decode.return_value = "# Old changelog content"
mock_project.files.get.return_value = mock_file
new_content = "# New changelog content"
commit_message = "Update CHANGELOG.md"
gitlab_provider.create_or_update_pr_file(
"CHANGELOG.md", "feature-branch", new_content, commit_message
)
mock_project.files.get.assert_called_once_with("CHANGELOG.md", "feature-branch")
mock_file.content = new_content
mock_file.save.assert_called_once_with(branch="feature-branch", commit_message=commit_message)
def test_create_or_update_pr_file_update_exception(self, gitlab_provider, mock_project):
mock_project.files.get.side_effect = Exception("Network error")
with pytest.raises(Exception):
gitlab_provider.create_or_update_pr_file(
"CHANGELOG.md", "feature-branch", "content", "message"
)
def test_has_create_or_update_pr_file_method(self, gitlab_provider):
assert hasattr(gitlab_provider, "create_or_update_pr_file")
assert callable(getattr(gitlab_provider, "create_or_update_pr_file"))
def test_method_signature_compatibility(self, gitlab_provider):
import inspect
sig = inspect.signature(gitlab_provider.create_or_update_pr_file)
params = list(sig.parameters.keys())
expected_params = ['file_path', 'branch', 'contents', 'message']
assert params == expected_params
@pytest.mark.parametrize("content,expected", [
("simple text", "simple text"),
(b"bytes content", "bytes content"),
("", ""),
(b"", ""),
("unicode: café", "unicode: café"),
(b"unicode: caf\xc3\xa9", "unicode: café"),
])
def test_content_encoding_handling(self, gitlab_provider, mock_project, content, expected):
mock_file = MagicMock(ProjectFile)
mock_file.decode.return_value = content
mock_project.files.get.return_value = mock_file
result = gitlab_provider.get_pr_file_content("test.md", "main")
assert result == expected

View File

@ -0,0 +1,247 @@
import pytest
from unittest.mock import MagicMock, patch, AsyncMock
from pr_agent.tools.pr_update_changelog import PRUpdateChangelog
class TestPRUpdateChangelog:
"""Test suite for the PR Update Changelog functionality."""
@pytest.fixture
def mock_git_provider(self):
"""Create a mock git provider."""
provider = MagicMock()
provider.get_pr_branch.return_value = "feature-branch"
provider.get_pr_file_content.return_value = ""
provider.pr.title = "Test PR"
provider.get_pr_description.return_value = "Test description"
provider.get_commit_messages.return_value = "fix: test commit"
provider.get_languages.return_value = {"Python": 80, "JavaScript": 20}
provider.get_files.return_value = ["test.py", "test.js"]
return provider
@pytest.fixture
def mock_ai_handler(self):
"""Create a mock AI handler."""
handler = MagicMock()
handler.chat_completion = AsyncMock(return_value=("Test changelog entry", "stop"))
return handler
@pytest.fixture
def changelog_tool(self, mock_git_provider, mock_ai_handler):
"""Create a PRUpdateChangelog instance with mocked dependencies."""
with patch('pr_agent.tools.pr_update_changelog.get_git_provider', return_value=lambda url: mock_git_provider), \
patch('pr_agent.tools.pr_update_changelog.get_main_pr_language', return_value="Python"), \
patch('pr_agent.tools.pr_update_changelog.get_settings') as mock_settings:
# Configure mock settings
mock_settings.return_value.pr_update_changelog.push_changelog_changes = False
mock_settings.return_value.pr_update_changelog.extra_instructions = ""
mock_settings.return_value.pr_update_changelog_prompt.system = "System prompt"
mock_settings.return_value.pr_update_changelog_prompt.user = "User prompt"
mock_settings.return_value.config.temperature = 0.2
tool = PRUpdateChangelog("https://gitlab.com/test/repo/-/merge_requests/1", ai_handler=lambda: mock_ai_handler)
return tool
def test_get_changelog_file_with_existing_content(self, changelog_tool, mock_git_provider):
"""Test retrieving existing changelog content."""
# Arrange
existing_content = "# Changelog\n\n## v1.0.0\n- Initial release\n- Bug fixes"
mock_git_provider.get_pr_file_content.return_value = existing_content
# Act
changelog_tool._get_changelog_file()
# Assert
assert changelog_tool.changelog_file == existing_content
assert "# Changelog" in changelog_tool.changelog_file_str
def test_get_changelog_file_with_no_existing_content(self, changelog_tool, mock_git_provider):
"""Test handling when no changelog file exists."""
# Arrange
mock_git_provider.get_pr_file_content.return_value = ""
# Act
changelog_tool._get_changelog_file()
# Assert
assert changelog_tool.changelog_file == ""
assert "Example:" in changelog_tool.changelog_file_str # Default template
def test_get_changelog_file_with_bytes_content(self, changelog_tool, mock_git_provider):
"""Test handling when git provider returns bytes instead of string."""
# Arrange
content_bytes = b"# Changelog\n\n## v1.0.0\n- Initial release"
mock_git_provider.get_pr_file_content.return_value = content_bytes
# Act
changelog_tool._get_changelog_file()
# Assert
assert isinstance(changelog_tool.changelog_file, str)
assert changelog_tool.changelog_file == "# Changelog\n\n## v1.0.0\n- Initial release"
def test_get_changelog_file_with_exception(self, changelog_tool, mock_git_provider):
"""Test handling exceptions during file retrieval."""
# Arrange
mock_git_provider.get_pr_file_content.side_effect = Exception("Network error")
# Act
changelog_tool._get_changelog_file()
# Assert
assert changelog_tool.changelog_file == ""
assert changelog_tool.changelog_file_str != "" # Should have default content
def test_prepare_changelog_update_with_existing_content(self, changelog_tool):
"""Test preparing changelog update when existing content exists."""
# Arrange
changelog_tool.prediction = "## v1.1.0\n- New feature\n- Bug fix"
changelog_tool.changelog_file = "# Changelog\n\n## v1.0.0\n- Initial release"
changelog_tool.commit_changelog = True
# Act
new_content, answer = changelog_tool._prepare_changelog_update()
# Assert
assert new_content.startswith("## v1.1.0\n- New feature\n- Bug fix\n\n")
assert "# Changelog\n\n## v1.0.0\n- Initial release" in new_content
assert answer == "## v1.1.0\n- New feature\n- Bug fix"
def test_prepare_changelog_update_without_existing_content(self, changelog_tool):
"""Test preparing changelog update when no existing content."""
# Arrange
changelog_tool.prediction = "## v1.0.0\n- Initial release"
changelog_tool.changelog_file = ""
changelog_tool.commit_changelog = True
# Act
new_content, answer = changelog_tool._prepare_changelog_update()
# Assert
assert new_content == "## v1.0.0\n- Initial release"
assert answer == "## v1.0.0\n- Initial release"
def test_prepare_changelog_update_no_commit(self, changelog_tool):
"""Test preparing changelog update when not committing."""
# Arrange
changelog_tool.prediction = "## v1.1.0\n- New feature"
changelog_tool.changelog_file = ""
changelog_tool.commit_changelog = False
# Act
new_content, answer = changelog_tool._prepare_changelog_update()
# Assert
assert new_content == "## v1.1.0\n- New feature"
assert "to commit the new content" in answer
@pytest.mark.asyncio
async def test_run_without_push_support(self, changelog_tool, mock_git_provider):
"""Test running changelog update when git provider doesn't support pushing."""
# Arrange
delattr(mock_git_provider, 'create_or_update_pr_file') # Remove the method
changelog_tool.commit_changelog = True
with patch('pr_agent.tools.pr_update_changelog.get_settings') as mock_settings:
mock_settings.return_value.pr_update_changelog.push_changelog_changes = True
mock_settings.return_value.config.publish_output = True
# Act
await changelog_tool.run()
# Assert
mock_git_provider.publish_comment.assert_called_once()
assert "not currently supported" in str(mock_git_provider.publish_comment.call_args)
@pytest.mark.asyncio
async def test_run_with_push_support(self, changelog_tool, mock_git_provider):
"""Test running changelog update when git provider supports pushing."""
# Arrange
mock_git_provider.create_or_update_pr_file = MagicMock()
changelog_tool.commit_changelog = True
changelog_tool.prediction = "## v1.1.0\n- New feature"
with patch('pr_agent.tools.pr_update_changelog.get_settings') as mock_settings, \
patch('pr_agent.tools.pr_update_changelog.retry_with_fallback_models') as mock_retry, \
patch('pr_agent.tools.pr_update_changelog.sleep'):
mock_settings.return_value.pr_update_changelog.push_changelog_changes = True
mock_settings.return_value.pr_update_changelog.get.return_value = True
mock_settings.return_value.config.publish_output = True
mock_settings.return_value.config.git_provider = "gitlab"
mock_retry.return_value = None
# Act
await changelog_tool.run()
# Assert
mock_git_provider.create_or_update_pr_file.assert_called_once()
call_args = mock_git_provider.create_or_update_pr_file.call_args
assert call_args[1]['file_path'] == 'CHANGELOG.md'
assert call_args[1]['branch'] == 'feature-branch'
def test_push_changelog_update(self, changelog_tool, mock_git_provider):
"""Test the push changelog update functionality."""
# Arrange
mock_git_provider.create_or_update_pr_file = MagicMock()
mock_git_provider.get_pr_branch.return_value = "feature-branch"
new_content = "# Updated changelog content"
answer = "Changes made"
with patch('pr_agent.tools.pr_update_changelog.get_settings') as mock_settings, \
patch('pr_agent.tools.pr_update_changelog.sleep'):
mock_settings.return_value.pr_update_changelog.get.return_value = True
# Act
changelog_tool._push_changelog_update(new_content, answer)
# Assert
mock_git_provider.create_or_update_pr_file.assert_called_once_with(
file_path="CHANGELOG.md",
branch="feature-branch",
contents=new_content,
message="[skip ci] Update CHANGELOG.md"
)
def test_gitlab_provider_method_detection(self, changelog_tool, mock_git_provider):
"""Test that the tool correctly detects GitLab provider method availability."""
# Arrange
mock_git_provider.create_or_update_pr_file = MagicMock()
# Act & Assert
assert hasattr(mock_git_provider, "create_or_update_pr_file")
@pytest.mark.parametrize("existing_content,new_entry,expected_order", [
(
"# Changelog\n\n## v1.0.0\n- Old feature",
"## v1.1.0\n- New feature",
["v1.1.0", "v1.0.0"]
),
(
"",
"## v1.0.0\n- Initial release",
["v1.0.0"]
),
(
"Some existing content",
"## v1.0.0\n- New entry",
["v1.0.0", "Some existing content"]
),
])
def test_changelog_order_preservation(self, changelog_tool, existing_content, new_entry, expected_order):
"""Test that changelog entries are properly ordered (newest first)."""
# Arrange
changelog_tool.prediction = new_entry
changelog_tool.changelog_file = existing_content
changelog_tool.commit_changelog = True
# Act
new_content, _ = changelog_tool._prepare_changelog_update()
# Assert
for i, expected in enumerate(expected_order[:-1]):
current_pos = new_content.find(expected)
next_pos = new_content.find(expected_order[i + 1])
assert current_pos < next_pos, f"Expected {expected} to come before {expected_order[i + 1]}"