diff --git a/src/timmy/artifacts.py b/src/timmy/artifacts.py new file mode 100644 index 00000000..4939c4cb --- /dev/null +++ b/src/timmy/artifacts.py @@ -0,0 +1,118 @@ +"""Artifact creation — gives Timmy hands to produce tangible output. + +Timmy can create three kinds of artifacts during conversation: + +1. **Notes** — markdown files saved to ``~/.timmy/notes/`` +2. **Decision log** — append-only log at ``~/.timmy/decisions.md`` +3. **Gitea issues** — filed via the existing MCP integration + +Usage:: + + from timmy.artifacts import save_note, append_decision, create_issue + + path = await save_note("api-design", "# API Design\\n...") + await append_decision("Chose REST over GraphQL for simplicity") + result = await create_issue("Add rate limiting", "We need...") +""" + +from __future__ import annotations + +import logging +import re +from datetime import UTC, datetime +from pathlib import Path + +from config import settings + +logger = logging.getLogger(__name__) + +TIMMY_HOME = Path.home() / ".timmy" +NOTES_DIR = TIMMY_HOME / "notes" +DECISION_LOG = TIMMY_HOME / "decisions.md" + + +def _slugify(text: str) -> str: + """Turn a title into a filesystem-safe slug.""" + slug = text.lower().strip() + slug = re.sub(r"[^a-z0-9]+", "-", slug) + return slug.strip("-")[:80] or "untitled" + + +async def save_note(title: str, content: str) -> Path: + """Save a markdown note to ``~/.timmy/notes/.md``. + + Args: + title: Human-readable title (slugified for filename). + content: Markdown body. + + Returns: + Path to the written file. + """ + import asyncio + + NOTES_DIR.mkdir(parents=True, exist_ok=True) + slug = _slugify(title) + ts = datetime.now(UTC).strftime("%Y%m%d-%H%M%S") + path = NOTES_DIR / f"{ts}-{slug}.md" + + header = f"# {title}\n\n_Saved by Timmy at {ts}_\n\n" + full = header + content + + await asyncio.to_thread(path.write_text, full, encoding="utf-8") + logger.info("Note saved: %s", path) + return path + + +async def append_decision(text: str) -> Path: + """Append an entry to the running decision log. + + Args: + text: Decision text (one or more lines). + + Returns: + Path to the decision log file. + """ + import asyncio + + TIMMY_HOME.mkdir(parents=True, exist_ok=True) + ts = datetime.now(UTC).strftime("%Y-%m-%d %H:%M UTC") + entry = f"\n## {ts}\n\n{text}\n" + + def _append(): + if not DECISION_LOG.exists(): + DECISION_LOG.write_text( + "# Timmy Decision Log\n\n_Architectural decisions captured during conversation._\n", + encoding="utf-8", + ) + with DECISION_LOG.open("a", encoding="utf-8") as f: + f.write(entry) + + await asyncio.to_thread(_append) + logger.info("Decision logged: %.60s", text) + return DECISION_LOG + + +async def create_issue(title: str, body: str = "", labels: str = "") -> str: + """Create a Gitea issue from conversation. + + Thin wrapper around :func:`timmy.mcp_tools.create_gitea_issue_via_mcp` + with graceful degradation when Gitea is unavailable. + + Returns: + Confirmation string or error explanation. + """ + if not settings.gitea_enabled or not settings.gitea_token: + logger.warning("Gitea not configured — saving issue as note instead") + fallback = f"## Issue: {title}\n\n{body}" + path = await save_note(f"issue-{title}", fallback) + return f"Gitea unavailable — saved as note: {path}" + + try: + from timmy.mcp_tools import create_gitea_issue_via_mcp + + return await create_gitea_issue_via_mcp(title, body, labels) + except Exception as exc: + logger.warning("Issue creation failed, saving as note: %s", exc) + fallback = f"## Issue: {title}\n\n{body}\n\n_Filed locally — Gitea error: {exc}_" + path = await save_note(f"issue-{title}", fallback) + return f"Gitea error — saved as note: {path}" diff --git a/tests/timmy/test_artifacts.py b/tests/timmy/test_artifacts.py new file mode 100644 index 00000000..6d34b35d --- /dev/null +++ b/tests/timmy/test_artifacts.py @@ -0,0 +1,121 @@ +"""Tests for timmy.artifacts — note saving, decision log, issue creation.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture(autouse=True) +def _temp_timmy_home(tmp_path, monkeypatch): + """Redirect ~/.timmy to a temp directory for all tests.""" + monkeypatch.setattr("timmy.artifacts.TIMMY_HOME", tmp_path) + monkeypatch.setattr("timmy.artifacts.NOTES_DIR", tmp_path / "notes") + monkeypatch.setattr("timmy.artifacts.DECISION_LOG", tmp_path / "decisions.md") + + +class TestSlugify: + def test_basic(self): + from timmy.artifacts import _slugify + + assert _slugify("API Design Notes") == "api-design-notes" + + def test_special_chars(self): + from timmy.artifacts import _slugify + + assert _slugify("foo/bar: baz!") == "foo-bar-baz" + + def test_empty(self): + from timmy.artifacts import _slugify + + assert _slugify("") == "untitled" + + def test_truncation(self): + from timmy.artifacts import _slugify + + result = _slugify("a" * 200) + assert len(result) <= 80 + + +class TestSaveNote: + async def test_creates_file(self, tmp_path): + from timmy.artifacts import save_note + + path = await save_note("Test Note", "Hello world") + + assert path.exists() + text = path.read_text() + assert "# Test Note" in text + assert "Hello world" in text + assert path.parent == tmp_path / "notes" + + async def test_filename_has_slug(self, tmp_path): + from timmy.artifacts import save_note + + path = await save_note("My Cool Idea", "content") + assert "my-cool-idea" in path.name + assert path.suffix == ".md" + + +class TestAppendDecision: + async def test_creates_log_if_missing(self, tmp_path): + from timmy.artifacts import append_decision + + path = await append_decision("Use REST over GraphQL") + + assert path.exists() + text = path.read_text() + assert "# Timmy Decision Log" in text + assert "Use REST over GraphQL" in text + + async def test_appends_to_existing(self, tmp_path): + from timmy.artifacts import append_decision + + await append_decision("First decision") + await append_decision("Second decision") + + text = (tmp_path / "decisions.md").read_text() + assert "First decision" in text + assert "Second decision" in text + + +class TestCreateIssue: + async def test_fallback_when_gitea_disabled(self, tmp_path, monkeypatch): + from timmy.artifacts import create_issue + + monkeypatch.setattr("timmy.artifacts.settings.gitea_enabled", False) + + result = await create_issue("Bug title", "Bug body") + + assert "saved as note" in result.lower() + notes = list((tmp_path / "notes").glob("*.md")) + assert len(notes) == 1 + assert "Bug title" in notes[0].read_text() + + async def test_delegates_to_mcp(self, monkeypatch): + from timmy.artifacts import create_issue + + monkeypatch.setattr("timmy.artifacts.settings.gitea_enabled", True) + monkeypatch.setattr("timmy.artifacts.settings.gitea_token", "fake-token") + + mock_mcp = AsyncMock(return_value="Created issue: Test") + with patch("timmy.mcp_tools.create_gitea_issue_via_mcp", mock_mcp): + result = await create_issue("Test", "body", "bug") + + assert "Created issue" in result + mock_mcp.assert_awaited_once_with("Test", "body", "bug") + + async def test_mcp_error_falls_back_to_note(self, tmp_path, monkeypatch): + from timmy.artifacts import create_issue + + monkeypatch.setattr("timmy.artifacts.settings.gitea_enabled", True) + monkeypatch.setattr("timmy.artifacts.settings.gitea_token", "fake-token") + + mock_mcp = AsyncMock(side_effect=RuntimeError("connection refused")) + with patch("timmy.mcp_tools.create_gitea_issue_via_mcp", mock_mcp): + result = await create_issue("Broken thing", "details") + + assert "saved as note" in result.lower() + notes = list((tmp_path / "notes").glob("*.md")) + assert len(notes) == 1