diff --git a/integrations/beads-mcp/README.md b/integrations/beads-mcp/README.md index 9e14fc8e..92931755 100644 --- a/integrations/beads-mcp/README.md +++ b/integrations/beads-mcp/README.md @@ -79,6 +79,7 @@ Then use in Claude Desktop config: - `dep` - Add dependency (blocks, related, parent-child, discovered-from) - `blocked` - Get blocked issues - `stats` - Get project statistics +- `reopen` - Reopen a closed issue with optional reason ## Development diff --git a/integrations/beads-mcp/src/beads_mcp/bd_client.py b/integrations/beads-mcp/src/beads_mcp/bd_client.py index 49eedada..b0e46d85 100644 --- a/integrations/beads-mcp/src/beads_mcp/bd_client.py +++ b/integrations/beads-mcp/src/beads_mcp/bd_client.py @@ -15,6 +15,7 @@ from .models import ( Issue, ListIssuesParams, ReadyWorkParams, + ReopenIssueParams, ShowIssueParams, Stats, UpdateIssueParams, @@ -381,6 +382,26 @@ class BdClient: return [Issue.model_validate(issue) for issue in data] + async def reopen(self, params: ReopenIssueParams) -> list[Issue]: + """Reopen one or more closed issues. + + Args: + params: Reopen parameters + + Returns: + List of reopened issues + """ + args = ["reopen", *params.issue_ids] + + if params.reason: + args.extend(["--reason", params.reason]) + + data = await self._run_command(*args) + if not isinstance(data, list): + raise BdCommandError(f"Invalid response for reopen {params.issue_ids}") + + return [Issue.model_validate(issue) for issue in data] + async def add_dependency(self, params: AddDependencyParams) -> None: """Add a dependency between issues. diff --git a/integrations/beads-mcp/src/beads_mcp/models.py b/integrations/beads-mcp/src/beads_mcp/models.py index b7740791..804c4b14 100644 --- a/integrations/beads-mcp/src/beads_mcp/models.py +++ b/integrations/beads-mcp/src/beads_mcp/models.py @@ -86,6 +86,13 @@ class CloseIssueParams(BaseModel): reason: str = "Completed" +class ReopenIssueParams(BaseModel): + """Parameters for reopening issues.""" + + issue_ids: list[str] + reason: str | None = None + + class AddDependencyParams(BaseModel): """Parameters for adding a dependency.""" diff --git a/integrations/beads-mcp/src/beads_mcp/server.py b/integrations/beads-mcp/src/beads_mcp/server.py index d5856b7e..37473e52 100644 --- a/integrations/beads-mcp/src/beads_mcp/server.py +++ b/integrations/beads-mcp/src/beads_mcp/server.py @@ -14,6 +14,7 @@ from beads_mcp.tools import ( beads_list_issues, beads_quickstart, beads_ready_work, + beads_reopen_issue, beads_show_issue, beads_stats, beads_update_issue, @@ -153,6 +154,15 @@ async def close_issue(issue_id: str, reason: str = "Completed") -> list[Issue]: return await beads_close_issue(issue_id=issue_id, reason=reason) +@mcp.tool( + name="reopen", + description="Reopen one or more closed issues. Sets status to 'open' and clears closed_at timestamp.", +) +async def reopen_issue(issue_ids: list[str], reason: str | None = None) -> list[Issue]: + """Reopen one or more closed issues.""" + return await beads_reopen_issue(issue_ids=issue_ids, reason=reason) + + @mcp.tool( name="dep", description="""Add a dependency between issues. Types: blocks (hard blocker), diff --git a/integrations/beads-mcp/src/beads_mcp/tools.py b/integrations/beads-mcp/src/beads_mcp/tools.py index f0e7023c..f79d8ad0 100644 --- a/integrations/beads-mcp/src/beads_mcp/tools.py +++ b/integrations/beads-mcp/src/beads_mcp/tools.py @@ -15,6 +15,7 @@ from .models import ( IssueType, ListIssuesParams, ReadyWorkParams, + ReopenIssueParams, ShowIssueParams, Stats, UpdateIssueParams, @@ -177,6 +178,20 @@ async def beads_close_issue( return await client.close(params) +async def beads_reopen_issue( + issue_ids: Annotated[list[str], "Issue IDs to reopen (e.g., ['bd-1', 'bd-2'])"], + reason: Annotated[str | None, "Reason for reopening"] = None, +) -> list[Issue]: + """Reopen one or more closed issues. + + Sets status to 'open' and clears the closed_at timestamp. + More explicit than 'update --status open'. + """ + client = await _get_client() + params = ReopenIssueParams(issue_ids=issue_ids, reason=reason) + return await client.reopen(params) + + async def beads_add_dependency( from_id: Annotated[str, "Issue that depends on another (e.g., bd-2)"], to_id: Annotated[str, "Issue that blocks or is related to from_id (e.g., bd-1)"], diff --git a/integrations/beads-mcp/tests/test_bd_client.py b/integrations/beads-mcp/tests/test_bd_client.py index e07e4b64..765f9652 100644 --- a/integrations/beads-mcp/tests/test_bd_client.py +++ b/integrations/beads-mcp/tests/test_bd_client.py @@ -12,6 +12,7 @@ from beads_mcp.models import ( CreateIssueParams, ListIssuesParams, ReadyWorkParams, + ReopenIssueParams, ShowIssueParams, UpdateIssueParams, ) @@ -437,6 +438,110 @@ async def test_close_invalid_response(bd_client, mock_process): await bd_client.close(params) +@pytest.mark.asyncio +async def test_reopen(bd_client, mock_process): + """Test reopen method.""" + issues_data = [ + { + "id": "bd-1", + "title": "Reopened issue", + "status": "open", + "priority": 1, + "issue_type": "bug", + "created_at": "2025-01-25T00:00:00Z", + "updated_at": "2025-01-25T02:00:00Z", + "closed_at": None, + } + ] + mock_process.communicate = AsyncMock(return_value=(json.dumps(issues_data).encode(), b"")) + + with patch("asyncio.create_subprocess_exec", return_value=mock_process): + params = ReopenIssueParams(issue_ids=["bd-1"]) + issues = await bd_client.reopen(params) + + assert len(issues) == 1 + assert issues[0].id == "bd-1" + assert issues[0].status == "open" + assert issues[0].closed_at is None + + +@pytest.mark.asyncio +async def test_reopen_multiple_issues(bd_client, mock_process): + """Test reopen method with multiple issues.""" + issues_data = [ + { + "id": "bd-1", + "title": "Reopened issue 1", + "status": "open", + "priority": 1, + "issue_type": "bug", + "created_at": "2025-01-25T00:00:00Z", + "updated_at": "2025-01-25T02:00:00Z", + "closed_at": None, + }, + { + "id": "bd-2", + "title": "Reopened issue 2", + "status": "open", + "priority": 2, + "issue_type": "feature", + "created_at": "2025-01-25T00:00:00Z", + "updated_at": "2025-01-25T02:00:00Z", + "closed_at": None, + }, + ] + mock_process.communicate = AsyncMock(return_value=(json.dumps(issues_data).encode(), b"")) + + with patch("asyncio.create_subprocess_exec", return_value=mock_process): + params = ReopenIssueParams(issue_ids=["bd-1", "bd-2"]) + issues = await bd_client.reopen(params) + + assert len(issues) == 2 + assert issues[0].id == "bd-1" + assert issues[1].id == "bd-2" + + +@pytest.mark.asyncio +async def test_reopen_with_reason(bd_client, mock_process): + """Test reopen method with reason parameter.""" + issues_data = [ + { + "id": "bd-1", + "title": "Reopened with reason", + "status": "open", + "priority": 1, + "issue_type": "bug", + "created_at": "2025-01-25T00:00:00Z", + "updated_at": "2025-01-25T02:00:00Z", + "closed_at": None, + } + ] + mock_process.communicate = AsyncMock(return_value=(json.dumps(issues_data).encode(), b"")) + + with patch("asyncio.create_subprocess_exec", return_value=mock_process): + params = ReopenIssueParams(issue_ids=["bd-1"], reason="Found regression") + issues = await bd_client.reopen(params) + + assert len(issues) == 1 + assert issues[0].id == "bd-1" + assert issues[0].status == "open" + + +@pytest.mark.asyncio +async def test_reopen_invalid_response(bd_client, mock_process): + """Test reopen method with invalid response type.""" + mock_process.communicate = AsyncMock( + return_value=(json.dumps({"error": "not a list"}).encode(), b"") + ) + + with ( + patch("asyncio.create_subprocess_exec", return_value=mock_process), + pytest.raises(BdCommandError, match="Invalid response for reopen"), + ): + params = ReopenIssueParams(issue_ids=["bd-1"]) + await bd_client.reopen(params) + + @pytest.mark.asyncio async def test_add_dependency(bd_client, mock_process): """Test add_dependency method.""" diff --git a/integrations/beads-mcp/tests/test_bd_client_integration.py b/integrations/beads-mcp/tests/test_bd_client_integration.py index 8f50de38..ab90c46f 100644 --- a/integrations/beads-mcp/tests/test_bd_client_integration.py +++ b/integrations/beads-mcp/tests/test_bd_client_integration.py @@ -14,6 +14,7 @@ from beads_mcp.models import ( CreateIssueParams, ListIssuesParams, ReadyWorkParams, + ReopenIssueParams, ShowIssueParams, UpdateIssueParams, ) @@ -178,21 +179,83 @@ async def test_close_issue(bd_client): assert closed.closed_at is not None +@pytest.mark.asyncio +async def test_reopen_issue(bd_client): + """Test reopening a closed issue with real bd.""" + # Create issue + create_params = CreateIssueParams( + title="BG's issue to reopen", + priority=1, + issue_type="bug", + ) + created = await bd_client.create(create_params) + + # Close issue + close_params = CloseIssueParams(issue_id=created.id, reason="Testing complete") + await bd_client.close(close_params) + + # Reopen issue + reopen_params = ReopenIssueParams(issue_ids=[created.id]) + reopened_issues = await bd_client.reopen(reopen_params) + + assert len(reopened_issues) >= 1 + reopened = reopened_issues[0] + assert reopened.id == created.id + assert reopened.status == "open" + assert reopened.closed_at is None + + +@pytest.mark.asyncio +async def test_reopen_multiple_issues(bd_client): + """Test reopening multiple closed issues with real bd.""" + # Create and close two issues + issue1 = await bd_client.create(CreateIssueParams(title="Issue 1 to reopen", priority=1, issue_type="task")) + issue2 = await bd_client.create(CreateIssueParams(title="Issue 2 to reopen", priority=1, issue_type="task")) + + await bd_client.close(CloseIssueParams(issue_id=issue1.id, reason="Done")) + await bd_client.close(CloseIssueParams(issue_id=issue2.id, reason="Done")) + + # Reopen both issues + reopen_params = ReopenIssueParams(issue_ids=[issue1.id, issue2.id]) + reopened_issues = await bd_client.reopen(reopen_params) + + assert len(reopened_issues) == 2 + reopened_ids = {issue.id for issue in reopened_issues} + assert issue1.id in reopened_ids + assert issue2.id in reopened_ids + assert all(issue.status == "open" for issue in reopened_issues) + assert all(issue.closed_at is None for issue in reopened_issues) + + +@pytest.mark.asyncio +async def test_reopen_with_reason(bd_client): + """Test reopening an issue with reason parameter.""" + # Create and close issue + created = await bd_client.create( + CreateIssueParams(title="Issue to reopen with reason", priority=1, issue_type="bug") + ) + await bd_client.close(CloseIssueParams(issue_id=created.id, reason="Done")) + + # Reopen with reason + reopen_params = ReopenIssueParams(issue_ids=[created.id], reason="BG found a regression in production") + reopened_issues = await bd_client.reopen(reopen_params) + + assert len(reopened_issues) >= 1 + reopened = reopened_issues[0] + assert reopened.id == created.id + assert reopened.status == "open" + assert reopened.closed_at is 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") - ) + 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" - ) + params = AddDependencyParams(from_id=issue1.id, to_id=issue2.id, dep_type="blocks") await bd_client.add_dependency(params) # Verify dependency by showing issue1 @@ -207,17 +270,13 @@ async def test_add_dependency(bd_client): 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") - ) + 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") - ) + blocked_issue = await bd_client.create(CreateIssueParams(title="Blocked issue", priority=1, issue_type="task")) # Add blocking dependency await bd_client.add_dependency( @@ -329,17 +388,11 @@ async def test_invalid_issue_id(bd_client): @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") - ) + 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" - ) + params = AddDependencyParams(from_id=issue1.id, to_id=issue2.id, dep_type="related") await bd_client.add_dependency(params) # Verify @@ -380,8 +433,9 @@ async def test_init_creates_beads_directory(bd_executable): # Verify database file was created with correct prefix db_files = list(beads_dir.glob("*.db")) assert len(db_files) > 0, "No database file created in .beads/" - assert any("test" in str(db.name) for db in db_files), \ + assert any("test" in str(db.name) for db in db_files), ( f"Database file doesn't contain prefix 'test': {[db.name for db in db_files]}" + ) # Verify success message assert "initialized" in result.lower() or "created" in result.lower() diff --git a/integrations/beads-mcp/tests/test_mcp_server_integration.py b/integrations/beads-mcp/tests/test_mcp_server_integration.py index 1e7069af..ac68753e 100644 --- a/integrations/beads-mcp/tests/test_mcp_server_integration.py +++ b/integrations/beads-mcp/tests/test_mcp_server_integration.py @@ -221,6 +221,96 @@ async def test_close_issue_tool(mcp_client): assert closed["closed_at"] is not None +@pytest.mark.asyncio +async def test_reopen_issue_tool(mcp_client): + """Test reopen_issue tool.""" + import json + + # Create and close issue + create_result = await mcp_client.call_tool( + "create", {"title": "Issue to reopen", "priority": 1, "issue_type": "bug"} + ) + created = json.loads(create_result.content[0].text) + issue_id = created["id"] + + await mcp_client.call_tool( + "close", {"issue_id": issue_id, "reason": "Done"} + ) + + # Reopen issue + reopen_result = await mcp_client.call_tool( + "reopen", {"issue_ids": [issue_id]} + ) + + reopened_issues = json.loads(reopen_result.content[0].text) + assert len(reopened_issues) >= 1 + reopened = reopened_issues[0] + assert reopened["id"] == issue_id + assert reopened["status"] == "open" + assert reopened["closed_at"] is None + + +@pytest.mark.asyncio +async def test_reopen_multiple_issues_tool(mcp_client): + """Test reopening multiple issues via MCP tool.""" + import json + + # Create and close two issues + issue1_result = await mcp_client.call_tool( + "create", {"title": "Issue 1 to reopen", "priority": 1, "issue_type": "task"} + ) + issue1 = json.loads(issue1_result.content[0].text) + + issue2_result = await mcp_client.call_tool( + "create", {"title": "Issue 2 to reopen", "priority": 1, "issue_type": "task"} + ) + issue2 = json.loads(issue2_result.content[0].text) + + await mcp_client.call_tool("close", {"issue_id": issue1["id"], "reason": "Done"}) + await mcp_client.call_tool("close", {"issue_id": issue2["id"], "reason": "Done"}) + + # Reopen both issues + reopen_result = await mcp_client.call_tool( + "reopen", {"issue_ids": [issue1["id"], issue2["id"]]} + ) + + reopened_issues = json.loads(reopen_result.content[0].text) + assert len(reopened_issues) == 2 + reopened_ids = {issue["id"] for issue in reopened_issues} + assert issue1["id"] in reopened_ids + assert issue2["id"] in reopened_ids + assert all(issue["status"] == "open" for issue in reopened_issues) + assert all(issue["closed_at"] is None for issue in reopened_issues) + + +@pytest.mark.asyncio +async def test_reopen_with_reason_tool(mcp_client): + """Test reopening issue with reason parameter via MCP tool.""" + import json + + # Create and close issue + create_result = await mcp_client.call_tool( + "create", {"title": "Issue to reopen with reason", "priority": 1, "issue_type": "bug"} + ) + created = json.loads(create_result.content[0].text) + issue_id = created["id"] + + await mcp_client.call_tool("close", {"issue_id": issue_id, "reason": "Done"}) + + # Reopen with reason + reopen_result = await mcp_client.call_tool( + "reopen", + {"issue_ids": [issue_id], "reason": "Found regression"} + ) + + reopened_issues = json.loads(reopen_result.content[0].text) + assert len(reopened_issues) >= 1 + reopened = reopened_issues[0] + assert reopened["id"] == issue_id + assert reopened["status"] == "open" + assert reopened["closed_at"] is None + + @pytest.mark.asyncio async def test_ready_work_tool(mcp_client): """Test ready_work tool.""" diff --git a/integrations/beads-mcp/tests/test_tools.py b/integrations/beads-mcp/tests/test_tools.py index 9fb0665a..2284d9a5 100644 --- a/integrations/beads-mcp/tests/test_tools.py +++ b/integrations/beads-mcp/tests/test_tools.py @@ -14,6 +14,7 @@ from beads_mcp.tools import ( beads_list_issues, beads_quickstart, beads_ready_work, + beads_reopen_issue, beads_show_issue, beads_stats, beads_update_issue, @@ -168,6 +169,66 @@ async def test_beads_close_issue(sample_issue): mock_client.close.assert_called_once() +@pytest.mark.asyncio +async def test_beads_reopen_issue(sample_issue): + """Test beads_reopen_issue tool.""" + reopened_issue = sample_issue.model_copy( + update={"status": "open", "closed_at": None} + ) + mock_client = AsyncMock() + mock_client.reopen = AsyncMock(return_value=[reopened_issue]) + + with patch("beads_mcp.tools._get_client", return_value=mock_client): + issues = await beads_reopen_issue(issue_ids=["bd-1"]) + + assert len(issues) == 1 + assert issues[0].status == "open" + assert issues[0].closed_at is None + mock_client.reopen.assert_called_once() + + +@pytest.mark.asyncio +async def test_beads_reopen_multiple_issues(sample_issue): + """Test beads_reopen_issue with multiple issues.""" + reopened_issue1 = sample_issue.model_copy( + update={"id": "bd-1", "status": "open", "closed_at": None} + ) + reopened_issue2 = sample_issue.model_copy( + update={"id": "bd-2", "status": "open", "closed_at": None} + ) + mock_client = AsyncMock() + mock_client.reopen = AsyncMock(return_value=[reopened_issue1, reopened_issue2]) + + with patch("beads_mcp.tools._get_client", return_value=mock_client): + issues = await beads_reopen_issue(issue_ids=["bd-1", "bd-2"]) + + assert len(issues) == 2 + assert issues[0].status == "open" + assert issues[1].status == "open" + assert all(issue.closed_at is None for issue in issues) + mock_client.reopen.assert_called_once() + + +@pytest.mark.asyncio +async def test_beads_reopen_issue_with_reason(sample_issue): + """Test beads_reopen_issue with reason parameter.""" + reopened_issue = sample_issue.model_copy( + update={"status": "open", "closed_at": None} + ) + mock_client = AsyncMock() + mock_client.reopen = AsyncMock(return_value=[reopened_issue]) + + with patch("beads_mcp.tools._get_client", return_value=mock_client): + issues = await beads_reopen_issue( + issue_ids=["bd-1"], reason="Found regression" + ) + + assert len(issues) == 1 + assert issues[0].status == "open" + assert issues[0].closed_at is None + mock_client.reopen.assert_called_once() + + @pytest.mark.asyncio async def test_beads_add_dependency_success(): """Test beads_add_dependency tool success.""" diff --git a/integrations/beads-mcp/uv.lock b/integrations/beads-mcp/uv.lock index f09674e3..50c753cc 100644 --- a/integrations/beads-mcp/uv.lock +++ b/integrations/beads-mcp/uv.lock @@ -48,7 +48,7 @@ wheels = [ [[package]] name = "beads-mcp" -version = "0.9.6" +version = "0.9.7" source = { editable = "." } dependencies = [ { name = "fastmcp" },