feat: add kanban board monitoring and Matrix notification bridge
- KanbanDB: read-only SQLite access to kanban state (tasks, events, comments, subs) - KanbanMonitor: async poll loop that watches kanban DB, formats events, sends via Matrix - format_event: human-readable event formatting (created, claimed, completed, etc.) - CLI: matrix-hermes kanban monitor/subscribe/unsubscribe/status commands - 29 tests covering DB, event formatting, monitor lifecycle, and notifications - Version bump 0.3.0 → 0.4.0
This commit is contained in:
@@ -0,0 +1,478 @@
|
||||
"""Tests for matrix_hermes.kanban — kanban board monitoring and notifications."""
|
||||
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from matrix_hermes.kanban import (
|
||||
KanbanDB,
|
||||
KanbanEvent,
|
||||
KanbanMonitor,
|
||||
KanbanSubscription,
|
||||
KanbanTask,
|
||||
format_event,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def kanban_db_path():
|
||||
"""Create a temporary kanban database with test schema and data."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
||||
db_path = f.name
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.executescript("""
|
||||
CREATE TABLE tasks (
|
||||
id TEXT PRIMARY KEY, title TEXT NOT NULL, assignee TEXT,
|
||||
status TEXT NOT NULL, priority INTEGER DEFAULT 0,
|
||||
created_by TEXT, created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE task_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id TEXT NOT NULL, run_id INTEGER,
|
||||
kind TEXT NOT NULL, payload TEXT, created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE task_comments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id TEXT NOT NULL, author TEXT NOT NULL,
|
||||
body TEXT NOT NULL, created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE kanban_notify_subs (
|
||||
task_id TEXT NOT NULL, platform TEXT NOT NULL,
|
||||
chat_id TEXT NOT NULL, thread_id TEXT NOT NULL DEFAULT '',
|
||||
user_id TEXT, notifier_profile TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_event_id INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (task_id, platform, chat_id, thread_id)
|
||||
);
|
||||
|
||||
-- Test tasks
|
||||
INSERT INTO tasks VALUES
|
||||
('t_test1', 'Test task one', 'coder', 'running', 1, 'user', 1000),
|
||||
('t_test2', 'Test task two', 'reviewer', 'todo', 0, 'user', 1000);
|
||||
|
||||
-- Test events
|
||||
INSERT INTO task_events (task_id, kind, payload, created_at) VALUES
|
||||
('t_test1', 'created', '{"assignee":"coder","status":"todo"}', 1000),
|
||||
('t_test1', 'claimed', '{"lock":"coder:123"}', 1005),
|
||||
('t_test1', 'spawned', '{"pid":42}', 1010),
|
||||
('t_test2', 'created', '{"assignee":"reviewer"}', 1020);
|
||||
|
||||
-- Test subscription
|
||||
INSERT INTO kanban_notify_subs VALUES
|
||||
('t_test1', 'matrix', '!room1:example.org', '', '@bot:example.org', 'monitor', 2000, 1);
|
||||
""")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
yield db_path
|
||||
|
||||
# Cleanup
|
||||
Path(db_path).unlink(missing_ok=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_matrix_client():
|
||||
"""Create a mock MatrixClient that is 'connected'."""
|
||||
client = MagicMock()
|
||||
client.connected = True
|
||||
client.user_id = "@bot:example.org"
|
||||
client.send_message = AsyncMock(return_value=MagicMock(success=True, event_id="$evt123"))
|
||||
client.get_joined_rooms = AsyncMock(return_value=["!room1:example.org"])
|
||||
client.join_room = AsyncMock(return_value=True)
|
||||
return client
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# KanbanDB tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestKanbanDB:
|
||||
"""Tests for KanbanDB."""
|
||||
|
||||
def test_connect_and_close(self, kanban_db_path):
|
||||
db = KanbanDB(kanban_db_path)
|
||||
db.connect()
|
||||
assert db._conn is not None
|
||||
db.close()
|
||||
assert db._conn is None
|
||||
|
||||
def test_get_task(self, kanban_db_path):
|
||||
db = KanbanDB(kanban_db_path)
|
||||
task = db.get_task("t_test1")
|
||||
assert task is not None
|
||||
assert task.id == "t_test1"
|
||||
assert task.title == "Test task one"
|
||||
assert task.assignee == "coder"
|
||||
assert task.status == "running"
|
||||
db.close()
|
||||
|
||||
def test_get_task_nonexistent(self, kanban_db_path):
|
||||
db = KanbanDB(kanban_db_path)
|
||||
task = db.get_task("t_nonexistent")
|
||||
assert task is None
|
||||
db.close()
|
||||
|
||||
def test_get_events_since(self, kanban_db_path):
|
||||
db = KanbanDB(kanban_db_path)
|
||||
events = db.get_events_since(0)
|
||||
assert len(events) == 4
|
||||
assert events[0].kind == "created"
|
||||
assert events[0].task_id == "t_test1"
|
||||
|
||||
# After first event
|
||||
events2 = db.get_events_since(events[0].id)
|
||||
assert len(events2) == 3
|
||||
assert events2[0].kind == "claimed"
|
||||
db.close()
|
||||
|
||||
def test_get_events_since_limit(self, kanban_db_path):
|
||||
db = KanbanDB(kanban_db_path)
|
||||
events = db.get_events_since(0, limit=2)
|
||||
assert len(events) == 2
|
||||
db.close()
|
||||
|
||||
def test_get_latest_event_id(self, kanban_db_path):
|
||||
db = KanbanDB(kanban_db_path)
|
||||
latest = db.get_latest_event_id()
|
||||
assert latest > 0
|
||||
db.close()
|
||||
|
||||
def test_get_matrix_subscriptions(self, kanban_db_path):
|
||||
db = KanbanDB(kanban_db_path)
|
||||
subs = db.get_matrix_subscriptions()
|
||||
assert len(subs) == 1
|
||||
assert subs[0].task_id == "t_test1"
|
||||
assert subs[0].chat_id == "!room1:example.org"
|
||||
assert subs[0].platform == "matrix"
|
||||
db.close()
|
||||
|
||||
def test_get_comments_since(self, kanban_db_path):
|
||||
# Add a comment
|
||||
conn = sqlite3.connect(kanban_db_path)
|
||||
conn.execute(
|
||||
"INSERT INTO task_comments (task_id, author, body, created_at) "
|
||||
"VALUES ('t_test1', 'user', 'Nice work!', 1050)"
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
db = KanbanDB(kanban_db_path)
|
||||
comments = db.get_comments_since(0)
|
||||
assert len(comments) == 1
|
||||
assert comments[0]["author"] == "user"
|
||||
assert comments[0]["body"] == "Nice work!"
|
||||
db.close()
|
||||
|
||||
def test_update_subscription_last_event(self, kanban_db_path):
|
||||
db = KanbanDB(kanban_db_path)
|
||||
db.update_subscription_last_event("t_test1", "!room1:example.org", 99)
|
||||
|
||||
# Verify
|
||||
conn = sqlite3.connect(kanban_db_path)
|
||||
row = conn.execute(
|
||||
"SELECT last_event_id FROM kanban_notify_subs "
|
||||
"WHERE task_id = 't_test1' AND platform = 'matrix'"
|
||||
).fetchone()
|
||||
conn.close()
|
||||
assert row[0] == 99
|
||||
|
||||
def test_auto_connect(self, kanban_db_path):
|
||||
db = KanbanDB(kanban_db_path)
|
||||
# Don't call connect explicitly
|
||||
task = db.get_task("t_test1")
|
||||
assert task is not None
|
||||
db.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# format_event tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFormatEvent:
|
||||
"""Tests for format_event."""
|
||||
|
||||
def test_created(self):
|
||||
event = KanbanEvent(
|
||||
id=1, task_id="t_test1", kind="created",
|
||||
payload={"assignee": "coder", "status": "todo"},
|
||||
created_at=1000,
|
||||
)
|
||||
task = KanbanTask(id="t_test1", title="My Task")
|
||||
msg = format_event(event, task)
|
||||
assert "New task" in msg
|
||||
assert "My Task" in msg
|
||||
|
||||
def test_claimed(self):
|
||||
event = KanbanEvent(
|
||||
id=2, task_id="t_test1", kind="claimed",
|
||||
payload={"lock": "coder:123"},
|
||||
created_at=1005,
|
||||
)
|
||||
msg = format_event(event)
|
||||
assert "Claimed" in msg
|
||||
assert "coder" in msg
|
||||
|
||||
def test_spawned(self):
|
||||
event = KanbanEvent(
|
||||
id=3, task_id="t_test1", kind="spawned",
|
||||
payload={"pid": 42},
|
||||
created_at=1010,
|
||||
)
|
||||
msg = format_event(event)
|
||||
assert "spawned" in msg.lower()
|
||||
assert "42" in msg
|
||||
|
||||
def test_completed_with_summary(self):
|
||||
event = KanbanEvent(
|
||||
id=4, task_id="t_test1", kind="completed",
|
||||
payload={"summary": "All done!"},
|
||||
created_at=2000,
|
||||
)
|
||||
msg = format_event(event)
|
||||
assert "Completed" in msg
|
||||
assert "All done!" in msg
|
||||
|
||||
def test_blocked_with_reason(self):
|
||||
event = KanbanEvent(
|
||||
id=5, task_id="t_test1", kind="blocked",
|
||||
payload={"reason": "Need auth token"},
|
||||
created_at=2000,
|
||||
)
|
||||
msg = format_event(event)
|
||||
assert "Blocked" in msg
|
||||
assert "Need auth token" in msg
|
||||
|
||||
def test_timed_out(self):
|
||||
event = KanbanEvent(
|
||||
id=6, task_id="t_test1", kind="timed_out",
|
||||
created_at=2000,
|
||||
)
|
||||
msg = format_event(event)
|
||||
assert "Timed out" in msg
|
||||
|
||||
def test_released(self):
|
||||
event = KanbanEvent(
|
||||
id=7, task_id="t_test1", kind="released",
|
||||
created_at=2000,
|
||||
)
|
||||
msg = format_event(event)
|
||||
assert "Released" in msg
|
||||
|
||||
def test_unknown_kind(self):
|
||||
event = KanbanEvent(
|
||||
id=8, task_id="t_test1", kind="weird_event",
|
||||
created_at=2000,
|
||||
)
|
||||
msg = format_event(event)
|
||||
assert "weird_event" in msg
|
||||
assert "t_test1" in msg
|
||||
|
||||
def test_timestamp_iso(self):
|
||||
event = KanbanEvent(
|
||||
id=1, task_id="t_test1", kind="created",
|
||||
created_at=1000,
|
||||
)
|
||||
assert "1970-01-01" in event.timestamp_iso
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# KanbanMonitor tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestKanbanMonitor:
|
||||
"""Tests for KanbanMonitor."""
|
||||
|
||||
def test_init_requires_connected_client(self, kanban_db_path):
|
||||
client = MagicMock()
|
||||
client.connected = False
|
||||
db = KanbanDB(kanban_db_path)
|
||||
|
||||
with pytest.raises(RuntimeError, match="connected"):
|
||||
KanbanMonitor(client, db)
|
||||
|
||||
def test_init_success(self, kanban_db_path, mock_matrix_client):
|
||||
db = KanbanDB(kanban_db_path)
|
||||
monitor = KanbanMonitor(mock_matrix_client, db, poll_interval=1.0)
|
||||
assert monitor.events_processed == 0
|
||||
assert monitor.notifications_sent == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_and_stop(self, kanban_db_path, mock_matrix_client):
|
||||
db = KanbanDB(kanban_db_path)
|
||||
monitor = KanbanMonitor(mock_matrix_client, db, poll_interval=0.1)
|
||||
|
||||
# Start
|
||||
await monitor.start()
|
||||
assert monitor._running
|
||||
|
||||
# Let it poll a couple times
|
||||
await asyncio_sleep(0.3)
|
||||
|
||||
# Stop
|
||||
await monitor.stop()
|
||||
assert not monitor._running
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_handler_called(self, kanban_db_path, mock_matrix_client):
|
||||
db = KanbanDB(kanban_db_path)
|
||||
monitor = KanbanMonitor(mock_matrix_client, db, poll_interval=0.1)
|
||||
|
||||
handler_events = []
|
||||
|
||||
async def my_handler(event, task):
|
||||
handler_events.append(event.kind)
|
||||
|
||||
monitor.on_event(my_handler)
|
||||
|
||||
await monitor.start()
|
||||
# Let the poll loop initialize and capture baseline event ID
|
||||
await asyncio_sleep(0.2)
|
||||
|
||||
# Insert a new event after the monitor has started
|
||||
conn = sqlite3.connect(kanban_db_path)
|
||||
conn.execute(
|
||||
"INSERT INTO task_events (task_id, kind, payload, created_at) "
|
||||
"VALUES ('t_test1', 'blocked', '{\"reason\":\"test\"}', 1200)"
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
await asyncio_sleep(0.5)
|
||||
await monitor.stop()
|
||||
|
||||
# Should have processed new event
|
||||
assert len(handler_events) > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notifies_subscribers(self, kanban_db_path, mock_matrix_client):
|
||||
db = KanbanDB(kanban_db_path)
|
||||
monitor = KanbanMonitor(mock_matrix_client, db, poll_interval=0.1)
|
||||
|
||||
await monitor.start()
|
||||
# Let the poll loop initialize and capture baseline event ID
|
||||
await asyncio_sleep(0.2)
|
||||
|
||||
# Add a new event after starting the monitor
|
||||
conn = sqlite3.connect(kanban_db_path)
|
||||
conn.execute(
|
||||
"INSERT INTO task_events (task_id, kind, payload, created_at) "
|
||||
"VALUES ('t_test1', 'completed', '{\"summary\":\"Done!\"}', 1100)"
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
await asyncio_sleep(0.5)
|
||||
await monitor.stop()
|
||||
|
||||
# Should have sent at least one notification
|
||||
assert monitor.notifications_sent >= 1
|
||||
|
||||
# Verify send_message was called
|
||||
assert mock_matrix_client.send_message.called
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_subscriptions_no_notifications(self, kanban_db_path, mock_matrix_client):
|
||||
# Remove the subscription
|
||||
conn = sqlite3.connect(kanban_db_path)
|
||||
conn.execute("DELETE FROM kanban_notify_subs")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# Add a new event
|
||||
conn = sqlite3.connect(kanban_db_path)
|
||||
conn.execute(
|
||||
"INSERT INTO task_events (task_id, kind, payload, created_at) "
|
||||
"VALUES ('t_test1', 'completed', '{}', 1100)"
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
db = KanbanDB(kanban_db_path)
|
||||
monitor = KanbanMonitor(mock_matrix_client, db, poll_interval=0.1)
|
||||
|
||||
await monitor.start()
|
||||
await asyncio_sleep(0.3)
|
||||
await monitor.stop()
|
||||
|
||||
# No notifications should be sent (no subscriptions)
|
||||
assert monitor.notifications_sent == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# KanbanSubscription tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestKanbanSubscription:
|
||||
"""Tests for KanbanSubscription dataclass."""
|
||||
|
||||
def test_defaults(self):
|
||||
sub = KanbanSubscription(
|
||||
task_id="t_test", platform="matrix", chat_id="!room:example.org",
|
||||
)
|
||||
assert sub.thread_id == ""
|
||||
assert sub.user_id == ""
|
||||
assert sub.last_event_id == 0
|
||||
|
||||
def test_full(self):
|
||||
sub = KanbanSubscription(
|
||||
task_id="t_test", platform="matrix", chat_id="!room:example.org",
|
||||
thread_id="$evt_thread", user_id="@bot:example.org", last_event_id=42,
|
||||
)
|
||||
assert sub.thread_id == "$evt_thread"
|
||||
assert sub.user_id == "@bot:example.org"
|
||||
assert sub.last_event_id == 42
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# KanbanTask tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestKanbanTask:
|
||||
"""Tests for KanbanTask dataclass."""
|
||||
|
||||
def test_minimal(self):
|
||||
task = KanbanTask(id="t123", title="Minimal task")
|
||||
assert task.id == "t123"
|
||||
assert task.title == "Minimal task"
|
||||
assert task.assignee == ""
|
||||
assert task.status == "todo"
|
||||
assert task.priority == 0
|
||||
|
||||
def test_full(self):
|
||||
task = KanbanTask(
|
||||
id="t456", title="Full task", assignee="coder",
|
||||
status="running", priority=1, created_by="user",
|
||||
)
|
||||
assert task.assignee == "coder"
|
||||
assert task.status == "running"
|
||||
|
||||
|
||||
# Helpers
|
||||
import asyncio as _asyncio
|
||||
|
||||
|
||||
def asyncio_sleep(seconds):
|
||||
"""Helper that works in both sync and async contexts for test waiting."""
|
||||
try:
|
||||
loop = _asyncio.get_running_loop()
|
||||
return _asyncio.sleep(seconds)
|
||||
except RuntimeError:
|
||||
time.sleep(seconds)
|
||||
fut = _asyncio.Future()
|
||||
fut.set_result(None)
|
||||
return fut
|
||||
Reference in New Issue
Block a user