diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f06a9b0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.pyc +*.egg-info/ +.coverage +.pytest_cache/ +dist/ +build/ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4c6a03b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,39 @@ +[build-system] +requires = ["setuptools>=68.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "opencode-github-integration" +version = "0.1.0" +description = "OpenCode GitHub Actions integration helpers" +requires-python = ">=3.10" +dependencies = [ + "httpx>=0.27", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "pytest-cov>=5.0", + "pytest-asyncio>=0.24", + "ruff>=0.4", + "respx>=0.21", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" + +[tool.ruff] +target-version = "py310" +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "I", "W"] + +[tool.coverage.run] +source = ["src"] + +[tool.coverage.report] +show_missing = true +fail_under = 80 diff --git a/src/opencode_github/__init__.py b/src/opencode_github/__init__.py new file mode 100644 index 0000000..71f9d28 --- /dev/null +++ b/src/opencode_github/__init__.py @@ -0,0 +1 @@ +"""OpenCode GitHub integration helpers.""" diff --git a/src/opencode_github/comment_parser.py b/src/opencode_github/comment_parser.py new file mode 100644 index 0000000..9f5e44c --- /dev/null +++ b/src/opencode_github/comment_parser.py @@ -0,0 +1,86 @@ +"""Parse GitHub comment payloads and extract commands.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ParsedCommand: + """A command extracted from a comment body.""" + + trigger: str + arguments: str + raw_body: str + + +def extract_commands(body: str, allowed_triggers: list[str]) -> list[ParsedCommand]: + """Return all recognised commands found in *body*. + + A command starts with one of the *allowed_triggers* at the beginning of a + line (ignoring leading whitespace) and extends to the end of that line. + + Parameters + ---------- + body: + The full comment body (may be multi-line). + allowed_triggers: + Trigger prefixes to recognise, e.g. ``["/oc", "/opencode"]``. + + Returns + ------- + list[ParsedCommand] + Extracted commands in order of appearance. Empty list when nothing + matches. + """ + if not body or not allowed_triggers: + return [] + + escaped = [re.escape(t) for t in sorted(allowed_triggers, key=len, reverse=True)] + pattern = re.compile( + r"^\s*(" + "|".join(escaped) + r")\b\s*(.*?)\s*$", + re.MULTILINE, + ) + + results: list[ParsedCommand] = [] + for match in pattern.finditer(body): + results.append( + ParsedCommand( + trigger=match.group(1), + arguments=match.group(2), + raw_body=body, + ) + ) + return results + + +def is_command_comment(body: str, allowed_triggers: list[str]) -> bool: + """Return ``True`` when *body* contains at least one recognised command.""" + return len(extract_commands(body, allowed_triggers)) > 0 + + +def split_arguments(arguments: str) -> list[str]: + """Split an argument string into tokens respecting double-quoted groups. + + >>> split_arguments('fix bug --verbose "hello world"') + ['fix', 'bug', '--verbose', 'hello world'] + """ + tokens: list[str] = [] + current: list[str] = [] + in_quotes = False + + for char in arguments: + if char == '"': + in_quotes = not in_quotes + elif char == " " and not in_quotes: + if current: + tokens.append("".join(current)) + current = [] + else: + current.append(char) + + if current: + tokens.append("".join(current)) + + return tokens diff --git a/src/opencode_github/config.py b/src/opencode_github/config.py new file mode 100644 index 0000000..705b120 --- /dev/null +++ b/src/opencode_github/config.py @@ -0,0 +1,63 @@ +"""Configuration loading for the OpenCode GitHub integration.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field + + +@dataclass(frozen=True) +class Config: + """Immutable runtime configuration resolved from environment variables.""" + + github_token: str + anthropic_api_key: str + model: str = "anthropic/claude-sonnet-4-20250514" + github_api_url: str = "https://api.github.com" + allowed_commands: list[str] = field(default_factory=lambda: ["/oc", "/opencode"]) + request_timeout: int = 30 + + @classmethod + def from_env(cls, environ: dict[str, str] | None = None) -> Config: + """Build a ``Config`` from environment variables. + + Parameters + ---------- + environ: + Mapping to read from. Defaults to ``os.environ``. + + Raises + ------ + ValueError + If a required variable is missing or empty. + """ + env = environ if environ is not None else dict(os.environ) + + github_token = env.get("GITHUB_TOKEN", "").strip() + if not github_token: + raise ValueError("GITHUB_TOKEN environment variable is required") + + anthropic_api_key = env.get("ANTHROPIC_API_KEY", "").strip() + if not anthropic_api_key: + raise ValueError("ANTHROPIC_API_KEY environment variable is required") + + model = env.get("OPENCODE_MODEL", "anthropic/claude-sonnet-4-20250514").strip() + github_api_url = env.get("GITHUB_API_URL", "https://api.github.com").strip() + + allowed_raw = env.get("OPENCODE_COMMANDS", "/oc,/opencode").strip() + allowed_commands = [cmd.strip() for cmd in allowed_raw.split(",") if cmd.strip()] + + timeout_raw = env.get("OPENCODE_TIMEOUT", "30").strip() + try: + request_timeout = int(timeout_raw) + except ValueError: + request_timeout = 30 + + return cls( + github_token=github_token, + anthropic_api_key=anthropic_api_key, + model=model, + github_api_url=github_api_url, + allowed_commands=allowed_commands, + request_timeout=request_timeout, + ) diff --git a/src/opencode_github/github_client.py b/src/opencode_github/github_client.py new file mode 100644 index 0000000..dfb357e --- /dev/null +++ b/src/opencode_github/github_client.py @@ -0,0 +1,138 @@ +"""Thin async wrapper around the GitHub REST API.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import httpx + + +@dataclass(frozen=True) +class PullRequest: + """Minimal pull-request representation.""" + + number: int + title: str + head_ref: str + base_ref: str + body: str + + +@dataclass(frozen=True) +class IssueComment: + """Minimal issue/PR comment representation.""" + + id: int + body: str + user_login: str + html_url: str + + +class GitHubAPIError(Exception): + """Raised when an API request returns a non-success status.""" + + def __init__(self, status_code: int, detail: str) -> None: + self.status_code = status_code + self.detail = detail + super().__init__(f"GitHub API error {status_code}: {detail}") + + +class GitHubClient: + """Async GitHub REST API client. + + Parameters + ---------- + token: + Personal-access or installation token. + base_url: + API root, e.g. ``https://api.github.com``. + timeout: + HTTP timeout in seconds. + """ + + def __init__( + self, + token: str, + base_url: str = "https://api.github.com", + timeout: int = 30, + ) -> None: + self._token = token + self._base_url = base_url.rstrip("/") + self._client = httpx.AsyncClient( + base_url=self._base_url, + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + timeout=timeout, + ) + + async def close(self) -> None: + await self._client.aclose() + + async def __aenter__(self) -> GitHubClient: + return self + + async def __aexit__(self, *exc: object) -> None: + await self.close() + + async def _request(self, method: str, path: str, **kwargs: Any) -> Any: + resp = await self._client.request(method, path, **kwargs) + if resp.status_code >= 400: + raise GitHubAPIError(resp.status_code, resp.text) + if resp.status_code == 204: + return None + return resp.json() + + async def get_pull_request(self, owner: str, repo: str, number: int) -> PullRequest: + data = await self._request("GET", f"/repos/{owner}/{repo}/pulls/{number}") + return PullRequest( + number=data["number"], + title=data["title"], + head_ref=data["head"]["ref"], + base_ref=data["base"]["ref"], + body=data.get("body") or "", + ) + + async def list_issue_comments( + self, owner: str, repo: str, issue_number: int + ) -> list[IssueComment]: + data = await self._request("GET", f"/repos/{owner}/{repo}/issues/{issue_number}/comments") + return [ + IssueComment( + id=c["id"], + body=c.get("body") or "", + user_login=c["user"]["login"], + html_url=c["html_url"], + ) + for c in data + ] + + async def create_issue_comment( + self, owner: str, repo: str, issue_number: int, body: str + ) -> IssueComment: + data = await self._request( + "POST", + f"/repos/{owner}/{repo}/issues/{issue_number}/comments", + json={"body": body}, + ) + return IssueComment( + id=data["id"], + body=data.get("body") or "", + user_login=data["user"]["login"], + html_url=data["html_url"], + ) + + async def add_reaction( + self, owner: str, repo: str, comment_id: int, reaction: str = "+1" + ) -> None: + await self._request( + "POST", + f"/repos/{owner}/{repo}/issues/comments/{comment_id}/reactions", + json={"content": reaction}, + ) + + async def get_repo(self, owner: str, repo: str) -> dict[str, Any]: + return await self._request("GET", f"/repos/{owner}/{repo}") diff --git a/src/opencode_github/webhook_handler.py b/src/opencode_github/webhook_handler.py new file mode 100644 index 0000000..9e3fa68 --- /dev/null +++ b/src/opencode_github/webhook_handler.py @@ -0,0 +1,125 @@ +"""Process incoming GitHub webhook payloads.""" + +from __future__ import annotations + +import hashlib +import hmac +import json +from dataclasses import dataclass +from enum import Enum +from typing import Any + + +class EventType(Enum): + """Supported GitHub webhook event types.""" + + ISSUE_COMMENT = "issue_comment" + PR_REVIEW_COMMENT = "pull_request_review_comment" + UNKNOWN = "unknown" + + +@dataclass(frozen=True) +class WebhookEvent: + """Parsed webhook event.""" + + event_type: EventType + action: str + comment_body: str + comment_id: int + sender_login: str + repo_owner: str + repo_name: str + issue_number: int + raw_payload: dict[str, Any] + + +def classify_event(event_header: str) -> EventType: + """Map a ``X-GitHub-Event`` header value to an ``EventType``.""" + try: + return EventType(event_header) + except ValueError: + return EventType.UNKNOWN + + +def verify_signature(payload_body: bytes, signature: str, secret: str) -> bool: + """Validate ``X-Hub-Signature-256`` against the shared webhook secret. + + Parameters + ---------- + payload_body: + Raw request body bytes. + signature: + Value of the ``X-Hub-Signature-256`` header (``sha256=…``). + secret: + The webhook secret configured on the repo. + + Returns + ------- + bool + ``True`` when the signature is valid. + """ + if not signature or not secret: + return False + + prefix = "sha256=" + if not signature.startswith(prefix): + return False + + expected = hmac.new( + secret.encode("utf-8"), + payload_body, + hashlib.sha256, + ).hexdigest() + + return hmac.compare_digest(expected, signature[len(prefix) :]) + + +def parse_payload(event_type: EventType, payload: dict[str, Any]) -> WebhookEvent | None: + """Convert a raw webhook JSON payload into a ``WebhookEvent``. + + Returns ``None`` for unsupported event types or payloads without the + expected structure. + """ + if event_type == EventType.UNKNOWN: + return None + + action = payload.get("action", "") + if action != "created": + return None + + comment: dict[str, Any] | None = payload.get("comment") + if comment is None: + return None + + repo_data: dict[str, Any] = payload.get("repository", {}) + owner_data: dict[str, Any] = repo_data.get("owner", {}) + + # For issue_comment events the issue number lives at payload.issue.number; + # for PR review comments it lives at payload.pull_request.number. + issue_number: int = 0 + if event_type == EventType.ISSUE_COMMENT: + issue_number = payload.get("issue", {}).get("number", 0) + elif event_type == EventType.PR_REVIEW_COMMENT: + issue_number = payload.get("pull_request", {}).get("number", 0) + + return WebhookEvent( + event_type=event_type, + action=action, + comment_body=comment.get("body", ""), + comment_id=comment.get("id", 0), + sender_login=comment.get("user", {}).get("login", ""), + repo_owner=owner_data.get("login", ""), + repo_name=repo_data.get("name", ""), + issue_number=issue_number, + raw_payload=payload, + ) + + +def parse_raw(event_header: str, body: bytes) -> WebhookEvent | None: + """Convenience wrapper: classify, decode JSON, and parse in one call.""" + event_type = classify_event(event_header) + try: + payload = json.loads(body) + except (json.JSONDecodeError, UnicodeDecodeError): + return None + return parse_payload(event_type, payload) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..ab227dd --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,59 @@ +"""Shared fixtures for the test suite.""" + +from __future__ import annotations + +import pytest + +from opencode_github.config import Config + + +@pytest.fixture() +def minimal_env() -> dict[str, str]: + """Minimal valid environment for ``Config.from_env``.""" + return { + "GITHUB_TOKEN": "ghp_test_token_123", + "ANTHROPIC_API_KEY": "sk-ant-test-key-456", + } + + +@pytest.fixture() +def sample_config(minimal_env: dict[str, str]) -> Config: + return Config.from_env(minimal_env) + + +@pytest.fixture() +def issue_comment_payload() -> dict: + """A realistic ``issue_comment`` webhook payload.""" + return { + "action": "created", + "issue": {"number": 42}, + "comment": { + "id": 1001, + "body": "/oc fix the typo in README", + "user": {"login": "contributor"}, + "html_url": "https://github.com/owner/repo/issues/42#issuecomment-1001", + }, + "repository": { + "name": "repo", + "owner": {"login": "owner"}, + }, + } + + +@pytest.fixture() +def pr_review_comment_payload() -> dict: + """A realistic ``pull_request_review_comment`` webhook payload.""" + return { + "action": "created", + "pull_request": {"number": 7}, + "comment": { + "id": 2002, + "body": "/opencode refactor this function", + "user": {"login": "reviewer"}, + "html_url": "https://github.com/owner/repo/pull/7#discussion_r2002", + }, + "repository": { + "name": "repo", + "owner": {"login": "owner"}, + }, + } diff --git a/tests/test_comment_parser.py b/tests/test_comment_parser.py new file mode 100644 index 0000000..4de0335 --- /dev/null +++ b/tests/test_comment_parser.py @@ -0,0 +1,96 @@ +"""Tests for opencode_github.comment_parser.""" + +from __future__ import annotations + +from opencode_github.comment_parser import ( + extract_commands, + is_command_comment, + split_arguments, +) + +TRIGGERS = ["/oc", "/opencode"] + + +class TestExtractCommands: + def test_single_command(self) -> None: + cmds = extract_commands("/oc fix typo", TRIGGERS) + assert len(cmds) == 1 + assert cmds[0].trigger == "/oc" + assert cmds[0].arguments == "fix typo" + + def test_multiple_commands(self) -> None: + body = "/oc first\nsome text\n/opencode second" + cmds = extract_commands(body, TRIGGERS) + assert len(cmds) == 2 + assert cmds[0].trigger == "/oc" + assert cmds[0].arguments == "first" + assert cmds[1].trigger == "/opencode" + assert cmds[1].arguments == "second" + + def test_leading_whitespace(self) -> None: + cmds = extract_commands(" /oc do something", TRIGGERS) + assert len(cmds) == 1 + assert cmds[0].arguments == "do something" + + def test_no_match(self) -> None: + assert extract_commands("just a regular comment", TRIGGERS) == [] + + def test_empty_body(self) -> None: + assert extract_commands("", TRIGGERS) == [] + + def test_empty_triggers(self) -> None: + assert extract_commands("/oc hello", []) == [] + + def test_command_without_arguments(self) -> None: + cmds = extract_commands("/oc", TRIGGERS) + assert len(cmds) == 1 + assert cmds[0].arguments == "" + + def test_mid_line_trigger_not_matched(self) -> None: + """Triggers must be at the start of a line.""" + assert extract_commands("please /oc fix it", TRIGGERS) == [] + + def test_raw_body_preserved(self) -> None: + body = "/oc hello world" + cmds = extract_commands(body, TRIGGERS) + assert cmds[0].raw_body is body + + def test_longer_trigger_matched_first(self) -> None: + body = "/opencode run" + cmds = extract_commands(body, TRIGGERS) + assert cmds[0].trigger == "/opencode" + + def test_trigger_prefix_not_partial_word(self) -> None: + """/ocean should NOT match /oc.""" + assert extract_commands("/ocean voyage", TRIGGERS) == [] + + +class TestIsCommandComment: + def test_true_for_matching(self) -> None: + assert is_command_comment("/oc hi", TRIGGERS) is True + + def test_false_for_no_match(self) -> None: + assert is_command_comment("nothing here", TRIGGERS) is False + + def test_false_for_empty(self) -> None: + assert is_command_comment("", TRIGGERS) is False + + +class TestSplitArguments: + def test_simple_tokens(self) -> None: + assert split_arguments("fix bug --verbose") == ["fix", "bug", "--verbose"] + + def test_quoted_group(self) -> None: + assert split_arguments('fix "hello world" --fast') == ["fix", "hello world", "--fast"] + + def test_empty_string(self) -> None: + assert split_arguments("") == [] + + def test_only_spaces(self) -> None: + assert split_arguments(" ") == [] + + def test_adjacent_quotes(self) -> None: + assert split_arguments('"a b" "c d"') == ["a b", "c d"] + + def test_no_quotes(self) -> None: + assert split_arguments("a b c") == ["a", "b", "c"] diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..183deff --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,90 @@ +"""Tests for opencode_github.config.""" + +from __future__ import annotations + +import pytest + +from opencode_github.config import Config + + +class TestConfigFromEnv: + """Config.from_env – happy path and validation.""" + + def test_minimal_env(self, minimal_env: dict[str, str]) -> None: + cfg = Config.from_env(minimal_env) + assert cfg.github_token == "ghp_test_token_123" + assert cfg.anthropic_api_key == "sk-ant-test-key-456" + assert cfg.model == "anthropic/claude-sonnet-4-20250514" + assert cfg.github_api_url == "https://api.github.com" + assert cfg.allowed_commands == ["/oc", "/opencode"] + assert cfg.request_timeout == 30 + + def test_custom_model(self, minimal_env: dict[str, str]) -> None: + minimal_env["OPENCODE_MODEL"] = "anthropic/claude-haiku-3" + cfg = Config.from_env(minimal_env) + assert cfg.model == "anthropic/claude-haiku-3" + + def test_custom_api_url(self, minimal_env: dict[str, str]) -> None: + minimal_env["GITHUB_API_URL"] = "https://ghe.corp.example.com/api/v3" + cfg = Config.from_env(minimal_env) + assert cfg.github_api_url == "https://ghe.corp.example.com/api/v3" + + def test_custom_commands(self, minimal_env: dict[str, str]) -> None: + minimal_env["OPENCODE_COMMANDS"] = "/ai, /bot , /run" + cfg = Config.from_env(minimal_env) + assert cfg.allowed_commands == ["/ai", "/bot", "/run"] + + def test_custom_timeout(self, minimal_env: dict[str, str]) -> None: + minimal_env["OPENCODE_TIMEOUT"] = "60" + cfg = Config.from_env(minimal_env) + assert cfg.request_timeout == 60 + + def test_invalid_timeout_falls_back(self, minimal_env: dict[str, str]) -> None: + minimal_env["OPENCODE_TIMEOUT"] = "not_a_number" + cfg = Config.from_env(minimal_env) + assert cfg.request_timeout == 30 + + def test_whitespace_stripped(self, minimal_env: dict[str, str]) -> None: + minimal_env["GITHUB_TOKEN"] = " token_with_spaces " + minimal_env["ANTHROPIC_API_KEY"] = "\tkey_with_tabs\t" + cfg = Config.from_env(minimal_env) + assert cfg.github_token == "token_with_spaces" + assert cfg.anthropic_api_key == "key_with_tabs" + + def test_missing_github_token_raises(self) -> None: + env = {"ANTHROPIC_API_KEY": "sk-key"} + with pytest.raises(ValueError, match="GITHUB_TOKEN"): + Config.from_env(env) + + def test_empty_github_token_raises(self) -> None: + env = {"GITHUB_TOKEN": " ", "ANTHROPIC_API_KEY": "sk-key"} + with pytest.raises(ValueError, match="GITHUB_TOKEN"): + Config.from_env(env) + + def test_missing_anthropic_key_raises(self) -> None: + env = {"GITHUB_TOKEN": "ghp_tok"} + with pytest.raises(ValueError, match="ANTHROPIC_API_KEY"): + Config.from_env(env) + + def test_empty_anthropic_key_raises(self) -> None: + env = {"GITHUB_TOKEN": "ghp_tok", "ANTHROPIC_API_KEY": ""} + with pytest.raises(ValueError, match="ANTHROPIC_API_KEY"): + Config.from_env(env) + + def test_uses_os_environ_by_default(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("GITHUB_TOKEN", "env_tok") + monkeypatch.setenv("ANTHROPIC_API_KEY", "env_key") + cfg = Config.from_env() + assert cfg.github_token == "env_tok" + assert cfg.anthropic_api_key == "env_key" + + def test_empty_commands_string(self, minimal_env: dict[str, str]) -> None: + minimal_env["OPENCODE_COMMANDS"] = " , , " + cfg = Config.from_env(minimal_env) + assert cfg.allowed_commands == [] + + +class TestConfigImmutability: + def test_frozen(self, sample_config: Config) -> None: + with pytest.raises(AttributeError): + sample_config.github_token = "changed" # type: ignore[misc] diff --git a/tests/test_github_client.py b/tests/test_github_client.py new file mode 100644 index 0000000..a1ed8f9 --- /dev/null +++ b/tests/test_github_client.py @@ -0,0 +1,155 @@ +"""Tests for opencode_github.github_client.""" + +from __future__ import annotations + +import httpx +import pytest +import respx + +from opencode_github.github_client import ( + GitHubAPIError, + GitHubClient, + IssueComment, + PullRequest, +) + +BASE = "https://api.github.com" + + +@pytest.fixture() +def mock_router() -> respx.MockRouter: + with respx.mock(base_url=BASE, assert_all_called=False) as router: + yield router + + +@pytest.fixture() +def client() -> GitHubClient: + return GitHubClient(token="test-token", base_url=BASE, timeout=5) + + +class TestGetPullRequest: + async def test_success(self, mock_router: respx.MockRouter, client: GitHubClient) -> None: + mock_router.get("/repos/owner/repo/pulls/1").mock( + return_value=httpx.Response( + 200, + json={ + "number": 1, + "title": "My PR", + "head": {"ref": "feature"}, + "base": {"ref": "main"}, + "body": "Description", + }, + ) + ) + pr = await client.get_pull_request("owner", "repo", 1) + assert isinstance(pr, PullRequest) + assert pr.number == 1 + assert pr.title == "My PR" + assert pr.head_ref == "feature" + assert pr.base_ref == "main" + assert pr.body == "Description" + + async def test_null_body(self, mock_router: respx.MockRouter, client: GitHubClient) -> None: + mock_router.get("/repos/owner/repo/pulls/2").mock( + return_value=httpx.Response( + 200, + json={ + "number": 2, + "title": "No body", + "head": {"ref": "fix"}, + "base": {"ref": "main"}, + "body": None, + }, + ) + ) + pr = await client.get_pull_request("owner", "repo", 2) + assert pr.body == "" + + async def test_404_raises(self, mock_router: respx.MockRouter, client: GitHubClient) -> None: + mock_router.get("/repos/owner/repo/pulls/999").mock( + return_value=httpx.Response(404, json={"message": "Not Found"}) + ) + with pytest.raises(GitHubAPIError) as exc_info: + await client.get_pull_request("owner", "repo", 999) + assert exc_info.value.status_code == 404 + + +class TestListIssueComments: + async def test_success(self, mock_router: respx.MockRouter, client: GitHubClient) -> None: + mock_router.get("/repos/owner/repo/issues/5/comments").mock( + return_value=httpx.Response( + 200, + json=[ + { + "id": 10, + "body": "hello", + "user": {"login": "alice"}, + "html_url": "https://github.com/owner/repo/issues/5#issuecomment-10", + }, + { + "id": 11, + "body": None, + "user": {"login": "bob"}, + "html_url": "https://github.com/owner/repo/issues/5#issuecomment-11", + }, + ], + ) + ) + comments = await client.list_issue_comments("owner", "repo", 5) + assert len(comments) == 2 + assert comments[0] == IssueComment( + id=10, + body="hello", + user_login="alice", + html_url="https://github.com/owner/repo/issues/5#issuecomment-10", + ) + assert comments[1].body == "" + + +class TestCreateIssueComment: + async def test_success(self, mock_router: respx.MockRouter, client: GitHubClient) -> None: + mock_router.post("/repos/owner/repo/issues/5/comments").mock( + return_value=httpx.Response( + 201, + json={ + "id": 20, + "body": "posted!", + "user": {"login": "bot"}, + "html_url": "https://github.com/owner/repo/issues/5#issuecomment-20", + }, + ) + ) + comment = await client.create_issue_comment("owner", "repo", 5, "posted!") + assert comment.id == 20 + assert comment.body == "posted!" + + +class TestAddReaction: + async def test_success(self, mock_router: respx.MockRouter, client: GitHubClient) -> None: + mock_router.post("/repos/owner/repo/issues/comments/10/reactions").mock( + return_value=httpx.Response(201, json={"id": 1, "content": "+1"}) + ) + await client.add_reaction("owner", "repo", 10) + + async def test_server_error(self, mock_router: respx.MockRouter, client: GitHubClient) -> None: + mock_router.post("/repos/owner/repo/issues/comments/10/reactions").mock( + return_value=httpx.Response(500, text="Internal Server Error") + ) + with pytest.raises(GitHubAPIError) as exc_info: + await client.add_reaction("owner", "repo", 10) + assert exc_info.value.status_code == 500 + + +class TestGetRepo: + async def test_success(self, mock_router: respx.MockRouter, client: GitHubClient) -> None: + mock_router.get("/repos/owner/repo").mock( + return_value=httpx.Response(200, json={"full_name": "owner/repo", "private": False}) + ) + data = await client.get_repo("owner", "repo") + assert data["full_name"] == "owner/repo" + + +class TestContextManager: + async def test_async_with(self) -> None: + async with GitHubClient(token="tok") as c: + assert c._token == "tok" diff --git a/tests/test_webhook_handler.py b/tests/test_webhook_handler.py new file mode 100644 index 0000000..6b5b99a --- /dev/null +++ b/tests/test_webhook_handler.py @@ -0,0 +1,121 @@ +"""Tests for opencode_github.webhook_handler.""" + +from __future__ import annotations + +import json + +from opencode_github.webhook_handler import ( + EventType, + classify_event, + parse_payload, + parse_raw, + verify_signature, +) + + +class TestClassifyEvent: + def test_issue_comment(self) -> None: + assert classify_event("issue_comment") == EventType.ISSUE_COMMENT + + def test_pr_review_comment(self) -> None: + assert classify_event("pull_request_review_comment") == EventType.PR_REVIEW_COMMENT + + def test_unknown(self) -> None: + assert classify_event("push") == EventType.UNKNOWN + + def test_empty_string(self) -> None: + assert classify_event("") == EventType.UNKNOWN + + +class TestVerifySignature: + SECRET = "my-webhook-secret" + + def _sign(self, body: bytes) -> str: + import hashlib + import hmac + + digest = hmac.new(self.SECRET.encode(), body, hashlib.sha256).hexdigest() + return f"sha256={digest}" + + def test_valid_signature(self) -> None: + body = b'{"action":"created"}' + sig = self._sign(body) + assert verify_signature(body, sig, self.SECRET) is True + + def test_invalid_signature(self) -> None: + body = b'{"action":"created"}' + assert verify_signature(body, "sha256=deadbeef", self.SECRET) is False + + def test_empty_signature(self) -> None: + assert verify_signature(b"data", "", self.SECRET) is False + + def test_empty_secret(self) -> None: + assert verify_signature(b"data", "sha256=abc", "") is False + + def test_missing_sha256_prefix(self) -> None: + body = b'{"action":"created"}' + sig = self._sign(body).replace("sha256=", "md5=") + assert verify_signature(body, sig, self.SECRET) is False + + def test_tampered_body(self) -> None: + body = b'{"action":"created"}' + sig = self._sign(body) + assert verify_signature(b'{"action":"deleted"}', sig, self.SECRET) is False + + +class TestParsePayload: + def test_issue_comment_created(self, issue_comment_payload: dict) -> None: + event = parse_payload(EventType.ISSUE_COMMENT, issue_comment_payload) + assert event is not None + assert event.event_type == EventType.ISSUE_COMMENT + assert event.action == "created" + assert event.comment_body == "/oc fix the typo in README" + assert event.comment_id == 1001 + assert event.sender_login == "contributor" + assert event.repo_owner == "owner" + assert event.repo_name == "repo" + assert event.issue_number == 42 + + def test_pr_review_comment_created(self, pr_review_comment_payload: dict) -> None: + event = parse_payload(EventType.PR_REVIEW_COMMENT, pr_review_comment_payload) + assert event is not None + assert event.event_type == EventType.PR_REVIEW_COMMENT + assert event.comment_body == "/opencode refactor this function" + assert event.issue_number == 7 + + def test_unknown_event_type(self, issue_comment_payload: dict) -> None: + assert parse_payload(EventType.UNKNOWN, issue_comment_payload) is None + + def test_non_created_action(self, issue_comment_payload: dict) -> None: + issue_comment_payload["action"] = "deleted" + assert parse_payload(EventType.ISSUE_COMMENT, issue_comment_payload) is None + + def test_missing_comment(self) -> None: + payload = {"action": "created", "repository": {"name": "r", "owner": {"login": "o"}}} + assert parse_payload(EventType.ISSUE_COMMENT, payload) is None + + def test_missing_repo_info(self) -> None: + payload = { + "action": "created", + "comment": {"id": 1, "body": "x", "user": {"login": "u"}, "html_url": ""}, + "issue": {"number": 1}, + } + event = parse_payload(EventType.ISSUE_COMMENT, payload) + assert event is not None + assert event.repo_owner == "" + assert event.repo_name == "" + + +class TestParseRaw: + def test_full_round_trip(self, issue_comment_payload: dict) -> None: + body = json.dumps(issue_comment_payload).encode() + event = parse_raw("issue_comment", body) + assert event is not None + assert event.issue_number == 42 + + def test_invalid_json(self) -> None: + assert parse_raw("issue_comment", b"not json") is None + + def test_unknown_event(self) -> None: + body = json.dumps({"action": "created"}).encode() + assert parse_raw("deployment", body) is None