"""Unit tests for timmy.backlog_triage — autonomous backlog triage loop.""" from datetime import UTC, datetime from unittest.mock import AsyncMock, MagicMock, patch import pytest from timmy.backlog_triage import ( AGENT_CLAUDE, AGENT_KIMI, KIMI_READY_LABEL, OWNER_LOGIN, READY_THRESHOLD, BacklogTriageLoop, ScoredIssue, TriageCycleResult, TriageDecision, _build_audit_comment, _build_daily_summary, _extract_tags, _score_acceptance, _score_alignment, _score_scope, decide, score_issue, ) # ── Fixtures ───────────────────────────────────────────────────────────────── def _make_raw_issue( number: int = 1, title: str = "Fix the login bug", body: str = "## Problem\nLogin fails on empty password.\n\n## Steps\nassert response == 200", labels: list | None = None, assignees: list | None = None, created_at: str = "2026-03-20T10:00:00Z", ) -> dict: return { "number": number, "title": title, "body": body, "labels": [{"name": lbl} for lbl in (labels or [])], "assignees": [{"login": a} for a in (assignees or [])], "created_at": created_at, } def _make_scored_issue( number: int = 1, title: str = "Fix login bug", issue_type: str = "bug", score: int = 7, ready: bool = True, is_p0: bool = True, is_blocked: bool = False, assignees: list | None = None, tags: set | None = None, labels: list | None = None, age_days: int = 3, ) -> ScoredIssue: return ScoredIssue( number=number, title=title, body="", labels=labels or [], tags=tags or {"bug"}, assignees=assignees or [], created_at=datetime.now(UTC), issue_type=issue_type, score=score, scope=2, acceptance=2, alignment=3, ready=ready, age_days=age_days, is_p0=is_p0, is_blocked=is_blocked, ) # ── _extract_tags ───────────────────────────────────────────────────────────── class TestExtractTags: def test_bracket_tags_in_title(self): tags = _extract_tags("[Bug] Login fails", []) assert "bug" in tags def test_multiple_brackets(self): tags = _extract_tags("[Bug][P0] Crash on startup", []) assert "bug" in tags assert "p0" in tags def test_label_names(self): tags = _extract_tags("Fix thing", ["security", "hotfix"]) assert "security" in tags assert "hotfix" in tags def test_labels_lowercased(self): tags = _extract_tags("Title", ["Bug", "FEATURE"]) assert "bug" in tags assert "feature" in tags def test_empty_inputs(self): tags = _extract_tags("", []) assert tags == set() # ── Scoring functions ───────────────────────────────────────────────────────── class TestScoreScope: def test_file_reference_adds_point(self): score = _score_scope("Fix auth", "Edit src/timmy/auth.py", set()) assert score >= 1 def test_function_reference_adds_point(self): score = _score_scope("Fix auth", "def validate_token()", set()) assert score >= 1 def test_short_title_adds_point(self): score = _score_scope("Short title", "", set()) assert score >= 1 def test_meta_tag_penalizes(self): score = _score_scope("Discussion about philosophy", "long body " * 5, {"philosophy"}) assert score <= 1 def test_max_score_3(self): score = _score_scope("Fix auth", "src/auth.py\ndef login()", set()) assert score <= 3 class TestScoreAcceptance: def test_acceptance_keywords(self): body = "should return 200\nmust pass tests\nexpect response" score = _score_acceptance("Title", body, set()) assert score >= 2 def test_test_reference_adds_point(self): score = _score_acceptance("Title", "Run tox -e unit", set()) assert score >= 1 def test_structured_sections(self): body = "## Problem\nX\n## Solution\nY" score = _score_acceptance("Title", body, set()) assert score >= 1 def test_meta_tag_penalizes(self): score = _score_acceptance("Title", "should do something", {"philosophy"}) # still counts but penalized assert score <= 2 def test_empty_body(self): score = _score_acceptance("Title", "", set()) assert score == 0 class TestScoreAlignment: def test_bug_tags_score_max(self): assert _score_alignment("", "", {"bug"}) == 3 def test_hotfix_tag_max(self): assert _score_alignment("", "", {"hotfix"}) == 3 def test_refactor_tag(self): score = _score_alignment("", "", {"refactor"}) assert score >= 2 def test_feature_tag(self): score = _score_alignment("", "", {"feature"}) assert score >= 2 def test_meta_tags_zero(self): assert _score_alignment("", "", {"philosophy"}) == 0 def test_loop_generated_bonus(self): score = _score_alignment("", "", {"loop-generated"}) assert score >= 1 # ── score_issue ─────────────────────────────────────────────────────────────── class TestScoreIssue: def test_bug_issue_classified_correctly(self): raw = _make_raw_issue(labels=["bug"], title="[Bug] Crash on startup") scored = score_issue(raw) assert scored.issue_type == "bug" assert scored.is_p0 is True def test_feature_issue_classified(self): raw = _make_raw_issue(labels=["feature"], title="Add voice support") scored = score_issue(raw) assert scored.issue_type == "feature" def test_philosophy_issue_classified(self): raw = _make_raw_issue(labels=["philosophy"], title="[Philosophy] Should Timmy sleep?") scored = score_issue(raw) assert scored.issue_type == "philosophy" def test_research_issue_classified(self): raw = _make_raw_issue(labels=["research"], title="Investigate model options") scored = score_issue(raw) assert scored.issue_type == "research" def test_ready_flag_set_when_score_high(self): body = ( "## Problem\nX breaks.\n## Solution\nFix src/timmy/agent.py def run()\n" "should return True\nmust pass tox -e unit" ) raw = _make_raw_issue(labels=["bug"], body=body) scored = score_issue(raw) assert scored.score >= READY_THRESHOLD assert scored.ready is True def test_is_blocked_detected_in_body(self): raw = _make_raw_issue(body="This is blocked by issue #50") scored = score_issue(raw) assert scored.is_blocked is True def test_is_blocked_detected_in_title(self): raw = _make_raw_issue(title="[blocking] Cannot proceed") scored = score_issue(raw) # "blocking" in brackets becomes a tag assert scored.is_blocked is True def test_unassigned_when_no_assignees(self): raw = _make_raw_issue(assignees=[]) scored = score_issue(raw) assert scored.is_unassigned is True def test_assigned_when_has_assignee(self): raw = _make_raw_issue(assignees=["claude"]) scored = score_issue(raw) assert scored.is_unassigned is False def test_age_days_computed(self): old_ts = "2026-01-01T00:00:00Z" raw = _make_raw_issue(created_at=old_ts) scored = score_issue(raw) assert scored.age_days > 0 def test_needs_kimi_for_research_label(self): raw = _make_raw_issue(labels=["kimi-ready"]) scored = score_issue(raw) assert scored.needs_kimi is True # ── decide ──────────────────────────────────────────────────────────────────── class TestDecide: def test_philosophy_skipped(self): issue = _make_scored_issue(issue_type="philosophy", tags={"philosophy"}) d = decide(issue) assert d.action == "skip" assert "philosophy" in d.reason.lower() def test_assigned_issue_skipped(self): issue = _make_scored_issue(assignees=["perplexity"]) d = decide(issue) assert d.action == "skip" assert "assigned" in d.reason.lower() def test_low_score_skipped(self): issue = _make_scored_issue(score=2, ready=False) d = decide(issue) assert d.action == "skip" assert "threshold" in d.reason.lower() def test_blocked_issue_flagged_for_alex(self): issue = _make_scored_issue(is_blocked=True) d = decide(issue) assert d.action == "flag_alex" assert d.agent == OWNER_LOGIN def test_research_issue_assigned_kimi(self): issue = _make_scored_issue( issue_type="research", tags={"research"}, is_p0=False, is_blocked=False, ) d = decide(issue) assert d.action == "assign_kimi" assert d.agent == AGENT_KIMI def test_kimi_ready_label_assigns_kimi(self): issue = _make_scored_issue( issue_type="unknown", tags={"kimi-ready"}, labels=["kimi-ready"], is_p0=False, is_blocked=False, ) d = decide(issue) assert d.action == "assign_kimi" def test_p0_bug_assigns_claude(self): issue = _make_scored_issue(issue_type="bug", is_p0=True, is_blocked=False) d = decide(issue) assert d.action == "assign_claude" assert d.agent == AGENT_CLAUDE def test_ready_feature_assigns_claude(self): issue = _make_scored_issue( issue_type="feature", is_p0=False, is_blocked=False, tags={"feature"}, ) d = decide(issue) assert d.action == "assign_claude" assert d.agent == AGENT_CLAUDE def test_decision_has_reason(self): issue = _make_scored_issue() d = decide(issue) assert len(d.reason) > 10 # ── _build_audit_comment ────────────────────────────────────────────────────── class TestBuildAuditComment: def test_contains_timmy_triage_header(self): d = TriageDecision(42, "assign_claude", "High priority bug", agent=AGENT_CLAUDE) comment = _build_audit_comment(d) assert "Timmy Triage" in comment def test_contains_issue_reason(self): d = TriageDecision(42, "assign_claude", "Urgent P0 bug", agent=AGENT_CLAUDE) comment = _build_audit_comment(d) assert "Urgent P0 bug" in comment def test_assign_claude_mentions_agent(self): d = TriageDecision(42, "assign_claude", "reason", agent=AGENT_CLAUDE) comment = _build_audit_comment(d) assert AGENT_CLAUDE in comment def test_assign_kimi_mentions_label(self): d = TriageDecision(42, "assign_kimi", "reason", agent=AGENT_KIMI) comment = _build_audit_comment(d) assert KIMI_READY_LABEL in comment def test_flag_alex_mentions_owner(self): d = TriageDecision(42, "flag_alex", "blocked", agent=OWNER_LOGIN) comment = _build_audit_comment(d) assert OWNER_LOGIN in comment def test_contains_override_note(self): d = TriageDecision(42, "assign_claude", "reason", agent=AGENT_CLAUDE) comment = _build_audit_comment(d) assert "override" in comment.lower() # ── _build_daily_summary ────────────────────────────────────────────────────── class TestBuildDailySummary: def _make_result(self, decisions=None) -> TriageCycleResult: return TriageCycleResult( timestamp=datetime.now(UTC).isoformat(), total_open=10, scored=8, ready=5, decisions=decisions or [], ) def test_contains_open_count(self): result = self._make_result() scored = [_make_scored_issue(number=i, ready=True, score=6) for i in range(1, 4)] summary = _build_daily_summary(result, scored) assert "10" in summary # total_open def test_contains_ready_count(self): result = self._make_result() summary = _build_daily_summary(result, []) assert "5" in summary def test_actions_taken_section(self): decisions = [ TriageDecision(1, "assign_claude", "P0 bug", agent="claude", executed=True), ] result = self._make_result(decisions=decisions) summary = _build_daily_summary(result, []) assert "Actions Taken" in summary assert "#1" in summary def test_top_issues_listed(self): scored = [_make_scored_issue(number=99, ready=True, score=8)] result = self._make_result() summary = _build_daily_summary(result, scored) assert "#99" in summary def test_footer_present(self): summary = _build_daily_summary(self._make_result(), []) assert "Auto-generated" in summary # ── BacklogTriageLoop ───────────────────────────────────────────────────────── class TestBacklogTriageLoop: def test_default_interval_from_settings(self): loop = BacklogTriageLoop() from config import settings assert loop._interval == float(settings.backlog_triage_interval_seconds) def test_custom_interval(self): loop = BacklogTriageLoop(interval=300) assert loop._interval == 300.0 def test_dry_run_default(self): loop = BacklogTriageLoop(dry_run=True) assert loop._dry_run is True def test_not_running_initially(self): loop = BacklogTriageLoop() assert loop.is_running is False def test_stop_sets_running_false(self): loop = BacklogTriageLoop() loop._running = True loop.stop() assert loop._running is False def test_cycle_count_starts_zero(self): loop = BacklogTriageLoop() assert loop.cycle_count == 0 @pytest.mark.asyncio async def test_run_once_skips_when_no_gitea_token(self): loop = BacklogTriageLoop() mock_settings = MagicMock() mock_settings.gitea_enabled = True mock_settings.gitea_token = "" mock_settings.backlog_triage_interval_seconds = 900 mock_settings.backlog_triage_dry_run = False mock_settings.backlog_triage_daily_summary = False with patch("timmy.backlog_triage.settings", mock_settings): result = await loop.run_once() assert result.total_open == 0 @pytest.mark.asyncio async def test_run_once_dry_run_no_api_writes(self): """In dry_run mode, decisions are made but no Gitea API writes happen.""" loop = BacklogTriageLoop(dry_run=True, daily_summary=False) raw_issues = [ _make_raw_issue( number=10, title="Fix crash", labels=["bug"], body=( "## Problem\nCrash on login.\n## Solution\nFix src/auth.py " "def login()\nshould return 200\nmust pass tox tests" ), ) ] mock_settings = MagicMock() mock_settings.gitea_enabled = True mock_settings.gitea_token = "fake-token" mock_settings.gitea_repo = "owner/repo" mock_settings.gitea_url = "http://gitea.local" mock_settings.backlog_triage_interval_seconds = 900 mock_settings.backlog_triage_dry_run = True mock_settings.backlog_triage_daily_summary = False mock_client = AsyncMock() mock_client.get.return_value = MagicMock( status_code=200, json=MagicMock(return_value=raw_issues) ) mock_ctx = AsyncMock() mock_ctx.__aenter__.return_value = mock_client mock_ctx.__aexit__.return_value = False with ( patch("timmy.backlog_triage.settings", mock_settings), patch("httpx.AsyncClient", return_value=mock_ctx), ): result = await loop.run_once() # No POST/PATCH calls in dry run mock_client.post.assert_not_called() mock_client.patch.assert_not_called() assert result.total_open == 1 assert loop.cycle_count == 1 assert len(loop.history) == 1 @pytest.mark.asyncio async def test_run_once_assigns_unassigned_bug(self): """Unassigned ready bug should be assigned to Claude with audit comment.""" loop = BacklogTriageLoop(dry_run=False, daily_summary=False) body = ( "## Problem\nCrash on login.\n## Solution\nFix src/auth.py " "def login()\nshould return 200\nmust pass tox tests" ) raw_issues = [_make_raw_issue(number=5, title="Fix crash", labels=["bug"], body=body)] mock_settings = MagicMock() mock_settings.gitea_enabled = True mock_settings.gitea_token = "fake-token" mock_settings.gitea_repo = "owner/repo" mock_settings.gitea_url = "http://gitea.local" mock_settings.backlog_triage_interval_seconds = 900 mock_settings.backlog_triage_dry_run = False mock_settings.backlog_triage_daily_summary = False # GET /issues returns our issue get_issues_resp = MagicMock(status_code=200) get_issues_resp.json.return_value = raw_issues # POST /comments returns success comment_resp = MagicMock(status_code=201) comment_resp.json.return_value = {"id": 1} # PATCH /issues/{n} (assign) returns success assign_resp = MagicMock(status_code=200) assign_resp.json.return_value = {"number": 5} mock_client = AsyncMock() mock_client.get.return_value = get_issues_resp mock_client.post.return_value = comment_resp mock_client.patch.return_value = assign_resp mock_ctx = AsyncMock() mock_ctx.__aenter__.return_value = mock_client mock_ctx.__aexit__.return_value = False with ( patch("timmy.backlog_triage.settings", mock_settings), patch("httpx.AsyncClient", return_value=mock_ctx), patch("asyncio.sleep", new_callable=AsyncMock), ): result = await loop.run_once() assert result.total_open == 1 # Comment should have been posted mock_client.post.assert_called() # Assign should have been called (PATCH) mock_client.patch.assert_called() @pytest.mark.asyncio async def test_run_once_skips_already_assigned(self): """Issues already assigned should not be acted upon.""" loop = BacklogTriageLoop(dry_run=False, daily_summary=False) raw_issues = [ _make_raw_issue( number=3, labels=["bug"], assignees=["perplexity"], body="## Problem\nX\nmust pass tox\nshould return 200 at least 3 times", ) ] mock_settings = MagicMock() mock_settings.gitea_enabled = True mock_settings.gitea_token = "tok" mock_settings.gitea_repo = "owner/repo" mock_settings.gitea_url = "http://gitea.local" mock_settings.backlog_triage_interval_seconds = 900 mock_settings.backlog_triage_dry_run = False mock_settings.backlog_triage_daily_summary = False get_resp = MagicMock(status_code=200) get_resp.json.return_value = raw_issues mock_client = AsyncMock() mock_client.get.return_value = get_resp mock_ctx = AsyncMock() mock_ctx.__aenter__.return_value = mock_client mock_ctx.__aexit__.return_value = False with ( patch("timmy.backlog_triage.settings", mock_settings), patch("httpx.AsyncClient", return_value=mock_ctx), ): result = await loop.run_once() # No writes for already-assigned issue mock_client.post.assert_not_called() mock_client.patch.assert_not_called() assert result.decisions[0].action == "skip" # ── ScoredIssue properties ──────────────────────────────────────────────────── class TestScoredIssueProperties: def test_is_unassigned_true_when_no_assignees(self): issue = _make_scored_issue(assignees=[]) assert issue.is_unassigned is True def test_is_unassigned_false_when_assigned(self): issue = _make_scored_issue(assignees=["claude"]) assert issue.is_unassigned is False def test_needs_kimi_for_research_tag(self): issue = _make_scored_issue(tags={"research"}) assert issue.needs_kimi is True def test_needs_kimi_for_kimi_ready_label(self): issue = _make_scored_issue(labels=["kimi-ready"], tags=set()) assert issue.needs_kimi is True def test_needs_kimi_false_for_bug(self): issue = _make_scored_issue(tags={"bug"}, labels=[]) assert issue.needs_kimi is False