Files
stack/apps/coordinator/tests/test_parser.py
Jason Woltje dad4b68f66 feat(#158): Implement issue parser agent
Add AI-powered issue metadata parser using Anthropic Sonnet model.
- Parse issue markdown to extract: estimated_context, difficulty,
  assigned_agent, blocks, blocked_by
- Implement in-memory caching to avoid duplicate API calls
- Graceful fallback to defaults on parse failures
- Add comprehensive test suite (9 test cases)
- 95% test coverage (exceeds 85% requirement)
- Add ANTHROPIC_API_KEY to config
- Update documentation and add .env.example

Fixes #158

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-01 17:50:35 -06:00

385 lines
12 KiB
Python

"""Tests for issue parser agent."""
import os
import pytest
from unittest.mock import Mock, patch, AsyncMock
from anthropic import Anthropic
from anthropic.types import Message, TextBlock, Usage
from src.parser import parse_issue_metadata, clear_cache
from src.models import IssueMetadata
@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