"""Unit tests for timmy.vassal.backlog — triage and fetch helpers.""" from __future__ import annotations import pytest from timmy.vassal.backlog import ( AgentTarget, _choose_agent, _extract_labels, _score_priority, triage_issues, ) # --------------------------------------------------------------------------- # _extract_labels # --------------------------------------------------------------------------- def test_extract_labels_empty(): assert _extract_labels({}) == [] def test_extract_labels_normalises_case(): issue = {"labels": [{"name": "HIGH"}, {"name": "Feature"}]} assert _extract_labels(issue) == ["high", "feature"] # --------------------------------------------------------------------------- # _score_priority # --------------------------------------------------------------------------- def test_priority_urgent(): assert _score_priority(["urgent"], []) == 100 def test_priority_high(): assert _score_priority(["high"], []) == 75 def test_priority_normal_default(): assert _score_priority([], []) == 50 def test_priority_assigned_penalised(): # already assigned → subtract 20 score = _score_priority([], ["some-agent"]) assert score == 30 def test_priority_label_substring_match(): # "critical" contains "critical" → 90 assert _score_priority(["critical-bug"], []) == 90 # --------------------------------------------------------------------------- # _choose_agent # --------------------------------------------------------------------------- def test_choose_claude_for_architecture(): target, rationale = _choose_agent("Refactor auth middleware", "", []) assert target == AgentTarget.CLAUDE assert "complex" in rationale or "high-complexity" in rationale def test_choose_kimi_for_research(): target, rationale = _choose_agent("Deep research on embedding models", "", []) assert target == AgentTarget.KIMI def test_choose_timmy_for_docs(): target, rationale = _choose_agent("Update documentation for CLI", "", []) assert target == AgentTarget.TIMMY def test_choose_timmy_default(): target, rationale = _choose_agent("Fix typo in README", "simple change", []) # Could route to timmy (docs/trivial) or default — either is valid assert isinstance(target, AgentTarget) def test_choose_agent_label_wins(): # "security" label → Claude target, _ = _choose_agent("Login page", "", ["security"]) assert target == AgentTarget.CLAUDE # --------------------------------------------------------------------------- # triage_issues # --------------------------------------------------------------------------- def _make_raw_issue( number: int, title: str, body: str = "", labels: list[str] | None = None, assignees: list[str] | None = None, ) -> dict: return { "number": number, "title": title, "body": body, "labels": [{"name": lbl} for lbl in (labels or [])], "assignees": [{"login": a} for a in (assignees or [])], "html_url": f"http://gitea/issues/{number}", } def test_triage_returns_sorted_by_priority(): issues = [ _make_raw_issue(1, "Routine docs update", labels=["docs"]), _make_raw_issue(2, "Critical security issue", labels=["urgent", "security"]), _make_raw_issue(3, "Normal feature", labels=[]), ] triaged = triage_issues(issues) # Highest priority first assert triaged[0].number == 2 assert triaged[0].priority_score == 100 # urgent label def test_triage_prs_can_be_included(): # triage_issues does not filter PRs — that's fetch_open_issues's job issues = [_make_raw_issue(10, "A PR-like issue")] triaged = triage_issues(issues) assert len(triaged) == 1 def test_triage_empty(): assert triage_issues([]) == [] def test_triage_routing(): issues = [ _make_raw_issue(1, "Benchmark LLM backends", body="comprehensive analysis"), _make_raw_issue(2, "Refactor agent loader", body="architecture change"), _make_raw_issue(3, "Fix typo in docs", labels=["docs"]), ] triaged = {i.number: i for i in triage_issues(issues)} assert triaged[1].agent_target == AgentTarget.KIMI assert triaged[2].agent_target == AgentTarget.CLAUDE assert triaged[3].agent_target == AgentTarget.TIMMY def test_triage_preserves_url(): issues = [_make_raw_issue(42, "Some issue")] triaged = triage_issues(issues) assert triaged[0].url == "http://gitea/issues/42" # --------------------------------------------------------------------------- # fetch_open_issues — no Gitea available in unit tests # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_fetch_open_issues_returns_empty_when_disabled(monkeypatch): """When Gitea is disabled, fetch returns [] without raising.""" import timmy.vassal.backlog as bl # Patch settings class FakeSettings: gitea_enabled = False gitea_token = "" gitea_url = "http://localhost:3000" gitea_repo = "owner/repo" monkeypatch.setattr(bl, "logger", bl.logger) # no-op just to confirm import # We can't easily monkeypatch `from config import settings` inside the function, # so test the no-token path via environment import os original = os.environ.pop("GITEA_TOKEN", None) try: result = await bl.fetch_open_issues() # Should return [] gracefully (no token configured by default in test env) assert isinstance(result, list) finally: if original is not None: os.environ["GITEA_TOKEN"] = original