blob: dc66e7d0f15ecd9d8de8ba74351412fa931b1253 [file]
"""gerrit-mcp-server extension for the Gerrit task plugin.
Exposes two MCP tools:
- get_task_tree: full task tree for a change (task--applicable pruned)
- get_actionable_tasks: depth-first walk returning only READY/FAIL nodes
Both tools require the 'task' Gerrit plugin via @requires_plugin; they
auto-route to a host that has it and raise a RuntimeError when none does.
"""
import json
from typing import Any, Dict, List, Optional, TypedDict
from urllib.parse import quote
from gerrit_mcp_server.extensions import ExtensionContext, requires_plugin
class _TaskTreeResult(TypedDict):
change_id: str
roots: List[Dict[str, Any]]
note: Optional[str]
class _ActionableTasksResult(TypedDict):
change_id: str
actionable_tasks: List[Dict[str, Any]]
def _find_actionable(node: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Walk the task tree depth-first; collect READY and FAIL nodes.
WAITING nodes are not returned but descended into — actionable leaf
nodes hide beneath WAITING parents in nested task hierarchies.
PASS, DUPLICATE, SKIPPED, and UNKNOWN are terminal (stop, don't yield).
"""
status = node.get("status", "")
if status in ("READY", "FAIL"):
return [node]
if status == "WAITING":
result: List[Dict[str, Any]] = []
for child in node.get("sub_tasks", []):
result.extend(_find_actionable(child))
return result
return []
def register(ctx: ExtensionContext) -> None:
"""Register get_task_tree and get_actionable_tasks MCP tools."""
async def _fetch_task_tree(
change_id: str,
task_only: Optional[str],
gerrit_base_url: Optional[str],
) -> _TaskTreeResult:
"""Core logic shared by both tools — not registered as an MCP tool."""
base_url = ctx.get_base_url(gerrit_base_url)
url = f"{base_url}/changes/?q=change:{quote(str(change_id))}&task--applicable"
if task_only:
url += f"&task--only={quote(task_only)}"
result_str = await ctx.run_curl([url], base_url)
try:
changes = json.loads(result_str)
except json.JSONDecodeError as e:
raise ValueError(f"Could not parse Gerrit response: {result_str}") from e
if not changes:
raise ValueError(f"No change found for ID: {change_id}")
change = changes[0]
plugins = change.get("plugins", [])
task_data = next((p for p in plugins if p.get("name") == "task"), None)
if task_data is None:
return {
"change_id": change_id,
"roots": [],
"note": "The task plugin returned no data for this change.",
}
return {
"change_id": change_id,
"roots": task_data.get("roots", []),
"note": None,
}
@ctx.mcp.tool()
@requires_plugin("task", ctx.plugin_registry)
async def get_task_tree(
change_id: str,
task_only: Optional[str] = None,
gerrit_base_url: Optional[str] = None,
) -> _TaskTreeResult:
"""Return the full task tree attached to a Gerrit change.
Uses task--applicable to prune tasks that don't apply to this change. Pass
task_only to scope to a single root task.
Returns an object with change_id and roots (list of task nodes, each with
name, status, hint, in_progress, has_pass, exported, change, and sub_tasks
fields). If the task plugin is not installed on the target host, a note is
included and roots is empty.
"""
return await _fetch_task_tree(change_id, task_only, gerrit_base_url)
@ctx.mcp.tool()
@requires_plugin("task", ctx.plugin_registry)
async def get_actionable_tasks(
change_id: str,
task_only: Optional[str] = None,
gerrit_base_url: Optional[str] = None,
) -> _ActionableTasksResult:
"""Return only actionable tasks (status READY or FAIL) for a change.
Walks the full task tree depth-first and collects every node whose
status is READY or FAIL. WAITING nodes are descended into but not
returned. PASS, DUPLICATE, SKIPPED, and UNKNOWN are not returned.
Pass task_only to scope to a single root task.
"""
tree = await _fetch_task_tree(change_id, task_only, gerrit_base_url)
actionable: List[Dict[str, Any]] = []
for root in tree.get("roots", []):
actionable.extend(_find_actionable(root))
return {"change_id": change_id, "actionable_tasks": actionable}