diff --git a/.gitignore b/.gitignore index a00be42b73..2e62a24fbf 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,6 @@ node_modules/ *.log # Desktop Service Store *.DS_Store + +# VSCode settings +.vscode/ diff --git a/.taskcluster.yml b/.taskcluster.yml index f98d2a7ed5..44d434f671 100644 --- a/.taskcluster.yml +++ b/.taskcluster.yml @@ -137,6 +137,28 @@ tasks: name: bugbug http service tests description: bugbug http service tests + - $mergeDeep: + - { $eval: default_task_definition } + - taskId: { $eval: as_slugid("mcp_tests_task") } + workerType: compute-smaller + payload: + env: + CODECOV_TOKEN: 66162f89-a4d9-420c-84bd-d10f12a428d9 + command: + - "/bin/bash" + - "-lcx" + - "git clone --quiet ${repository} && + cd bugbug && + git -c advice.detachedHead=false checkout ${head_rev} && + pip install --disable-pip-version-check --no-cache-dir --progress-bar off . && + pip install --disable-pip-version-check --no-cache-dir --progress-bar off -r test-requirements.txt && + pip install --disable-pip-version-check --no-cache-dir --progress-bar off ./mcp[dev] && + pytest --cov=bugbug_mcp mcp/tests/ -vvv && + bash <(curl -s https://codecov.io/bash)" + metadata: + name: bugbug mcp tests + description: bugbug mcp tests + - $mergeDeep: - { $eval: default_task_definition } - taskId: { $eval: as_slugid("packaging_test_task") } @@ -253,6 +275,7 @@ tasks: - { $eval: as_slugid("lint_task") } - { $eval: as_slugid("tests_task") } - { $eval: as_slugid("http_tests_task") } + - { $eval: as_slugid("mcp_tests_task") } - { $eval: as_slugid("frontend_build") } - { $eval: as_slugid("packaging_test_task") } - { $eval: as_slugid("version_check_task") } @@ -283,6 +306,7 @@ tasks: - { $eval: as_slugid("lint_task") } - { $eval: as_slugid("version_check_task") } - { $eval: as_slugid("tests_task") } + - { $eval: as_slugid("mcp_tests_task") } - { $eval: as_slugid("http_tests_task") } - { $eval: as_slugid("frontend_build") } - { $eval: as_slugid("packaging_test_task") } diff --git a/mcp/pyproject.toml b/mcp/pyproject.toml index 51b79b659a..8773162304 100644 --- a/mcp/pyproject.toml +++ b/mcp/pyproject.toml @@ -14,6 +14,15 @@ llms-txt = [ "beautifulsoup4>=4.13.5", "requests>=2.32.5", ] +dev = [ + "pytest>=9.0.0", + "pytest-asyncio>=1.3.0", + "pytest-mock>=3.15.0", +] [tool.uv.sources] bugbug = { path = "..", editable = true } + +[tool.pytest.ini_options] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" diff --git a/mcp/src/bugbug_mcp/server.py b/mcp/src/bugbug_mcp/server.py index c8223d85fc..e1f489b557 100644 --- a/mcp/src/bugbug_mcp/server.py +++ b/mcp/src/bugbug_mcp/server.py @@ -1,6 +1,7 @@ """MCP server for Firefox Development.""" import functools +import logging import os from pathlib import Path from typing import Annotated @@ -18,7 +19,16 @@ from bugbug.tools.core.platforms.phabricator import PhabricatorPatch from bugbug.utils import get_secret -os.environ["OPENAI_API_KEY"] = get_secret("OPENAI_API_KEY") +logger = logging.getLogger(__name__) + +# Set OPENAI_API_KEY if available (optional for most MCP tools) +try: + os.environ["OPENAI_API_KEY"] = get_secret("OPENAI_API_KEY") +except ValueError: + # API key not found, will skip code review features + logger.warning( + "OPENAI_API_KEY not found. Code review features will be unavailable." + ) mcp = FastMCP("Firefox Development MCP Server") @@ -109,6 +119,75 @@ def get_bugzilla_bug(bug_id: int) -> str: return Bug.get(bug_id).to_md() +@mcp.tool() +def bugzilla_quick_search( + search_query: Annotated[ + str, + "A quick search string to find bugs. Can include bug numbers, keywords, status, product, component, etc. Examples: 'firefox crash', 'FIXED', 'status:NEW product:Core'", + ], + limit: Annotated[int, "Maximum number of bugs to return (default: 20)"] = 20, +) -> str: + """Search for bugs in Bugzilla using quick search syntax. + + Quick search allows natural language searches and supports various shortcuts: + - Bug numbers: "12345" or "bug 12345" + - Keywords: "crash", "regression" + - Status: "NEW", "FIXED", "ASSIGNED" + - Products/Components: "Firefox", "Core::DOM" + - Combinations: "firefox crash NEW" + + Returns a formatted list of matching bugs with their ID, status, summary, and link. + """ + from libmozdata.bugzilla import Bugzilla + + bugs = [] + + def bughandler(bug): + bugs.append(bug) + + # Use Bugzilla quicksearch API + params = { + "quicksearch": search_query, + "limit": limit, + } + + Bugzilla( + params, + include_fields=[ + "id", + "status", + "summary", + "product", + "component", + "priority", + "severity", + ], + bughandler=bughandler, + ).get_data().wait() + + if not bugs: + return f"No bugs found matching: {search_query}" + + # Format results concisely for LLM consumption + result = f"Found {len(bugs)} bug(s) matching '{search_query}':\n\n" + + for bug in bugs: + bug_id = bug["id"] + status = bug.get("status", "N/A") + summary = bug.get("summary", "N/A") + product = bug.get("product", "N/A") + component = bug.get("component", "N/A") + priority = bug.get("priority", "N/A") + severity = bug.get("severity", "N/A") + + result += f"Bug {bug_id} [{status}] - {summary}\n" + result += f" Product: {product}::{component}\n" + result += f" Priority: {priority} | Severity: {severity}\n" + result += f" URL: https://bugzilla.mozilla.org/show_bug.cgi?id={bug_id}\n\n" + + return result + + @mcp.resource( uri="phabricator://revision/D{revision_id}", name="Phabricator Revision Content", @@ -163,9 +242,17 @@ async def read_fx_doc_section( def main(): - phabricator.set_api_key( - get_secret("PHABRICATOR_URL"), get_secret("PHABRICATOR_TOKEN") - ) + # Set Phabricator API key if available (optional) + try: + phabricator.set_api_key( + get_secret("PHABRICATOR_URL"), get_secret("PHABRICATOR_TOKEN") + ) + except ValueError: + # Phabricator secrets not available, will skip Phabricator features + logger.warning( + "PHABRICATOR_URL or PHABRICATOR_TOKEN not found. " + "Phabricator features will be unavailable." + ) mcp.run( transport="streamable-http", diff --git a/mcp/tests/test_bugzilla_tools.py b/mcp/tests/test_bugzilla_tools.py new file mode 100644 index 0000000000..e1dab02846 --- /dev/null +++ b/mcp/tests/test_bugzilla_tools.py @@ -0,0 +1,137 @@ +"""Tests for Bugzilla MCP tools.""" + +from unittest.mock import MagicMock + +import pytest +from fastmcp.client import Client +from fastmcp.client.transports import FastMCPTransport + + +@pytest.fixture +async def mcp_client(): + """Create an MCP client for testing.""" + from bugbug_mcp.server import mcp + + async with Client(mcp) as client: + yield client + + +def setup_bugzilla_mock(mocker, bugs): + """Helper to setup Bugzilla mock with given bugs.""" + mock_bugzilla_class = mocker.patch("libmozdata.bugzilla.Bugzilla") + mock_instance = MagicMock() + + # Store bugs to be returned + mock_instance._bugs = bugs + + # When Bugzilla is initialized, capture the bughandler + def mock_init(params, include_fields, bughandler): + mock_instance._bughandler = bughandler + return mock_instance + + # When get_data().wait() is called, invoke the handler with bugs + def mock_get_data(): + for bug in mock_instance._bugs: + mock_instance._bughandler(bug) + return mock_instance + + mock_instance.get_data = mock_get_data + mock_instance.wait = MagicMock() + mock_bugzilla_class.side_effect = mock_init + + return mock_bugzilla_class + + +class TestBugzillaQuickSearch: + """Test the bugzilla_quick_search tool.""" + + async def test_quick_search_basic( + self, mocker, mcp_client: Client[FastMCPTransport] + ): + """Test basic quick search functionality.""" + mock_bugs = [ + { + "id": 123456, + "status": "NEW", + "summary": "Test bug 1", + "product": "Firefox", + "component": "General", + "priority": "P1", + "severity": "S2", + }, + { + "id": 789012, + "status": "ASSIGNED", + "summary": "Test bug 2", + "product": "Core", + "component": "DOM", + "priority": "P2", + "severity": "S3", + }, + ] + + mock_bugzilla = setup_bugzilla_mock(mocker, mock_bugs) + + result = await mcp_client.call_tool( + name="bugzilla_quick_search", + arguments={"search_query": "firefox crash", "limit": 2}, + ) + + # Verify API call + mock_bugzilla.assert_called_once() + call_args = mock_bugzilla.call_args[0][0] + assert call_args["quicksearch"] == "firefox crash" + assert call_args["limit"] == 2 + + # Verify result + result_text = result.content[0].text + assert "Found 2 bug(s)" in result_text + assert "Bug 123456 [NEW]" in result_text + assert "Bug 789012 [ASSIGNED]" in result_text + assert "Test bug 1" in result_text + assert "Firefox::General" in result_text + assert "Core::DOM" in result_text + + async def test_quick_search_no_results( + self, mocker, mcp_client: Client[FastMCPTransport] + ): + """Test quick search with no results.""" + setup_bugzilla_mock(mocker, []) + + result = await mcp_client.call_tool( + name="bugzilla_quick_search", + arguments={"search_query": "nonexistent query"}, + ) + + result_text = result.content[0].text + assert "No bugs found matching: nonexistent query" in result_text + + async def test_quick_search_custom_limit( + self, mocker, mcp_client: Client[FastMCPTransport] + ): + """Test quick search with custom limit.""" + mock_bugzilla = setup_bugzilla_mock(mocker, []) + + await mcp_client.call_tool( + name="bugzilla_quick_search", + arguments={"search_query": "test", "limit": 50}, + ) + + call_args = mock_bugzilla.call_args[0][0] + assert call_args["limit"] == 50 + + async def test_quick_search_handles_missing_fields( + self, mocker, mcp_client: Client[FastMCPTransport] + ): + """Test that missing fields are handled gracefully.""" + mock_bugs = [{"id": 123456, "summary": "Test bug"}] + setup_bugzilla_mock(mocker, mock_bugs) + + result = await mcp_client.call_tool( + name="bugzilla_quick_search", arguments={"search_query": "test"} + ) + + result_text = result.content[0].text + assert "Bug 123456" in result_text + assert "Test bug" in result_text + assert "N/A" in result_text