diff --git a/src/dashboard/app.py b/src/dashboard/app.py index 0eb083c..f0d3eb1 100644 --- a/src/dashboard/app.py +++ b/src/dashboard/app.py @@ -160,6 +160,69 @@ async def _thinking_loop() -> None: await asyncio.sleep(settings.thinking_interval_seconds) +def handle_bug_report(task): + """Process a bug report: log the decision and dispatch a fix task to Forge. + + Timmy receives the bug report, decides it needs fixing, and creates + a code_fix task assigned to Forge. Every decision is logged to the + event log so there is a full audit trail of what Timmy decided and why. + """ + from swarm.event_log import EventType, log_event + from swarm.task_queue.models import create_task + + decision = { + "action": "dispatch_to_forge", + "reason": f"Bug report received, dispatching fix to Forge: {task.title}", + "priority": task.priority.value, + "source_task_id": task.id, + } + + # Dispatch a fix task to Forge + try: + fix_task = create_task( + title=f"[Fix] {task.title}", + description=( + f"## Bug Report\n\n{task.description or task.title}\n\n" + f"## Task\n\nImplement a fix for this bug and write a test proving the fix." + ), + assigned_to="forge", + created_by="timmy", + priority=task.priority.value, + task_type="code_fix", + requires_approval=False, + auto_approve=True, + parent_task_id=task.id, + ) + decision["outcome"] = "fix_dispatched" + decision["fix_task_id"] = fix_task.id + except Exception as e: + decision["outcome"] = "dispatch_failed" + decision["error"] = str(e) + + # Log the decision trail to the event log + try: + log_event( + EventType.BUG_REPORT_CREATED, + source="bug_report_handler", + task_id=task.id, + agent_id="timmy", + data=decision, + ) + except Exception: + pass + + # Return structured result (stored in task.result) + if decision.get("fix_task_id"): + return ( + f"Fix dispatched to Forge (task {decision['fix_task_id']}) | " + f"Decision: {decision['reason']}" + ) + return ( + f"Bug tracked internally (dispatch failed) | " + f"Decision: {decision['reason']} | Error: {decision.get('error', 'unknown')}" + ) + + async def _task_processor_loop() -> None: """Background task: Timmy's task queue processor.""" from swarm.task_processor import task_processor @@ -223,9 +286,6 @@ async def _task_processor_loop() -> None: pass return f"Error: {str(e)}" - def handle_bug_report(task): - return f"Bug report acknowledged: {task.title}" - def handle_task_request(task): try: now = datetime.now() diff --git a/src/dashboard/routes/bugs.py b/src/dashboard/routes/bugs.py index 1bd0237..e5e49f7 100644 --- a/src/dashboard/routes/bugs.py +++ b/src/dashboard/routes/bugs.py @@ -1,8 +1,9 @@ """Bug Report routes -- error feedback loop dashboard. -GET /bugs -- Bug reports dashboard page -GET /api/bugs -- List bug reports (JSON) -GET /api/bugs/stats -- Bug report statistics +GET /bugs -- Bug reports dashboard page +GET /api/bugs -- List bug reports (JSON) +GET /api/bugs/stats -- Bug report statistics +POST /api/bugs/submit -- Submit structured bug reports (from AI test runs) """ import logging @@ -13,7 +14,7 @@ from fastapi import APIRouter, Request from fastapi.responses import HTMLResponse, JSONResponse from fastapi.templating import Jinja2Templates -from swarm.task_queue.models import list_tasks +from swarm.task_queue.models import create_task, list_tasks logger = logging.getLogger(__name__) router = APIRouter(tags=["bugs"]) @@ -84,3 +85,77 @@ async def api_bug_stats(): s = bug.status.value stats[s] = stats.get(s, 0) + 1 return {"stats": stats, "total": len(all_bugs)} + + +# ── Bug Report Submission ──────────────────────────────────────────────────── + +# Severity → task priority mapping +_SEVERITY_MAP = {"P0": "urgent", "P1": "high", "P2": "normal"} + + +def _format_bug_description(bug: dict, reporter: str) -> str: + """Format a bug dict into a markdown task description.""" + parts = [ + f"**Reporter:** {reporter}", + f"**Severity:** {bug['severity']}", + "", + "## Problem", + bug["description"], + ] + if bug.get("evidence"): + parts += ["", "## Evidence", bug["evidence"]] + if bug.get("root_cause"): + parts += ["", "## Suspected Root Cause", bug["root_cause"]] + if bug.get("fix_options"): + parts += ["", "## Suggested Fixes"] + for i, fix in enumerate(bug["fix_options"], 1): + parts.append(f"{i}. {fix}") + return "\n".join(parts) + + +@router.post("/api/bugs/submit", response_class=JSONResponse) +async def submit_bugs(request: Request): + """Submit structured bug reports from an AI test run. + + Body: { "reporter": "comet", "bugs": [ { "title", "severity", "description", ... } ] } + """ + try: + body = await request.json() + except Exception: + return JSONResponse(status_code=400, content={"error": "Invalid JSON"}) + + reporter = body.get("reporter", "unknown") + bugs = body.get("bugs", []) + + if not bugs: + return JSONResponse(status_code=400, content={"error": "No bugs provided"}) + + task_ids = [] + for bug in bugs: + title = bug.get("title", "") + severity = bug.get("severity", "") + description = bug.get("description", "") + + if not title or not severity or not description: + return JSONResponse( + status_code=400, + content={"error": f"Bug missing required fields (title, severity, description)"}, + ) + + priority = _SEVERITY_MAP.get(severity, "normal") + + task = create_task( + title=f"[{severity}] {title}", + description=_format_bug_description(bug, reporter), + task_type="bug_report", + assigned_to="timmy", + created_by=reporter, + priority=priority, + requires_approval=False, + auto_approve=True, + ) + task_ids.append(task.id) + + logger.info("Bug report submitted: %d bug(s) from %s", len(task_ids), reporter) + + return {"created": len(task_ids), "task_ids": task_ids} diff --git a/src/timmy/cli.py b/src/timmy/cli.py index 40cc58f..e5e8bc7 100644 --- a/src/timmy/cli.py +++ b/src/timmy/cli.py @@ -121,5 +121,104 @@ def down(): subprocess.run(["docker", "compose", "down"], check=True) +@app.command(name="ingest-report") +def ingest_report( + file: Optional[str] = typer.Argument( + None, help="Path to JSON report file (reads stdin if omitted)", + ), + dry_run: bool = typer.Option( + False, "--dry-run", help="Validate report and show what would be created", + ), +): + """Ingest a structured test report and create bug_report tasks. + + Reads a JSON report with an array of bugs and creates one task per bug + in the internal task queue. The task processor will then attempt to + create GitHub Issues for each. + + Examples: + timmy ingest-report report.json + timmy ingest-report --dry-run report.json + cat report.json | timmy ingest-report + """ + import json + import sys + + # Read input + if file: + try: + with open(file) as f: + raw = f.read() + except FileNotFoundError: + typer.echo(f"File not found: {file}", err=True) + raise typer.Exit(1) + else: + if sys.stdin.isatty(): + typer.echo("Reading from stdin (paste JSON, then Ctrl+D)...") + raw = sys.stdin.read() + + # Parse JSON + try: + data = json.loads(raw) + except json.JSONDecodeError as exc: + typer.echo(f"Invalid JSON: {exc}", err=True) + raise typer.Exit(1) + + reporter = data.get("reporter", "unknown") + bugs = data.get("bugs", []) + + if not bugs: + typer.echo("No bugs in report.", err=True) + raise typer.Exit(1) + + typer.echo(f"Report: {len(bugs)} bug(s) from {reporter}") + + if dry_run: + for bug in bugs: + typer.echo(f" [{bug.get('severity', '?')}] {bug.get('title', '(no title)')}") + typer.echo("(dry run — no tasks created)") + return + + # Import and create tasks + from swarm.task_queue.models import create_task + + severity_map = {"P0": "urgent", "P1": "high", "P2": "normal"} + created = 0 + for bug in bugs: + title = bug.get("title", "") + severity = bug.get("severity", "P2") + description = bug.get("description", "") + + if not title or not description: + typer.echo(f" SKIP (missing title or description)") + continue + + # Format description with extra fields + parts = [f"**Reporter:** {reporter}", f"**Severity:** {severity}", "", description] + if bug.get("evidence"): + parts += ["", "## Evidence", bug["evidence"]] + if bug.get("root_cause"): + parts += ["", "## Root Cause", bug["root_cause"]] + if bug.get("fix_options"): + parts += ["", "## Fix Options"] + for i, fix in enumerate(bug["fix_options"], 1): + parts.append(f"{i}. {fix}") + + task = create_task( + title=f"[{severity}] {title}", + description="\n".join(parts), + task_type="bug_report", + assigned_to="timmy", + created_by=reporter, + priority=severity_map.get(severity, "normal"), + requires_approval=False, + auto_approve=True, + ) + typer.echo(f" OK [{severity}] {title} → {task.id}") + created += 1 + + typer.echo(f"\n{created} task(s) created.") + + def main(): app() diff --git a/tests/dashboard/test_bug_ingestion.py b/tests/dashboard/test_bug_ingestion.py new file mode 100644 index 0000000..334f3a2 --- /dev/null +++ b/tests/dashboard/test_bug_ingestion.py @@ -0,0 +1,336 @@ +"""Tests for bug report ingestion pipeline. + +TDD — these tests are written FIRST, before the implementation. +Tests cover: + 1. POST /api/bugs/submit endpoint + 2. handle_bug_report handler with decision trail + 3. CLI ingest-report command +""" + +import json +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture(autouse=True) +def _isolate_db(tmp_path, monkeypatch): + """Point task_queue and event_log SQLite to a temp directory.""" + db = tmp_path / "swarm.db" + monkeypatch.setattr("swarm.task_queue.models.DB_PATH", db) + monkeypatch.setattr("swarm.event_log.DB_PATH", db) + + +@pytest.fixture +def client(): + from dashboard.app import app + + with TestClient(app) as c: + yield c + + +# ── Sample data ────────────────────────────────────────────────────────── + + +def _sample_report(bugs=None): + """Build a minimal test report.""" + if bugs is None: + bugs = [{"title": "Test bug", "severity": "P1", "description": "Something broke"}] + return {"reporter": "comet", "bugs": bugs} + + +def _sample_bug(**overrides): + """Build a single bug entry with defaults.""" + bug = { + "title": "Widget crashes on click", + "severity": "P0", + "description": "Clicking the save button crashes the app", + } + bug.update(overrides) + return bug + + +# ── Test Group 1: Bug Submission Endpoint ──────────────────────────────── + + +class TestBugSubmitEndpoint: + + def test_submit_single_bug(self, client): + """POST one bug creates one bug_report task.""" + resp = client.post("/api/bugs/submit", json=_sample_report()) + assert resp.status_code == 200 + data = resp.json() + assert data["created"] == 1 + assert len(data["task_ids"]) == 1 + + def test_submit_multiple_bugs(self, client): + """POST 3 bugs creates 3 tasks.""" + bugs = [ + _sample_bug(title="Bug A", severity="P0"), + _sample_bug(title="Bug B", severity="P1"), + _sample_bug(title="Bug C", severity="P2"), + ] + resp = client.post("/api/bugs/submit", json=_sample_report(bugs)) + assert resp.status_code == 200 + data = resp.json() + assert data["created"] == 3 + assert len(data["task_ids"]) == 3 + + def test_submit_maps_severity_to_priority(self, client): + """P0→urgent, P1→high, P2→normal.""" + from swarm.task_queue.models import get_task + + bugs = [ + _sample_bug(title="P0 bug", severity="P0"), + _sample_bug(title="P1 bug", severity="P1"), + _sample_bug(title="P2 bug", severity="P2"), + ] + resp = client.post("/api/bugs/submit", json=_sample_report(bugs)) + data = resp.json() + + tasks = [get_task(tid) for tid in data["task_ids"]] + priorities = {t.title: t.priority.value for t in tasks} + + assert priorities["[P0] P0 bug"] == "urgent" + assert priorities["[P1] P1 bug"] == "high" + assert priorities["[P2] P2 bug"] == "normal" + + def test_submit_formats_description(self, client): + """Evidence, root_cause, fix_options appear in task description.""" + from swarm.task_queue.models import get_task + + bug = _sample_bug( + evidence="Console shows null pointer", + root_cause="Missing null check in handler", + fix_options=["Add guard clause", "Use optional chaining"], + ) + resp = client.post("/api/bugs/submit", json=_sample_report([bug])) + data = resp.json() + + task = get_task(data["task_ids"][0]) + assert "Console shows null pointer" in task.description + assert "Missing null check" in task.description + assert "Add guard clause" in task.description + + def test_submit_sets_task_type(self, client): + """Created tasks have task_type='bug_report'.""" + from swarm.task_queue.models import get_task + + resp = client.post("/api/bugs/submit", json=_sample_report()) + data = resp.json() + + task = get_task(data["task_ids"][0]) + assert task.task_type == "bug_report" + + def test_submit_rejects_empty_bugs(self, client): + """400 when bugs array is empty.""" + resp = client.post("/api/bugs/submit", json=_sample_report(bugs=[])) + assert resp.status_code == 400 + + def test_submit_rejects_missing_fields(self, client): + """400 when required fields are missing.""" + resp = client.post("/api/bugs/submit", json={"reporter": "comet", "bugs": [{"title": "x"}]}) + assert resp.status_code == 400 + + def test_submit_records_reporter(self, client): + """Task created_by reflects the reporter.""" + from swarm.task_queue.models import get_task + + resp = client.post("/api/bugs/submit", json=_sample_report()) + data = resp.json() + + task = get_task(data["task_ids"][0]) + assert task.created_by == "comet" + + +# ── Test Group 2: Bug Report Handler + Decision Trail ──────────────────── + + +class TestBugReportHandler: + + def _make_task(self, **overrides): + """Create a real bug_report task in the queue.""" + from swarm.task_queue.models import create_task + + defaults = { + "title": "[P0] Widget crash", + "description": "The widget crashes when clicked", + "task_type": "bug_report", + "priority": "urgent", + "created_by": "comet", + } + defaults.update(overrides) + return create_task(**defaults) + + def _get_handler(self): + """Import the handle_bug_report function directly.""" + from dashboard.app import handle_bug_report + + return handle_bug_report + + def test_handler_dispatches_fix_to_forge(self): + """Handler creates a code_fix task assigned to Forge.""" + from swarm.task_queue.models import get_task, list_tasks + + handler = self._get_handler() + task = self._make_task() + result = handler(task) + + # Should mention Forge dispatch + assert "Forge" in result + assert "Fix dispatched" in result + + # A code_fix task should exist assigned to forge + all_tasks = list_tasks() + fix_tasks = [t for t in all_tasks if t.task_type == "code_fix" and t.assigned_to == "forge"] + assert len(fix_tasks) == 1 + fix = fix_tasks[0] + assert fix.title == f"[Fix] {task.title}" + assert fix.created_by == "timmy" + assert fix.parent_task_id == task.id + + def test_handler_logs_decision_to_event_log(self): + """Handler logs a decision entry to the event log.""" + handler = self._get_handler() + task = self._make_task() + handler(task) + + from swarm.event_log import EventType, list_events + + events = list_events(event_type=EventType.BUG_REPORT_CREATED, task_id=task.id) + assert len(events) >= 1 + + decision = json.loads(events[0].data) + assert decision["action"] == "dispatch_to_forge" + assert decision["outcome"] == "fix_dispatched" + assert "fix_task_id" in decision + + def test_handler_fix_task_links_to_bug(self): + """Fix task has parent_task_id pointing to the original bug.""" + from swarm.task_queue.models import get_task + + handler = self._get_handler() + task = self._make_task() + handler(task) + + from swarm.event_log import EventType, list_events + + events = list_events(event_type=EventType.BUG_REPORT_CREATED, task_id=task.id) + decision = json.loads(events[0].data) + fix_task = get_task(decision["fix_task_id"]) + + assert fix_task.parent_task_id == task.id + assert fix_task.task_type == "code_fix" + + def test_handler_graceful_fallback_on_dispatch_failure(self): + """When create_task fails, handler still returns a result and logs the error.""" + handler = self._get_handler() + task = self._make_task() # Create task before patching + + # Patch only the handler's dispatch call (re-imported inside the function body) + with patch("swarm.task_queue.models.create_task", side_effect=RuntimeError("db locked")): + result = handler(task) + + assert result is not None + assert "dispatch failed" in result.lower() + + from swarm.event_log import EventType, list_events + + events = list_events(event_type=EventType.BUG_REPORT_CREATED, task_id=task.id) + assert len(events) >= 1 + decision = json.loads(events[0].data) + assert decision["outcome"] == "dispatch_failed" + assert "db locked" in decision.get("error", "") + + def test_handler_decision_includes_reason(self): + """Decision dict always has action, reason, priority, outcome.""" + handler = self._get_handler() + task = self._make_task() + handler(task) + + from swarm.event_log import EventType, list_events + + events = list_events(event_type=EventType.BUG_REPORT_CREATED, task_id=task.id) + decision = json.loads(events[0].data) + + assert "action" in decision + assert "reason" in decision + assert "priority" in decision + assert "outcome" in decision + + def test_handler_result_is_not_just_acknowledged(self): + """task.result should contain structured info, not just 'acknowledged'.""" + handler = self._get_handler() + task = self._make_task() + result = handler(task) + + assert "acknowledged" not in result.lower() or "Decision" in result + + +# ── Test Group 3: CLI Command ──────────────────────────────────────────── + + +class TestIngestReportCLI: + + def test_cli_ingest_from_file(self, tmp_path): + """CLI reads a JSON file and creates tasks.""" + from typer.testing import CliRunner + from timmy.cli import app + + report = _sample_report([ + _sample_bug(title="CLI Bug A", severity="P1"), + _sample_bug(title="CLI Bug B", severity="P2"), + ]) + report_file = tmp_path / "report.json" + report_file.write_text(json.dumps(report)) + + runner = CliRunner() + result = runner.invoke(app, ["ingest-report", str(report_file)]) + + assert result.exit_code == 0 + assert "2" in result.stdout # 2 bugs created + + def test_cli_dry_run(self, tmp_path): + """--dry-run shows bugs but creates nothing.""" + from typer.testing import CliRunner + from timmy.cli import app + from swarm.task_queue.models import list_tasks + + report = _sample_report([_sample_bug(title="Dry Run Bug")]) + report_file = tmp_path / "report.json" + report_file.write_text(json.dumps(report)) + + runner = CliRunner() + result = runner.invoke(app, ["ingest-report", "--dry-run", str(report_file)]) + + assert result.exit_code == 0 + assert "dry run" in result.stdout.lower() + + # No tasks should have been created + tasks = list_tasks() + bug_tasks = [t for t in tasks if t.task_type == "bug_report" and "Dry Run" in t.title] + assert len(bug_tasks) == 0 + + def test_cli_invalid_json(self, tmp_path): + """CLI exits with error on invalid JSON.""" + from typer.testing import CliRunner + from timmy.cli import app + + bad_file = tmp_path / "bad.json" + bad_file.write_text("not json {{{") + + runner = CliRunner() + result = runner.invoke(app, ["ingest-report", str(bad_file)]) + + assert result.exit_code != 0 + + def test_cli_missing_file(self): + """CLI exits with error when file doesn't exist.""" + from typer.testing import CliRunner + from timmy.cli import app + + runner = CliRunner() + result = runner.invoke(app, ["ingest-report", "/nonexistent/file.json"]) + + assert result.exit_code != 0