Implements MCP server for beads issue tracker, exposing all bd CLI functionality to MCP clients like Claude Desktop. Features: - Complete bd command coverage (init, create, list, ready, show, update, close, dep, blocked, stats) - Type-safe Pydantic models with validation - Comprehensive test suite (unit + integration tests) - Production-ready Python package structure - Environment variable configuration support - Quickstart resource (beads://quickstart) Ready for PyPI publication after real-world testing. Co-authored-by: ghoseb <baishampayan.ghose@gmail.com>
352 lines
10 KiB
Python
352 lines
10 KiB
Python
"""Real integration tests for BdClient using actual bd binary."""
|
|
|
|
import os
|
|
import shutil
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from beads_mcp.bd_client import BdClient, BdCommandError, BdNotFoundError
|
|
from beads_mcp.models import (
|
|
AddDependencyParams,
|
|
CloseIssueParams,
|
|
CreateIssueParams,
|
|
DependencyType,
|
|
IssueStatus,
|
|
IssueType,
|
|
ListIssuesParams,
|
|
ReadyWorkParams,
|
|
ShowIssueParams,
|
|
UpdateIssueParams,
|
|
)
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def bd_executable():
|
|
"""Verify bd is available in PATH."""
|
|
bd_path = shutil.which("bd")
|
|
if not bd_path:
|
|
pytest.fail(
|
|
"bd executable not found in PATH. "
|
|
"Please install bd or add it to your PATH before running integration tests."
|
|
)
|
|
return bd_path
|
|
|
|
|
|
@pytest.fixture
|
|
def temp_db():
|
|
"""Create a temporary database file."""
|
|
fd, db_path = tempfile.mkstemp(suffix=".db", prefix="beads_test_", dir="/tmp")
|
|
os.close(fd)
|
|
# Remove the file so bd init can create it
|
|
os.unlink(db_path)
|
|
yield db_path
|
|
# Cleanup
|
|
if os.path.exists(db_path):
|
|
os.unlink(db_path)
|
|
|
|
|
|
@pytest.fixture
|
|
async def bd_client(bd_executable, temp_db):
|
|
"""Create BdClient with temporary database - fully hermetic."""
|
|
client = BdClient(bd_path=bd_executable, beads_db=temp_db)
|
|
|
|
# Initialize database with explicit BEADS_DB - no chdir needed!
|
|
env = os.environ.copy()
|
|
# Clear any existing BEADS_DB to ensure we use only temp_db
|
|
env.pop("BEADS_DB", None)
|
|
env["BEADS_DB"] = temp_db
|
|
|
|
import asyncio
|
|
|
|
# Use temp dir for subprocess to run in (prevents .beads/ discovery)
|
|
with tempfile.TemporaryDirectory(prefix="beads_test_workspace_", dir="/tmp") as temp_dir:
|
|
process = await asyncio.create_subprocess_exec(
|
|
bd_executable,
|
|
"init",
|
|
"--prefix",
|
|
"test",
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
env=env,
|
|
cwd=temp_dir, # Run in temp dir, not project dir
|
|
)
|
|
stdout, stderr = await process.communicate()
|
|
|
|
if process.returncode != 0:
|
|
pytest.fail(f"Failed to initialize test database: {stderr.decode()}")
|
|
|
|
yield client
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_and_show_issue(bd_client):
|
|
"""Test creating and showing an issue with real bd."""
|
|
# Create issue
|
|
params = CreateIssueParams(
|
|
title="Test integration issue",
|
|
description="This is a real integration test",
|
|
priority=1,
|
|
issue_type="bug",
|
|
)
|
|
created = await bd_client.create(params)
|
|
|
|
assert created.id is not None
|
|
assert created.title == "Test integration issue"
|
|
assert created.description == "This is a real integration test"
|
|
assert created.priority == 1
|
|
assert created.issue_type == "bug"
|
|
assert created.status == "open"
|
|
|
|
# Show issue
|
|
show_params = ShowIssueParams(issue_id=created.id)
|
|
shown = await bd_client.show(show_params)
|
|
|
|
assert shown.id == created.id
|
|
assert shown.title == created.title
|
|
assert shown.description == created.description
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_issues(bd_client):
|
|
"""Test listing issues with real bd."""
|
|
# Create multiple issues
|
|
for i in range(3):
|
|
params = CreateIssueParams(
|
|
title=f"Test issue {i}",
|
|
priority=i,
|
|
issue_type="task",
|
|
)
|
|
await bd_client.create(params)
|
|
|
|
# List all issues
|
|
params = ListIssuesParams()
|
|
issues = await bd_client.list_issues(params)
|
|
|
|
assert len(issues) >= 3
|
|
|
|
# List with status filter
|
|
params = ListIssuesParams(status="open")
|
|
issues = await bd_client.list_issues(params)
|
|
|
|
assert all(issue.status == "open" for issue in issues)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_issue(bd_client):
|
|
"""Test updating an issue with real bd."""
|
|
# Create issue
|
|
create_params = CreateIssueParams(
|
|
title="Issue to update",
|
|
priority=2,
|
|
issue_type="feature",
|
|
)
|
|
created = await bd_client.create(create_params)
|
|
|
|
# Update issue
|
|
update_params = UpdateIssueParams(
|
|
issue_id=created.id,
|
|
status="in_progress",
|
|
priority=0,
|
|
title="Updated title",
|
|
)
|
|
updated = await bd_client.update(update_params)
|
|
|
|
assert updated.id == created.id
|
|
assert updated.status == "in_progress"
|
|
assert updated.priority == 0
|
|
assert updated.title == "Updated title"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_issue(bd_client):
|
|
"""Test closing an issue with real bd."""
|
|
# Create issue
|
|
create_params = CreateIssueParams(
|
|
title="Issue to close",
|
|
priority=1,
|
|
issue_type="bug",
|
|
)
|
|
created = await bd_client.create(create_params)
|
|
|
|
# Close issue
|
|
close_params = CloseIssueParams(issue_id=created.id, reason="Testing complete")
|
|
closed_issues = await bd_client.close(close_params)
|
|
|
|
assert len(closed_issues) >= 1
|
|
closed = closed_issues[0]
|
|
assert closed.id == created.id
|
|
assert closed.status == "closed"
|
|
assert closed.closed_at is not None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_dependency(bd_client):
|
|
"""Test adding dependencies with real bd."""
|
|
# Create two issues
|
|
issue1 = await bd_client.create(
|
|
CreateIssueParams(title="Issue 1", priority=1, issue_type="task")
|
|
)
|
|
issue2 = await bd_client.create(
|
|
CreateIssueParams(title="Issue 2", priority=1, issue_type="task")
|
|
)
|
|
|
|
# Add dependency: issue2 blocks issue1
|
|
params = AddDependencyParams(
|
|
from_id=issue1.id, to_id=issue2.id, dep_type="blocks"
|
|
)
|
|
await bd_client.add_dependency(params)
|
|
|
|
# Verify dependency by showing issue1
|
|
show_params = ShowIssueParams(issue_id=issue1.id)
|
|
shown = await bd_client.show(show_params)
|
|
|
|
assert len(shown.dependencies) > 0
|
|
assert any(dep.id == issue2.id for dep in shown.dependencies)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ready_work(bd_client):
|
|
"""Test getting ready work with real bd."""
|
|
# Create issue with no dependencies (should be ready)
|
|
ready_issue = await bd_client.create(
|
|
CreateIssueParams(title="Ready issue", priority=1, issue_type="task")
|
|
)
|
|
|
|
# Create blocked issue
|
|
blocking_issue = await bd_client.create(
|
|
CreateIssueParams(title="Blocking issue", priority=1, issue_type="task")
|
|
)
|
|
blocked_issue = await bd_client.create(
|
|
CreateIssueParams(title="Blocked issue", priority=1, issue_type="task")
|
|
)
|
|
|
|
# Add blocking dependency
|
|
await bd_client.add_dependency(
|
|
AddDependencyParams(
|
|
from_id=blocked_issue.id,
|
|
to_id=blocking_issue.id,
|
|
dep_type="blocks",
|
|
)
|
|
)
|
|
|
|
# Get ready work
|
|
params = ReadyWorkParams(limit=100)
|
|
ready_issues = await bd_client.ready(params)
|
|
|
|
# ready_issue should be in ready work
|
|
ready_ids = [issue.id for issue in ready_issues]
|
|
assert ready_issue.id in ready_ids
|
|
|
|
# blocked_issue should NOT be in ready work
|
|
assert blocked_issue.id not in ready_ids
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_quickstart(bd_client):
|
|
"""Test quickstart command with real bd."""
|
|
result = await bd_client.quickstart()
|
|
|
|
assert len(result) > 0
|
|
assert "beads" in result.lower() or "bd" in result.lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_with_labels(bd_client):
|
|
"""Test creating issue with labels."""
|
|
params = CreateIssueParams(
|
|
title="Issue with labels",
|
|
priority=1,
|
|
issue_type="feature",
|
|
labels=["urgent", "backend"],
|
|
)
|
|
created = await bd_client.create(params)
|
|
|
|
# Note: bd currently doesn't return labels in JSON output
|
|
# This test verifies the command succeeds with labels parameter
|
|
assert created.id is not None
|
|
assert created.title == "Issue with labels"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_with_assignee(bd_client):
|
|
"""Test creating issue with assignee."""
|
|
params = CreateIssueParams(
|
|
title="Assigned issue",
|
|
priority=1,
|
|
issue_type="task",
|
|
assignee="testuser",
|
|
)
|
|
created = await bd_client.create(params)
|
|
|
|
assert created.assignee == "testuser"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_with_filters(bd_client):
|
|
"""Test listing issues with multiple filters."""
|
|
# Create issues with different attributes
|
|
await bd_client.create(
|
|
CreateIssueParams(
|
|
title="Bug P0",
|
|
priority=0,
|
|
issue_type="bug",
|
|
assignee="alice",
|
|
)
|
|
)
|
|
await bd_client.create(
|
|
CreateIssueParams(
|
|
title="Feature P1",
|
|
priority=1,
|
|
issue_type="feature",
|
|
assignee="bob",
|
|
)
|
|
)
|
|
|
|
# Filter by priority
|
|
params = ListIssuesParams(priority=0)
|
|
issues = await bd_client.list_issues(params)
|
|
assert all(issue.priority == 0 for issue in issues)
|
|
|
|
# Filter by type
|
|
params = ListIssuesParams(issue_type="bug")
|
|
issues = await bd_client.list_issues(params)
|
|
assert all(issue.issue_type == "bug" for issue in issues)
|
|
|
|
# Filter by assignee
|
|
params = ListIssuesParams(assignee="alice")
|
|
issues = await bd_client.list_issues(params)
|
|
assert all(issue.assignee == "alice" for issue in issues)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_invalid_issue_id(bd_client):
|
|
"""Test showing non-existent issue."""
|
|
params = ShowIssueParams(issue_id="test-999")
|
|
|
|
with pytest.raises(BdCommandError, match="bd command failed"):
|
|
await bd_client.show(params)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dependency_types(bd_client):
|
|
"""Test different dependency types."""
|
|
issue1 = await bd_client.create(
|
|
CreateIssueParams(title="Issue 1", priority=1, issue_type="task")
|
|
)
|
|
issue2 = await bd_client.create(
|
|
CreateIssueParams(title="Issue 2", priority=1, issue_type="task")
|
|
)
|
|
|
|
# Test related dependency
|
|
params = AddDependencyParams(
|
|
from_id=issue1.id, to_id=issue2.id, dep_type="related"
|
|
)
|
|
await bd_client.add_dependency(params)
|
|
|
|
# Verify
|
|
show_params = ShowIssueParams(issue_id=issue1.id)
|
|
shown = await bd_client.show(show_params)
|
|
assert len(shown.dependencies) > 0
|