Files
Aperant/tests/test_github_batch_issues.py
T
AndyMik90 0da488636c test: fix all failing backend tests and type errors
- Fix 26 failing backend tests by correcting mock patch paths
- Fix macOS symlink resolution in context_gatherer.py (/var -> /private/var)
- Fix missing mock attributes in test_github_orchestrator.py
- Fix non-deterministic test failure in test_github_batch_issues.py
- Fix ClaudeRateLimitEvent type in claude-profile-manager.test.ts
- Add new passing backend tests for various modules

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 18:58:49 +01:00

797 lines
23 KiB
Python

#!/usr/bin/env python3
"""
Tests for GitHub Issue Batching
================================
Tests for the batch issue processing system that groups similar issues
for combined auto-fix.
"""
import sys
import json
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
# Add backend directory to path
_backend_dir = Path(__file__).parent.parent / "apps" / "backend"
if str(_backend_dir) not in sys.path:
sys.path.insert(0, str(_backend_dir))
@pytest.fixture
def github_dir(temp_dir):
"""Create GitHub directory structure."""
github_dir = temp_dir / ".auto-claude" / "github"
github_dir.mkdir(parents=True)
(github_dir / "batches").mkdir()
return github_dir
@pytest.fixture
def sample_issues():
"""Sample GitHub issues for testing."""
return [
{
"number": 1,
"title": "Login button not working",
"body": "The login button doesn't respond to clicks",
"labels": [{"name": "bug"}],
},
{
"number": 2,
"title": "Logout functionality broken",
"body": "Users can't log out properly",
"labels": [{"name": "bug"}],
},
{
"number": 3,
"title": "Add dark mode",
"body": "Need dark theme support",
"labels": [{"name": "feature"}],
},
{
"number": 4,
"title": "Authentication timeout",
"body": "Sessions expire too quickly",
"labels": [{"name": "bug"}, {"name": "security"}],
},
]
# =============================================================================
# ISSUE BATCH ITEM TESTS
# =============================================================================
def test_issue_batch_item_creation():
"""Test creating issue batch item."""
from runners.github.batch_issues import IssueBatchItem
item = IssueBatchItem(
issue_number=123,
title="Test issue",
body="Test body",
labels=["bug", "critical"],
similarity_to_primary=0.85,
)
assert item.issue_number == 123
assert item.title == "Test issue"
assert item.similarity_to_primary == 0.85
assert len(item.labels) == 2
def test_issue_batch_item_to_dict():
"""Test serializing issue batch item to dict."""
from runners.github.batch_issues import IssueBatchItem
item = IssueBatchItem(
issue_number=123,
title="Test",
body="Body",
labels=["bug"],
)
data = item.to_dict()
assert data["issue_number"] == 123
assert data["title"] == "Test"
assert data["labels"] == ["bug"]
def test_issue_batch_item_from_dict():
"""Test deserializing issue batch item from dict."""
from runners.github.batch_issues import IssueBatchItem
data = {
"issue_number": 123,
"title": "Test",
"body": "Body",
"labels": ["bug"],
"similarity_to_primary": 0.9,
}
item = IssueBatchItem.from_dict(data)
assert item.issue_number == 123
assert item.similarity_to_primary == 0.9
# =============================================================================
# ISSUE BATCH TESTS
# =============================================================================
def test_issue_batch_creation():
"""Test creating issue batch."""
from runners.github.batch_issues import IssueBatch, IssueBatchItem
items = [
IssueBatchItem(1, "Issue 1", "Body 1", ["bug"]),
IssueBatchItem(2, "Issue 2", "Body 2", ["bug"]),
]
batch = IssueBatch(
batch_id="001",
repo="test/repo",
primary_issue=1,
issues=items,
common_themes=["authentication", "login"],
)
assert batch.batch_id == "001"
assert batch.primary_issue == 1
assert len(batch.issues) == 2
assert len(batch.common_themes) == 2
@pytest.mark.asyncio
async def test_issue_batch_save_and_load(github_dir):
"""Test saving and loading issue batch."""
from runners.github.batch_issues import IssueBatch, IssueBatchItem
items = [
IssueBatchItem(1, "Issue 1", "Body 1", ["bug"]),
]
batch = IssueBatch(
batch_id="test_001",
repo="test/repo",
primary_issue=1,
issues=items,
common_themes=["test"],
)
await batch.save(github_dir)
loaded = IssueBatch.load(github_dir, "test_001")
assert loaded is not None
assert loaded.batch_id == "test_001"
assert loaded.primary_issue == 1
assert len(loaded.issues) == 1
def test_issue_batch_get_issue_numbers():
"""Test getting issue numbers from batch."""
from runners.github.batch_issues import IssueBatch, IssueBatchItem
items = [
IssueBatchItem(1, "Issue 1", "Body 1", []),
IssueBatchItem(2, "Issue 2", "Body 2", []),
IssueBatchItem(3, "Issue 3", "Body 3", []),
]
batch = IssueBatch(
batch_id="001",
repo="test/repo",
primary_issue=1,
issues=items,
)
numbers = batch.get_issue_numbers()
assert numbers == [1, 2, 3]
def test_issue_batch_update_status():
"""Test updating batch status."""
from runners.github.batch_issues import IssueBatch, BatchStatus
batch = IssueBatch(
batch_id="001",
repo="test/repo",
primary_issue=1,
issues=[],
)
assert batch.status == BatchStatus.PENDING
batch.update_status(BatchStatus.BUILDING, error=None)
assert batch.status == BatchStatus.BUILDING
# =============================================================================
# CLAUDE BATCH ANALYZER TESTS
# =============================================================================
@pytest.mark.asyncio
async def test_claude_batch_analyzer_single_issue():
"""Test analyzer with single issue."""
from runners.github.batch_issues import ClaudeBatchAnalyzer
analyzer = ClaudeBatchAnalyzer()
issues = [
{"number": 1, "title": "Bug", "body": "Description", "labels": []}
]
batches = await analyzer.analyze_and_batch_issues(issues)
assert len(batches) == 1
assert batches[0]["issue_numbers"] == [1]
assert batches[0]["confidence"] == 1.0
@pytest.mark.asyncio
async def test_claude_batch_analyzer_empty_issues():
"""Test analyzer with empty issues list."""
from runners.github.batch_issues import ClaudeBatchAnalyzer
analyzer = ClaudeBatchAnalyzer()
batches = await analyzer.analyze_and_batch_issues([])
assert batches == []
def test_parse_json_response_simple():
"""Test parsing simple JSON response."""
from runners.github.batch_issues import ClaudeBatchAnalyzer
analyzer = ClaudeBatchAnalyzer()
response = '{"batches": [{"issue_numbers": [1, 2]}]}'
result = analyzer._parse_json_response(response)
assert "batches" in result
assert len(result["batches"]) == 1
def test_parse_json_response_with_markdown():
"""Test parsing JSON wrapped in markdown."""
from runners.github.batch_issues import ClaudeBatchAnalyzer
analyzer = ClaudeBatchAnalyzer()
response = '```json\n{"batches": [{"issue_numbers": [1]}]}\n```'
result = analyzer._parse_json_response(response)
assert "batches" in result
# =============================================================================
# ISSUE BATCHER INITIALIZATION TESTS
# =============================================================================
def test_issue_batcher_initialization(github_dir):
"""Test issue batcher initialization."""
from runners.github.batch_issues import IssueBatcher
batcher = IssueBatcher(
github_dir=github_dir,
repo="test/repo",
min_batch_size=2,
max_batch_size=5,
)
assert batcher.github_dir == github_dir
assert batcher.repo == "test/repo"
assert batcher.min_batch_size == 2
assert batcher.max_batch_size == 5
# =============================================================================
# PRE-GROUPING TESTS
# =============================================================================
def test_pre_group_by_labels(github_dir, sample_issues):
"""Test pre-grouping issues by labels."""
from runners.github.batch_issues import IssueBatcher
batcher = IssueBatcher(
github_dir=github_dir,
repo="test/repo",
)
pre_groups = batcher._pre_group_by_labels_and_keywords(sample_issues)
# Should have at least 2 groups (bug and feature)
assert len(pre_groups) >= 2
# Bug issues should be grouped together
bug_group = next((g for g in pre_groups if len(g) > 1), None)
assert bug_group is not None
def test_group_by_title_keywords(github_dir):
"""Test grouping by title keywords."""
from runners.github.batch_issues import IssueBatcher
batcher = IssueBatcher(
github_dir=github_dir,
repo="test/repo",
)
# Use titles that only match a single specific keyword to avoid
# set iteration order issues (keywords are stored in a set)
issues = [
{"number": 1, "title": "Login page broken", "body": "", "labels": []},
{"number": 2, "title": "Login button missing", "body": "", "labels": []},
{"number": 3, "title": "Dashboard widget", "body": "", "labels": []},
]
groups = batcher._group_by_title_keywords(issues)
# Should group login-related issues together (both match "login" keyword)
login_group = next((g for g in groups if len(g) == 2), None)
assert login_group is not None
# Verify both login issues are in the same group
group_numbers = {i["number"] for i in login_group}
assert group_numbers == {1, 2}
# =============================================================================
# BATCH CREATION TESTS
# =============================================================================
@pytest.mark.asyncio
async def test_create_batches_success(github_dir, sample_issues):
"""Test creating batches from issues."""
from runners.github.batch_issues import IssueBatcher
with patch("runners.github.batch_issues.ClaudeBatchAnalyzer") as mock_analyzer_class:
with patch("runners.github.batch_issues.BatchValidator"):
mock_analyzer = mock_analyzer_class.return_value
mock_analyzer.analyze_and_batch_issues = AsyncMock(
return_value=[
{
"issue_numbers": [1, 2],
"theme": "Login issues",
"reasoning": "Both related to auth",
"confidence": 0.9,
},
{
"issue_numbers": [3],
"theme": "Dark mode",
"reasoning": "Feature request",
"confidence": 1.0,
},
]
)
batcher = IssueBatcher(
github_dir=github_dir,
repo="test/repo",
validate_batches=False, # Disable validation for this test
)
batches = await batcher.create_batches(sample_issues)
# Should create batches
assert len(batches) >= 1
assert all(b.repo == "test/repo" for b in batches)
@pytest.mark.asyncio
async def test_create_batches_excludes_existing(github_dir, sample_issues):
"""Test batch creation excludes already-batched issues."""
from runners.github.batch_issues import IssueBatcher, IssueBatch, IssueBatchItem
# Create existing batch with issue 1
existing_batch = IssueBatch(
batch_id="existing",
repo="test/repo",
primary_issue=1,
issues=[IssueBatchItem(1, "Issue 1", "Body", [])],
)
await existing_batch.save(github_dir)
with patch("runners.github.batch_issues.ClaudeBatchAnalyzer") as mock_analyzer_class:
with patch("runners.github.batch_issues.BatchValidator"):
# Set up analyzer to return batches for issues 2, 3
mock_analyzer = mock_analyzer_class.return_value
mock_analyzer.analyze_and_batch_issues = AsyncMock(
return_value=[
{
"issue_numbers": [2, 3],
"theme": "Test theme",
"reasoning": "Test reasoning",
"confidence": 0.9,
}
]
)
batcher = IssueBatcher(
github_dir=github_dir,
repo="test/repo",
validate_batches=False,
)
# Load existing batches
batcher._load_batch_index()
# Exclude issue 1
exclude = {1}
batches = await batcher.create_batches(sample_issues, exclude_issue_numbers=exclude)
# Should not include issue 1 in new batches
for batch in batches:
assert 1 not in batch.get_issue_numbers()
# =============================================================================
# BATCH RETRIEVAL TESTS
# =============================================================================
@pytest.mark.asyncio
async def test_get_batch_for_issue(github_dir):
"""Test retrieving batch containing an issue."""
from runners.github.batch_issues import IssueBatcher, IssueBatch, IssueBatchItem
# Create batch
batch = IssueBatch(
batch_id="test_batch",
repo="test/repo",
primary_issue=1,
issues=[
IssueBatchItem(1, "Issue 1", "Body", []),
IssueBatchItem(2, "Issue 2", "Body", []),
],
)
await batch.save(github_dir)
batcher = IssueBatcher(
github_dir=github_dir,
repo="test/repo",
)
# Update index manually
batcher._batch_index[1] = "test_batch"
batcher._batch_index[2] = "test_batch"
retrieved = batcher.get_batch_for_issue(1)
assert retrieved is not None
assert retrieved.batch_id == "test_batch"
assert 1 in retrieved.get_issue_numbers()
def test_get_batch_for_nonexistent_issue(github_dir):
"""Test retrieving batch for non-existent issue."""
from runners.github.batch_issues import IssueBatcher
batcher = IssueBatcher(
github_dir=github_dir,
repo="test/repo",
)
batch = batcher.get_batch_for_issue(999)
assert batch is None
@pytest.mark.asyncio
async def test_get_all_batches(github_dir):
"""Test retrieving all batches."""
from runners.github.batch_issues import IssueBatcher, IssueBatch, IssueBatchItem
# Create multiple batches
batch1 = IssueBatch(
batch_id="batch_001",
repo="test/repo",
primary_issue=1,
issues=[IssueBatchItem(1, "Issue 1", "Body", [])],
)
batch2 = IssueBatch(
batch_id="batch_002",
repo="test/repo",
primary_issue=2,
issues=[IssueBatchItem(2, "Issue 2", "Body", [])],
)
await batch1.save(github_dir)
await batch2.save(github_dir)
batcher = IssueBatcher(
github_dir=github_dir,
repo="test/repo",
)
all_batches = batcher.get_all_batches()
assert len(all_batches) == 2
batch_ids = {b.batch_id for b in all_batches}
assert "batch_001" in batch_ids
assert "batch_002" in batch_ids
# =============================================================================
# BATCH STATUS FILTERING TESTS
# =============================================================================
@pytest.mark.asyncio
async def test_get_pending_batches(github_dir):
"""Test retrieving pending batches."""
from runners.github.batch_issues import IssueBatcher, IssueBatch, BatchStatus, IssueBatchItem
# Create batches with different statuses
pending = IssueBatch(
batch_id="pending",
repo="test/repo",
primary_issue=1,
issues=[IssueBatchItem(1, "Issue 1", "Body", [])],
status=BatchStatus.PENDING,
)
building = IssueBatch(
batch_id="building",
repo="test/repo",
primary_issue=2,
issues=[IssueBatchItem(2, "Issue 2", "Body", [])],
status=BatchStatus.BUILDING,
)
completed = IssueBatch(
batch_id="completed",
repo="test/repo",
primary_issue=3,
issues=[IssueBatchItem(3, "Issue 3", "Body", [])],
status=BatchStatus.COMPLETED,
)
await pending.save(github_dir)
await building.save(github_dir)
await completed.save(github_dir)
batcher = IssueBatcher(
github_dir=github_dir,
repo="test/repo",
)
pending_batches = batcher.get_pending_batches()
assert len(pending_batches) == 1
assert pending_batches[0].status == BatchStatus.PENDING
@pytest.mark.asyncio
async def test_get_active_batches(github_dir):
"""Test retrieving active batches."""
from runners.github.batch_issues import IssueBatcher, IssueBatch, BatchStatus, IssueBatchItem
# Create batches
building = IssueBatch(
batch_id="building",
repo="test/repo",
primary_issue=1,
issues=[IssueBatchItem(1, "Issue 1", "Body", [])],
status=BatchStatus.BUILDING,
)
qa_review = IssueBatch(
batch_id="qa",
repo="test/repo",
primary_issue=2,
issues=[IssueBatchItem(2, "Issue 2", "Body", [])],
status=BatchStatus.QA_REVIEW,
)
completed = IssueBatch(
batch_id="completed",
repo="test/repo",
primary_issue=3,
issues=[IssueBatchItem(3, "Issue 3", "Body", [])],
status=BatchStatus.COMPLETED,
)
await building.save(github_dir)
await qa_review.save(github_dir)
await completed.save(github_dir)
batcher = IssueBatcher(
github_dir=github_dir,
repo="test/repo",
)
active = batcher.get_active_batches()
assert len(active) == 2
assert all(b.status in (BatchStatus.BUILDING, BatchStatus.QA_REVIEW) for b in active)
# =============================================================================
# BATCH REMOVAL TESTS
# =============================================================================
@pytest.mark.asyncio
async def test_remove_batch(github_dir):
"""Test removing a batch."""
from runners.github.batch_issues import IssueBatcher, IssueBatch, IssueBatchItem
# Create batch
batch = IssueBatch(
batch_id="to_remove",
repo="test/repo",
primary_issue=1,
issues=[
IssueBatchItem(1, "Issue 1", "Body", []),
IssueBatchItem(2, "Issue 2", "Body", []),
],
)
await batch.save(github_dir)
batcher = IssueBatcher(
github_dir=github_dir,
repo="test/repo",
)
# Add to index
batcher._batch_index[1] = "to_remove"
batcher._batch_index[2] = "to_remove"
batcher._save_batch_index()
# Remove batch
removed = batcher.remove_batch("to_remove")
assert removed is True
assert 1 not in batcher._batch_index
assert 2 not in batcher._batch_index
# File should be deleted
batch_file = github_dir / "batches" / "batch_to_remove.json"
assert not batch_file.exists()
# =============================================================================
# ISSUE MEMBERSHIP TESTS
# =============================================================================
def test_is_issue_in_batch(github_dir):
"""Test checking if issue is in a batch."""
from runners.github.batch_issues import IssueBatcher
batcher = IssueBatcher(
github_dir=github_dir,
repo="test/repo",
)
# Add issue to index
batcher._batch_index[123] = "batch_001"
assert batcher.is_issue_in_batch(123) is True
assert batcher.is_issue_in_batch(456) is False
# =============================================================================
# THEME EXTRACTION TESTS
# =============================================================================
def test_extract_common_themes(github_dir):
"""Test extracting common themes from issues."""
from runners.github.batch_issues import IssueBatcher
batcher = IssueBatcher(
github_dir=github_dir,
repo="test/repo",
)
issues = [
{"title": "Login API timeout", "body": "The authentication endpoint is slow"},
{"title": "OAuth login failing", "body": "Users can't log in with OAuth"},
]
themes = batcher._extract_common_themes(issues)
# Should identify common themes like "authentication", "login", "api", "oauth"
assert any(theme in ["authentication", "login", "api", "oauth"] for theme in themes)
# =============================================================================
# CLUSTERING TESTS
# =============================================================================
def test_cluster_issues(github_dir):
"""Test clustering issues by similarity."""
from runners.github.batch_issues import IssueBatcher
batcher = IssueBatcher(
github_dir=github_dir,
repo="test/repo",
similarity_threshold=0.7,
max_batch_size=5,
)
issues = [
{"number": 1, "title": "Issue 1", "body": "", "labels": []},
{"number": 2, "title": "Issue 2", "body": "", "labels": []},
{"number": 3, "title": "Issue 3", "body": "", "labels": []},
]
# Create similarity matrix (1 and 2 are similar, 3 is different)
similarity_matrix = {
(1, 2): 0.9,
(2, 1): 0.9,
(1, 3): 0.3,
(3, 1): 0.3,
(2, 3): 0.3,
(3, 2): 0.3,
}
clusters = batcher._cluster_issues(issues, similarity_matrix)
# Should create 2 clusters: [1, 2] and [3]
assert len(clusters) >= 1
# Find cluster with issues 1 and 2
large_cluster = max(clusters, key=len)
if len(large_cluster) > 1:
assert 1 in large_cluster and 2 in large_cluster
# =============================================================================
# BATCH INDEX MANAGEMENT TESTS
# =============================================================================
def test_save_and_load_batch_index(github_dir):
"""Test saving and loading batch index."""
from runners.github.batch_issues import IssueBatcher
batcher = IssueBatcher(
github_dir=github_dir,
repo="test/repo",
)
# Add entries
batcher._batch_index[1] = "batch_001"
batcher._batch_index[2] = "batch_001"
batcher._batch_index[3] = "batch_002"
# Save
batcher._save_batch_index()
# Create new batcher and load
new_batcher = IssueBatcher(
github_dir=github_dir,
repo="test/repo",
)
new_batcher._load_batch_index()
assert new_batcher._batch_index[1] == "batch_001"
assert new_batcher._batch_index[2] == "batch_001"
assert new_batcher._batch_index[3] == "batch_002"
def test_generate_batch_id(github_dir):
"""Test batch ID generation."""
from runners.github.batch_issues import IssueBatcher
batcher = IssueBatcher(
github_dir=github_dir,
repo="test/repo",
)
batch_id = batcher._generate_batch_id(primary_issue=123)
assert batch_id.startswith("123_")
assert len(batch_id) > 4 # Should have timestamp