fix: Gitea webhook adapter — normalize events to sensory bus (#309)
Co-authored-by: Kimi Agent <kimi@timmy.local> Co-committed-by: Kimi Agent <kimi@timmy.local>
This commit was merged in pull request #309.
This commit is contained in:
0
tests/timmy/adapters/__init__.py
Normal file
0
tests/timmy/adapters/__init__.py
Normal file
240
tests/timmy/adapters/test_gitea_adapter.py
Normal file
240
tests/timmy/adapters/test_gitea_adapter.py
Normal file
@@ -0,0 +1,240 @@
|
||||
"""Tests for the Gitea webhook adapter."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from timmy.adapters.gitea_adapter import (
|
||||
BOT_USERNAMES,
|
||||
_extract_actor,
|
||||
_is_bot,
|
||||
_is_pr_merge,
|
||||
_normalize_issue_comment,
|
||||
_normalize_issue_opened,
|
||||
_normalize_pull_request,
|
||||
_normalize_push,
|
||||
handle_webhook,
|
||||
)
|
||||
|
||||
# ── Fixtures: sample payloads ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _sender(login: str) -> dict:
|
||||
return {"sender": {"login": login}}
|
||||
|
||||
|
||||
def _push_payload(actor: str = "rockachopa", ref: str = "refs/heads/main") -> dict:
|
||||
return {
|
||||
**_sender(actor),
|
||||
"ref": ref,
|
||||
"repository": {"full_name": "rockachopa/Timmy-time-dashboard"},
|
||||
"commits": [
|
||||
{"message": "fix: something\n\nDetails here"},
|
||||
{"message": "chore: cleanup"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _issue_payload(actor: str = "rockachopa", action: str = "opened") -> dict:
|
||||
return {
|
||||
**_sender(actor),
|
||||
"action": action,
|
||||
"repository": {"full_name": "rockachopa/Timmy-time-dashboard"},
|
||||
"issue": {"number": 42, "title": "Bug in dashboard"},
|
||||
}
|
||||
|
||||
|
||||
def _issue_comment_payload(actor: str = "rockachopa") -> dict:
|
||||
return {
|
||||
**_sender(actor),
|
||||
"action": "created",
|
||||
"repository": {"full_name": "rockachopa/Timmy-time-dashboard"},
|
||||
"issue": {"number": 42, "title": "Bug in dashboard"},
|
||||
"comment": {"body": "I think this is related to the config change"},
|
||||
}
|
||||
|
||||
|
||||
def _pr_payload(
|
||||
actor: str = "rockachopa",
|
||||
action: str = "opened",
|
||||
merged: bool = False,
|
||||
) -> dict:
|
||||
return {
|
||||
**_sender(actor),
|
||||
"action": action,
|
||||
"repository": {"full_name": "rockachopa/Timmy-time-dashboard"},
|
||||
"pull_request": {
|
||||
"number": 99,
|
||||
"title": "feat: add new feature",
|
||||
"merged": merged,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ── Unit tests: helpers ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestExtractActor:
|
||||
def test_normal_sender(self):
|
||||
assert _extract_actor({"sender": {"login": "rockachopa"}}) == "rockachopa"
|
||||
|
||||
def test_missing_sender(self):
|
||||
assert _extract_actor({}) == "unknown"
|
||||
|
||||
|
||||
class TestIsBot:
|
||||
@pytest.mark.parametrize("name", list(BOT_USERNAMES))
|
||||
def test_known_bots(self, name):
|
||||
assert _is_bot(name) is True
|
||||
|
||||
def test_owner_not_bot(self):
|
||||
assert _is_bot("rockachopa") is False
|
||||
|
||||
def test_case_insensitive(self):
|
||||
assert _is_bot("Kimi") is True
|
||||
|
||||
|
||||
class TestIsPrMerge:
|
||||
def test_merged_pr(self):
|
||||
payload = _pr_payload(action="closed", merged=True)
|
||||
assert _is_pr_merge("pull_request", payload) is True
|
||||
|
||||
def test_closed_not_merged(self):
|
||||
payload = _pr_payload(action="closed", merged=False)
|
||||
assert _is_pr_merge("pull_request", payload) is False
|
||||
|
||||
def test_opened_pr(self):
|
||||
payload = _pr_payload(action="opened")
|
||||
assert _is_pr_merge("pull_request", payload) is False
|
||||
|
||||
def test_non_pr_event(self):
|
||||
assert _is_pr_merge("push", {}) is False
|
||||
|
||||
|
||||
# ── Unit tests: normalizers ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestNormalizePush:
|
||||
def test_basic(self):
|
||||
data = _normalize_push(_push_payload(), "rockachopa")
|
||||
assert data["actor"] == "rockachopa"
|
||||
assert data["ref"] == "refs/heads/main"
|
||||
assert data["num_commits"] == 2
|
||||
assert data["head_message"] == "fix: something"
|
||||
assert data["repo"] == "rockachopa/Timmy-time-dashboard"
|
||||
|
||||
def test_empty_commits(self):
|
||||
payload = {**_push_payload(), "commits": []}
|
||||
data = _normalize_push(payload, "rockachopa")
|
||||
assert data["num_commits"] == 0
|
||||
assert data["head_message"] == ""
|
||||
|
||||
|
||||
class TestNormalizeIssueOpened:
|
||||
def test_basic(self):
|
||||
data = _normalize_issue_opened(_issue_payload(), "rockachopa")
|
||||
assert data["issue_number"] == 42
|
||||
assert data["title"] == "Bug in dashboard"
|
||||
assert data["action"] == "opened"
|
||||
|
||||
|
||||
class TestNormalizeIssueComment:
|
||||
def test_basic(self):
|
||||
data = _normalize_issue_comment(_issue_comment_payload(), "rockachopa")
|
||||
assert data["issue_number"] == 42
|
||||
assert data["comment_body"].startswith("I think this is related")
|
||||
|
||||
def test_long_comment_truncated(self):
|
||||
payload = _issue_comment_payload()
|
||||
payload["comment"]["body"] = "x" * 500
|
||||
data = _normalize_issue_comment(payload, "rockachopa")
|
||||
assert len(data["comment_body"]) == 200
|
||||
|
||||
|
||||
class TestNormalizePullRequest:
|
||||
def test_opened(self):
|
||||
data = _normalize_pull_request(_pr_payload(), "rockachopa")
|
||||
assert data["pr_number"] == 99
|
||||
assert data["merged"] is False
|
||||
assert data["action"] == "opened"
|
||||
|
||||
def test_merged(self):
|
||||
payload = _pr_payload(action="closed", merged=True)
|
||||
data = _normalize_pull_request(payload, "rockachopa")
|
||||
assert data["merged"] is True
|
||||
|
||||
|
||||
# ── Integration tests: handle_webhook ────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestHandleWebhook:
|
||||
@patch("timmy.adapters.gitea_adapter.emit", new_callable=AsyncMock)
|
||||
async def test_push_emitted(self, mock_emit):
|
||||
result = await handle_webhook("push", _push_payload())
|
||||
assert result is True
|
||||
mock_emit.assert_called_once()
|
||||
args = mock_emit.call_args
|
||||
assert args[0][0] == "gitea.push"
|
||||
assert args[1]["data"]["num_commits"] == 2
|
||||
|
||||
@patch("timmy.adapters.gitea_adapter.emit", new_callable=AsyncMock)
|
||||
async def test_issue_opened_emitted(self, mock_emit):
|
||||
result = await handle_webhook("issues", _issue_payload())
|
||||
assert result is True
|
||||
mock_emit.assert_called_once()
|
||||
assert mock_emit.call_args[0][0] == "gitea.issue.opened"
|
||||
|
||||
@patch("timmy.adapters.gitea_adapter.emit", new_callable=AsyncMock)
|
||||
async def test_issue_comment_emitted(self, mock_emit):
|
||||
result = await handle_webhook("issue_comment", _issue_comment_payload())
|
||||
assert result is True
|
||||
assert mock_emit.call_args[0][0] == "gitea.issue.comment"
|
||||
|
||||
@patch("timmy.adapters.gitea_adapter.emit", new_callable=AsyncMock)
|
||||
async def test_pull_request_emitted(self, mock_emit):
|
||||
result = await handle_webhook("pull_request", _pr_payload())
|
||||
assert result is True
|
||||
assert mock_emit.call_args[0][0] == "gitea.pull_request"
|
||||
|
||||
@patch("timmy.adapters.gitea_adapter.emit", new_callable=AsyncMock)
|
||||
async def test_unsupported_event_filtered(self, mock_emit):
|
||||
result = await handle_webhook("fork", {"sender": {"login": "someone"}})
|
||||
assert result is False
|
||||
mock_emit.assert_not_called()
|
||||
|
||||
@patch("timmy.adapters.gitea_adapter.emit", new_callable=AsyncMock)
|
||||
async def test_bot_push_filtered(self, mock_emit):
|
||||
result = await handle_webhook("push", _push_payload(actor="kimi"))
|
||||
assert result is False
|
||||
mock_emit.assert_not_called()
|
||||
|
||||
@patch("timmy.adapters.gitea_adapter.emit", new_callable=AsyncMock)
|
||||
async def test_bot_issue_filtered(self, mock_emit):
|
||||
result = await handle_webhook("issues", _issue_payload(actor="hermes"))
|
||||
assert result is False
|
||||
mock_emit.assert_not_called()
|
||||
|
||||
@patch("timmy.adapters.gitea_adapter.emit", new_callable=AsyncMock)
|
||||
async def test_bot_pr_merge_not_filtered(self, mock_emit):
|
||||
"""Bot PR merges should still be emitted."""
|
||||
payload = _pr_payload(actor="kimi", action="closed", merged=True)
|
||||
result = await handle_webhook("pull_request", payload)
|
||||
assert result is True
|
||||
mock_emit.assert_called_once()
|
||||
data = mock_emit.call_args[1]["data"]
|
||||
assert data["merged"] is True
|
||||
|
||||
@patch("timmy.adapters.gitea_adapter.emit", new_callable=AsyncMock)
|
||||
async def test_bot_pr_close_without_merge_filtered(self, mock_emit):
|
||||
"""Bot PR close (not merge) should be filtered."""
|
||||
payload = _pr_payload(actor="manus", action="closed", merged=False)
|
||||
result = await handle_webhook("pull_request", payload)
|
||||
assert result is False
|
||||
mock_emit.assert_not_called()
|
||||
|
||||
@patch("timmy.adapters.gitea_adapter.emit", new_callable=AsyncMock)
|
||||
async def test_owner_activity_always_emitted(self, mock_emit):
|
||||
result = await handle_webhook("push", _push_payload(actor="rockachopa"))
|
||||
assert result is True
|
||||
mock_emit.assert_called_once()
|
||||
Reference in New Issue
Block a user