Implements the Coordinator class with main orchestration loop: - Async loop architecture with configurable poll interval - process_queue() method gets next ready issue and spawns agent (stub) - Graceful shutdown handling with stop() method - Error handling that allows loop to continue after failures - Logging for all actions (start, stop, processing, errors) - Integration with QueueManager from #159 - Active agent tracking for future agent management Configuration settings added: - COORDINATOR_POLL_INTERVAL (default: 5.0s) - COORDINATOR_MAX_CONCURRENT_AGENTS (default: 10) - COORDINATOR_ENABLED (default: true) Tests: 27 new tests covering all acceptance criteria Coverage: 92% overall (100% for coordinator.py) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
396 lines
13 KiB
Python
396 lines
13 KiB
Python
"""Tests for issue parser agent."""
|
|
|
|
from unittest.mock import Mock, patch
|
|
|
|
import pytest
|
|
from anthropic import Anthropic
|
|
from anthropic.types import Message, TextBlock, Usage
|
|
|
|
from src.parser import clear_cache, parse_issue_metadata
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def setup_test_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Set up test environment variables."""
|
|
monkeypatch.setenv("GITEA_WEBHOOK_SECRET", "test-secret")
|
|
monkeypatch.setenv("GITEA_URL", "https://test.example.com")
|
|
monkeypatch.setenv("ANTHROPIC_API_KEY", "test-anthropic-key")
|
|
monkeypatch.setenv("LOG_LEVEL", "debug")
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_complete_issue_body() -> str:
|
|
"""Complete issue body with all fields."""
|
|
return """## Objective
|
|
|
|
Create AI agent (Sonnet) that parses issue markdown body to extract structured metadata.
|
|
|
|
## Implementation Details
|
|
|
|
1. Create parse_issue_metadata() function
|
|
2. Use Anthropic API with Sonnet model
|
|
|
|
## Context Estimate
|
|
|
|
• Files to modify: 3 (parser.py, agent.py, models.py)
|
|
• Implementation complexity: medium (20000 tokens)
|
|
• Test requirements: medium (10000 tokens)
|
|
• Documentation: medium (3000 tokens)
|
|
• **Total estimated: 46800 tokens**
|
|
• **Recommended agent: sonnet**
|
|
|
|
## Difficulty
|
|
|
|
medium
|
|
|
|
## Dependencies
|
|
|
|
• Blocked by: #157 (COORD-001 - needs webhook to trigger parser)
|
|
• Blocks: #159 (COORD-003 - queue needs parsed metadata)
|
|
|
|
## Acceptance Criteria
|
|
|
|
[ ] Parser extracts all required fields
|
|
[ ] Returns valid JSON matching schema
|
|
"""
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_minimal_issue_body() -> str:
|
|
"""Minimal issue body with only required fields."""
|
|
return """## Objective
|
|
|
|
Fix the login bug.
|
|
|
|
## Acceptance Criteria
|
|
|
|
[ ] Bug is fixed
|
|
"""
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_malformed_issue_body() -> str:
|
|
"""Malformed issue body to test graceful failure."""
|
|
return """This is just random text without proper sections.
|
|
|
|
Some more random content here.
|
|
"""
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_anthropic_response() -> Message:
|
|
"""Mock Anthropic API response."""
|
|
return Message(
|
|
id="msg_123",
|
|
type="message",
|
|
role="assistant",
|
|
content=[
|
|
TextBlock(
|
|
type="text",
|
|
text=(
|
|
'{"estimated_context": 46800, "difficulty": "medium", '
|
|
'"assigned_agent": "sonnet", "blocks": [159], "blocked_by": [157]}'
|
|
),
|
|
)
|
|
],
|
|
model="claude-sonnet-4.5-20250929",
|
|
stop_reason="end_turn",
|
|
usage=Usage(input_tokens=500, output_tokens=50)
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_anthropic_minimal_response() -> Message:
|
|
"""Mock Anthropic API response for minimal issue."""
|
|
return Message(
|
|
id="msg_124",
|
|
type="message",
|
|
role="assistant",
|
|
content=[
|
|
TextBlock(
|
|
type="text",
|
|
text=(
|
|
'{"estimated_context": 50000, "difficulty": "medium", '
|
|
'"assigned_agent": "sonnet", "blocks": [], "blocked_by": []}'
|
|
),
|
|
)
|
|
],
|
|
model="claude-sonnet-4.5-20250929",
|
|
stop_reason="end_turn",
|
|
usage=Usage(input_tokens=200, output_tokens=40)
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def reset_cache() -> None:
|
|
"""Clear cache before each test."""
|
|
clear_cache()
|
|
|
|
|
|
class TestParseIssueMetadata:
|
|
"""Tests for parse_issue_metadata function."""
|
|
|
|
@patch("src.parser.Anthropic")
|
|
def test_parse_complete_issue(
|
|
self,
|
|
mock_anthropic_class: Mock,
|
|
sample_complete_issue_body: str,
|
|
mock_anthropic_response: Message
|
|
) -> None:
|
|
"""Test parsing complete issue body with all fields."""
|
|
# Setup mock
|
|
mock_client = Mock(spec=Anthropic)
|
|
mock_messages = Mock()
|
|
mock_messages.create = Mock(return_value=mock_anthropic_response)
|
|
mock_client.messages = mock_messages
|
|
mock_anthropic_class.return_value = mock_client
|
|
|
|
# Parse issue
|
|
result = parse_issue_metadata(sample_complete_issue_body, 158)
|
|
|
|
# Verify result
|
|
assert result.estimated_context == 46800
|
|
assert result.difficulty == "medium"
|
|
assert result.assigned_agent == "sonnet"
|
|
assert result.blocks == [159]
|
|
assert result.blocked_by == [157]
|
|
|
|
# Verify API was called correctly
|
|
mock_messages.create.assert_called_once()
|
|
call_args = mock_messages.create.call_args
|
|
assert call_args.kwargs["model"] == "claude-sonnet-4.5-20250929"
|
|
assert call_args.kwargs["max_tokens"] == 1024
|
|
assert call_args.kwargs["temperature"] == 0
|
|
|
|
@patch("src.parser.Anthropic")
|
|
def test_parse_minimal_issue(
|
|
self,
|
|
mock_anthropic_class: Mock,
|
|
sample_minimal_issue_body: str,
|
|
mock_anthropic_minimal_response: Message
|
|
) -> None:
|
|
"""Test parsing minimal issue body uses defaults."""
|
|
# Setup mock
|
|
mock_client = Mock(spec=Anthropic)
|
|
mock_messages = Mock()
|
|
mock_messages.create = Mock(return_value=mock_anthropic_minimal_response)
|
|
mock_client.messages = mock_messages
|
|
mock_anthropic_class.return_value = mock_client
|
|
|
|
# Parse issue
|
|
result = parse_issue_metadata(sample_minimal_issue_body, 999)
|
|
|
|
# Verify defaults are used
|
|
assert result.estimated_context == 50000
|
|
assert result.difficulty == "medium"
|
|
assert result.assigned_agent == "sonnet"
|
|
assert result.blocks == []
|
|
assert result.blocked_by == []
|
|
|
|
@patch("src.parser.Anthropic")
|
|
def test_parse_malformed_issue_returns_defaults(
|
|
self,
|
|
mock_anthropic_class: Mock,
|
|
sample_malformed_issue_body: str
|
|
) -> None:
|
|
"""Test malformed issue body returns graceful defaults."""
|
|
# Setup mock to return invalid JSON
|
|
mock_client = Mock(spec=Anthropic)
|
|
mock_messages = Mock()
|
|
mock_messages.create = Mock(
|
|
return_value=Message(
|
|
id="msg_125",
|
|
type="message",
|
|
role="assistant",
|
|
content=[TextBlock(type="text", text='{"invalid": "json"')],
|
|
model="claude-sonnet-4.5-20250929",
|
|
stop_reason="end_turn",
|
|
usage=Usage(input_tokens=100, output_tokens=20)
|
|
)
|
|
)
|
|
mock_client.messages = mock_messages
|
|
mock_anthropic_class.return_value = mock_client
|
|
|
|
# Parse issue
|
|
result = parse_issue_metadata(sample_malformed_issue_body, 888)
|
|
|
|
# Verify defaults
|
|
assert result.estimated_context == 50000
|
|
assert result.difficulty == "medium"
|
|
assert result.assigned_agent == "sonnet"
|
|
assert result.blocks == []
|
|
assert result.blocked_by == []
|
|
|
|
@patch("src.parser.Anthropic")
|
|
def test_api_failure_returns_defaults(
|
|
self,
|
|
mock_anthropic_class: Mock,
|
|
sample_complete_issue_body: str
|
|
) -> None:
|
|
"""Test API failure returns defaults with error logged."""
|
|
# Setup mock to raise exception
|
|
mock_client = Mock(spec=Anthropic)
|
|
mock_messages = Mock()
|
|
mock_messages.create = Mock(side_effect=Exception("API Error"))
|
|
mock_client.messages = mock_messages
|
|
mock_anthropic_class.return_value = mock_client
|
|
|
|
# Parse issue
|
|
result = parse_issue_metadata(sample_complete_issue_body, 777)
|
|
|
|
# Verify defaults
|
|
assert result.estimated_context == 50000
|
|
assert result.difficulty == "medium"
|
|
assert result.assigned_agent == "sonnet"
|
|
assert result.blocks == []
|
|
assert result.blocked_by == []
|
|
|
|
@patch("src.parser.Anthropic")
|
|
def test_caching_avoids_duplicate_api_calls(
|
|
self,
|
|
mock_anthropic_class: Mock,
|
|
sample_complete_issue_body: str,
|
|
mock_anthropic_response: Message
|
|
) -> None:
|
|
"""Test that caching prevents duplicate API calls for same issue."""
|
|
# Setup mock
|
|
mock_client = Mock(spec=Anthropic)
|
|
mock_messages = Mock()
|
|
mock_messages.create = Mock(return_value=mock_anthropic_response)
|
|
mock_client.messages = mock_messages
|
|
mock_anthropic_class.return_value = mock_client
|
|
|
|
# Parse same issue twice
|
|
result1 = parse_issue_metadata(sample_complete_issue_body, 158)
|
|
result2 = parse_issue_metadata(sample_complete_issue_body, 158)
|
|
|
|
# Verify API was called only once
|
|
assert mock_messages.create.call_count == 1
|
|
|
|
# Verify both results are identical
|
|
assert result1.model_dump() == result2.model_dump()
|
|
|
|
@patch("src.parser.Anthropic")
|
|
def test_different_issues_not_cached(
|
|
self,
|
|
mock_anthropic_class: Mock,
|
|
sample_complete_issue_body: str,
|
|
sample_minimal_issue_body: str,
|
|
mock_anthropic_response: Message
|
|
) -> None:
|
|
"""Test that different issues result in separate API calls."""
|
|
# Setup mock
|
|
mock_client = Mock(spec=Anthropic)
|
|
mock_messages = Mock()
|
|
mock_messages.create = Mock(return_value=mock_anthropic_response)
|
|
mock_client.messages = mock_messages
|
|
mock_anthropic_class.return_value = mock_client
|
|
|
|
# Parse different issues
|
|
parse_issue_metadata(sample_complete_issue_body, 158)
|
|
parse_issue_metadata(sample_minimal_issue_body, 159)
|
|
|
|
# Verify API was called twice
|
|
assert mock_messages.create.call_count == 2
|
|
|
|
@patch("src.parser.Anthropic")
|
|
def test_difficulty_validation(
|
|
self,
|
|
mock_anthropic_class: Mock,
|
|
sample_complete_issue_body: str
|
|
) -> None:
|
|
"""Test that difficulty values are validated."""
|
|
# Setup mock with invalid difficulty
|
|
mock_client = Mock(spec=Anthropic)
|
|
mock_messages = Mock()
|
|
mock_messages.create = Mock(
|
|
return_value=Message(
|
|
id="msg_126",
|
|
type="message",
|
|
role="assistant",
|
|
content=[
|
|
TextBlock(
|
|
type="text",
|
|
text=(
|
|
'{"estimated_context": 10000, "difficulty": "invalid", '
|
|
'"assigned_agent": "sonnet", "blocks": [], "blocked_by": []}'
|
|
),
|
|
)
|
|
],
|
|
model="claude-sonnet-4.5-20250929",
|
|
stop_reason="end_turn",
|
|
usage=Usage(input_tokens=100, output_tokens=20)
|
|
)
|
|
)
|
|
mock_client.messages = mock_messages
|
|
mock_anthropic_class.return_value = mock_client
|
|
|
|
# Parse issue
|
|
result = parse_issue_metadata(sample_complete_issue_body, 666)
|
|
|
|
# Should default to "medium" for invalid difficulty
|
|
assert result.difficulty == "medium"
|
|
|
|
@patch("src.parser.Anthropic")
|
|
def test_agent_validation(
|
|
self,
|
|
mock_anthropic_class: Mock,
|
|
sample_complete_issue_body: str
|
|
) -> None:
|
|
"""Test that agent values are validated."""
|
|
# Setup mock with invalid agent
|
|
mock_client = Mock(spec=Anthropic)
|
|
mock_messages = Mock()
|
|
mock_messages.create = Mock(
|
|
return_value=Message(
|
|
id="msg_127",
|
|
type="message",
|
|
role="assistant",
|
|
content=[
|
|
TextBlock(
|
|
type="text",
|
|
text=(
|
|
'{"estimated_context": 10000, "difficulty": "medium", '
|
|
'"assigned_agent": "invalid_agent", "blocks": [], "blocked_by": []}'
|
|
),
|
|
)
|
|
],
|
|
model="claude-sonnet-4.5-20250929",
|
|
stop_reason="end_turn",
|
|
usage=Usage(input_tokens=100, output_tokens=20)
|
|
)
|
|
)
|
|
mock_client.messages = mock_messages
|
|
mock_anthropic_class.return_value = mock_client
|
|
|
|
# Parse issue
|
|
result = parse_issue_metadata(sample_complete_issue_body, 555)
|
|
|
|
# Should default to "sonnet" for invalid agent
|
|
assert result.assigned_agent == "sonnet"
|
|
|
|
@patch("src.parser.Anthropic")
|
|
def test_parse_time_performance(
|
|
self,
|
|
mock_anthropic_class: Mock,
|
|
sample_complete_issue_body: str,
|
|
mock_anthropic_response: Message
|
|
) -> None:
|
|
"""Test that parsing completes within performance target."""
|
|
import time
|
|
|
|
# Setup mock
|
|
mock_client = Mock(spec=Anthropic)
|
|
mock_messages = Mock()
|
|
mock_messages.create = Mock(return_value=mock_anthropic_response)
|
|
mock_client.messages = mock_messages
|
|
mock_anthropic_class.return_value = mock_client
|
|
|
|
# Measure parse time
|
|
start_time = time.time()
|
|
parse_issue_metadata(sample_complete_issue_body, 158)
|
|
elapsed_time = time.time() - start_time
|
|
|
|
# Should complete within 2 seconds (mocked, so should be instant)
|
|
assert elapsed_time < 2.0
|