Merge pull request 'feat: add Gitea issue creation — Timmy's self-improvement channel' (#9) from claude/sharp-mcnulty into main

Reviewed-on: http://localhost:3000/rockachopa/Timmy-time-dashboard/pulls/9
This commit is contained in:
rockachopa
2026-03-12 18:39:46 -04:00
8 changed files with 1173 additions and 0 deletions

View File

@@ -215,6 +215,16 @@ class Settings(BaseSettings):
thinking_enabled: bool = True
thinking_interval_seconds: int = 300 # 5 minutes between thoughts
thinking_distill_every: int = 10 # distill facts from thoughts every Nth thought
thinking_issue_every: int = 20 # file Gitea issues from thoughts every Nth thought
# ── Gitea Integration ─────────────────────────────────────────────
# Local Gitea instance for issue tracking and self-improvement.
# Timmy can file issues when he notices bugs or improvement opportunities.
gitea_url: str = "http://localhost:3000"
gitea_token: str = "" # GITEA_TOKEN env var; falls back to ~/.config/gitea/token
gitea_repo: str = "rockachopa/Timmy-time-dashboard" # owner/repo
gitea_enabled: bool = True
gitea_timeout: int = 30
# ── Loop QA (Self-Testing) ─────────────────────────────────────────
# Self-test orchestrator that probes capabilities alongside the thinking loop.

View File

@@ -0,0 +1,290 @@
"""Gitea Hand — issue tracking and self-improvement channel for Timmy.
Provides Gitea API capabilities with:
- Token auth (env var, config, or ~/.config/gitea/token fallback)
- Structured result parsing
- Dedup checks before creating issues
- Graceful degradation: log warning, return fallback, never crash
Follows project conventions:
- Config via ``from config import settings``
- Singleton pattern for module-level import
- Async httpx client (like Paperclip client pattern)
"""
from __future__ import annotations
import logging
import time
from dataclasses import dataclass, field
from difflib import SequenceMatcher
from pathlib import Path
from config import settings
logger = logging.getLogger(__name__)
_TOKEN_FILE = Path.home() / ".config" / "gitea" / "token"
@dataclass
class GiteaResult:
"""Result from a Gitea API operation."""
operation: str
success: bool
data: dict = field(default_factory=dict)
error: str = ""
latency_ms: float = 0.0
def _resolve_token() -> str:
"""Resolve Gitea API token from settings or filesystem fallback."""
if settings.gitea_token:
return settings.gitea_token
try:
return _TOKEN_FILE.read_text().strip()
except (FileNotFoundError, PermissionError):
return ""
def _title_similar(a: str, b: str, threshold: float = 0.6) -> bool:
"""Check if two issue titles are similar enough to be duplicates."""
return SequenceMatcher(None, a.lower(), b.lower()).ratio() > threshold
class GiteaHand:
"""Gitea API hand for Timmy.
All methods degrade gracefully — if Gitea is unreachable or the
API call fails, the hand returns a ``GiteaResult(success=False)``
rather than raising.
"""
def __init__(
self,
base_url: str | None = None,
token: str | None = None,
repo: str | None = None,
timeout: int | None = None,
) -> None:
self._base_url = (base_url or settings.gitea_url).rstrip("/")
self._token = token or _resolve_token()
self._repo = repo or settings.gitea_repo
self._timeout = timeout or settings.gitea_timeout
self._client = None
if not self._token:
logger.warning(
"Gitea token not configured — set GITEA_TOKEN or place token in %s",
_TOKEN_FILE,
)
else:
logger.info(
"GiteaHand initialised — %s/%s",
self._base_url,
self._repo,
)
@property
def available(self) -> bool:
"""Check if Gitea integration is configured and enabled."""
return bool(settings.gitea_enabled and self._token and self._repo)
def _get_client(self):
"""Lazy-initialise the async HTTP client."""
import httpx
if self._client is None or self._client.is_closed:
self._client = httpx.AsyncClient(
base_url=self._base_url,
headers={
"Authorization": f"token {self._token}",
"Accept": "application/json",
"Content-Type": "application/json",
},
timeout=self._timeout,
)
return self._client
async def _request(self, method: str, path: str, **kwargs) -> GiteaResult:
"""Make an API request with full error handling."""
start = time.time()
operation = f"{method.upper()} {path}"
if not self.available:
return GiteaResult(
operation=operation,
success=False,
error="Gitea not configured (missing token or repo)",
)
try:
client = self._get_client()
resp = await client.request(method, path, **kwargs)
latency = (time.time() - start) * 1000
if resp.status_code >= 400:
error_body = resp.text[:500]
logger.warning(
"Gitea API %s returned %d: %s",
operation,
resp.status_code,
error_body,
)
return GiteaResult(
operation=operation,
success=False,
error=f"HTTP {resp.status_code}: {error_body}",
latency_ms=latency,
)
return GiteaResult(
operation=operation,
success=True,
data=resp.json() if resp.text else {},
latency_ms=latency,
)
except Exception as exc:
latency = (time.time() - start) * 1000
logger.warning("Gitea API %s failed: %s", operation, exc)
return GiteaResult(
operation=operation,
success=False,
error=str(exc),
latency_ms=latency,
)
# ── Issue operations ─────────────────────────────────────────────────
async def create_issue(
self,
title: str,
body: str = "",
labels: list[str] | None = None,
) -> GiteaResult:
"""Create an issue in the configured repository.
Args:
title: Issue title (required).
body: Issue body in markdown.
labels: Optional list of label names (must exist in repo).
Returns:
GiteaResult with issue data (number, html_url, etc.).
"""
owner, repo = self._repo.split("/", 1)
payload: dict = {"title": title, "body": body}
# Resolve label names to IDs if provided
if labels:
label_ids = await self._resolve_label_ids(owner, repo, labels)
if label_ids:
payload["labels"] = label_ids
return await self._request(
"POST",
f"/api/v1/repos/{owner}/{repo}/issues",
json=payload,
)
async def list_issues(
self,
state: str = "open",
labels: list[str] | None = None,
limit: int = 50,
) -> GiteaResult:
"""List issues in the configured repository.
Args:
state: Filter by state ("open", "closed", "all").
labels: Filter by label names.
limit: Max issues to return.
Returns:
GiteaResult with list of issue dicts.
"""
owner, repo = self._repo.split("/", 1)
params: dict = {"state": state, "limit": limit, "type": "issues"}
if labels:
params["labels"] = ",".join(labels)
return await self._request(
"GET",
f"/api/v1/repos/{owner}/{repo}/issues",
params=params,
)
async def get_issue(self, number: int) -> GiteaResult:
"""Get a single issue by number."""
owner, repo = self._repo.split("/", 1)
return await self._request(
"GET",
f"/api/v1/repos/{owner}/{repo}/issues/{number}",
)
async def add_comment(self, number: int, body: str) -> GiteaResult:
"""Add a comment to an issue."""
owner, repo = self._repo.split("/", 1)
return await self._request(
"POST",
f"/api/v1/repos/{owner}/{repo}/issues/{number}/comments",
json={"body": body},
)
async def close_issue(self, number: int) -> GiteaResult:
"""Close an issue."""
owner, repo = self._repo.split("/", 1)
return await self._request(
"PATCH",
f"/api/v1/repos/{owner}/{repo}/issues/{number}",
json={"state": "closed"},
)
# ── Dedup helper ─────────────────────────────────────────────────────
async def find_duplicate(self, title: str, threshold: float = 0.6) -> dict | None:
"""Check if an open issue with a similar title already exists.
Returns the matching issue dict, or None if no duplicate found.
"""
result = await self.list_issues(state="open", limit=100)
if not result.success or not isinstance(result.data, list):
return None
for issue in result.data:
existing_title = issue.get("title", "")
if _title_similar(title, existing_title, threshold):
return issue
return None
# ── Label helper ─────────────────────────────────────────────────────
async def _resolve_label_ids(self, owner: str, repo: str, label_names: list[str]) -> list[int]:
"""Resolve label names to IDs. Returns empty list on failure."""
result = await self._request(
"GET",
f"/api/v1/repos/{owner}/{repo}/labels",
)
if not result.success or not isinstance(result.data, list):
return []
name_to_id = {label["name"].lower(): label["id"] for label in result.data}
return [name_to_id[name.lower()] for name in label_names if name.lower() in name_to_id]
# ── Status ───────────────────────────────────────────────────────────
def info(self) -> dict:
"""Return a status summary for the dashboard."""
return {
"base_url": self._base_url,
"repo": self._repo,
"available": self.available,
"has_token": bool(self._token),
}
# ── Module-level singleton ──────────────────────────────────────────────────
gitea_hand = GiteaHand()

View File

@@ -228,6 +228,9 @@ class ThinkingEngine:
# Post-hook: distill facts from recent thoughts periodically
self._maybe_distill()
# Post-hook: file Gitea issues for actionable observations
self._maybe_file_issues()
# Post-hook: update MEMORY.md with latest reflection
self._update_memory(thought)
@@ -378,6 +381,88 @@ class ThinkingEngine:
except Exception as exc:
logger.debug("Thought distillation skipped: %s", exc)
def _maybe_file_issues(self) -> None:
"""Every N thoughts, classify recent thoughts and file Gitea issues.
Asks the LLM to review recent thoughts for actionable items —
bugs, broken features, stale state, or improvement opportunities.
Creates Gitea issues for anything worth tracking, with dedup to
avoid flooding.
Only runs when:
- Gitea is enabled and configured
- Thought count is divisible by thinking_issue_every
- LLM extracts at least one actionable item
"""
try:
interval = settings.thinking_issue_every
if interval <= 0:
return
count = self.count_thoughts()
if count == 0 or count % interval != 0:
return
# Check Gitea availability before spending LLM tokens
from infrastructure.hands.gitea import gitea_hand
if not gitea_hand.available:
return
recent = self.get_recent_thoughts(limit=interval)
if len(recent) < interval:
return
thought_text = "\n".join(f"- [{t.seed_type}] {t.content}" for t in reversed(recent))
classify_prompt = (
"You are reviewing your own recent thoughts for actionable items.\n"
"Extract 0-2 items that are CONCRETE bugs, broken features, stale "
"state, or clear improvement opportunities in your own codebase.\n\n"
"Rules:\n"
"- Only include things that could become a real code fix or feature\n"
"- Skip vague reflections, philosophical musings, or repeated themes\n"
"- Each item needs a specific, descriptive title and body\n"
"- Category must be one of: bug, feature, suggestion, maintenance\n\n"
"Return ONLY a JSON array of objects with keys: "
'"title", "body", "category"\n'
"Return [] if nothing is actionable.\n\n"
f"Recent thoughts:\n{thought_text}\n\nJSON array:"
)
raw = self._call_agent(classify_prompt)
if not raw or not raw.strip():
return
import json
# Strip markdown code fences if present
cleaned = raw.strip()
if cleaned.startswith("```"):
cleaned = cleaned.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
items = json.loads(cleaned)
if not isinstance(items, list) or not items:
return
from timmy.tools_gitea import create_gitea_issue
for item in items[:2]: # Safety cap
if not isinstance(item, dict):
continue
title = item.get("title", "").strip()
body = item.get("body", "").strip()
category = item.get("category", "suggestion").strip()
if not title or len(title) < 10:
continue
label = category if category in ("bug", "feature") else ""
result = create_gitea_issue(title=title, body=body, labels=label)
logger.info("Thought→Issue: %s%s", title[:60], result[:80])
except Exception as exc:
logger.debug("Thought issue filing skipped: %s", exc)
def _gather_system_snapshot(self) -> str:
"""Gather lightweight real system state for grounding thoughts in reality.

View File

@@ -43,6 +43,8 @@ SAFE_TOOLS = frozenset(
"check_ollama_health",
"get_memory_status",
"list_swarm_agents",
"create_gitea_issue",
"list_gitea_issues",
}
)

View File

@@ -566,6 +566,16 @@ def create_full_toolkit(base_dir: str | Path | None = None):
except Exception:
logger.debug("Delegation tools not available")
# Gitea issue management — sovereign self-improvement channel
try:
from timmy.tools_gitea import create_gitea_issue, list_gitea_issues
toolkit.register(create_gitea_issue, name="create_gitea_issue")
toolkit.register(list_gitea_issues, name="list_gitea_issues")
logger.info("Gitea issue tools registered")
except Exception:
logger.debug("Gitea tools not available")
return toolkit

209
src/timmy/tools_gitea.py Normal file
View File

@@ -0,0 +1,209 @@
"""Gitea tool functions — Timmy's self-improvement channel.
Provides sync tool wrappers around the async GiteaHand for use as
Agno-registered agent tools. When Timmy notices a bug, stale state,
or improvement opportunity, he can file a Gitea issue directly.
Usage::
from timmy.tools_gitea import create_gitea_issue, list_gitea_issues
# In agent conversation or thinking post-hook:
result = create_gitea_issue(
title="memory_forget tool returns error for valid queries",
body="The memory_forget operation fails silently...",
labels="bug",
)
"""
from __future__ import annotations
import asyncio
import logging
import sqlite3
import uuid
from datetime import datetime
from pathlib import Path
from config import settings
logger = logging.getLogger(__name__)
def _run_async(coro):
"""Run an async coroutine from sync context (tool functions must be sync).
When no event loop is running, uses ``asyncio.run()``.
When called from within an existing loop (e.g. FastAPI), spawns a
new thread to avoid "cannot call asyncio.run from running loop".
"""
try:
asyncio.get_running_loop()
except RuntimeError:
# No running loop — safe to use asyncio.run()
return asyncio.run(coro)
# Already in an async context — run in a new thread
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
return pool.submit(asyncio.run, coro).result(timeout=60)
def _bridge_to_work_order(title: str, body: str, category: str) -> None:
"""Also create a local work order so the dashboard tracks it."""
try:
db_path = Path(settings.repo_root) / "data" / "work_orders.db"
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(db_path))
conn.execute(
"""CREATE TABLE IF NOT EXISTS work_orders (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT DEFAULT '',
priority TEXT DEFAULT 'medium',
category TEXT DEFAULT 'suggestion',
submitter TEXT DEFAULT 'dashboard',
related_files TEXT DEFAULT '',
status TEXT DEFAULT 'submitted',
result TEXT DEFAULT '',
rejection_reason TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now')),
completed_at TEXT
)"""
)
conn.execute(
"INSERT INTO work_orders (id, title, description, category, submitter, created_at) "
"VALUES (?, ?, ?, ?, ?, ?)",
(
str(uuid.uuid4()),
title,
body,
category,
"timmy-thinking",
datetime.utcnow().isoformat(),
),
)
conn.commit()
conn.close()
except Exception as exc:
logger.debug("Work order bridge failed: %s", exc)
def create_gitea_issue(title: str, body: str = "", labels: str = "") -> str:
"""Create an issue in the project's Gitea repository.
Use this when you notice a bug, broken feature, stale state, or
improvement opportunity in your own codebase. Issues are tracked
in Gitea and also bridged to the local work order queue.
Args:
title: Short, descriptive issue title.
body: Detailed description in markdown (symptoms, expected
behaviour, relevant files).
labels: Comma-separated label names (e.g. "bug,thinking-engine").
Returns:
Confirmation with issue URL, or explanation if skipped/failed.
"""
from infrastructure.hands.gitea import gitea_hand
if not gitea_hand.available:
return (
"Gitea integration is not configured. "
"Set GITEA_TOKEN or place a token in ~/.config/gitea/token."
)
label_list = [tag.strip() for tag in labels.split(",") if tag.strip()] if labels else []
async def _create():
# Dedup check — don't file if a similar issue is already open
duplicate = await gitea_hand.find_duplicate(title)
if duplicate:
number = duplicate.get("number", "?")
url = duplicate.get("html_url", "")
return f"Skipped — similar issue already open: #{number} ({url})"
# Append auto-filing signature
full_body = body
if full_body:
full_body += "\n\n"
full_body += "---\n🤖 *Auto-filed by Timmy's thinking engine*"
result = await gitea_hand.create_issue(
title=title,
body=full_body,
labels=label_list or None,
)
if not result.success:
return f"Failed to create issue: {result.error}"
issue_number = result.data.get("number", "?")
issue_url = result.data.get("html_url", "")
# Bridge to local work order system
category = "bug" if "bug" in label_list else "suggestion"
_bridge_to_work_order(title, body, category)
# Emit event if bus is available
try:
from infrastructure.events.bus import emit
asyncio.ensure_future(
emit(
"gitea.issue.created",
source="timmy-tools",
data={
"issue_number": issue_number,
"title": title,
"url": issue_url,
},
)
)
except Exception:
pass
logger.info("Created Gitea issue #%s: %s", issue_number, title)
return f"Created issue #{issue_number}: {title}\n{issue_url}"
return _run_async(_create())
def list_gitea_issues(state: str = "open") -> str:
"""List issues in the project's Gitea repository.
Use this to check what issues are already filed before creating
new ones, or to review the current backlog.
Args:
state: Filter by state — "open" (default), "closed", or "all".
Returns:
Formatted list of issues with number, title, and labels.
"""
from infrastructure.hands.gitea import gitea_hand
if not gitea_hand.available:
return "Gitea integration is not configured."
async def _list():
result = await gitea_hand.list_issues(state=state, limit=25)
if not result.success:
return f"Failed to list issues: {result.error}"
issues = result.data
if not isinstance(issues, list) or not issues:
return f"No {state} issues found."
lines = [f"## {state.capitalize()} Issues ({len(issues)})\n"]
for issue in issues:
number = issue.get("number", "?")
title = issue.get("title", "Untitled")
labels = ", ".join(label.get("name", "") for label in issue.get("labels", []))
label_str = f" [{labels}]" if labels else ""
lines.append(f"- **#{number}** {title}{label_str}")
return "\n".join(lines)
return _run_async(_list())

352
tests/test_hands_gitea.py Normal file
View File

@@ -0,0 +1,352 @@
"""Tests for the Gitea Hand.
Covers:
- GiteaResult dataclass defaults
- Token resolution (settings vs filesystem fallback)
- Availability checks
- Title similarity dedup
- HTTP request handling (mocked)
- Info summary
- Graceful degradation on failure
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
# GiteaResult dataclass
# ---------------------------------------------------------------------------
def test_gitea_result_defaults():
"""GiteaResult should have sensible defaults."""
from infrastructure.hands.gitea import GiteaResult
r = GiteaResult(operation="GET /issues", success=True)
assert r.data == {}
assert r.error == ""
assert r.latency_ms == 0.0
def test_gitea_result_with_data():
"""GiteaResult should carry data."""
from infrastructure.hands.gitea import GiteaResult
r = GiteaResult(
operation="POST /issues",
success=True,
data={"number": 42, "html_url": "http://localhost:3000/issues/42"},
latency_ms=15.3,
)
assert r.data["number"] == 42
assert r.success is True
# ---------------------------------------------------------------------------
# Title similarity
# ---------------------------------------------------------------------------
def test_title_similar_matches():
"""Similar titles should be detected as duplicates."""
from infrastructure.hands.gitea import _title_similar
assert _title_similar("memory_forget tool fails", "Memory_forget tool fails") is True
assert (
_title_similar(
"MEMORY.md not updating after thoughts",
"MEMORY.md hasn't updated since March 8",
)
is True
)
def test_title_similar_rejects_different():
"""Different titles should not match."""
from infrastructure.hands.gitea import _title_similar
assert _title_similar("fix login page CSS", "add dark mode toggle") is False
assert _title_similar("memory bug", "completely different topic") is False
# ---------------------------------------------------------------------------
# Token resolution
# ---------------------------------------------------------------------------
def test_resolve_token_from_settings():
"""Token from settings should be preferred."""
from infrastructure.hands.gitea import _resolve_token
with patch("infrastructure.hands.gitea.settings") as mock_settings:
mock_settings.gitea_token = "from-settings"
assert _resolve_token() == "from-settings"
def test_resolve_token_from_file(tmp_path):
"""Token should fall back to filesystem."""
from infrastructure.hands.gitea import _resolve_token
token_file = tmp_path / "token"
token_file.write_text("from-file\n")
with (
patch("infrastructure.hands.gitea.settings") as mock_settings,
patch("infrastructure.hands.gitea._TOKEN_FILE", token_file),
):
mock_settings.gitea_token = ""
assert _resolve_token() == "from-file"
def test_resolve_token_missing(tmp_path):
"""Empty string when no token available."""
from infrastructure.hands.gitea import _resolve_token
with (
patch("infrastructure.hands.gitea.settings") as mock_settings,
patch("infrastructure.hands.gitea._TOKEN_FILE", tmp_path / "nonexistent"),
):
mock_settings.gitea_token = ""
assert _resolve_token() == ""
# ---------------------------------------------------------------------------
# Availability
# ---------------------------------------------------------------------------
def test_available_when_configured():
"""Hand should be available when token and repo are set."""
from infrastructure.hands.gitea import GiteaHand
with patch("infrastructure.hands.gitea.settings") as mock_settings:
mock_settings.gitea_enabled = True
mock_settings.gitea_url = "http://localhost:3000"
mock_settings.gitea_token = "test-token"
mock_settings.gitea_repo = "owner/repo"
mock_settings.gitea_timeout = 30
hand = GiteaHand(token="test-token")
assert hand.available is True
def test_not_available_without_token():
"""Hand should not be available without token."""
from infrastructure.hands.gitea import GiteaHand
with (
patch("infrastructure.hands.gitea.settings") as mock_settings,
patch("infrastructure.hands.gitea._resolve_token", return_value=""),
):
mock_settings.gitea_enabled = True
mock_settings.gitea_url = "http://localhost:3000"
mock_settings.gitea_token = ""
mock_settings.gitea_repo = "owner/repo"
mock_settings.gitea_timeout = 30
hand = GiteaHand(token="")
assert hand.available is False
def test_not_available_when_disabled():
"""Hand should not be available when disabled."""
from infrastructure.hands.gitea import GiteaHand
with patch("infrastructure.hands.gitea.settings") as mock_settings:
mock_settings.gitea_enabled = False
mock_settings.gitea_url = "http://localhost:3000"
mock_settings.gitea_token = "test-token"
mock_settings.gitea_repo = "owner/repo"
mock_settings.gitea_timeout = 30
hand = GiteaHand(token="test-token")
assert hand.available is False
# ---------------------------------------------------------------------------
# HTTP request handling
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_request_returns_error_when_unavailable():
"""_request should return error when not configured."""
from infrastructure.hands.gitea import GiteaHand
with patch("infrastructure.hands.gitea.settings") as mock_settings:
mock_settings.gitea_enabled = False
mock_settings.gitea_url = "http://localhost:3000"
mock_settings.gitea_token = ""
mock_settings.gitea_repo = "owner/repo"
mock_settings.gitea_timeout = 30
hand = GiteaHand(token="")
result = await hand._request("GET", "/test")
assert result.success is False
assert "not configured" in result.error
@pytest.mark.asyncio
async def test_create_issue_success():
"""create_issue should POST and return issue data."""
from infrastructure.hands.gitea import GiteaHand
mock_response = MagicMock()
mock_response.status_code = 201
mock_response.text = '{"number": 1, "html_url": "http://localhost:3000/issues/1"}'
mock_response.json.return_value = {
"number": 1,
"html_url": "http://localhost:3000/issues/1",
}
mock_client = AsyncMock()
mock_client.request = AsyncMock(return_value=mock_response)
mock_client.is_closed = False
with patch("infrastructure.hands.gitea.settings") as mock_settings:
mock_settings.gitea_enabled = True
mock_settings.gitea_url = "http://localhost:3000"
mock_settings.gitea_token = "test-token"
mock_settings.gitea_repo = "owner/repo"
mock_settings.gitea_timeout = 30
hand = GiteaHand(token="test-token")
hand._client = mock_client
result = await hand.create_issue("Test issue", "Body text")
assert result.success is True
assert result.data["number"] == 1
# Verify the API call
mock_client.request.assert_called_once()
call_args = mock_client.request.call_args
assert call_args[0] == ("POST", "/api/v1/repos/owner/repo/issues")
@pytest.mark.asyncio
async def test_create_issue_handles_http_error():
"""create_issue should handle HTTP errors gracefully."""
from infrastructure.hands.gitea import GiteaHand
mock_response = MagicMock()
mock_response.status_code = 500
mock_response.text = "Internal Server Error"
mock_client = AsyncMock()
mock_client.request = AsyncMock(return_value=mock_response)
mock_client.is_closed = False
with patch("infrastructure.hands.gitea.settings") as mock_settings:
mock_settings.gitea_enabled = True
mock_settings.gitea_url = "http://localhost:3000"
mock_settings.gitea_token = "test-token"
mock_settings.gitea_repo = "owner/repo"
mock_settings.gitea_timeout = 30
hand = GiteaHand(token="test-token")
hand._client = mock_client
result = await hand.create_issue("Test issue")
assert result.success is False
assert "500" in result.error
@pytest.mark.asyncio
async def test_create_issue_handles_connection_error():
"""create_issue should handle connection errors gracefully."""
from infrastructure.hands.gitea import GiteaHand
mock_client = AsyncMock()
mock_client.request = AsyncMock(side_effect=ConnectionError("refused"))
mock_client.is_closed = False
with patch("infrastructure.hands.gitea.settings") as mock_settings:
mock_settings.gitea_enabled = True
mock_settings.gitea_url = "http://localhost:3000"
mock_settings.gitea_token = "test-token"
mock_settings.gitea_repo = "owner/repo"
mock_settings.gitea_timeout = 30
hand = GiteaHand(token="test-token")
hand._client = mock_client
result = await hand.create_issue("Test issue")
assert result.success is False
assert "refused" in result.error
@pytest.mark.asyncio
async def test_find_duplicate_detects_match():
"""find_duplicate should detect similar open issues."""
from infrastructure.hands.gitea import GiteaHand, GiteaResult
existing_issues = [
{"number": 5, "title": "MEMORY.md not updating", "html_url": "http://example.com/5"},
{"number": 6, "title": "Add dark mode", "html_url": "http://example.com/6"},
]
with patch("infrastructure.hands.gitea.settings") as mock_settings:
mock_settings.gitea_enabled = True
mock_settings.gitea_url = "http://localhost:3000"
mock_settings.gitea_token = "test-token"
mock_settings.gitea_repo = "owner/repo"
mock_settings.gitea_timeout = 30
hand = GiteaHand(token="test-token")
# Mock list_issues
hand.list_issues = AsyncMock(
return_value=GiteaResult(
operation="GET",
success=True,
data=existing_issues,
)
)
dup = await hand.find_duplicate("MEMORY.md hasn't updated")
assert dup is not None
assert dup["number"] == 5
@pytest.mark.asyncio
async def test_find_duplicate_no_match():
"""find_duplicate should return None when no similar issue exists."""
from infrastructure.hands.gitea import GiteaHand, GiteaResult
with patch("infrastructure.hands.gitea.settings") as mock_settings:
mock_settings.gitea_enabled = True
mock_settings.gitea_url = "http://localhost:3000"
mock_settings.gitea_token = "test-token"
mock_settings.gitea_repo = "owner/repo"
mock_settings.gitea_timeout = 30
hand = GiteaHand(token="test-token")
hand.list_issues = AsyncMock(
return_value=GiteaResult(
operation="GET",
success=True,
data=[
{"number": 1, "title": "Completely unrelated issue"},
],
)
)
dup = await hand.find_duplicate("memory_forget tool throws error")
assert dup is None
# ---------------------------------------------------------------------------
# Info summary
# ---------------------------------------------------------------------------
def test_info_returns_summary():
"""info() should return a dict with status."""
from infrastructure.hands.gitea import GiteaHand
with patch("infrastructure.hands.gitea.settings") as mock_settings:
mock_settings.gitea_enabled = True
mock_settings.gitea_url = "http://localhost:3000"
mock_settings.gitea_token = "test-token"
mock_settings.gitea_repo = "owner/repo"
mock_settings.gitea_timeout = 30
hand = GiteaHand(token="test-token")
info = hand.info()
assert "base_url" in info
assert "repo" in info
assert "available" in info
assert info["available"] is True

View File

@@ -0,0 +1,215 @@
"""Tests for Gitea tool functions.
Covers:
- create_gitea_issue tool (success, dedup skip, unavailable)
- list_gitea_issues tool (success, empty, unavailable)
- Work order bridge
- Tool safety classification
"""
from unittest.mock import AsyncMock, MagicMock, patch
# ---------------------------------------------------------------------------
# Tool safety classification
# ---------------------------------------------------------------------------
def test_gitea_tools_are_safe():
"""Gitea tools should be classified as safe (no confirmation needed)."""
from timmy.tool_safety import requires_confirmation
assert requires_confirmation("create_gitea_issue") is False
assert requires_confirmation("list_gitea_issues") is False
# ---------------------------------------------------------------------------
# create_gitea_issue
# ---------------------------------------------------------------------------
# All patches target infrastructure.hands.gitea.gitea_hand because
# tools_gitea.py uses deferred imports inside function bodies.
def test_create_issue_unavailable():
"""Should return message when Gitea is not configured."""
mock_hand = MagicMock()
mock_hand.available = False
with patch("infrastructure.hands.gitea.gitea_hand", mock_hand):
from timmy.tools_gitea import create_gitea_issue
result = create_gitea_issue("Test issue", "Body")
assert "not configured" in result
def test_create_issue_success():
"""Should create issue and return confirmation."""
from infrastructure.hands.gitea import GiteaResult
mock_hand = MagicMock()
mock_hand.available = True
mock_hand.find_duplicate = AsyncMock(return_value=None)
mock_hand.create_issue = AsyncMock(
return_value=GiteaResult(
operation="POST",
success=True,
data={"number": 42, "html_url": "http://localhost:3000/issues/42"},
)
)
with (
patch("infrastructure.hands.gitea.gitea_hand", mock_hand),
patch("timmy.tools_gitea._bridge_to_work_order"),
):
from timmy.tools_gitea import create_gitea_issue
result = create_gitea_issue("Test bug", "Bug description", "bug")
assert "#42" in result
assert "Test bug" in result
def test_create_issue_dedup_skip():
"""Should skip when similar issue already exists."""
mock_hand = MagicMock()
mock_hand.available = True
mock_hand.find_duplicate = AsyncMock(
return_value={"number": 10, "html_url": "http://localhost:3000/issues/10"}
)
with patch("infrastructure.hands.gitea.gitea_hand", mock_hand):
from timmy.tools_gitea import create_gitea_issue
result = create_gitea_issue("Existing issue")
assert "Skipped" in result
assert "#10" in result
def test_create_issue_api_failure():
"""Should return error message on API failure."""
from infrastructure.hands.gitea import GiteaResult
mock_hand = MagicMock()
mock_hand.available = True
mock_hand.find_duplicate = AsyncMock(return_value=None)
mock_hand.create_issue = AsyncMock(
return_value=GiteaResult(
operation="POST",
success=False,
error="HTTP 500: Internal Server Error",
)
)
with (
patch("infrastructure.hands.gitea.gitea_hand", mock_hand),
patch("timmy.tools_gitea._bridge_to_work_order"),
):
from timmy.tools_gitea import create_gitea_issue
result = create_gitea_issue("Test issue")
assert "Failed" in result
# ---------------------------------------------------------------------------
# list_gitea_issues
# ---------------------------------------------------------------------------
def test_list_issues_unavailable():
"""Should return message when Gitea is not configured."""
mock_hand = MagicMock()
mock_hand.available = False
with patch("infrastructure.hands.gitea.gitea_hand", mock_hand):
from timmy.tools_gitea import list_gitea_issues
result = list_gitea_issues()
assert "not configured" in result
def test_list_issues_success():
"""Should return formatted issue list."""
from infrastructure.hands.gitea import GiteaResult
mock_hand = MagicMock()
mock_hand.available = True
mock_hand.list_issues = AsyncMock(
return_value=GiteaResult(
operation="GET",
success=True,
data=[
{"number": 1, "title": "Bug fix", "labels": [{"name": "bug"}]},
{"number": 2, "title": "Feature request", "labels": []},
],
)
)
with patch("infrastructure.hands.gitea.gitea_hand", mock_hand):
from timmy.tools_gitea import list_gitea_issues
result = list_gitea_issues("open")
assert "#1" in result
assert "Bug fix" in result
assert "[bug]" in result
assert "#2" in result
def test_list_issues_empty():
"""Should return empty message when no issues."""
from infrastructure.hands.gitea import GiteaResult
mock_hand = MagicMock()
mock_hand.available = True
mock_hand.list_issues = AsyncMock(
return_value=GiteaResult(
operation="GET",
success=True,
data=[],
)
)
with patch("infrastructure.hands.gitea.gitea_hand", mock_hand):
from timmy.tools_gitea import list_gitea_issues
result = list_gitea_issues()
assert "No open issues" in result
# ---------------------------------------------------------------------------
# Work order bridge
# ---------------------------------------------------------------------------
def test_bridge_to_work_order(tmp_path):
"""Should create a work order in the local database."""
from timmy.tools_gitea import _bridge_to_work_order
# Point to a "data" subdir inside tmp_path so the code creates it
data_dir = tmp_path / "data"
data_dir.mkdir()
db_path = data_dir / "work_orders.db"
with patch("timmy.tools_gitea.settings") as mock_settings:
mock_settings.repo_root = str(tmp_path)
_bridge_to_work_order("Test WO", "Description", "bug")
import sqlite3
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
rows = conn.execute("SELECT * FROM work_orders").fetchall()
conn.close()
assert len(rows) == 1
assert rows[0]["title"] == "Test WO"
assert rows[0]["submitter"] == "timmy-thinking"
assert rows[0]["category"] == "bug"
def test_bridge_to_work_order_graceful_failure():
"""Should not raise when bridge fails."""
from timmy.tools_gitea import _bridge_to_work_order
with patch("timmy.tools_gitea.settings") as mock_settings:
mock_settings.repo_root = "/nonexistent/path/that/cannot/exist"
# Should not raise
_bridge_to_work_order("Test", "Desc", "bug")