diff --git a/README.md b/README.md index 707c58f..e461f76 100644 --- a/README.md +++ b/README.md @@ -60,10 +60,81 @@ utility-mcp-server/ ### Current MCP Tools 1. **`generate_release_notes`**: Generates structured release notes from git commits and tags + - **Remote Repository Support**: Fetch commits from GitHub/GitLab via API (no local clone needed) + - **Local Repository Support**: Fallback to local git repositories via subprocess + - **AI Agent Integration**: Returns structured data for intelligent AI-driven categorization + - **Provider Pattern**: Extensible architecture for adding new git hosting services ### Release Notes Tool -The `generate_release_notes` tool automatically creates comprehensive release notes from git commits, categorizing changes into features, enhancements, bug fixes, and breaking changes. See [USE_CASES.md](USE_CASES.md) for detailed use cases and integration scenarios. +The `generate_release_notes` tool automatically creates comprehensive release notes from git commits. It supports both remote repositories (GitHub, GitLab) and local repositories, with optional AI agent integration for intelligent categorization. + +#### Remote Repository Support + +The tool can fetch commits directly from GitHub and GitLab repositories without requiring a local clone: + +**GitHub:** +- Provide `repo_url` like `https://github.com/owner/repo` +- Optionally provide `github_token` (or set `GITHUB_TOKEN` environment variable) +- Uses PyGithub API to fetch commits and PR associations + +**GitLab:** +- Provide `repo_url` like `https://gitlab.com/owner/repo` +- Optionally provide `gitlab_token` (or set `GITLAB_TOKEN` environment variable) +- Uses python-gitlab API to fetch commits and MR associations + +**Local Repository (fallback):** +- Provide `repo_path` to a local git repository +- Uses git subprocess commands + +#### AI Agent Integration + +The tool returns structured commit data **with embedded AI instructions** for intelligent categorization: +- Returns raw commit list with hashes, messages, authors, dates, and PR/MR numbers +- **Includes `ai_instructions`** field with comprehensive guidance on categorization +- Instructions travel with data - ensures consistent categorization across all AI agents +- AI creates dynamic categories based on actual changes instead of predefined patterns +- Better context understanding than regex-based categorization + +**Example response structure:** +```python +result = await generate_release_notes( + version="v1.0.0", + previous_version="v0.9.0", + repo_url="https://github.com/owner/repo" +) + +# Returns: +{ + "status": "success", + "data": { + "commits": [...], + "version": "v1.0.0", + ... + }, + "ai_instructions": { + "role": "release_notes_categorizer", + "task": "Analyze commits and create intelligent release notes", + "guidelines": [ + "Create dynamic categories based on actual changes", + "Group related commits intelligently", + "Understand context beyond pattern matching", + ... + ], + "categorization_strategy": {...}, + "suggested_sections": {...}, + "output_format": {...} + } +} +``` + +**Why instructions are embedded:** +- ✅ Instructions version-controlled with tool +- ✅ Consistent categorization across all workflows +- ✅ Self-documenting - AI knows how to use the data +- ✅ No need to duplicate instructions in each workflow + +See [USE_CASES.md](USE_CASES.md) for detailed use cases and integration scenarios. ## Installation @@ -200,6 +271,51 @@ Once configured, you can use the `generate_release_notes` tool in your conversat The tool will analyze git commits between the specified tags and generate structured release notes with categorized changes. +## Usage Examples + +### Remote GitHub Repository + +```python +# Using with AI agent integration +result = await generate_release_notes( + version="v1.0.0", + previous_version="v0.9.0", + repo_url="https://github.com/owner/repo", + github_token=os.getenv('GITHUB_TOKEN'), # Optional, but recommended + return_raw_data=True # Returns structured data for AI processing +) + +# Standard formatted output +result = await generate_release_notes( + version="v1.0.0", + previous_version="v0.9.0", + repo_url="https://github.com/owner/repo", + github_token=os.getenv('GITHUB_TOKEN') +) +``` + +### Remote GitLab Repository + +```python +result = await generate_release_notes( + version="v2.0.0", + previous_version="v1.5.0", + repo_url="https://gitlab.com/owner/repo", + gitlab_token=os.getenv('GITLAB_TOKEN') # Optional for public repos +) +``` + +### Local Repository + +```python +# Existing behavior - still fully supported +result = await generate_release_notes( + version="v1.0.0", + previous_version="v0.9.0", + repo_path="/path/to/local/repo" +) +``` + ## Running as HTTP Server For development, testing, or integration with other tools, you can run the MCP server as an HTTP service. diff --git a/pyproject.toml b/pyproject.toml index e3368d7..9d50198 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,14 +7,14 @@ packages = ["utility_mcp_server"] [project] name = "utility-mcp-server" -version = "0.1.1" -description = "A utility Model Context Protocol (MCP) server" +version = "0.2.0" +description = "A utility Model Context Protocol (MCP) server with remote repository support" readme = "README.md" license = {text = "Apache-2.0"} authors = [ {name = "Jitendra Yejare", email = "jyejare@redhat.com"} ] -keywords = ["mcp", "model-context-protocol", "release-notes", "fastmcp", "ai-tools"] +keywords = ["mcp", "model-context-protocol", "release-notes", "fastmcp", "ai-tools", "github", "gitlab"] classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", @@ -39,6 +39,9 @@ dependencies = [ "psycopg==3.2.3", "itsdangerous==2.2.0", "requests-oauthlib==2.0.0", + "PyGithub>=2.1.1", + "python-gitlab>=4.0.0", + "packaging>=23.0", ] [project.optional-dependencies] diff --git a/tests/test_git_providers.py b/tests/test_git_providers.py new file mode 100644 index 0000000..3b97321 --- /dev/null +++ b/tests/test_git_providers.py @@ -0,0 +1,283 @@ +"""Tests for git provider abstractions.""" + +from unittest.mock import Mock, patch, MagicMock +import pytest + +from utility_mcp_server.src.tools.git_providers import ( + GitHubProvider, + GitLabProvider, + LocalGitProvider, + get_git_provider, +) + + +class TestGitHubProvider: + """Tests for GitHubProvider class.""" + + @patch("github.Github") + def test_init_with_token(self, mock_github_class): + """Test initialization with GitHub token.""" + mock_repo = Mock() + mock_github = Mock() + mock_github.get_repo.return_value = mock_repo + mock_github_class.return_value = mock_github + + provider = GitHubProvider("https://github.com/owner/repo", token="test_token") + + assert provider.owner == "owner" + assert provider.repo_name == "repo" + assert provider.repo_url == "https://github.com/owner/repo" + mock_github_class.assert_called_once_with("test_token") + + @patch("github.Github") + def test_validate_tag_exists(self, mock_github_class): + """Test tag validation for existing tag.""" + mock_repo = Mock() + mock_repo.get_git_ref.return_value = Mock() + mock_github = Mock() + mock_github.get_repo.return_value = mock_repo + mock_github_class.return_value = mock_github + + provider = GitHubProvider("https://github.com/owner/repo") + + assert provider._validate_tag("v1.0.0") is True + mock_repo.get_git_ref.assert_called_with("tags/v1.0.0") + + @patch("github.Github") + def test_validate_tag_not_exists(self, mock_github_class): + """Test tag validation for non-existing tag.""" + mock_repo = Mock() + mock_repo.get_git_ref.side_effect = Exception("Tag not found") + mock_github = Mock() + mock_github.get_repo.return_value = mock_repo + mock_github_class.return_value = mock_github + + provider = GitHubProvider("https://github.com/owner/repo") + + assert provider._validate_tag("v1.0.0") is False + + @patch("github.Github") + def test_get_previous_tag(self, mock_github_class): + """Test auto-detection of previous tag.""" + # Mock tags + tag1 = Mock() + tag1.name = "v1.0.0" + tag2 = Mock() + tag2.name = "v0.9.0" + tag3 = Mock() + tag3.name = "v0.8.0" + + mock_repo = Mock() + mock_repo.get_tags.return_value = [tag1, tag2, tag3] + mock_github = Mock() + mock_github.get_repo.return_value = mock_repo + mock_github_class.return_value = mock_github + + provider = GitHubProvider("https://github.com/owner/repo") + + prev_tag = provider._get_previous_tag("v1.0.0") + assert prev_tag == "v0.9.0" + + @patch("github.Github") + def test_extract_pr_from_message(self, mock_github_class): + """Test PR number extraction from commit message.""" + mock_repo = Mock() + mock_github = Mock() + mock_github.get_repo.return_value = mock_repo + mock_github_class.return_value = mock_github + + provider = GitHubProvider("https://github.com/owner/repo") + + # Test various formats + assert provider._extract_pr_from_message("feat: add feature (#123)") == "123" + assert provider._extract_pr_from_message("Merge pull request #456") == "456" + assert provider._extract_pr_from_message("PR #789: fix bug") == "789" + assert provider._extract_pr_from_message("feat: no PR here") == "Not Found" + + @patch("github.Github") + def test_get_compare_url(self, mock_github_class): + """Test compare URL generation.""" + mock_repo = Mock() + mock_github = Mock() + mock_github.get_repo.return_value = mock_repo + mock_github_class.return_value = mock_github + + provider = GitHubProvider("https://github.com/owner/repo") + + url = provider.get_compare_url("v0.9.0", "v1.0.0") + assert url == "https://github.com/owner/repo/compare/v0.9.0...v1.0.0" + + +class TestGitLabProvider: + """Tests for GitLabProvider class.""" + + @patch("gitlab.Gitlab") + def test_init_with_token(self, mock_gitlab_class): + """Test initialization with GitLab token.""" + mock_project = Mock() + mock_gitlab = Mock() + mock_gitlab.projects.get.return_value = mock_project + mock_gitlab_class.return_value = mock_gitlab + + provider = GitLabProvider("https://gitlab.com/owner/repo", token="test_token") + + assert provider.gitlab_url == "https://gitlab.com" + assert provider.project_path == "owner/repo" + mock_gitlab_class.assert_called_once_with( + "https://gitlab.com", private_token="test_token" + ) + + @patch("gitlab.Gitlab") + def test_validate_tag_exists(self, mock_gitlab_class): + """Test tag validation for existing tag.""" + mock_tag = Mock() + mock_project = Mock() + mock_project.tags.get.return_value = mock_tag + mock_gitlab = Mock() + mock_gitlab.projects.get.return_value = mock_project + mock_gitlab_class.return_value = mock_gitlab + + provider = GitLabProvider("https://gitlab.com/owner/repo") + + assert provider._validate_tag("v1.0.0") is True + mock_project.tags.get.assert_called_with("v1.0.0") + + @patch("gitlab.Gitlab") + def test_extract_mr_from_message(self, mock_gitlab_class): + """Test MR number extraction from commit message.""" + mock_project = Mock() + mock_gitlab = Mock() + mock_gitlab.projects.get.return_value = mock_project + mock_gitlab_class.return_value = mock_gitlab + + provider = GitLabProvider("https://gitlab.com/owner/repo") + + # Test various formats + assert provider._extract_mr_from_message("feat: add feature (!123)") == "123" + assert provider._extract_mr_from_message("Merge request !456") == "456" + assert provider._extract_mr_from_message("MR !789: fix bug") == "789" + assert provider._extract_mr_from_message("feat: no MR here") == "Not Found" + + @patch("gitlab.Gitlab") + def test_get_compare_url(self, mock_gitlab_class): + """Test compare URL generation.""" + mock_project = Mock() + mock_gitlab = Mock() + mock_gitlab.projects.get.return_value = mock_project + mock_gitlab_class.return_value = mock_gitlab + + provider = GitLabProvider("https://gitlab.com/owner/repo") + + url = provider.get_compare_url("v0.9.0", "v1.0.0") + assert url == "https://gitlab.com/owner/repo/-/compare/v0.9.0...v1.0.0" + + +class TestLocalGitProvider: + """Tests for LocalGitProvider class.""" + + def test_init(self): + """Test initialization.""" + provider = LocalGitProvider("/path/to/repo") + assert provider.repo_path == "/path/to/repo" + + @patch("subprocess.run") + def test_validate_tag_exists(self, mock_run): + """Test tag validation for existing tag.""" + mock_run.return_value = Mock(returncode=0) + + provider = LocalGitProvider("/path/to/repo") + + assert provider._validate_tag("v1.0.0") is True + mock_run.assert_called_once() + + @patch("subprocess.run") + def test_validate_tag_not_exists(self, mock_run): + """Test tag validation for non-existing tag.""" + from subprocess import CalledProcessError + + mock_run.side_effect = CalledProcessError(1, "git") + + provider = LocalGitProvider("/path/to/repo") + + assert provider._validate_tag("v1.0.0") is False + + @patch("subprocess.run") + def test_get_previous_tag(self, mock_run): + """Test auto-detection of previous tag.""" + mock_run.return_value = Mock(stdout="v1.0.0\nv0.9.0\nv0.8.0\n", returncode=0) + + provider = LocalGitProvider("/path/to/repo") + + prev_tag = provider._get_previous_tag("v1.0.0") + assert prev_tag == "v0.9.0" + + @patch("subprocess.run") + def test_fetch_commits(self, mock_run): + """Test fetching commits between tags.""" + # Mock tag validation + mock_run.side_effect = [ + Mock(returncode=0), # validate to_tag + Mock(stdout="v1.0.0\nv0.9.0\n", returncode=0), # get_previous_tag + Mock(returncode=0), # validate from_tag + Mock( # fetch commits + stdout="abc123|feat: add feature|Author|2024-01-01\n", returncode=0 + ), + ] + + provider = LocalGitProvider("/path/to/repo") + + commits = provider.fetch_commits(None, "v1.0.0") + + assert len(commits) == 1 + assert commits[0]["hash"] == "abc123" + assert commits[0]["message"] == "feat: add feature" + assert commits[0]["author"] == "Author" + assert commits[0]["date"] == "2024-01-01" + assert commits[0]["pr_number"] == "Not Found" + + def test_get_compare_url(self): + """Test compare URL returns empty for local repos.""" + provider = LocalGitProvider("/path/to/repo") + url = provider.get_compare_url("v0.9.0", "v1.0.0") + assert url == "" + + +class TestGetGitProvider: + """Tests for get_git_provider factory function.""" + + @patch("utility_mcp_server.src.tools.git_providers.GitHubProvider") + def test_github_provider(self, mock_provider): + """Test GitHub provider selection.""" + provider = get_git_provider( + repo_url="https://github.com/owner/repo", github_token="token" + ) + + mock_provider.assert_called_once_with("https://github.com/owner/repo", "token") + + @patch("utility_mcp_server.src.tools.git_providers.GitLabProvider") + def test_gitlab_provider(self, mock_provider): + """Test GitLab provider selection.""" + provider = get_git_provider( + repo_url="https://gitlab.com/owner/repo", gitlab_token="token" + ) + + mock_provider.assert_called_once_with("https://gitlab.com/owner/repo", "token") + + @patch("utility_mcp_server.src.tools.git_providers.LocalGitProvider") + def test_local_provider(self, mock_provider): + """Test local provider selection.""" + provider = get_git_provider(repo_path="/path/to/repo") + + mock_provider.assert_called_once_with("/path/to/repo") + + def test_no_provider_error(self): + """Test error when no provider can be determined.""" + with pytest.raises( + ValueError, match="Must provide either repo_url or repo_path" + ): + get_git_provider() + + def test_unsupported_provider_error(self): + """Test error for unsupported git hosting service.""" + with pytest.raises(ValueError, match="Unsupported git hosting service"): + get_git_provider(repo_url="https://bitbucket.org/owner/repo") diff --git a/tests/test_release_notes_tool.py b/tests/test_release_notes_tool.py index 06eb84c..0f0dc3c 100644 --- a/tests/test_release_notes_tool.py +++ b/tests/test_release_notes_tool.py @@ -1,527 +1,260 @@ """Tests for the release notes generation tool.""" -import asyncio -import subprocess from unittest.mock import Mock, patch - import pytest -from utility_mcp_server.src.tools.release_notes_tool import ( - _categorize_commit, - _extract_commit_hash, - _extract_pr_number, - _format_commit_link, - _format_pr_link, - _generate_breaking_changes_section, - _generate_bug_fixes_section, - _generate_release_notes_section, - _get_commits_between_tags, - _run_git_command, - generate_release_notes, -) - - -class TestRunGitCommand: - """Tests for _run_git_command function.""" - - @patch("utility_mcp_server.src.tools.release_notes_tool.subprocess.run") - def test_run_git_command_success(self, mock_run): - """Test successful git command execution.""" - mock_run.return_value = Mock(stdout="output\n", returncode=0) - - result = _run_git_command(["git", "status"]) - - assert result == "output" - mock_run.assert_called_once() - - @patch("utility_mcp_server.src.tools.release_notes_tool.subprocess.run") - def test_run_git_command_with_cwd(self, mock_run): - """Test git command with working directory.""" - mock_run.return_value = Mock(stdout="output\n", returncode=0) - - result = _run_git_command(["git", "log"], cwd="/path/to/repo") - - mock_run.assert_called_once() - call_kwargs = mock_run.call_args[1] - assert call_kwargs["cwd"] == "/path/to/repo" - - @patch("utility_mcp_server.src.tools.release_notes_tool.subprocess.run") - def test_run_git_command_failure(self, mock_run): - """Test git command failure.""" - mock_run.side_effect = subprocess.CalledProcessError( - 1, "git", stderr="error message" - ) - - with pytest.raises(subprocess.CalledProcessError): - _run_git_command(["git", "invalid"]) - - -class TestGetCommitsBetweenTags: - """Tests for _get_commits_between_tags function.""" - - @patch("utility_mcp_server.src.tools.release_notes_tool._run_git_command") - def test_get_commits_with_both_tags(self, mock_run): - """Test getting commits between two tags.""" - mock_run.return_value = "abc123|feat: add feature|Author|2024-01-01" - - commits = _get_commits_between_tags("v0.1.0", "v0.2.0") - - assert len(commits) == 1 - assert commits[0]["hash"] == "abc123" - assert commits[0]["message"] == "feat: add feature" - assert commits[0]["author"] == "Author" - assert commits[0]["date"] == "2024-01-01" - - @patch("utility_mcp_server.src.tools.release_notes_tool._run_git_command") - def test_get_commits_without_from_tag(self, mock_run): - """Test getting commits up to a tag.""" - mock_run.return_value = "abc123|initial commit|Author|2024-01-01" - - commits = _get_commits_between_tags(None, "v0.1.0") - - assert len(commits) == 1 - - @patch("utility_mcp_server.src.tools.release_notes_tool._run_git_command") - def test_get_commits_multiple(self, mock_run): - """Test getting multiple commits.""" - mock_run.return_value = ( - "abc123|feat: feature 1|Author1|2024-01-01\n" - "def456|fix: bug fix|Author2|2024-01-02\n" - "ghi789|docs: update readme|Author3|2024-01-03" - ) - - commits = _get_commits_between_tags("v0.1.0", "v0.2.0") - - assert len(commits) == 3 - assert commits[0]["message"] == "feat: feature 1" - assert commits[1]["message"] == "fix: bug fix" - assert commits[2]["message"] == "docs: update readme" - - @patch("utility_mcp_server.src.tools.release_notes_tool._run_git_command") - def test_get_commits_empty(self, mock_run): - """Test when no commits found.""" - mock_run.return_value = "" - - commits = _get_commits_between_tags("v0.1.0", "v0.2.0") - - assert commits == [] - - @patch("utility_mcp_server.src.tools.release_notes_tool._run_git_command") - def test_get_commits_error(self, mock_run): - """Test error handling when git command fails.""" - mock_run.side_effect = Exception("Git error") - - commits = _get_commits_between_tags("v0.1.0", "v0.2.0") - - assert commits == [] - - -class TestExtractPrNumber: - """Tests for _extract_pr_number function.""" - - def test_extract_pr_hash_format(self): - """Test extracting PR number with # format.""" - assert _extract_pr_number("feat: add feature #123") == "123" - - def test_extract_pr_parenthesis_format(self): - """Test extracting PR number with (#num) format.""" - assert _extract_pr_number("feat: add feature (#456)") == "456" - - def test_extract_pr_pr_prefix(self): - """Test extracting PR number with PR# prefix.""" - assert _extract_pr_number("feat: add feature PR#789") == "789" - assert _extract_pr_number("feat: add feature PR #789") == "789" +from utility_mcp_server.src.tools.release_notes_tool import generate_release_notes - def test_extract_pr_pull_format(self): - """Test extracting PR number from pull/num format.""" - assert _extract_pr_number("Merge pull/123 from branch") == "123" - def test_extract_pr_no_match(self): - """Test when no PR number found.""" - assert _extract_pr_number("feat: add feature") is None - - def test_extract_pr_multiple_numbers(self): - """Test that first PR number is extracted.""" - assert _extract_pr_number("feat: #123 and #456") == "123" - - -class TestExtractCommitHash: - """Tests for _extract_commit_hash function.""" - - def test_extract_hash_bracket_format(self): - """Test extracting hash from [hash] format.""" - assert ( - _extract_commit_hash("[abc1234] commit message", "full_hash") == "abc1234" - ) - - def test_extract_hash_commit_keyword(self): - """Test extracting hash with commit keyword.""" - assert ( - _extract_commit_hash("commit abc1234def message", "full_hash") - == "abc1234def" - ) - - def test_extract_hash_fallback(self): - """Test fallback to provided hash.""" - result = _extract_commit_hash("no hash here", "abc1234567890") - assert result == "abc1234" - - def test_extract_hash_empty_hash(self): - """Test with empty hash string.""" - result = _extract_commit_hash("no hash here", "") - assert result is None - - -class TestCategorizeCommit: - """Tests for _categorize_commit function.""" - - def test_categorize_feature(self): - """Test categorizing feature commits.""" - commit_type, _ = _categorize_commit("feat: add new feature") - assert commit_type == "feature" - - commit_type, _ = _categorize_commit("Add new functionality") - assert commit_type == "feature" - - def test_categorize_fix(self): - """Test categorizing fix commits.""" - commit_type, _ = _categorize_commit("fix: resolve bug") - assert commit_type == "fix" - - commit_type, _ = _categorize_commit("Bug fix for issue") - assert commit_type == "fix" - - def test_categorize_breaking(self): - """Test categorizing breaking changes.""" - commit_type, _ = _categorize_commit("breaking: remove deprecated api") - assert commit_type == "breaking" - - def test_categorize_enhancement(self): - """Test categorizing enhancements.""" - commit_type, _ = _categorize_commit("improve performance") - assert commit_type == "enhancement" - - commit_type, _ = _categorize_commit("refactor code structure") - assert commit_type == "enhancement" - - def test_categorize_docs(self): - """Test categorizing documentation commits.""" - commit_type, _ = _categorize_commit("documentation changes") - assert commit_type == "docs" - - commit_type, _ = _categorize_commit("readme edits") - assert commit_type == "docs" - - def test_categorize_category_api(self): - """Test categorizing api-related commits.""" - _, category = _categorize_commit("feat: add api endpoint") - assert category == "api" - - def test_categorize_category_database(self): - """Test categorizing database-related commits.""" - _, category = _categorize_commit("fix: database connection issue") - assert category == "database" - - def test_categorize_category_kubernetes(self): - """Test categorizing kubernetes-related commits.""" - _, category = _categorize_commit("feat: add k8s operator") - assert category == "kubernetes" - - def test_categorize_default(self): - """Test default categorization.""" - commit_type, category = _categorize_commit("random commit message") - assert commit_type == "enhancement" - assert category == "general" - - -class TestFormatLinks: - """Tests for link formatting functions.""" - - def test_format_pr_link(self): - """Test PR link formatting.""" - link = _format_pr_link("123", "https://github.com/org/repo") - assert link == "[#123](https://github.com/org/repo/pull/123)" - - def test_format_commit_link(self): - """Test commit link formatting.""" - link = _format_commit_link("abc1234", "https://github.com/org/repo") - assert link == "[abc1234](https://github.com/org/repo/commit/abc1234)" - - -class TestGenerateSections: - """Tests for section generation functions.""" +class TestGenerateReleaseNotes: + """Tests for generate_release_notes function.""" - def test_generate_release_notes_section(self): - """Test release notes section generation.""" - items = [ + @pytest.mark.asyncio + @patch("utility_mcp_server.src.tools.release_notes_tool.get_git_provider") + async def test_generate_release_notes_success(self, mock_get_provider): + """Test successful release notes generation.""" + # Mock provider + mock_provider = Mock() + mock_provider.__class__.__name__ = "GitHubProvider" + mock_provider.fetch_commits.return_value = [ { - "title": "Feature 1", - "description": "Description 1", - "pr": "123", - "commit": "abc1234", - } + "hash": "abc123", + "message": "feat: add new feature", + "author": "John Doe", + "date": "2024-01-01", + "pr_number": "123", + }, + { + "hash": "def456", + "message": "fix: bug fix", + "author": "Jane Smith", + "date": "2024-01-02", + "pr_number": "124", + }, ] - - section = _generate_release_notes_section( - "New Features", - "New features in this release.", - items, - "https://github.com/org/repo", + mock_provider.get_compare_url.return_value = ( + "https://github.com/owner/repo/compare/v0.9.0...v1.0.0" ) + mock_get_provider.return_value = mock_provider - assert "### New Features" in section - assert "New features in this release." in section - assert "| Feature | Description | PR |" in section - assert "Feature 1" in section - assert "[#123]" in section - - def test_generate_release_notes_section_empty(self): - """Test section generation with empty items.""" - section = _generate_release_notes_section( - "Empty", "No items.", [], "https://github.com/org/repo" - ) - - assert section == "" - - def test_generate_release_notes_section_commit_fallback(self): - """Test section uses commit link when no PR.""" - items = [ - {"title": "Feature", "description": "Desc", "pr": None, "commit": "abc1234"} - ] - - section = _generate_release_notes_section( - "Features", "Desc.", items, "https://github.com/org/repo" + result = await generate_release_notes( + version="v1.0.0", + previous_version="v0.9.0", + repo_url="https://github.com/owner/repo", ) - assert "[abc1234]" in section - - def test_generate_bug_fixes_section(self): - """Test bug fixes section generation.""" - items = [ - {"title": "Fix 1", "description": "Fixed bug", "pr": "456", "commit": None} - ] - - section = _generate_bug_fixes_section( - "Bug Fixes", - "Bug fixes in this release.", - items, - "https://github.com/org/repo", + assert result["status"] == "success" + assert result["operation"] == "fetch_commits" + assert result["data"]["version"] == "v1.0.0" + assert result["data"]["previous_version"] == "v0.9.0" + assert result["data"]["commit_count"] == 2 + assert len(result["data"]["commits"]) == 2 + assert result["data"]["commits"][0]["hash"] == "abc123" + assert result["data"]["commits"][1]["hash"] == "def456" + assert result["data"]["metadata"]["provider"] == "GitHubProvider" + + # Verify ai_instructions are included + assert "ai_instructions" in result + assert result["ai_instructions"]["role"] == "release_notes_categorizer" + assert "guidelines" in result["ai_instructions"] + assert "categorization_strategy" in result["ai_instructions"] + assert "suggested_sections" in result["ai_instructions"] + + @pytest.mark.asyncio + @patch("utility_mcp_server.src.tools.release_notes_tool.get_git_provider") + async def test_generate_release_notes_no_version(self, mock_get_provider): + """Test error when version is not provided.""" + result = await generate_release_notes( + version="", repo_url="https://github.com/owner/repo" ) - assert "### Bug Fixes" in section - assert "| Fix | Description | PR |" in section - assert "Fix 1" in section + assert result["status"] == "error" + assert "Version is required" in result["error"] - def test_generate_bug_fixes_section_empty(self): - """Test bug fixes section with empty items.""" - section = _generate_bug_fixes_section( - "Bug Fixes", "No fixes.", [], "https://github.com/org/repo" + @pytest.mark.asyncio + @patch("utility_mcp_server.src.tools.release_notes_tool.get_git_provider") + async def test_generate_release_notes_no_commits(self, mock_get_provider): + """Test when no commits are found between tags.""" + mock_provider = Mock() + mock_provider.fetch_commits.return_value = [] + mock_get_provider.return_value = mock_provider + + result = await generate_release_notes( + version="v1.0.0", + previous_version="v0.9.0", + repo_url="https://github.com/owner/repo", ) - assert section == "" - - def test_generate_breaking_changes_section(self): - """Test breaking changes section generation.""" - items = [ + assert result["status"] == "error" + assert "No commits found" in result["error"] + + @pytest.mark.asyncio + @patch("utility_mcp_server.src.tools.release_notes_tool.get_git_provider") + async def test_generate_release_notes_with_github(self, mock_get_provider): + """Test release notes generation with GitHub.""" + mock_provider = Mock() + mock_provider.__class__.__name__ = "GitHubProvider" + mock_provider.fetch_commits.return_value = [ { - "title": "Breaking Change", - "description": "Removed API", - "pr": "789", - "impact": "HIGH", + "hash": "abc123", + "message": "feat: GitHub feature", + "author": "Author", + "date": "2024-01-01", + "pr_number": "42", } ] - - section = _generate_breaking_changes_section( - items, "https://github.com/org/repo" + mock_provider.get_compare_url.return_value = ( + "https://github.com/owner/repo/compare/v0.9.0...v1.0.0" ) + mock_get_provider.return_value = mock_provider - assert "### Breaking Changes" in section - assert "| Change | Description | PR | Impact |" in section - assert "Breaking Change" in section - assert "HIGH" in section - - def test_generate_breaking_changes_section_empty(self): - """Test breaking changes section with empty items.""" - section = _generate_breaking_changes_section([], "https://github.com/org/repo") - - assert section == "" - + result = await generate_release_notes( + version="v1.0.0", + previous_version="v0.9.0", + repo_url="https://github.com/owner/repo", + github_token="test_token", + ) -class TestGenerateReleaseNotes: - """Tests for the main generate_release_notes function.""" + assert result["status"] == "success" + assert result["data"]["commits"][0]["pr_number"] == "42" + mock_get_provider.assert_called_once_with( + repo_url="https://github.com/owner/repo", + repo_path=None, + github_token="test_token", + gitlab_token=None, + ) - @patch("utility_mcp_server.src.tools.release_notes_tool._get_commits_between_tags") - def test_generate_release_notes_success(self, mock_get_commits): - """Test successful release notes generation.""" - mock_get_commits.return_value = [ + @pytest.mark.asyncio + @patch("utility_mcp_server.src.tools.release_notes_tool.get_git_provider") + async def test_generate_release_notes_with_gitlab(self, mock_get_provider): + """Test release notes generation with GitLab.""" + mock_provider = Mock() + mock_provider.__class__.__name__ = "GitLabProvider" + mock_provider.fetch_commits.return_value = [ { - "hash": "abc1234567890", - "message": "feat: add new feature (#123)", + "hash": "abc123", + "message": "feat: GitLab feature", "author": "Author", "date": "2024-01-01", - }, - { - "hash": "def4567890123", - "message": "fix: bug fix (#456)", - "author": "Author2", - "date": "2024-01-02", - }, + "pr_number": "21", + } ] - - result = asyncio.run( - generate_release_notes( - version="v0.2.0", - previous_version="v0.1.0", - repo_url="https://github.com/org/repo", - release_date="January 15, 2024", - ) + mock_provider.get_compare_url.return_value = ( + "https://gitlab.com/owner/repo/-/compare/v0.9.0...v1.0.0" ) + mock_get_provider.return_value = mock_provider - assert result["status"] == "success" - assert result["operation"] == "generate_release_notes" - assert result["version"] == "v0.2.0" - assert result["previous_version"] == "v0.1.0" - assert "release_notes" in result - assert "statistics" in result - assert "# v0.2.0 Release Notes" in result["release_notes"] - assert "January 15, 2024" in result["release_notes"] - - @patch("utility_mcp_server.src.tools.release_notes_tool._get_commits_between_tags") - def test_generate_release_notes_no_previous(self, mock_get_commits): - """Test release notes without previous version.""" - mock_get_commits.return_value = [] - - result = asyncio.run(generate_release_notes(version="v0.1.0")) - - assert result["status"] == "success" - assert result["previous_version"] is None - - def test_generate_release_notes_no_version(self): - """Test error when no version provided.""" - result = asyncio.run(generate_release_notes(version="")) - - assert result["status"] == "error" - assert "Version is required" in result["error"] - - @patch("utility_mcp_server.src.tools.release_notes_tool._get_commits_between_tags") - def test_generate_release_notes_empty_commits(self, mock_get_commits): - """Test release notes with no commits.""" - mock_get_commits.return_value = [] - - result = asyncio.run( - generate_release_notes(version="v0.2.0", previous_version="v0.1.0") + result = await generate_release_notes( + version="v1.0.0", + previous_version="v0.9.0", + repo_url="https://gitlab.com/owner/repo", + gitlab_token="test_token", ) assert result["status"] == "success" - assert result["statistics"]["total_commits"] == 0 + assert result["data"]["commits"][0]["pr_number"] == "21" + mock_get_provider.assert_called_once_with( + repo_url="https://gitlab.com/owner/repo", + repo_path=None, + github_token=None, + gitlab_token="test_token", + ) - @patch("utility_mcp_server.src.tools.release_notes_tool._get_commits_between_tags") - def test_generate_release_notes_with_breaking_changes(self, mock_get_commits): - """Test release notes with breaking changes.""" - mock_get_commits.return_value = [ + @pytest.mark.asyncio + @patch("utility_mcp_server.src.tools.release_notes_tool.get_git_provider") + async def test_generate_release_notes_with_local_repo(self, mock_get_provider): + """Test release notes generation with local repository.""" + mock_provider = Mock() + mock_provider.__class__.__name__ = "LocalGitProvider" + mock_provider.fetch_commits.return_value = [ { - "hash": "abc1234567890", - "message": "breaking: remove deprecated API (#123)", + "hash": "abc123", + "message": "feat: local feature", "author": "Author", "date": "2024-01-01", + "pr_number": "Not Found", } ] + mock_provider.get_compare_url.return_value = "" + mock_get_provider.return_value = mock_provider - result = asyncio.run( - generate_release_notes( - version="v1.0.0", - previous_version="v0.9.0", - repo_url="https://github.com/org/repo", - ) + result = await generate_release_notes( + version="v1.0.0", previous_version="v0.9.0", repo_path="/path/to/repo" ) assert result["status"] == "success" - assert result["statistics"]["breaking_changes"] == 1 - assert "Breaking Changes" in result["release_notes"] - - @patch("utility_mcp_server.src.tools.release_notes_tool._get_commits_between_tags") - def test_generate_release_notes_default_date(self, mock_get_commits): - """Test release notes uses current date when not provided.""" - mock_get_commits.return_value = [] - - result = asyncio.run(generate_release_notes(version="v0.1.0")) - - assert result["status"] == "success" - assert "Release Date:" in result["release_notes"] + assert result["data"]["commits"][0]["pr_number"] == "Not Found" + mock_get_provider.assert_called_once_with( + repo_url=None, + repo_path="/path/to/repo", + github_token=None, + gitlab_token=None, + ) - @patch("utility_mcp_server.src.tools.release_notes_tool._get_commits_between_tags") - def test_generate_release_notes_statistics(self, mock_get_commits): - """Test release notes statistics calculation.""" - mock_get_commits.return_value = [ + @pytest.mark.asyncio + @patch("utility_mcp_server.src.tools.release_notes_tool.get_git_provider") + async def test_generate_release_notes_auto_previous_version( + self, mock_get_provider + ): + """Test auto-detection of previous version.""" + mock_provider = Mock() + mock_provider.__class__.__name__ = "GitHubProvider" + mock_provider.fetch_commits.return_value = [ { - "hash": "a" * 40, + "hash": "abc123", "message": "feat: feature", - "author": "A", + "author": "Author", "date": "2024-01-01", - }, - { - "hash": "b" * 40, - "message": "fix: bug", - "author": "B", - "date": "2024-01-02", - }, - { - "hash": "c" * 40, - "message": "improve: enhance", - "author": "C", - "date": "2024-01-03", - }, + "pr_number": "123", + } ] + mock_get_provider.return_value = mock_provider - result = asyncio.run( - generate_release_notes(version="v0.2.0", previous_version="v0.1.0") + result = await generate_release_notes( + version="v1.0.0", repo_url="https://github.com/owner/repo" ) assert result["status"] == "success" - assert result["statistics"]["total_commits"] == 3 - - @patch("utility_mcp_server.src.tools.release_notes_tool._get_commits_between_tags") - def test_generate_release_notes_with_repo_url(self, mock_get_commits): - """Test release notes includes repo URL.""" - mock_get_commits.return_value = [] - - result = asyncio.run( - generate_release_notes( - version="v0.2.0", - previous_version="v0.1.0", - repo_url="https://github.com/org/repo", - ) - ) - - assert result["status"] == "success" - assert "https://github.com/org/repo" in result["release_notes"] - assert "Full Changelog" in result["release_notes"] - - @patch("utility_mcp_server.src.tools.release_notes_tool._get_commits_between_tags") - def test_generate_release_notes_long_commit_message(self, mock_get_commits): - """Test handling of long commit messages.""" - long_message = "feat: " + "a" * 100 + " (#123)" - mock_get_commits.return_value = [ + assert result["data"]["previous_version"] == "auto-detected" + # Provider's fetch_commits should be called with None as from_tag + mock_provider.fetch_commits.assert_called_once_with(None, "v1.0.0") + + @pytest.mark.asyncio + @patch("utility_mcp_server.src.tools.release_notes_tool.get_git_provider") + async def test_generate_release_notes_with_custom_date(self, mock_get_provider): + """Test release notes generation with custom release date.""" + mock_provider = Mock() + mock_provider.__class__.__name__ = "GitHubProvider" + mock_provider.fetch_commits.return_value = [ { - "hash": "abc1234567890", - "message": long_message, + "hash": "abc123", + "message": "feat: feature", "author": "Author", "date": "2024-01-01", + "pr_number": "123", } ] + mock_get_provider.return_value = mock_provider - result = asyncio.run(generate_release_notes(version="v0.2.0")) + result = await generate_release_notes( + version="v1.0.0", + previous_version="v0.9.0", + repo_url="https://github.com/owner/repo", + release_date="2024-12-25", + ) assert result["status"] == "success" + assert result["data"]["release_date"] == "2024-12-25" - @patch("utility_mcp_server.src.tools.release_notes_tool._get_commits_between_tags") - def test_generate_release_notes_exception_handling(self, mock_get_commits): - """Test exception handling in release notes generation.""" - mock_get_commits.side_effect = Exception("Unexpected error") + @pytest.mark.asyncio + @patch("utility_mcp_server.src.tools.release_notes_tool.get_git_provider") + async def test_generate_release_notes_provider_error(self, mock_get_provider): + """Test error handling when provider fails.""" + mock_get_provider.side_effect = Exception("Provider initialization failed") - result = asyncio.run( - generate_release_notes(version="v0.2.0", previous_version="v0.1.0") + result = await generate_release_notes( + version="v1.0.0", repo_url="https://github.com/owner/repo" ) assert result["status"] == "error" - assert "Failed to generate release notes" in result["message"] + assert "Provider initialization failed" in result["error"] diff --git a/utility_mcp_server/src/tools/git_providers.py b/utility_mcp_server/src/tools/git_providers.py new file mode 100644 index 0000000..f4c6e0f --- /dev/null +++ b/utility_mcp_server/src/tools/git_providers.py @@ -0,0 +1,646 @@ +"""Git provider abstractions for fetching commits from various sources. + +This module provides a unified interface for fetching git commits from: +- GitHub (via PyGithub API) +- GitLab (via python-gitlab API) +- Local git repositories (via subprocess) +""" + +import os +import re +import subprocess +from abc import ABC, abstractmethod +from typing import Dict, List, Optional + +from packaging import version as packaging_version + +from utility_mcp_server.utils.pylogger import get_python_logger + +logger = get_python_logger() + + +class GitProvider(ABC): + """Abstract base class for git hosting providers.""" + + @abstractmethod + def fetch_commits( + self, from_tag: Optional[str], to_tag: str + ) -> List[Dict[str, str]]: + """Fetch commits between tags. + + Args: + from_tag: Starting tag (None for all commits up to to_tag) + to_tag: Ending tag + + Returns: + List of commit dictionaries with keys: + - hash: Commit SHA + - message: Full commit message + - author: Author name + - date: Commit date in YYYY-MM-DD format + - pr_number: PR/MR number (if available) + """ + pass + + @abstractmethod + def get_compare_url(self, from_tag: str, to_tag: str) -> str: + """Get URL for comparing tags.""" + pass + + +class GitHubProvider(GitProvider): + """GitHub provider using PyGithub API.""" + + def __init__(self, repo_url: str, token: Optional[str] = None): + """Initialize GitHub provider. + + Args: + repo_url: Repository URL (e.g., https://github.com/owner/repo) + token: GitHub API token (optional, increases rate limits) + """ + try: + from github import Github + except ImportError: + raise ImportError( + "PyGithub not installed. Install with: pip install PyGithub" + ) + + self.token = token or os.getenv("GITHUB_TOKEN") + self.g = Github(self.token) if self.token else Github() + + # Parse owner/repo from URL + # https://github.com/owner/repo.git -> owner/repo + parts = repo_url.rstrip("/").replace(".git", "").split("/") + self.owner = parts[-2] + self.repo_name = parts[-1] + self.repo = self.g.get_repo(f"{self.owner}/{self.repo_name}") + self.repo_url = f"https://github.com/{self.owner}/{self.repo_name}" + + logger.info(f"Initialized GitHub provider for {self.owner}/{self.repo_name}") + + def _validate_tag(self, tag: str) -> bool: + """Validate that a tag exists in the repository. + + Args: + tag: Tag name to validate + + Returns: + True if tag exists, False otherwise + """ + try: + self.repo.get_git_ref(f"tags/{tag}") + return True + except Exception: + return False + + def _get_previous_tag(self, current_tag: str) -> Optional[str]: + """Get the tag that comes before the current tag. + + Args: + current_tag: Current tag name + + Returns: + Previous tag name or None if not found + """ + try: + # Get all tags + all_tags = list(self.repo.get_tags()) + logger.info(f"Found {len(all_tags)} total tags in repository") + + # Filter to version tags and sort by semantic version + version_tags = [] + for tag in all_tags: + try: + # Try to parse as semantic version + # Remove 'v' prefix if present for parsing + tag_version = tag.name.lstrip("v") + parsed = packaging_version.parse(tag_version) + version_tags.append((tag.name, parsed)) + except Exception: + # Skip tags that don't follow semantic versioning + continue + + if not version_tags: + logger.warning("No semantic version tags found") + return None + + # Sort by version (descending - newest first) + version_tags.sort(key=lambda x: x[1], reverse=True) + logger.info(f"Sorted {len(version_tags)} semantic version tags") + + # Find current tag index + current_index = None + for i, (tag_name, _) in enumerate(version_tags): + if tag_name == current_tag: + current_index = i + break + + if current_index is None: + logger.warning(f"Current tag {current_tag} not found in version tags") + return None + + # Return previous tag if exists (next in sorted list) + if current_index + 1 < len(version_tags): + prev_tag = version_tags[current_index + 1][0] + logger.info( + f"Auto-detected previous tag: {prev_tag} (from {current_tag})" + ) + return prev_tag + + logger.warning(f"No previous tag found before {current_tag}") + return None + except Exception as e: + logger.error(f"Failed to auto-detect previous tag: {e}") + return None + + def fetch_commits( + self, from_tag: Optional[str], to_tag: str + ) -> List[Dict[str, str]]: + """Fetch commits using GitHub API.""" + try: + # Validate to_tag + if not self._validate_tag(to_tag): + raise ValueError(f"Tag '{to_tag}' does not exist in repository") + + # Auto-fetch previous tag if not provided + if not from_tag: + from_tag = self._get_previous_tag(to_tag) + if not from_tag: + raise ValueError( + f"Could not auto-detect previous tag for '{to_tag}'. " + "Please provide 'previous_version' parameter explicitly." + ) + + # Validate from_tag + if not self._validate_tag(from_tag): + raise ValueError(f"Tag '{from_tag}' does not exist in repository") + + logger.info(f"Fetching commits from GitHub: {from_tag} to {to_tag}") + + comparison = self.repo.compare(from_tag, to_tag) + + # Log comparison details + logger.info(f"GitHub comparison total_commits: {comparison.total_commits}") + logger.info(f"Comparison status: {comparison.status}") + + commits = [] + # Explicitly iterate through all pages of commits + commit_list = list(comparison.commits) + logger.info(f"Retrieved {len(commit_list)} commits from comparison.commits") + + # Warn if we might be hitting GitHub API limits + if comparison.total_commits > 250: + logger.warning( + f"Comparison has {comparison.total_commits} commits, but GitHub " + f"compare API returns max 250. Consider using smaller version ranges." + ) + + for commit in commit_list: + # Try to get associated PR + pr_number = self._extract_pr_from_message(commit.commit.message) + try: + prs = commit.get_pulls() + if prs.totalCount > 0: + pr_number = str(prs[0].number) + except Exception: + # PR extraction from API failed, use message extraction + pass + + commits.append( + { + "hash": commit.sha, + "message": commit.commit.message, + "author": commit.commit.author.name, + "date": commit.commit.author.date.strftime("%Y-%m-%d"), + "pr_number": pr_number, + } + ) + + logger.info( + f"Fetched {len(commits)} commits from GitHub ({from_tag}...{to_tag})" + ) + + # Warn if actual commits differ from total_commits + if len(commits) < comparison.total_commits: + logger.warning( + f"Only fetched {len(commits)} commits but comparison reports " + f"{comparison.total_commits} total. Some commits may be missing." + ) + + return commits + + except Exception as e: + logger.error(f"Failed to fetch commits from GitHub: {e}") + raise RuntimeError(f"Failed to fetch commits from GitHub: {str(e)}") + + def _extract_pr_from_message(self, message: str) -> str: + """Extract PR number from commit message. + + Args: + message: Commit message + + Returns: + PR number as string or "Not Found" + """ + # Check first line only to avoid false positives from issue references in body + first_line = message.split("\n")[0] if message else "" + + # Patterns specific to PR references (more specific than generic #number) + patterns = [ + r"\(#(\d+)\)", # (#123) - most common for squash/merge commits + r"Merge pull request #(\d+)", # Merge pull request #123 + r"PR\s*#(\d+)", # PR #123 + r"pull/(\d+)", # pull/123 + ] + + for pattern in patterns: + match = re.search(pattern, first_line, re.IGNORECASE) + if match: + return match.group(1) + + return "Not Found" + + def get_compare_url(self, from_tag: str, to_tag: str) -> str: + """Get GitHub compare URL.""" + return f"{self.repo_url}/compare/{from_tag}...{to_tag}" + + +class GitLabProvider(GitProvider): + """GitLab provider using python-gitlab API.""" + + def __init__(self, repo_url: str, token: Optional[str] = None): + """Initialize GitLab provider. + + Args: + repo_url: Repository URL (e.g., https://gitlab.com/owner/repo) + token: GitLab API token (optional, for private repos) + """ + try: + import gitlab + except ImportError: + raise ImportError( + "python-gitlab not installed. Install with: pip install python-gitlab" + ) + + self.token = token or os.getenv("GITLAB_TOKEN") + + # Parse GitLab URL + # https://gitlab.com/owner/repo.git -> gitlab.com, owner/repo + parts = ( + repo_url.replace("https://", "") + .replace("http://", "") + .rstrip("/") + .replace(".git", "") + .split("/") + ) + self.gitlab_url = f"https://{parts[0]}" + self.project_path = "/".join(parts[1:]) + + self.gl = gitlab.Gitlab(self.gitlab_url, private_token=self.token) + self.project = self.gl.projects.get(self.project_path) + + logger.info(f"Initialized GitLab provider for {self.project_path}") + + def _validate_tag(self, tag: str) -> bool: + """Validate that a tag exists in the repository. + + Args: + tag: Tag name to validate + + Returns: + True if tag exists, False otherwise + """ + try: + self.project.tags.get(tag) + return True + except Exception: + return False + + def _get_previous_tag(self, current_tag: str) -> Optional[str]: + """Get the tag that comes before the current tag. + + Args: + current_tag: Current tag name + + Returns: + Previous tag name or None if not found + """ + try: + # Get all tags + all_tags = self.project.tags.list(get_all=True) + logger.info(f"Found {len(all_tags)} total tags in repository") + + # Filter to version tags and sort by semantic version + version_tags = [] + for tag in all_tags: + try: + # Try to parse as semantic version + # Remove 'v' prefix if present for parsing + tag_version = tag.name.lstrip("v") + parsed = packaging_version.parse(tag_version) + version_tags.append((tag.name, parsed)) + except Exception: + # Skip tags that don't follow semantic versioning + continue + + if not version_tags: + logger.warning("No semantic version tags found") + return None + + # Sort by version (descending - newest first) + version_tags.sort(key=lambda x: x[1], reverse=True) + logger.info(f"Sorted {len(version_tags)} semantic version tags") + + # Find current tag index + current_index = None + for i, (tag_name, _) in enumerate(version_tags): + if tag_name == current_tag: + current_index = i + break + + if current_index is None: + logger.warning(f"Current tag {current_tag} not found in version tags") + return None + + # Return previous tag if exists (next in sorted list) + if current_index + 1 < len(version_tags): + prev_tag = version_tags[current_index + 1][0] + logger.info( + f"Auto-detected previous tag: {prev_tag} (from {current_tag})" + ) + return prev_tag + + logger.warning(f"No previous tag found before {current_tag}") + return None + except Exception as e: + logger.error(f"Failed to auto-detect previous tag: {e}") + return None + + def fetch_commits( + self, from_tag: Optional[str], to_tag: str + ) -> List[Dict[str, str]]: + """Fetch commits using GitLab API.""" + try: + # Validate to_tag + if not self._validate_tag(to_tag): + raise ValueError(f"Tag '{to_tag}' does not exist in repository") + + # Auto-fetch previous tag if not provided + if not from_tag: + from_tag = self._get_previous_tag(to_tag) + if not from_tag: + raise ValueError( + f"Could not auto-detect previous tag for '{to_tag}'. " + "Please provide 'previous_version' parameter explicitly." + ) + + # Validate from_tag + if not self._validate_tag(from_tag): + raise ValueError(f"Tag '{from_tag}' does not exist in repository") + + logger.info(f"Fetching commits from GitLab: {from_tag} to {to_tag}") + + # Get commits between tags + comparison = self.project.repository_compare(from_tag, to_tag) + + commits = [] + for commit in comparison["commits"]: + commits.append( + { + "hash": commit["id"], + "message": commit["message"], + "author": commit["author_name"], + "date": commit["created_at"][:10], # YYYY-MM-DD + "pr_number": self._extract_mr_from_message(commit["message"]), + } + ) + + logger.info(f"Fetched {len(commits)} commits from GitLab") + return commits + + except Exception as e: + logger.error(f"Failed to fetch commits from GitLab: {e}") + raise RuntimeError(f"Failed to fetch commits from GitLab: {str(e)}") + + def _extract_mr_from_message(self, message: str) -> str: + """Extract MR (Merge Request) number from commit message. + + Args: + message: Commit message + + Returns: + MR number as string or "Not Found" + """ + # Check first line only to avoid false positives from issue references in body + first_line = message.split("\n")[0] if message else "" + + # Patterns specific to MR references + patterns = [ + r"\(!(\d+)\)", # (!123) - common for squash/merge commits + r"Merge request\s*!(\d+)", # Merge request !123 + r"MR\s*!(\d+)", # MR !123 + r"!(\d+)", # !123 (more generic, checked last) + ] + + for pattern in patterns: + match = re.search(pattern, first_line, re.IGNORECASE) + if match: + return match.group(1) + + return "Not Found" + + def get_compare_url(self, from_tag: str, to_tag: str) -> str: + """Get GitLab compare URL.""" + return f"{self.gitlab_url}/{self.project_path}/-/compare/{from_tag}...{to_tag}" + + +class LocalGitProvider(GitProvider): + """Fallback provider for local git repositories.""" + + def __init__(self, repo_path: str): + """Initialize local git provider. + + Args: + repo_path: Path to local git repository + """ + self.repo_path = repo_path + logger.info(f"Initialized local git provider for {repo_path}") + + def _validate_tag(self, tag: str) -> bool: + """Validate that a tag exists in the repository. + + Args: + tag: Tag name to validate + + Returns: + True if tag exists, False otherwise + """ + try: + cmd = ["git", "rev-parse", f"tags/{tag}"] + subprocess.run( + cmd, + capture_output=True, + text=True, + cwd=self.repo_path, + check=True, + ) + return True + except subprocess.CalledProcessError: + return False + + def _get_previous_tag(self, current_tag: str) -> Optional[str]: + """Get the tag that comes before the current tag. + + Args: + current_tag: Current tag name + + Returns: + Previous tag name or None if not found + """ + try: + # Get all tags sorted by version (descending) + cmd = ["git", "tag", "--sort=-version:refname"] + result = subprocess.run( + cmd, + capture_output=True, + text=True, + cwd=self.repo_path, + check=True, + ) + + tags = [ + tag.strip() for tag in result.stdout.strip().split("\n") if tag.strip() + ] + logger.info(f"Found {len(tags)} total tags in repository") + + # Find current tag index + try: + current_index = tags.index(current_tag) + # Return next tag (previous version) + if current_index + 1 < len(tags): + prev_tag = tags[current_index + 1] + logger.info( + f"Auto-detected previous tag: {prev_tag} (from {current_tag})" + ) + return prev_tag + else: + logger.warning(f"No previous tag found before {current_tag}") + return None + except ValueError: + logger.warning(f"Current tag {current_tag} not found in tag list") + return None + + except Exception as e: + logger.error(f"Failed to auto-detect previous tag: {e}") + return None + + def fetch_commits( + self, from_tag: Optional[str], to_tag: str + ) -> List[Dict[str, str]]: + """Fetch commits using local git commands.""" + try: + # Validate to_tag + if not self._validate_tag(to_tag): + raise ValueError(f"Tag '{to_tag}' does not exist in repository") + + # Auto-fetch previous tag if not provided + if not from_tag: + from_tag = self._get_previous_tag(to_tag) + if not from_tag: + raise ValueError( + f"Could not auto-detect previous tag for '{to_tag}'. " + "Please provide 'previous_version' parameter explicitly." + ) + + # Validate from_tag + if not self._validate_tag(from_tag): + raise ValueError(f"Tag '{from_tag}' does not exist in repository") + + logger.info(f"Fetching commits from local repo: {from_tag} to {to_tag}") + + range_spec = f"{from_tag}..{to_tag}" + + format_str = "%H|%s|%an|%ad" + date_format = "%Y-%m-%d" + cmd = [ + "git", + "log", + f"--format={format_str}", + f"--date=format:{date_format}", + range_spec, + ] + + result = subprocess.run( + cmd, + capture_output=True, + text=True, + cwd=self.repo_path, + check=True, + ) + + commits = [] + for line in result.stdout.strip().split("\n"): + if not line.strip(): + continue + parts = line.split("|", 3) + if len(parts) >= 4: + commits.append( + { + "hash": parts[0], + "message": parts[1], + "author": parts[2], + "date": parts[3], + "pr_number": "Not Found", + } + ) + + logger.info(f"Fetched {len(commits)} commits from local git") + return commits + + except subprocess.CalledProcessError as e: + logger.error(f"Failed to run git command: {e.stderr}") + raise RuntimeError(f"Failed to fetch commits from local git: {e.stderr}") + except Exception as e: + logger.error(f"Failed to fetch commits: {e}") + raise RuntimeError(f"Failed to fetch commits from local git: {str(e)}") + + def get_compare_url(self, from_tag: str, to_tag: str) -> str: + """No URL for local repos.""" + return "" + + +def get_git_provider( + repo_url: Optional[str] = None, + repo_path: Optional[str] = None, + github_token: Optional[str] = None, + gitlab_token: Optional[str] = None, +) -> GitProvider: + """Factory function to get appropriate git provider. + + Args: + repo_url: Repository URL (for remote providers) + repo_path: Local repository path (fallback) + github_token: GitHub API token + gitlab_token: GitLab API token + + Returns: + GitProvider instance + + Raises: + ValueError: If neither repo_url nor repo_path is provided, + or if repo_url is from unsupported hosting service + """ + if repo_url: + if "github.com" in repo_url: + return GitHubProvider(repo_url, github_token) + elif "gitlab.com" in repo_url or "gitlab" in repo_url: + return GitLabProvider(repo_url, gitlab_token) + else: + raise ValueError( + f"Unsupported git hosting service: {repo_url}. " + "Supported: GitHub, GitLab" + ) + elif repo_path: + return LocalGitProvider(repo_path) + else: + raise ValueError("Must provide either repo_url or repo_path") diff --git a/utility_mcp_server/src/tools/release_notes_tool.py b/utility_mcp_server/src/tools/release_notes_tool.py index 65dc789..af365f2 100644 --- a/utility_mcp_server/src/tools/release_notes_tool.py +++ b/utility_mcp_server/src/tools/release_notes_tool.py @@ -1,348 +1,237 @@ """Release notes generation tool for the Utility MCP Server. -This tool provides functionality to generate release notes from git commits -or tags in a structured markdown format similar to Feast release notes. +This tool provides functionality to fetch git commits between tags +for AI-powered release notes generation. + +Supports both local and remote repositories (GitHub, GitLab). """ -import re -import subprocess from datetime import datetime -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional +from utility_mcp_server.src.tools.git_providers import get_git_provider from utility_mcp_server.utils.pylogger import get_python_logger logger = get_python_logger() -def _run_git_command(cmd: List[str], cwd: Optional[str] = None) -> str: - """Run a git command and return the output. +def _categorize_commit(commit: Dict[str, str]) -> str: + """Categorize a commit based on its message. Args: - cmd: List of command arguments starting with 'git' - cwd: Working directory for the command + commit: Commit dictionary with 'message' key Returns: - Command output as string - - Raises: - subprocess.CalledProcessError: If command fails + Category name: 'breaking', 'security', 'feature', 'bugfix', 'performance', + 'docs', 'refactor', 'test', 'chore', or 'other' """ - try: - result = subprocess.run( - cmd, - capture_output=True, - text=True, - check=True, - cwd=cwd, - ) - return result.stdout.strip() - except subprocess.CalledProcessError as e: - logger.error(f"Failed to run git command: {e.stderr}") - raise - - -def _get_commits_between_tags( - from_tag: Optional[str], - to_tag: str, - repo_path: Optional[str] = None, -) -> List[Dict[str, str]]: - """Get commits between two git tags. + message = commit["message"].lower() + full_message = commit["message"] # Keep original case for BREAKING CHANGE + + # Check for breaking changes + if ( + "breaking change" in full_message + or "breaking:" in message + or message.startswith("!") + or "!:" in message[:20] + ): + return "breaking" + + # Check for security updates + if any( + keyword in message + for keyword in ["security", "cve-", "vulnerability", "exploit", "xss", "csrf"] + ): + return "security" + + # Check conventional commit prefixes and keywords + if message.startswith("feat:") or message.startswith("feature:"): + return "feature" + + if ( + message.startswith("fix:") + or message.startswith("bugfix:") + or "bug fix" in message + ): + return "bugfix" + + if ( + message.startswith("perf:") + or message.startswith("performance:") + or "performance" in message + ): + return "performance" + + if message.startswith("docs:") or message.startswith("doc:"): + return "docs" + + if message.startswith("refactor:") or message.startswith("refactoring:"): + return "refactor" + + if message.startswith("test:") or message.startswith("tests:"): + return "test" + + if ( + message.startswith("chore:") + or message.startswith("build:") + or message.startswith("ci:") + ): + return "chore" + + # Keyword-based categorization for non-conventional commits + if any(keyword in message for keyword in ["add", "new", "implement", "create"]): + return "feature" + + if any(keyword in message for keyword in ["fix", "resolve", "correct", "patch"]): + return "bugfix" + + if any( + keyword in message + for keyword in ["update", "improve", "enhance", "optimize", "speed"] + ): + return "performance" + + if any(keyword in message for keyword in ["document", "readme", "comment"]): + return "docs" + + return "other" + + +def _format_commit_entry(commit: Dict[str, str], repo_url: str) -> str: + """Format a single commit entry with links. Args: - from_tag: Starting tag (None for all commits up to to_tag) - to_tag: Ending tag - repo_path: Path to git repository (None for current directory) + commit: Commit dictionary + repo_url: Repository URL Returns: - List of commit dictionaries with hash, message, author, date + Formatted commit line """ - try: - if from_tag: - range_spec = f"{from_tag}..{to_tag}" + message = commit["message"].split("\n")[0] # First line only + pr_number = commit.get("pr_number", "Not Found") + + # Format commit entry + if pr_number and pr_number != "Not Found": + if "github.com" in (repo_url or ""): + pr_link = f"[#{pr_number}]({repo_url}/pull/{pr_number})" + elif "gitlab.com" in (repo_url or ""): + pr_link = f"[!{pr_number}]({repo_url}/-/merge_requests/{pr_number})" else: - range_spec = f"..{to_tag}" - - format_str = "%H|%s|%an|%ad" - date_format = "%Y-%m-%d" - cmd = [ - "git", - "log", - f"--format={format_str}", - f"--date=format:{date_format}", - range_spec, - ] - - output = _run_git_command(cmd, cwd=repo_path) - commits = [] - - for line in output.split("\n"): - if not line.strip(): - continue - parts = line.split("|", 3) - if len(parts) >= 4: - commits.append( - { - "hash": parts[0], - "message": parts[1], - "author": parts[2], - "date": parts[3], - }, - ) - - return commits - except Exception as e: - logger.error(f"Failed to get commits: {e}") - return [] - - -def _extract_pr_number(message: str) -> Optional[str]: - """Extract PR number from commit message. - - Args: - message: Commit message - - Returns: - PR number as string or None - """ - patterns = [ - r"#(\d+)", - r"\(#(\d+)\)", - r"PR\s*#(\d+)", - r"pull/(\d+)", - ] - - for pattern in patterns: - match = re.search(pattern, message) - if match: - return match.group(1) - - return None - - -def _extract_commit_hash(message: str, hash_str: str) -> Optional[str]: - """Extract commit hash reference from message or use provided hash. - - Args: - message: Commit message - hash_str: Full commit hash - - Returns: - Short commit hash or None - """ - patterns = [ - r"\[([a-f0-9]{7,})\]", - r"commit\s+([a-f0-9]{7,})", - ] - - for pattern in patterns: - match = re.search(pattern, message, re.IGNORECASE) - if match: - return match.group(1) - - return hash_str[:7] if hash_str else None + pr_link = f"#{pr_number}" + return f"- {message} ({pr_link})" + else: + # Use commit hash + hash_short = commit["hash"][:7] + if repo_url: + commit_link = f"[{hash_short}]({repo_url}/commit/{commit['hash']})" + return f"- {message} ({commit_link})" + else: + return f"- {message} ({hash_short})" -def _categorize_commit(message: str) -> Tuple[str, str]: - """Categorize commit into type and category. +def _format_release_notes_markdown( + version: str, + previous_version: str, + release_date: str, + repo_url: str, + compare_url: str, + commits: List[Dict[str, str]], +) -> str: + """Format release notes as markdown with emojis and statistics. Args: - message: Commit message + version: Version tag + previous_version: Previous version tag + release_date: Release date + repo_url: Repository URL + compare_url: Compare URL + commits: List of commits Returns: - Tuple of (type, category) where type is 'feature', 'fix', 'breaking', etc. + Formatted markdown string """ - message_lower = message.lower() - - type_keywords = { - "feature": ["feat", "add", "new", "implement", "support"], - "fix": ["fix", "bug", "resolve", "correct", "repair"], - "breaking": ["breaking", "remove", "deprecate", "drop"], - "enhancement": ["improve", "enhance", "update", "refactor", "optimize"], - "docs": ["doc", "readme", "documentation"], - "test": ["test", "spec"], - "chore": ["chore", "ci", "build", "deps"], + # Build header + markdown = f"# {version} Release Notes\n\n" + markdown += f"**Release Date:** {release_date}\n" + if previous_version and previous_version != "auto-detected": + markdown += f"**Previous Version:** {previous_version}\n" + if repo_url: + markdown += f"**Repository:** {repo_url}\n" + if compare_url: + markdown += f"\n[View Full Changelog]({compare_url})\n" + markdown += "\n---\n\n" + + # Categorize commits + categories: Dict[str, List[Dict[str, str]]] = { + "breaking": [], + "security": [], + "feature": [], + "bugfix": [], + "performance": [], + "docs": [], + "refactor": [], + "test": [], + "chore": [], + "other": [], } - category_keywords = { - "ui/ux": ["ui", "ux", "interface", "dark mode", "theme", "visual"], - "api": ["api", "endpoint", "route", "rest", "grpc"], - "database": [ - "database", - "db", - "sql", - "store", - "storage", - "dynamodb", - "snowflake", - "trino", - "clickhouse", - ], - "cli": ["cli", "command", "cmd"], - "kubernetes": ["k8s", "kubernetes", "operator", "pod", "deployment"], - "integration": ["integration", "test", "ci/cd"], - "configuration": ["config", "setting", "environment", "variable"], - "materialization": ["materialization", "materialize"], - "compute": ["compute", "spark", "dask", "engine"], - "rag": ["rag", "retrieval", "ai", "mcp"], - "architecture": ["architecture", "store", "registry", "server"], + for commit in commits: + category = _categorize_commit(commit) + categories[category].append(commit) + + # Define category metadata (emoji, title, description) + category_meta = { + "breaking": ("⚠️", "Breaking Changes", "Changes that may require action"), + "security": ("🔒", "Security Updates", "Security fixes and improvements"), + "feature": ("🎉", "New Features", "New functionality added"), + "bugfix": ("🐛", "Bug Fixes", "Issues resolved"), + "performance": ("⚡", "Performance Improvements", "Speed and efficiency gains"), + "docs": ("📚", "Documentation", "Documentation updates"), + "refactor": ( + "🔄", + "Refactoring", + "Code improvements without functional changes", + ), + "test": ("🧪", "Testing", "Test additions and improvements"), + "chore": ("🔧", "Chores", "Maintenance and tooling updates"), + "other": ("📦", "Other Changes", "Miscellaneous updates"), } - commit_type = "enhancement" - for type_name, keywords in type_keywords.items(): - if any(keyword in message_lower for keyword in keywords): - commit_type = type_name - break - - category = "general" - for cat_name, keywords in category_keywords.items(): - if any(keyword in message_lower for keyword in keywords): - category = cat_name - break - - return commit_type, category + # Add categorized sections + for category_key, (emoji, title, _) in category_meta.items(): + if categories[category_key]: + markdown += f"## {emoji} {title}\n\n" + for commit in categories[category_key]: + markdown += _format_commit_entry(commit, repo_url) + "\n" + markdown += "\n" + # Add statistics section + markdown += "---\n\n" + markdown += "## 📊 Release Statistics\n\n" -def _format_pr_link(pr_number: str, repo_url: str) -> str: - """Format PR link in markdown. + # Calculate contributors + contributors = set(commit.get("author", "Unknown") for commit in commits) - Args: - pr_number: PR number - repo_url: Repository URL + markdown += f"- **Total Commits:** {len(commits)}\n" + markdown += f"- **Contributors:** {len(contributors)}\n" - Returns: - Formatted PR link - """ - return f"[#{pr_number}]({repo_url}/pull/{pr_number})" + # Count commits with PR numbers + prs = [c for c in commits if c.get("pr_number") and c["pr_number"] != "Not Found"] + if prs: + markdown += f"- **Pull Requests:** {len(prs)}\n" + # Add category breakdown + markdown += "\n**Commits by Category:**\n" + for category_key, (emoji, title, _) in category_meta.items(): + count = len(categories[category_key]) + if count > 0: + markdown += f"- {emoji} {title}: {count}\n" -def _format_commit_link(commit_hash: str, repo_url: str) -> str: - """Format commit link in markdown. - - Args: - commit_hash: Commit hash - repo_url: Repository URL - - Returns: - Formatted commit link - """ - return f"[{commit_hash}]({repo_url}/commit/{commit_hash})" - - -def _generate_release_notes_section( - title: str, - description: str, - items: List[Dict[str, str]], - repo_url: Optional[str] = None, -) -> str: - """Generate a release notes section. - - Args: - title: Section title - description: Section description - items: List of items with 'title', 'description', 'pr', 'commit' keys - repo_url: Repository URL for links - - Returns: - Formatted markdown section - """ - if not items: - return "" - - section = f"### {title}\n{description}\n\n" - section += "| Feature | Description | PR |\n" - section += "|---------|-------------|-----|\n" + # Add contributor list + if contributors and len(contributors) <= 20: + markdown += f"\n**Contributors:** {', '.join(sorted(contributors))}\n" - for item in items: - title_text = item.get("title", "") - desc_text = item.get("description", "") - pr_ref = item.get("pr") - commit_ref = item.get("commit") - - pr_link = "" - if pr_ref and repo_url: - pr_link = _format_pr_link(pr_ref, repo_url) - elif commit_ref and repo_url: - pr_link = _format_commit_link(commit_ref, repo_url) - - section += f"| **{title_text}** | {desc_text} | {pr_link} |\n" - - return section + "\n" - - -def _generate_bug_fixes_section( - title: str, - description: str, - items: List[Dict[str, str]], - repo_url: Optional[str] = None, -) -> str: - """Generate a bug fixes section. - - Args: - title: Section title - description: Section description - items: List of items with 'title', 'description', 'pr', 'commit' keys - repo_url: Repository URL for links - - Returns: - Formatted markdown section - """ - if not items: - return "" - - section = f"### {title}\n{description}\n\n" - section += "| Fix | Description | PR |\n" - section += "|-----|-------------|-----|\n" - - for item in items: - title_text = item.get("title", "") - desc_text = item.get("description", "") - pr_ref = item.get("pr") - commit_ref = item.get("commit") - - pr_link = "" - if pr_ref and repo_url: - pr_link = _format_pr_link(pr_ref, repo_url) - elif commit_ref and repo_url: - pr_link = _format_commit_link(commit_ref, repo_url) - - section += f"| **{title_text}** | {desc_text} | {pr_link} |\n" - - return section + "\n" - - -def _generate_breaking_changes_section( - items: List[Dict[str, str]], - repo_url: Optional[str] = None, -) -> str: - """Generate breaking changes section. - - Args: - items: List of items with 'title', 'description', 'pr', 'impact' keys - repo_url: Repository URL for links - - Returns: - Formatted markdown section - """ - if not items: - return "" - - section = "### Breaking Changes\n" - section += "| Change | Description | PR | Impact |\n" - section += "|--------|-------------|-----|--------|\n" - - for item in items: - title_text = item.get("title", "") - desc_text = item.get("description", "") - pr_ref = item.get("pr") - impact = item.get("impact", "") - - pr_link = "" - if pr_ref and repo_url: - pr_link = _format_pr_link(pr_ref, repo_url) - - section += f"| **{title_text}** | {desc_text} | {pr_link} | {impact} |\n" - - return section + "\n" + return markdown async def generate_release_notes( @@ -350,209 +239,270 @@ async def generate_release_notes( previous_version: Optional[str] = None, repo_path: Optional[str] = None, repo_url: Optional[str] = None, + github_token: Optional[str] = None, + gitlab_token: Optional[str] = None, release_date: Optional[str] = None, + formatted_output: bool = False, ) -> Dict[str, Any]: """Generate release notes from git commits between tags. TOOL_NAME=generate_release_notes DISPLAY_NAME=Release Notes Generator - USECASE=Generate structured release notes from git commits or tags - INSTRUCTIONS=1. Provide version tag, 2. Optionally provide previous version tag, 3. Receive formatted release notes - INPUT_DESCRIPTION=version (string): current version tag, previous_version (string, optional): previous version tag, repo_path (string, optional): path to git repo, repo_url (string, optional): repository URL for links, release_date (string, optional): release date in YYYY-MM-DD format - OUTPUT_DESCRIPTION=Dictionary with status, release_notes markdown content, and statistics - EXAMPLES=generate_release_notes("v0.50.0", "v0.49.0", repo_url="https://github.com/org/repo") - PREREQUISITES=Git repository with tags - RELATED_TOOLS=None - standalone release notes generation + USECASE=Fetch git commits between tags for AI-powered release notes generation + INSTRUCTIONS=1. Provide version tag, 2. Optionally provide previous version tag (auto-detected if omitted), 3. Specify repo_url for remote access or repo_path for local repos + INPUT_DESCRIPTION=version (string): current version tag, previous_version (string, optional): previous version tag (auto-detected if omitted), repo_path (string, optional): path to local git repo, repo_url (string, optional): repository URL for remote access (GitHub/GitLab), github_token (string, optional): GitHub API token, gitlab_token (string, optional): GitLab API token, release_date (string, optional): release date in YYYY-MM-DD format, formatted_output (boolean, optional): return pre-formatted markdown with emojis and statistics (default: False for AI agents, set True for direct use in IDEs) + OUTPUT_DESCRIPTION=Dictionary with raw commit data for AI processing, including commit hash, message, author, date, and PR/MR number + EXAMPLES=generate_release_notes("v0.50.0", repo_url="https://github.com/org/repo") + PREREQUISITES=Git repository with tags (local or remote) + RELATED_TOOLS=None - standalone release notes data fetching - I/O-bound operation - uses async def for potential future API calls. + I/O-bound operation - uses async def for API calls. - Generates structured release notes in markdown format from git commits - between two tags, categorizing them into features, bug fixes, breaking - changes, etc. + Fetches git commits between two tags from local or remote repositories + (GitHub, GitLab) and returns structured data for AI-powered categorization + and release notes generation. + + The tool automatically detects the previous tag if not provided, validates + that tags exist, and extracts PR/MR numbers from commits. Args: version: Current version tag (e.g., "v0.50.0") - previous_version: Previous version tag (e.g., "v0.49.0") - repo_path: Path to git repository (None for current directory) - repo_url: Repository URL for generating PR/commit links + previous_version: Previous version tag (e.g., "v0.49.0"). If not provided, + automatically detects the tag before the current version. + repo_path: Path to local git repository (for local repos) + repo_url: Repository URL for remote access (e.g., "https://github.com/owner/repo") + github_token: GitHub API token (optional, increases rate limits) + gitlab_token: GitLab API token (optional, for private repos) release_date: Release date in YYYY-MM-DD format (None for today) + formatted_output: If True, returns pre-formatted markdown with emojis and statistics. + Use this when calling directly from IDEs (Cursor, VS Code) for + immediate readable output. Keep False (default) for AI agents that + will do their own intelligent categorization. Returns: - Dict[str, Any]: Dictionary containing release notes markdown and metadata + Dict[str, Any]: Dictionary with raw commit data and AI instructions: + { + "status": "success", + "operation": "fetch_commits", + "data": { + "version": "v1.0.0", + "previous_version": "v0.9.0", + "release_date": "2024-01-15", + "repo_url": "https://github.com/owner/repo", + "compare_url": "https://github.com/owner/repo/compare/v0.9.0...v1.0.0", + "commits": [ + { + "hash": "abc123...", + "message": "feat: Add new feature", + "author": "John Doe", + "date": "2024-01-10", + "pr_number": "123" or "Not Found" + }, + ... + ], + "commit_count": 42, + "metadata": { + "provider": "GitHubProvider", + "fetched_at": "2024-01-15T10:30:00" + } + }, + "ai_instructions": { + "role": "release_notes_categorizer", + "task": "Analyze commits and create intelligent release notes", + "guidelines": [...], + "categorization_strategy": {...}, + "suggested_sections": {...}, + "output_format": {...}, + "context_understanding": {...}, + "best_practices": [...] + }, + "message": "Successfully fetched 42 commits for AI processing" + } + + The ai_instructions field provides comprehensive guidance for AI agents on how to + analyze the commits and generate professional release notes with intelligent + categorization. This ensures consistent, high-quality output regardless of which + AI agent or workflow uses this tool. """ try: if not version: raise ValueError("Version is required") if not release_date: - release_date = datetime.now().strftime("%B %d, %Y") + release_date = datetime.now().strftime("%Y-%m-%d") - logger.info(f"Generating release notes for version {version}") + logger.info(f"Generating release notes data for version {version}") - commits = _get_commits_between_tags(previous_version, version, repo_path) + # Use git provider abstraction for remote or local access + provider = get_git_provider( + repo_url=repo_url, + repo_path=repo_path, + github_token=github_token, + gitlab_token=gitlab_token, + ) + + # Fetch commits between tags (auto-detects previous_version if None) + commits = provider.fetch_commits(previous_version, version) if not commits: logger.warning(f"No commits found between {previous_version} and {version}") - - features: List[Dict[str, str]] = [] - breaking_changes: List[Dict[str, str]] = [] - - feature_categories: Dict[str, List[Dict[str, str]]] = {} - enhancement_categories: Dict[str, List[Dict[str, str]]] = {} - bug_fix_categories: Dict[str, List[Dict[str, str]]] = {} - - for commit in commits: - message = commit["message"] - pr_number = _extract_pr_number(message) - commit_hash = _extract_commit_hash(message, commit["hash"]) - - commit_type, category = _categorize_commit(message) - - title = message.split("\n")[0] - if len(title) > 80: - title = title[:77] + "..." - - item: dict[str, str] = { - "title": title, - "description": message.split("\n")[0], - "pr": pr_number or "", - "commit": commit_hash or "", - } - - if commit_type == "breaking": - item["impact"] = "**HIGH**: Review breaking changes before upgrading" - breaking_changes.append(item) - elif commit_type == "feature": - if category not in feature_categories: - feature_categories[category] = [] - feature_categories[category].append(item) - elif commit_type == "fix": - if category not in bug_fix_categories: - bug_fix_categories[category] = [] - bug_fix_categories[category].append(item) - else: - if category not in enhancement_categories: - enhancement_categories[category] = [] - enhancement_categories[category].append(item) - - release_notes = f"# {version} Release Notes\n\n" - release_notes += f"**Release Date:** {release_date} \n" - - if previous_version: - release_notes += f"**Previous Version:** {previous_version} \n" - - if repo_url: - release_notes += f"**Repository:** [{repo_url}]({repo_url})\n" - - release_notes += "\n---\n\n" - - if feature_categories or features: - release_notes += "## 🎉 Major Features\n\n" - - category_descriptions = { - "ui/ux": "Comprehensive user interface improvements bringing modern design and enhanced functionality.", - "api": "API enhancements and new endpoints.", - "database": "Database and storage improvements.", - "cli": "Command-line interface enhancements.", - "kubernetes": "Kubernetes and operator improvements.", - "integration": "Integration and testing improvements.", - "configuration": "Configuration and settings enhancements.", - "materialization": "Materialization engine improvements.", - "compute": "Compute engine updates.", - "rag": "RAG and AI integration enhancements.", - "architecture": "Architecture and infrastructure improvements.", - } - - for category, items in sorted(feature_categories.items()): - title = category.replace("_", " ").title() - description = category_descriptions.get( - category, f"{title} improvements." - ) - release_notes += _generate_release_notes_section( - title, - description, - items, - repo_url, - ) - - if enhancement_categories: - release_notes += "## 🚀 Additional Enhancements\n\n" - - category_descriptions = { - "cli": "Enhanced command-line interface and improved capabilities.", - "configuration": "Enhanced configuration options for improved deployment flexibility.", - "kubernetes": "Improved Kubernetes integration with enhanced operator capabilities.", - "database": "Enhanced support for various database systems and storage engines.", - "compute": "Improved compute capabilities for data processing.", + return { + "status": "error", + "error": f"No commits found between {previous_version or 'auto-detected previous tag'} and {version}", + "message": "No commits to generate release notes from", } - for category, items in sorted(enhancement_categories.items()): - title = category.replace("_", " ").title() - description = category_descriptions.get( - category, f"{title} enhancements." - ) - release_notes += _generate_release_notes_section( - title, - description, - items, - repo_url, - ) - - if bug_fix_categories: - release_notes += "## 🐛 Key Bug Fixes\n\n" - - category_descriptions = { - "database": "Critical fixes for various database integrations.", - "integration": "Test stability and integration improvements.", - "api": "Fixes for API endpoints and configuration handling.", - "ui": "User interface and dependency fixes.", - } - - for category, items in sorted(bug_fix_categories.items()): - title = category.replace("_", " ").title() - description = category_descriptions.get(category, f"{title} fixes.") - release_notes += _generate_bug_fixes_section( - title, - description, - items, - repo_url, - ) - - if breaking_changes: - release_notes += "## 🔄 Breaking Changes\n\n" - release_notes += _generate_breaking_changes_section( - breaking_changes, repo_url + # Get compare URL if available + compare_url = "" + if hasattr(provider, "get_compare_url"): + try: + # If previous_version was auto-detected, extract it from first commit metadata + actual_from_tag = previous_version + if not actual_from_tag and commits: + # Provider already validated and set from_tag, we can get it from logs + # For now, we'll skip compare URL if previous_version wasn't explicit + pass + if actual_from_tag: + compare_url = provider.get_compare_url(actual_from_tag, version) + except Exception as e: + logger.warning(f"Could not generate compare URL: {e}") + + logger.info(f"Successfully fetched {len(commits)} commits") + + # If formatted_output is True, return pre-formatted markdown + if formatted_output: + formatted_markdown = _format_release_notes_markdown( + version=version, + previous_version=previous_version or "auto-detected", + release_date=release_date, + repo_url=repo_url or "", + compare_url=compare_url, + commits=commits, ) + return { + "status": "success", + "operation": "generate_formatted_release_notes", + "formatted_output": formatted_markdown, + "data": { + "version": version, + "previous_version": previous_version or "auto-detected", + "release_date": release_date, + "commit_count": len(commits), + }, + "message": f"Successfully generated formatted release notes for {len(commits)} commits", + } - if previous_version and repo_url: - release_notes += "## 🔗 Links and Resources\n\n" - release_notes += f"- **Full Changelog**: [{previous_version}...{version}]({repo_url}/compare/{previous_version}...{version})\n" - - stats = { - "total_commits": len(commits), - "features": sum(len(items) for items in feature_categories.values()), - "enhancements": sum( - len(items) for items in enhancement_categories.values() - ), - "bug_fixes": sum(len(items) for items in bug_fix_categories.values()), - "breaking_changes": len(breaking_changes), - } - - release_notes += "\n## 📊 Release Statistics\n\n" - release_notes += f"- **Total Commits**: {stats['total_commits']}\n" - release_notes += f"- **New Features**: {stats['features']}\n" - release_notes += f"- **Enhancements**: {stats['enhancements']}\n" - release_notes += f"- **Bug Fixes**: {stats['bug_fixes']}\n" - release_notes += f"- **Breaking Changes**: {stats['breaking_changes']}\n" - + # Default: Return raw data for AI processing return { "status": "success", - "operation": "generate_release_notes", - "version": version, - "previous_version": previous_version, - "release_notes": release_notes, - "statistics": stats, - "message": f"Successfully generated release notes for {version}", + "operation": "fetch_commits", + "data": { + "version": version, + "previous_version": previous_version or "auto-detected", + "release_date": release_date, + "repo_url": repo_url, + "compare_url": compare_url, + "commits": commits, + "commit_count": len(commits), + "metadata": { + "provider": provider.__class__.__name__, + "fetched_at": datetime.now().isoformat(), + }, + }, + "ai_instructions": { + "role": "release_notes_categorizer", + "task": "Analyze the provided commits and create intelligent, context-aware release notes with dynamic categorization", + "architecture": { + "tool_responsibility": "Fetch raw commit data from git repositories (GitHub, GitLab, or local)", + "ai_responsibility": "Analyze commits, create dynamic categories, format professional release notes", + }, + "guidelines": [ + "Create dynamic categories based on actual changes (not predefined templates)", + "Group related commits together intelligently, even if they have different message formats", + "Understand context beyond pattern matching (e.g., 'refactor auth' might be a breaking change)", + "Highlight important changes: breaking changes, security fixes, critical bugs", + "Analyze ALL commits regardless of conventional commit format", + "Explain your categorization choices to help users understand the release narrative", + ], + "categorization_strategy": { + "step1": "Read all commits first to understand the release scope", + "step2": "Identify major themes: What are the key areas of change?", + "step3": "Create relevant categories that reflect actual changes", + "step4": "Group intelligently: Related commits go together even if messages differ", + "step5": "Prioritize: Breaking changes and security updates come first", + }, + "suggested_sections": { + "always_consider": [ + "⚠️ Breaking Changes (highest priority, always show first if any exist)", + "🔒 Security Updates (critical security fixes)", + "🎉 New Features (grouped by theme/component)", + "🐛 Bug Fixes (separate Critical from Minor)", + ], + "conditionally_add": [ + "⚡ Performance Improvements (if multiple performance-related commits)", + "📚 Documentation (if significant doc updates)", + "🔧 Infrastructure/DevOps (if deployment/build changes)", + "♿ Accessibility (if a11y improvements)", + "🌍 Internationalization (if i18n work)", + "🧪 Testing (if major test additions)", + "🎨 UI/UX (if interface improvements)", + "🔄 Refactoring (if major code restructuring)", + ], + "note": "Don't force commits into categories - create sections that make sense for THIS release", + }, + "output_format": { + "format": "markdown", + "structure": [ + "# {version} Release Notes", + "**Release Date:** {release_date}", + "**Previous Version:** {previous_version}", + "**Repository:** {repo_url}", + "[View Full Changelog]({compare_url})", + "---", + "## Sections (dynamic based on commits)", + "---", + "## 📊 Release Statistics", + "- Total Commits: {commit_count}", + "- Contributors: {contributor_count}", + "- Other relevant stats...", + ], + "pr_links": "Use [#{pr_number}]({repo_url}/pull/{pr_number}) for GitHub or [!{mr_number}]({repo_url}/-/merge_requests/{mr_number}) for GitLab", + "commit_links": "Use [{short_hash}]({repo_url}/commit/{full_hash})", + "handle_not_found": "If pr_number is 'Not Found', reference commit hash instead", + }, + "context_understanding": { + "examples": [ + { + "commit": "refactor: rewrite authentication system", + "appears_to_be": "Enhancement/Refactoring", + "might_actually_be": "Breaking Change if API changes", + "action": "Read full commit message for context, check if it mentions breaking changes or API changes", + }, + { + "commit": "update: dependencies", + "appears_to_be": "Chore/Maintenance", + "might_actually_be": "Security Update if fixing CVEs", + "action": "Check if commit mentions security, CVE, vulnerability", + }, + { + "commit": "improve: performance", + "appears_to_be": "Enhancement", + "might_actually_be": "Critical Fix if resolving timeouts/crashes", + "action": "Look for context about severity in commit body", + }, + ], + "tip": "Always read the full commit message (not just first line) for context", + }, + "best_practices": [ + "Lead with impact: Breaking changes and security updates come first", + "Group related changes: Create subsections for related features", + "Provide context: Explain WHY categories exist, what they mean to users", + "Be comprehensive: Don't skip commits that don't fit conventional formats", + "Be concise: Users want to understand changes quickly", + "Link to details: Use PR/MR numbers and commit links for more context", + ], + }, + "message": f"Successfully fetched {len(commits)} commits for AI processing", } except Exception as e: @@ -560,5 +510,5 @@ async def generate_release_notes( return { "status": "error", "error": str(e), - "message": "Failed to generate release notes", + "message": "Failed to fetch release notes data", }