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:
Alexander Whitestone
2026-03-23 14:44:57 -04:00
parent 128aa4427f
commit fb74f192e6
4 changed files with 170 additions and 152 deletions

140
src/timmy/gitea_tools.py Normal file
View 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,
),
]

View File

@@ -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
View 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

View File

@@ -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"