WIP: Gemini Code progress on #1134
Automated salvage commit — agent session ended (exit 124). Work in progress, may need continuation.
This commit is contained in:
140
src/timmy/gitea_tools.py
Normal file
140
src/timmy/gitea_tools.py
Normal file
@@ -0,0 +1,140 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
from typing import Any
|
||||
|
||||
from src.timmy.mcp_tool import MCPToolDef
|
||||
from config import settings
|
||||
|
||||
async def _list_issues(**kwargs: Any) -> str:
|
||||
state = kwargs.get("state", "open")
|
||||
limit = kwargs.get("limit", 10)
|
||||
owner, repo = settings.gitea_repo.split("/", 1)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.get(
|
||||
f"{settings.gitea_url}/api/v1/repos/{owner}/{repo}/issues",
|
||||
headers={"Authorization": f"token {settings.gitea_token}"},
|
||||
params={"state": state, "limit": limit, "type": "issues"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
issues = resp.json()
|
||||
if not issues:
|
||||
return f"No {state} issues found."
|
||||
lines = []
|
||||
for issue in issues:
|
||||
labels = ", ".join(lb["name"] for lb in issue.get("labels", []))
|
||||
label_str = f" [{labels}]" if labels else ""
|
||||
lines.append(f"#{issue['number']}: {issue['title']}{label_str}")
|
||||
return "\n".join(lines)
|
||||
except Exception as exc:
|
||||
return f"Error listing issues: {exc}"
|
||||
|
||||
async def _create_issue(**kwargs: Any) -> str:
|
||||
title = kwargs.get("title", "")
|
||||
body = kwargs.get("body", "")
|
||||
if not title:
|
||||
return "Error: title is required"
|
||||
owner, repo = settings.gitea_repo.split("/", 1)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.post(
|
||||
f"{settings.gitea_url}/api/v1/repos/{owner}/{repo}/issues",
|
||||
headers={
|
||||
"Authorization": f"token {settings.gitea_token}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={"title": title, "body": body},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return f"Created issue #{data['number']}: {data['title']}"
|
||||
except Exception as exc:
|
||||
return f"Error creating issue: {exc}"
|
||||
|
||||
async def _read_issue(**kwargs: Any) -> str:
|
||||
number = kwargs.get("number")
|
||||
if not number:
|
||||
return "Error: issue number is required"
|
||||
owner, repo = settings.gitea_repo.split("/", 1)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.get(
|
||||
f"{settings.gitea_url}/api/v1/repos/{owner}/{repo}/issues/{number}",
|
||||
headers={"Authorization": f"token {settings.gitea_token}"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
issue = resp.json()
|
||||
labels = ", ".join(lb["name"] for lb in issue.get("labels", []))
|
||||
parts = [
|
||||
f"#{issue['number']}: {issue['title']}",
|
||||
f"State: {issue['state']}",
|
||||
]
|
||||
if labels:
|
||||
parts.append(f"Labels: {labels}")
|
||||
if issue.get("body"):
|
||||
parts.append(f"\n{issue['body']}")
|
||||
return "\n".join(parts)
|
||||
except Exception as exc:
|
||||
return f"Error reading issue: {exc}"
|
||||
|
||||
def get_gitea_tool_defs() -> list[MCPToolDef]:
|
||||
"""Build Gitea MCP tool definitions for direct Ollama bridge use."""
|
||||
if not settings.gitea_enabled or not settings.gitea_token:
|
||||
return []
|
||||
|
||||
return [
|
||||
MCPToolDef(
|
||||
name="list_issues",
|
||||
description="List issues in the Gitea repository. Returns issue numbers and titles.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"state": {
|
||||
"type": "string",
|
||||
"description": "Filter by state: open, closed, or all (default: open)",
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of issues to return (default: 10)",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
handler=_list_issues,
|
||||
),
|
||||
MCPToolDef(
|
||||
name="create_issue",
|
||||
description="Create a new issue in the Gitea repository.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Issue title (required)",
|
||||
},
|
||||
"body": {
|
||||
"type": "string",
|
||||
"description": "Issue body in markdown (optional)",
|
||||
},
|
||||
},
|
||||
"required": ["title"],
|
||||
},
|
||||
handler=_create_issue,
|
||||
),
|
||||
MCPToolDef(
|
||||
name="read_issue",
|
||||
description="Read details of a specific issue by number.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"number": {
|
||||
"type": "integer",
|
||||
"description": "Issue number to read",
|
||||
},
|
||||
},
|
||||
"required": ["number"],
|
||||
},
|
||||
handler=_read_issue,
|
||||
),
|
||||
]
|
||||
@@ -39,6 +39,7 @@ from typing import Any
|
||||
import httpx
|
||||
|
||||
from config import settings
|
||||
from src.timmy.gitea_tools import get_gitea_tool_defs
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -58,14 +59,7 @@ class BridgeResult:
|
||||
error: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class MCPToolDef:
|
||||
"""An MCP tool definition translated for Ollama."""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
parameters: dict[str, Any]
|
||||
handler: Any # async callable(**kwargs) -> str
|
||||
from src.timmy.mcp_tool import MCPToolDef
|
||||
|
||||
|
||||
def _mcp_schema_to_ollama_tool(tool: MCPToolDef) -> dict:
|
||||
@@ -148,137 +142,7 @@ def _build_gitea_tools() -> list[MCPToolDef]:
|
||||
These tools call the Gitea REST API directly via httpx rather than
|
||||
spawning an MCP server subprocess, keeping the bridge lightweight.
|
||||
"""
|
||||
if not settings.gitea_enabled or not settings.gitea_token:
|
||||
return []
|
||||
|
||||
base_url = settings.gitea_url
|
||||
token = settings.gitea_token
|
||||
owner, repo = settings.gitea_repo.split("/", 1)
|
||||
|
||||
async def _list_issues(**kwargs: Any) -> str:
|
||||
state = kwargs.get("state", "open")
|
||||
limit = kwargs.get("limit", 10)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.get(
|
||||
f"{base_url}/api/v1/repos/{owner}/{repo}/issues",
|
||||
headers={"Authorization": f"token {token}"},
|
||||
params={"state": state, "limit": limit, "type": "issues"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
issues = resp.json()
|
||||
if not issues:
|
||||
return f"No {state} issues found."
|
||||
lines = []
|
||||
for issue in issues:
|
||||
labels = ", ".join(lb["name"] for lb in issue.get("labels", []))
|
||||
label_str = f" [{labels}]" if labels else ""
|
||||
lines.append(f"#{issue['number']}: {issue['title']}{label_str}")
|
||||
return "\n".join(lines)
|
||||
except Exception as exc:
|
||||
return f"Error listing issues: {exc}"
|
||||
|
||||
async def _create_issue(**kwargs: Any) -> str:
|
||||
title = kwargs.get("title", "")
|
||||
body = kwargs.get("body", "")
|
||||
if not title:
|
||||
return "Error: title is required"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.post(
|
||||
f"{base_url}/api/v1/repos/{owner}/{repo}/issues",
|
||||
headers={
|
||||
"Authorization": f"token {token}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={"title": title, "body": body},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return f"Created issue #{data['number']}: {data['title']}"
|
||||
except Exception as exc:
|
||||
return f"Error creating issue: {exc}"
|
||||
|
||||
async def _read_issue(**kwargs: Any) -> str:
|
||||
number = kwargs.get("number")
|
||||
if not number:
|
||||
return "Error: issue number is required"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.get(
|
||||
f"{base_url}/api/v1/repos/{owner}/{repo}/issues/{number}",
|
||||
headers={"Authorization": f"token {token}"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
issue = resp.json()
|
||||
labels = ", ".join(lb["name"] for lb in issue.get("labels", []))
|
||||
parts = [
|
||||
f"#{issue['number']}: {issue['title']}",
|
||||
f"State: {issue['state']}",
|
||||
]
|
||||
if labels:
|
||||
parts.append(f"Labels: {labels}")
|
||||
if issue.get("body"):
|
||||
parts.append(f"\n{issue['body']}")
|
||||
return "\n".join(parts)
|
||||
except Exception as exc:
|
||||
return f"Error reading issue: {exc}"
|
||||
|
||||
return [
|
||||
MCPToolDef(
|
||||
name="list_issues",
|
||||
description="List issues in the Gitea repository. Returns issue numbers and titles.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"state": {
|
||||
"type": "string",
|
||||
"description": "Filter by state: open, closed, or all (default: open)",
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of issues to return (default: 10)",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
handler=_list_issues,
|
||||
),
|
||||
MCPToolDef(
|
||||
name="create_issue",
|
||||
description="Create a new issue in the Gitea repository.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Issue title (required)",
|
||||
},
|
||||
"body": {
|
||||
"type": "string",
|
||||
"description": "Issue body in markdown (optional)",
|
||||
},
|
||||
},
|
||||
"required": ["title"],
|
||||
},
|
||||
handler=_create_issue,
|
||||
),
|
||||
MCPToolDef(
|
||||
name="read_issue",
|
||||
description="Read details of a specific issue by number.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"number": {
|
||||
"type": "integer",
|
||||
"description": "Issue number to read",
|
||||
},
|
||||
},
|
||||
"required": ["number"],
|
||||
},
|
||||
handler=_read_issue,
|
||||
),
|
||||
]
|
||||
return get_gitea_tool_defs()
|
||||
|
||||
|
||||
class MCPBridge:
|
||||
|
||||
14
src/timmy/mcp_tool.py
Normal file
14
src/timmy/mcp_tool.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""MCP Tool Definition."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable
|
||||
|
||||
@dataclass
|
||||
class MCPToolDef:
|
||||
"""An MCP tool definition translated for Ollama."""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
parameters: dict[str, Any]
|
||||
handler: Callable[..., Any] # async callable(**kwargs) -> str
|
||||
@@ -8,11 +8,11 @@ import pytest
|
||||
from timmy.mcp_bridge import (
|
||||
BridgeResult,
|
||||
MCPBridge,
|
||||
MCPToolDef,
|
||||
_build_gitea_tools,
|
||||
_build_shell_tool,
|
||||
_mcp_schema_to_ollama_tool,
|
||||
)
|
||||
from timmy.gitea_tools import get_gitea_tool_defs as _build_gitea_tools
|
||||
from timmy.mcp_tool import MCPToolDef
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _mcp_schema_to_ollama_tool
|
||||
@@ -89,7 +89,7 @@ def test_build_shell_tool_graceful_on_import_error():
|
||||
|
||||
def test_gitea_tools_empty_when_disabled():
|
||||
"""Gitea tools returns empty list when disabled."""
|
||||
with patch("timmy.mcp_bridge.settings") as mock_settings:
|
||||
with patch("src.timmy.gitea_tools.settings") as mock_settings:
|
||||
mock_settings.gitea_enabled = False
|
||||
mock_settings.gitea_token = ""
|
||||
result = _build_gitea_tools()
|
||||
@@ -98,7 +98,7 @@ def test_gitea_tools_empty_when_disabled():
|
||||
|
||||
def test_gitea_tools_empty_when_no_token():
|
||||
"""Gitea tools returns empty list when no token."""
|
||||
with patch("timmy.mcp_bridge.settings") as mock_settings:
|
||||
with patch("src.timmy.gitea_tools.settings") as mock_settings:
|
||||
mock_settings.gitea_enabled = True
|
||||
mock_settings.gitea_token = ""
|
||||
result = _build_gitea_tools()
|
||||
@@ -107,7 +107,7 @@ def test_gitea_tools_empty_when_no_token():
|
||||
|
||||
def test_gitea_tools_returns_three_tools():
|
||||
"""Gitea tools returns list_issues, create_issue, read_issue."""
|
||||
with patch("timmy.mcp_bridge.settings") as mock_settings:
|
||||
with patch("src.timmy.gitea_tools.settings") as mock_settings:
|
||||
mock_settings.gitea_enabled = True
|
||||
mock_settings.gitea_token = "tok123"
|
||||
mock_settings.gitea_url = "http://localhost:3000"
|
||||
@@ -469,7 +469,7 @@ async def test_bridge_context_manager():
|
||||
@pytest.mark.asyncio
|
||||
async def test_gitea_list_issues_handler():
|
||||
"""list_issues handler calls Gitea API and formats results."""
|
||||
with patch("timmy.mcp_bridge.settings") as mock_settings:
|
||||
with patch("src.timmy.gitea_tools.settings") as mock_settings:
|
||||
mock_settings.gitea_enabled = True
|
||||
mock_settings.gitea_token = "tok123"
|
||||
mock_settings.gitea_url = "http://localhost:3000"
|
||||
@@ -490,7 +490,7 @@ async def test_gitea_list_issues_handler():
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with patch("timmy.mcp_bridge.httpx.AsyncClient", return_value=mock_client):
|
||||
with patch("src.timmy.gitea_tools.httpx.AsyncClient", return_value=mock_client):
|
||||
result = await list_tool.handler(state="open", limit=10)
|
||||
|
||||
assert "#1: Bug one [bug]" in result
|
||||
@@ -500,7 +500,7 @@ async def test_gitea_list_issues_handler():
|
||||
@pytest.mark.asyncio
|
||||
async def test_gitea_create_issue_handler():
|
||||
"""create_issue handler calls Gitea API and returns confirmation."""
|
||||
with patch("timmy.mcp_bridge.settings") as mock_settings:
|
||||
with patch("src.timmy.gitea_tools.settings") as mock_settings:
|
||||
mock_settings.gitea_enabled = True
|
||||
mock_settings.gitea_token = "tok123"
|
||||
mock_settings.gitea_url = "http://localhost:3000"
|
||||
@@ -518,7 +518,7 @@ async def test_gitea_create_issue_handler():
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with patch("timmy.mcp_bridge.httpx.AsyncClient", return_value=mock_client):
|
||||
with patch("src.timmy.gitea_tools.httpx.AsyncClient", return_value=mock_client):
|
||||
result = await create_tool.handler(title="New bug", body="Description")
|
||||
|
||||
assert "#42" in result
|
||||
@@ -528,7 +528,7 @@ async def test_gitea_create_issue_handler():
|
||||
@pytest.mark.asyncio
|
||||
async def test_gitea_create_issue_requires_title():
|
||||
"""create_issue handler returns error when title is missing."""
|
||||
with patch("timmy.mcp_bridge.settings") as mock_settings:
|
||||
with patch("src.timmy.gitea_tools.settings") as mock_settings:
|
||||
mock_settings.gitea_enabled = True
|
||||
mock_settings.gitea_token = "tok123"
|
||||
mock_settings.gitea_url = "http://localhost:3000"
|
||||
@@ -543,7 +543,7 @@ async def test_gitea_create_issue_requires_title():
|
||||
@pytest.mark.asyncio
|
||||
async def test_gitea_read_issue_handler():
|
||||
"""read_issue handler calls Gitea API and formats result."""
|
||||
with patch("timmy.mcp_bridge.settings") as mock_settings:
|
||||
with patch("src.timmy.gitea_tools.settings") as mock_settings:
|
||||
mock_settings.gitea_enabled = True
|
||||
mock_settings.gitea_token = "tok123"
|
||||
mock_settings.gitea_url = "http://localhost:3000"
|
||||
@@ -567,7 +567,7 @@ async def test_gitea_read_issue_handler():
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with patch("timmy.mcp_bridge.httpx.AsyncClient", return_value=mock_client):
|
||||
with patch("src.timmy.gitea_tools.httpx.AsyncClient", return_value=mock_client):
|
||||
result = await read_tool.handler(number=5)
|
||||
|
||||
assert "#5" in result
|
||||
@@ -579,7 +579,7 @@ async def test_gitea_read_issue_handler():
|
||||
@pytest.mark.asyncio
|
||||
async def test_gitea_read_issue_requires_number():
|
||||
"""read_issue handler returns error when number is missing."""
|
||||
with patch("timmy.mcp_bridge.settings") as mock_settings:
|
||||
with patch("src.timmy.gitea_tools.settings") as mock_settings:
|
||||
mock_settings.gitea_enabled = True
|
||||
mock_settings.gitea_token = "tok123"
|
||||
mock_settings.gitea_url = "http://localhost:3000"
|
||||
|
||||
Reference in New Issue
Block a user