From 32a718dacd67661c50957dc8f70cde274e3b9506 Mon Sep 17 00:00:00 2001 From: Baishampayan Ghose Date: Thu, 16 Oct 2025 18:08:37 +0530 Subject: [PATCH] feat(mcp): Add `reopen` command support for closed issues Implements the `bd` reopen command across the entire MCP stack, enabling agents to reopen closed issues with optional reason tracking for audit trails. This addresses the need to handle regressions and incorrectly closed issues without manual `bd` CLI intervention. The reopen command is more explicit than `bd update --status open` and emits a dedicated Reopened event in the audit log, making it easier to track why issues were reopened during analysis. Changes: - `models.py`: Add ReopenIssueParams with issue_ids list and optional reason - `bd_client.py`: Implement reopen() method with JSON response parsing - `tools.py`: Add beads_reopen_issue() wrapper with Annotated types for MCP - `server.py`: Register 'reopen' MCP tool with description and parameters Testing (10 new): - `test_bd_client.py`: 4 unit tests (mocked subprocess) - `test_bd_client_integration.py`: 3 integration tests (real `bd` CLI) - `test_mcp_server_integration.py`: 3 MCP integration tests (FastMCP Client) - `test_tools.py`: 3 tools wrapper tests (mocked BdClient) Also updated `README.md`. --- integrations/beads-mcp/README.md | 1 + .../beads-mcp/src/beads_mcp/bd_client.py | 21 ++++ .../beads-mcp/src/beads_mcp/models.py | 7 ++ .../beads-mcp/src/beads_mcp/server.py | 10 ++ integrations/beads-mcp/src/beads_mcp/tools.py | 15 +++ .../beads-mcp/tests/test_bd_client.py | 105 ++++++++++++++++++ .../tests/test_bd_client_integration.py | 104 ++++++++++++----- .../tests/test_mcp_server_integration.py | 90 +++++++++++++++ integrations/beads-mcp/tests/test_tools.py | 61 ++++++++++ integrations/beads-mcp/uv.lock | 2 +- 10 files changed, 390 insertions(+), 26 deletions(-) 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" },