feat: add bug report ingestion pipeline with Forge dispatch (#99)

Replace the stub `handle_bug_report` handler with a real implementation
that logs a decision trail and dispatches code_fix tasks to Forge for
automated fixing. Add `POST /api/bugs/submit` endpoint and `timmy
ingest-report` CLI command so AI test runners (Comet) can submit
structured bug reports without manual copy-paste.

- POST /api/bugs/submit: accepts JSON reports, creates bug_report tasks
- timmy ingest-report: CLI for file/stdin JSON ingestion with --dry-run
- handle_bug_report: logs decision trail to event_log, dispatches
  code_fix task to Forge with parent_task_id linking back to the bug
- 18 TDD tests covering endpoint, handler, and CLI

Co-authored-by: Alexander Payne <apayne@MM.local>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alexander Whitestone
2026-02-28 21:15:53 -05:00
committed by GitHub
parent 2e92838033
commit 6e67c3b421
4 changed files with 577 additions and 7 deletions

View File

@@ -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()

View File

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

View File

@@ -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()

View File

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