forked from Rockachopa/Timmy-time-dashboard
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:
committed by
GitHub
parent
2e92838033
commit
6e67c3b421
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
|
||||
336
tests/dashboard/test_bug_ingestion.py
Normal file
336
tests/dashboard/test_bug_ingestion.py
Normal 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
|
||||
Reference in New Issue
Block a user