diff --git a/src/opencode_github/__init__.py b/src/opencode_github/__init__.py index 71f9d28..cb5189f 100644 --- a/src/opencode_github/__init__.py +++ b/src/opencode_github/__init__.py @@ -1 +1,44 @@ """OpenCode GitHub integration helpers.""" + +from opencode_github.comment_parser import ( + ParsedCommand, + extract_commands, + is_command_comment, + split_arguments, +) +from opencode_github.config import Config +from opencode_github.github_client import ( + GitHubAPIError, + GitHubClient, + IssueComment, + PullRequest, +) +from opencode_github.handler import HandlerResult, WebhookProcessor +from opencode_github.webhook_handler import ( + EventType, + WebhookEvent, + classify_event, + parse_payload, + parse_raw, + verify_signature, +) + +__all__ = [ + "Config", + "EventType", + "GitHubAPIError", + "GitHubClient", + "HandlerResult", + "IssueComment", + "ParsedCommand", + "PullRequest", + "WebhookEvent", + "WebhookProcessor", + "classify_event", + "extract_commands", + "is_command_comment", + "parse_payload", + "parse_raw", + "split_arguments", + "verify_signature", +] diff --git a/src/opencode_github/handler.py b/src/opencode_github/handler.py new file mode 100644 index 0000000..7bdb59a --- /dev/null +++ b/src/opencode_github/handler.py @@ -0,0 +1,104 @@ +"""End-to-end webhook processing that ties all components together.""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field + +from opencode_github.comment_parser import ParsedCommand, extract_commands +from opencode_github.config import Config +from opencode_github.github_client import GitHubClient +from opencode_github.webhook_handler import WebhookEvent, parse_raw, verify_signature + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class HandlerResult: + """Outcome of processing a single webhook delivery.""" + + event: WebhookEvent | None + commands: list[ParsedCommand] = field(default_factory=list) + acknowledged: bool = False + skipped_reason: str = "" + + +class WebhookProcessor: + """Orchestrate signature verification, payload parsing, and command extraction. + + Parameters + ---------- + config: + Runtime configuration (tokens, allowed commands, etc.). + webhook_secret: + Optional shared secret for verifying ``X-Hub-Signature-256``. + When ``None`` signature verification is skipped. + """ + + def __init__(self, config: Config, webhook_secret: str | None = None) -> None: + self._config = config + self._webhook_secret = webhook_secret + self._client = GitHubClient( + token=config.github_token, + base_url=config.github_api_url, + timeout=config.request_timeout, + ) + + async def close(self) -> None: + await self._client.close() + + async def __aenter__(self) -> WebhookProcessor: + return self + + async def __aexit__(self, *exc: object) -> None: + await self.close() + + async def process( + self, + event_header: str, + body: bytes, + signature: str | None = None, + ) -> HandlerResult: + """Process a raw webhook delivery. + + Parameters + ---------- + event_header: + Value of the ``X-GitHub-Event`` request header. + body: + Raw request body bytes. + signature: + Value of the ``X-Hub-Signature-256`` header, if present. + + Returns + ------- + HandlerResult + """ + if self._webhook_secret: + if not signature or not verify_signature(body, signature, self._webhook_secret): + logger.warning("Webhook signature verification failed") + return HandlerResult(event=None, skipped_reason="invalid_signature") + + event = parse_raw(event_header, body) + if event is None: + return HandlerResult(event=None, skipped_reason="unsupported_event") + + commands = extract_commands(event.comment_body, self._config.allowed_commands) + if not commands: + return HandlerResult(event=event, skipped_reason="no_commands") + + await self._acknowledge(event) + + return HandlerResult(event=event, commands=commands, acknowledged=True) + + async def _acknowledge(self, event: WebhookEvent) -> None: + """Add a reaction to the triggering comment so the user knows we saw it.""" + try: + await self._client.add_reaction( + event.repo_owner, + event.repo_name, + event.comment_id, + reaction="eyes", + ) + except Exception: + logger.debug("Failed to add acknowledgement reaction", exc_info=True) diff --git a/tests/test_handler.py b/tests/test_handler.py new file mode 100644 index 0000000..854f2e9 --- /dev/null +++ b/tests/test_handler.py @@ -0,0 +1,159 @@ +"""Tests for opencode_github.handler.""" + +from __future__ import annotations + +import hashlib +import hmac +import json + +import httpx +import respx + +from opencode_github.config import Config +from opencode_github.handler import WebhookProcessor + +BASE = "https://api.github.com" + + +def _sign(body: bytes, secret: str) -> str: + digest = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest() + return f"sha256={digest}" + + +def _make_config() -> Config: + return Config( + github_token="test-token", + anthropic_api_key="test-key", + github_api_url=BASE, + ) + + +def _issue_comment_body(comment_text: str = "/oc fix the bug") -> bytes: + return json.dumps( + { + "action": "created", + "issue": {"number": 42}, + "comment": { + "id": 1001, + "body": comment_text, + "user": {"login": "contributor"}, + "html_url": "https://github.com/owner/repo/issues/42#issuecomment-1001", + }, + "repository": { + "name": "repo", + "owner": {"login": "owner"}, + }, + } + ).encode() + + +class TestProcess: + async def test_valid_command_acknowledged(self) -> None: + config = _make_config() + body = _issue_comment_body() + + with respx.mock(base_url=BASE, assert_all_called=False) as router: + router.post("/repos/owner/repo/issues/comments/1001/reactions").mock( + return_value=httpx.Response(201, json={"id": 1, "content": "eyes"}) + ) + async with WebhookProcessor(config) as processor: + result = await processor.process("issue_comment", body) + + assert result.event is not None + assert result.acknowledged is True + assert len(result.commands) == 1 + assert result.commands[0].trigger == "/oc" + assert result.commands[0].arguments == "fix the bug" + assert result.skipped_reason == "" + + async def test_no_commands_skipped(self) -> None: + config = _make_config() + body = _issue_comment_body("just a regular comment") + + async with WebhookProcessor(config) as processor: + result = await processor.process("issue_comment", body) + + assert result.event is not None + assert result.acknowledged is False + assert result.commands == [] + assert result.skipped_reason == "no_commands" + + async def test_unsupported_event_skipped(self) -> None: + config = _make_config() + body = json.dumps({"action": "created"}).encode() + + async with WebhookProcessor(config) as processor: + result = await processor.process("push", body) + + assert result.event is None + assert result.skipped_reason == "unsupported_event" + + async def test_signature_verification_pass(self) -> None: + secret = "webhook-secret" + config = _make_config() + body = _issue_comment_body() + sig = _sign(body, secret) + + with respx.mock(base_url=BASE, assert_all_called=False) as router: + router.post("/repos/owner/repo/issues/comments/1001/reactions").mock( + return_value=httpx.Response(201, json={"id": 1, "content": "eyes"}) + ) + async with WebhookProcessor(config, webhook_secret=secret) as processor: + result = await processor.process("issue_comment", body, signature=sig) + + assert result.acknowledged is True + + async def test_signature_verification_fail(self) -> None: + secret = "webhook-secret" + config = _make_config() + body = _issue_comment_body() + + async with WebhookProcessor(config, webhook_secret=secret) as processor: + result = await processor.process("issue_comment", body, signature="sha256=bad") + + assert result.event is None + assert result.skipped_reason == "invalid_signature" + + async def test_missing_signature_when_secret_set(self) -> None: + config = _make_config() + + async with WebhookProcessor(config, webhook_secret="secret") as processor: + result = await processor.process("issue_comment", b"{}", signature=None) + + assert result.skipped_reason == "invalid_signature" + + async def test_acknowledge_failure_does_not_raise(self) -> None: + config = _make_config() + body = _issue_comment_body() + + with respx.mock(base_url=BASE, assert_all_called=False) as router: + router.post("/repos/owner/repo/issues/comments/1001/reactions").mock( + return_value=httpx.Response(500, text="Internal Server Error") + ) + async with WebhookProcessor(config) as processor: + result = await processor.process("issue_comment", body) + + assert result.commands != [] + assert result.acknowledged is True + + async def test_multiple_commands_extracted(self) -> None: + config = _make_config() + body = _issue_comment_body("/oc first task\nsome text\n/opencode second task") + + with respx.mock(base_url=BASE, assert_all_called=False) as router: + router.post("/repos/owner/repo/issues/comments/1001/reactions").mock( + return_value=httpx.Response(201, json={"id": 1, "content": "eyes"}) + ) + async with WebhookProcessor(config) as processor: + result = await processor.process("issue_comment", body) + + assert len(result.commands) == 2 + assert result.commands[0].arguments == "first task" + assert result.commands[1].arguments == "second task" + + +class TestContextManager: + async def test_async_with(self) -> None: + config = _make_config() + async with WebhookProcessor(config) as processor: + assert processor._config is config