The suggest_labels function now dynamically detects the label naming convention used in the repository (slash format like Type/Bug or colon-space format like Type: Bug) instead of hardcoding slash format. Changes: - Added _build_label_lookup() to parse and normalize label formats - Added _find_label() to find actual labels from the lookup - Updated suggest_labels() to accept optional repo parameter - Labels are fetched first, then suggestions match actual names - Supports Efforts/Effort normalization (handles singular/plural) Fixes issue #73 sub-issue 3: label format mismatch Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
479 lines
16 KiB
Python
479 lines
16 KiB
Python
"""
|
|
Unit tests for label tools with suggestion logic.
|
|
"""
|
|
import pytest
|
|
from unittest.mock import Mock, patch
|
|
from mcp_server.tools.labels import LabelTools
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_gitea_client():
|
|
"""Fixture providing mocked Gitea client"""
|
|
client = Mock()
|
|
client.repo = 'test_org/test_repo'
|
|
client.is_org_repo = Mock(return_value=True)
|
|
return client
|
|
|
|
|
|
@pytest.fixture
|
|
def label_tools(mock_gitea_client):
|
|
"""Fixture providing LabelTools instance"""
|
|
return LabelTools(mock_gitea_client)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_labels(label_tools):
|
|
"""Test getting all labels (org + repo)"""
|
|
label_tools.gitea.get_org_labels = Mock(return_value=[
|
|
{'name': 'Type/Bug'},
|
|
{'name': 'Type/Feature'}
|
|
])
|
|
label_tools.gitea.get_labels = Mock(return_value=[
|
|
{'name': 'Component/Backend'},
|
|
{'name': 'Component/Frontend'}
|
|
])
|
|
|
|
result = await label_tools.get_labels()
|
|
|
|
assert len(result['organization']) == 2
|
|
assert len(result['repository']) == 2
|
|
assert result['total_count'] == 4
|
|
|
|
|
|
# ========================================
|
|
# LABEL LOOKUP TESTS (NEW)
|
|
# ========================================
|
|
|
|
def test_build_label_lookup_slash_format():
|
|
"""Test building label lookup with slash format labels"""
|
|
mock_client = Mock()
|
|
mock_client.repo = 'test/repo'
|
|
tools = LabelTools(mock_client)
|
|
|
|
labels = ['Type/Bug', 'Type/Feature', 'Priority/High', 'Priority/Low']
|
|
lookup = tools._build_label_lookup(labels)
|
|
|
|
assert 'type' in lookup
|
|
assert 'bug' in lookup['type']
|
|
assert lookup['type']['bug'] == 'Type/Bug'
|
|
assert lookup['type']['feature'] == 'Type/Feature'
|
|
assert 'priority' in lookup
|
|
assert lookup['priority']['high'] == 'Priority/High'
|
|
|
|
|
|
def test_build_label_lookup_colon_space_format():
|
|
"""Test building label lookup with colon-space format labels"""
|
|
mock_client = Mock()
|
|
mock_client.repo = 'test/repo'
|
|
tools = LabelTools(mock_client)
|
|
|
|
labels = ['Type: Bug', 'Type: Feature', 'Priority: High', 'Effort: M']
|
|
lookup = tools._build_label_lookup(labels)
|
|
|
|
assert 'type' in lookup
|
|
assert 'bug' in lookup['type']
|
|
assert lookup['type']['bug'] == 'Type: Bug'
|
|
assert lookup['type']['feature'] == 'Type: Feature'
|
|
assert 'priority' in lookup
|
|
assert lookup['priority']['high'] == 'Priority: High'
|
|
# Test singular "Effort" (not "Efforts")
|
|
assert 'effort' in lookup
|
|
assert lookup['effort']['m'] == 'Effort: M'
|
|
|
|
|
|
def test_build_label_lookup_efforts_normalization():
|
|
"""Test that 'Efforts' is normalized to 'effort' for matching"""
|
|
mock_client = Mock()
|
|
mock_client.repo = 'test/repo'
|
|
tools = LabelTools(mock_client)
|
|
|
|
labels = ['Efforts/XS', 'Efforts/S', 'Efforts/M']
|
|
lookup = tools._build_label_lookup(labels)
|
|
|
|
# 'Efforts' should be normalized to 'effort'
|
|
assert 'effort' in lookup
|
|
assert lookup['effort']['xs'] == 'Efforts/XS'
|
|
|
|
|
|
def test_find_label():
|
|
"""Test finding labels from lookup"""
|
|
mock_client = Mock()
|
|
mock_client.repo = 'test/repo'
|
|
tools = LabelTools(mock_client)
|
|
|
|
lookup = {
|
|
'type': {'bug': 'Type: Bug', 'feature': 'Type: Feature'},
|
|
'priority': {'high': 'Priority: High', 'low': 'Priority: Low'}
|
|
}
|
|
|
|
assert tools._find_label(lookup, 'type', 'bug') == 'Type: Bug'
|
|
assert tools._find_label(lookup, 'priority', 'high') == 'Priority: High'
|
|
assert tools._find_label(lookup, 'type', 'nonexistent') is None
|
|
assert tools._find_label(lookup, 'nonexistent', 'bug') is None
|
|
|
|
|
|
# ========================================
|
|
# SUGGEST LABELS WITH DYNAMIC FORMAT TESTS
|
|
# ========================================
|
|
|
|
def _create_tools_with_labels(labels):
|
|
"""Helper to create LabelTools with mocked labels"""
|
|
import asyncio
|
|
mock_client = Mock()
|
|
mock_client.repo = 'test/repo'
|
|
mock_client.is_org_repo = Mock(return_value=False)
|
|
mock_client.get_labels = Mock(return_value=[{'name': l} for l in labels])
|
|
return LabelTools(mock_client)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_suggest_labels_with_slash_format():
|
|
"""Test label suggestion with slash format labels"""
|
|
labels = [
|
|
'Type/Bug', 'Type/Feature', 'Type/Refactor',
|
|
'Priority/Critical', 'Priority/High', 'Priority/Medium', 'Priority/Low',
|
|
'Complexity/Simple', 'Complexity/Medium', 'Complexity/Complex',
|
|
'Component/Auth'
|
|
]
|
|
tools = _create_tools_with_labels(labels)
|
|
|
|
context = "Fix critical bug in login authentication"
|
|
suggestions = await tools.suggest_labels(context)
|
|
|
|
assert 'Type/Bug' in suggestions
|
|
assert 'Priority/Critical' in suggestions
|
|
assert 'Component/Auth' in suggestions
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_suggest_labels_with_colon_space_format():
|
|
"""Test label suggestion with colon-space format labels"""
|
|
labels = [
|
|
'Type: Bug', 'Type: Feature', 'Type: Refactor',
|
|
'Priority: Critical', 'Priority: High', 'Priority: Medium', 'Priority: Low',
|
|
'Complexity: Simple', 'Complexity: Medium', 'Complexity: Complex',
|
|
'Effort: XS', 'Effort: S', 'Effort: M', 'Effort: L', 'Effort: XL'
|
|
]
|
|
tools = _create_tools_with_labels(labels)
|
|
|
|
context = "Fix critical bug for tiny 1 hour fix"
|
|
suggestions = await tools.suggest_labels(context)
|
|
|
|
# Should return colon-space format labels
|
|
assert 'Type: Bug' in suggestions
|
|
assert 'Priority: Critical' in suggestions
|
|
assert 'Effort: XS' in suggestions
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_suggest_labels_bug():
|
|
"""Test label suggestion for bug context"""
|
|
labels = [
|
|
'Type/Bug', 'Type/Feature',
|
|
'Priority/Critical', 'Priority/High', 'Priority/Medium', 'Priority/Low',
|
|
'Complexity/Simple', 'Complexity/Medium', 'Complexity/Complex',
|
|
'Component/Auth'
|
|
]
|
|
tools = _create_tools_with_labels(labels)
|
|
|
|
context = "Fix critical bug in login authentication"
|
|
suggestions = await tools.suggest_labels(context)
|
|
|
|
assert 'Type/Bug' in suggestions
|
|
assert 'Priority/Critical' in suggestions
|
|
assert 'Component/Auth' in suggestions
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_suggest_labels_feature():
|
|
"""Test label suggestion for feature context"""
|
|
labels = ['Type/Feature', 'Priority/Medium', 'Complexity/Medium']
|
|
tools = _create_tools_with_labels(labels)
|
|
|
|
context = "Add new feature to implement user dashboard"
|
|
suggestions = await tools.suggest_labels(context)
|
|
|
|
assert 'Type/Feature' in suggestions
|
|
assert any('Priority' in label for label in suggestions)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_suggest_labels_refactor():
|
|
"""Test label suggestion for refactor context"""
|
|
labels = ['Type/Refactor', 'Priority/Medium', 'Complexity/Medium', 'Component/Backend']
|
|
tools = _create_tools_with_labels(labels)
|
|
|
|
context = "Refactor architecture to extract service layer"
|
|
suggestions = await tools.suggest_labels(context)
|
|
|
|
assert 'Type/Refactor' in suggestions
|
|
assert 'Component/Backend' in suggestions
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_suggest_labels_documentation():
|
|
"""Test label suggestion for documentation context"""
|
|
labels = ['Type/Documentation', 'Priority/Medium', 'Complexity/Medium', 'Component/API', 'Component/Docs']
|
|
tools = _create_tools_with_labels(labels)
|
|
|
|
context = "Update documentation for API endpoints"
|
|
suggestions = await tools.suggest_labels(context)
|
|
|
|
assert 'Type/Documentation' in suggestions
|
|
assert 'Component/API' in suggestions or 'Component/Docs' in suggestions
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_suggest_labels_priority():
|
|
"""Test priority detection in suggestions"""
|
|
labels = ['Type/Feature', 'Priority/Critical', 'Priority/High', 'Priority/Medium', 'Priority/Low', 'Complexity/Medium']
|
|
tools = _create_tools_with_labels(labels)
|
|
|
|
# Critical priority
|
|
context = "Urgent blocker in production"
|
|
suggestions = await tools.suggest_labels(context)
|
|
assert 'Priority/Critical' in suggestions
|
|
|
|
# High priority
|
|
context = "Important feature needed asap"
|
|
suggestions = await tools.suggest_labels(context)
|
|
assert 'Priority/High' in suggestions
|
|
|
|
# Low priority
|
|
context = "Nice-to-have optional improvement"
|
|
suggestions = await tools.suggest_labels(context)
|
|
assert 'Priority/Low' in suggestions
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_suggest_labels_complexity():
|
|
"""Test complexity detection in suggestions"""
|
|
labels = ['Type/Feature', 'Priority/Medium', 'Complexity/Simple', 'Complexity/Medium', 'Complexity/Complex']
|
|
tools = _create_tools_with_labels(labels)
|
|
|
|
# Simple complexity
|
|
context = "Simple quick fix for typo"
|
|
suggestions = await tools.suggest_labels(context)
|
|
assert 'Complexity/Simple' in suggestions
|
|
|
|
# Complex complexity
|
|
context = "Complex challenging architecture redesign"
|
|
suggestions = await tools.suggest_labels(context)
|
|
assert 'Complexity/Complex' in suggestions
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_suggest_labels_efforts():
|
|
"""Test efforts detection in suggestions"""
|
|
labels = ['Type/Feature', 'Priority/Medium', 'Complexity/Medium', 'Efforts/XS', 'Efforts/S', 'Efforts/M', 'Efforts/L', 'Efforts/XL']
|
|
tools = _create_tools_with_labels(labels)
|
|
|
|
# XS effort
|
|
context = "Tiny fix that takes 1 hour"
|
|
suggestions = await tools.suggest_labels(context)
|
|
assert 'Efforts/XS' in suggestions
|
|
|
|
# L effort
|
|
context = "Large feature taking 1 week"
|
|
suggestions = await tools.suggest_labels(context)
|
|
assert 'Efforts/L' in suggestions
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_suggest_labels_components():
|
|
"""Test component detection in suggestions"""
|
|
labels = ['Type/Feature', 'Priority/Medium', 'Complexity/Medium', 'Component/Backend', 'Component/Frontend', 'Component/API', 'Component/Database']
|
|
tools = _create_tools_with_labels(labels)
|
|
|
|
# Backend component
|
|
context = "Update backend API service"
|
|
suggestions = await tools.suggest_labels(context)
|
|
assert 'Component/Backend' in suggestions
|
|
assert 'Component/API' in suggestions
|
|
|
|
# Frontend component
|
|
context = "Fix frontend UI component"
|
|
suggestions = await tools.suggest_labels(context)
|
|
assert 'Component/Frontend' in suggestions
|
|
|
|
# Database component
|
|
context = "Add database migration for schema"
|
|
suggestions = await tools.suggest_labels(context)
|
|
assert 'Component/Database' in suggestions
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_suggest_labels_tech_stack():
|
|
"""Test tech stack detection in suggestions"""
|
|
labels = ['Type/Feature', 'Priority/Medium', 'Complexity/Medium', 'Tech/Python', 'Tech/FastAPI', 'Tech/Docker', 'Tech/PostgreSQL']
|
|
tools = _create_tools_with_labels(labels)
|
|
|
|
# Python
|
|
context = "Update Python FastAPI endpoint"
|
|
suggestions = await tools.suggest_labels(context)
|
|
assert 'Tech/Python' in suggestions
|
|
assert 'Tech/FastAPI' in suggestions
|
|
|
|
# Docker
|
|
context = "Fix Dockerfile configuration"
|
|
suggestions = await tools.suggest_labels(context)
|
|
assert 'Tech/Docker' in suggestions
|
|
|
|
# PostgreSQL
|
|
context = "Optimize PostgreSQL query"
|
|
suggestions = await tools.suggest_labels(context)
|
|
assert 'Tech/PostgreSQL' in suggestions
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_suggest_labels_source():
|
|
"""Test source detection in suggestions"""
|
|
labels = ['Type/Feature', 'Priority/Medium', 'Complexity/Medium', 'Source/Development', 'Source/Staging', 'Source/Production']
|
|
tools = _create_tools_with_labels(labels)
|
|
|
|
# Development
|
|
context = "Issue found in development environment"
|
|
suggestions = await tools.suggest_labels(context)
|
|
assert 'Source/Development' in suggestions
|
|
|
|
# Production
|
|
context = "Critical production issue"
|
|
suggestions = await tools.suggest_labels(context)
|
|
assert 'Source/Production' in suggestions
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_suggest_labels_risk():
|
|
"""Test risk detection in suggestions"""
|
|
labels = ['Type/Feature', 'Priority/Medium', 'Complexity/Medium', 'Risk/High', 'Risk/Low']
|
|
tools = _create_tools_with_labels(labels)
|
|
|
|
# High risk
|
|
context = "Breaking change to major API"
|
|
suggestions = await tools.suggest_labels(context)
|
|
assert 'Risk/High' in suggestions
|
|
|
|
# Low risk
|
|
context = "Safe minor update with low risk"
|
|
suggestions = await tools.suggest_labels(context)
|
|
assert 'Risk/Low' in suggestions
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_suggest_labels_multiple_categories():
|
|
"""Test that suggestions span multiple categories"""
|
|
labels = [
|
|
'Type/Bug', 'Type/Feature',
|
|
'Priority/Critical', 'Priority/Medium',
|
|
'Complexity/Complex', 'Complexity/Medium',
|
|
'Component/Backend', 'Component/API', 'Component/Auth',
|
|
'Tech/FastAPI', 'Tech/PostgreSQL',
|
|
'Source/Production'
|
|
]
|
|
tools = _create_tools_with_labels(labels)
|
|
|
|
context = """
|
|
Urgent critical bug in production backend API service.
|
|
Need to fix broken authentication endpoint.
|
|
This is a complex issue requiring FastAPI and PostgreSQL expertise.
|
|
"""
|
|
|
|
suggestions = await tools.suggest_labels(context)
|
|
|
|
# Should have Type
|
|
assert any('Type/' in label for label in suggestions)
|
|
|
|
# Should have Priority
|
|
assert any('Priority/' in label for label in suggestions)
|
|
|
|
# Should have Component
|
|
assert any('Component/' in label for label in suggestions)
|
|
|
|
# Should have Tech
|
|
assert any('Tech/' in label for label in suggestions)
|
|
|
|
# Should have Source
|
|
assert any('Source/' in label for label in suggestions)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_suggest_labels_empty_repo():
|
|
"""Test suggestions when no repo specified and no labels available"""
|
|
mock_client = Mock()
|
|
mock_client.repo = None
|
|
tools = LabelTools(mock_client)
|
|
|
|
context = "Fix a bug"
|
|
suggestions = await tools.suggest_labels(context)
|
|
|
|
# Should return empty list when no repo
|
|
assert suggestions == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_suggest_labels_no_matching_labels():
|
|
"""Test suggestions return empty when no matching labels exist"""
|
|
labels = ['Custom/Label', 'Other/Thing'] # No standard labels
|
|
tools = _create_tools_with_labels(labels)
|
|
|
|
context = "Fix a bug"
|
|
suggestions = await tools.suggest_labels(context)
|
|
|
|
# Should return empty list since no Type/Bug or similar exists
|
|
assert len(suggestions) == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_labels_org_owned_repo():
|
|
"""Test getting labels for organization-owned repository"""
|
|
mock_client = Mock()
|
|
mock_client.repo = 'myorg/myrepo'
|
|
mock_client.is_org_repo = Mock(return_value=True)
|
|
mock_client.get_org_labels = Mock(return_value=[
|
|
{'name': 'Type/Bug', 'id': 1},
|
|
{'name': 'Type/Feature', 'id': 2}
|
|
])
|
|
mock_client.get_labels = Mock(return_value=[
|
|
{'name': 'Component/Backend', 'id': 3}
|
|
])
|
|
|
|
tools = LabelTools(mock_client)
|
|
result = await tools.get_labels()
|
|
|
|
# Should fetch both org and repo labels
|
|
mock_client.is_org_repo.assert_called_once_with('myorg/myrepo')
|
|
mock_client.get_org_labels.assert_called_once_with('myorg')
|
|
mock_client.get_labels.assert_called_once_with('myorg/myrepo')
|
|
|
|
assert len(result['organization']) == 2
|
|
assert len(result['repository']) == 1
|
|
assert result['total_count'] == 3
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_labels_user_owned_repo():
|
|
"""Test getting labels for user-owned repository (no org labels)"""
|
|
mock_client = Mock()
|
|
mock_client.repo = 'lmiranda/personal-portfolio'
|
|
mock_client.is_org_repo = Mock(return_value=False)
|
|
mock_client.get_labels = Mock(return_value=[
|
|
{'name': 'bug', 'id': 1},
|
|
{'name': 'enhancement', 'id': 2}
|
|
])
|
|
|
|
tools = LabelTools(mock_client)
|
|
result = await tools.get_labels()
|
|
|
|
# Should check if org repo
|
|
mock_client.is_org_repo.assert_called_once_with('lmiranda/personal-portfolio')
|
|
|
|
# Should NOT call get_org_labels for user-owned repos
|
|
mock_client.get_org_labels.assert_not_called()
|
|
|
|
# Should still get repo labels
|
|
mock_client.get_labels.assert_called_once_with('lmiranda/personal-portfolio')
|
|
|
|
assert len(result['organization']) == 0
|
|
assert len(result['repository']) == 2
|
|
assert result['total_count'] == 2
|