Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions src/opencode_github/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
104 changes: 104 additions & 0 deletions src/opencode_github/handler.py
Original file line number Diff line number Diff line change
@@ -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)
159 changes: 159 additions & 0 deletions tests/test_handler.py
Original file line number Diff line number Diff line change
@@ -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