From 6890e6514f5a135d9cab4327393deaece9d8f010 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 6 Jun 2026 16:39:31 +0000 Subject: [PATCH] feat: scaffold project with shared utilities Add Python package structure with shared utility modules that prevent code duplication across domain modules: - utils/errors: unified exception hierarchy - utils/env: env-var loading & validation - utils/crypto: HMAC-SHA256 helpers - utils/http: GitHub API HTTP helpers - utils/text: regex extraction & input sanitisation Domain modules (config, comment_parser, github_client, webhook_handler) delegate common patterns to utils/ instead of reimplementing them. Includes pyproject.toml, .gitignore, and 44 passing tests. Co-Authored-By: dominicpape --- .gitignore | 8 ++ README.md | 46 ++++++++- pyproject.toml | 26 ++++++ src/opencode_github/__init__.py | 1 + src/opencode_github/comment_parser.py | 47 ++++++++++ src/opencode_github/config.py | 37 ++++++++ src/opencode_github/github_client.py | 70 ++++++++++++++ src/opencode_github/utils/__init__.py | 39 ++++++++ src/opencode_github/utils/crypto.py | 25 +++++ src/opencode_github/utils/env.py | 31 +++++++ src/opencode_github/utils/errors.py | 45 +++++++++ src/opencode_github/utils/http.py | 62 +++++++++++++ src/opencode_github/utils/text.py | 23 +++++ src/opencode_github/webhook_handler.py | 85 +++++++++++++++++ tests/__init__.py | 0 tests/test_comment_parser.py | 47 ++++++++++ tests/test_config.py | 45 +++++++++ tests/test_github_client.py | 28 ++++++ tests/test_utils.py | 124 +++++++++++++++++++++++++ tests/test_webhook_handler.py | 88 ++++++++++++++++++ 20 files changed, 876 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 pyproject.toml create mode 100644 src/opencode_github/__init__.py create mode 100644 src/opencode_github/comment_parser.py create mode 100644 src/opencode_github/config.py create mode 100644 src/opencode_github/github_client.py create mode 100644 src/opencode_github/utils/__init__.py create mode 100644 src/opencode_github/utils/crypto.py create mode 100644 src/opencode_github/utils/env.py create mode 100644 src/opencode_github/utils/errors.py create mode 100644 src/opencode_github/utils/http.py create mode 100644 src/opencode_github/utils/text.py create mode 100644 src/opencode_github/webhook_handler.py create mode 100644 tests/__init__.py create mode 100644 tests/test_comment_parser.py create mode 100644 tests/test_config.py create mode 100644 tests/test_github_client.py create mode 100644 tests/test_utils.py create mode 100644 tests/test_webhook_handler.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b53e636 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.pyc +*.egg-info/ +.venv/ +.pytest_cache/ +.ruff_cache/ +dist/ +build/ diff --git a/README.md b/README.md index 6934471..f6e90f3 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,47 @@ # OpenCode GitHub Integration -Repository for OpenCode GitHub Actions integration. +Python library and GitHub Action for automating repository modifications and +issue responses using Anthropic's Claude model. + +## Project Structure + +``` +src/opencode_github/ +├── utils/ # Shared utilities (used by every domain module) +│ ├── crypto.py # HMAC-SHA256 signature helpers +│ ├── env.py # Environment-variable loading & validation +│ ├── errors.py # Unified exception hierarchy +│ ├── http.py # GitHub API HTTP client helpers +│ └── text.py # Regex extraction & input sanitisation +├── config.py # Runtime configuration (env → dataclass) +├── comment_parser.py # Slash-command extraction (/oc, /opencode) +├── github_client.py # Async GitHub REST API client +└── webhook_handler.py # Webhook payload validation & event normalisation +``` + +### Why shared utilities? + +Every domain module delegates common patterns to `utils/` instead of +reimplementing them: + +| Utility | Used by | +|---------|---------| +| `utils.env` | `config` (env-var loading) | +| `utils.errors` | all modules (consistent exception types) | +| `utils.http` | `github_client` (headers, response parsing, client factory) | +| `utils.crypto` | `webhook_handler` (HMAC verification) | +| `utils.text` | `comment_parser`, `webhook_handler` (regex, sanitisation) | + +## Setup + +```bash +uv venv .venv && source .venv/bin/activate +uv pip install -e '.[dev]' +``` + +## Lint & Test + +```bash +ruff check src/ tests/ +pytest -v +``` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..dece614 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,26 @@ +[project] +name = "opencode-github" +version = "0.1.0" +description = "OpenCode GitHub Actions integration" +requires-python = ">=3.10" +dependencies = [ + "httpx>=0.27,<1", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "pytest-asyncio>=0.23", + "ruff>=0.4", +] + +[tool.ruff] +target-version = "py310" +line-length = 88 + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W", "UP"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" diff --git a/src/opencode_github/__init__.py b/src/opencode_github/__init__.py new file mode 100644 index 0000000..b08ab70 --- /dev/null +++ b/src/opencode_github/__init__.py @@ -0,0 +1 @@ +"""OpenCode GitHub integration library.""" diff --git a/src/opencode_github/comment_parser.py b/src/opencode_github/comment_parser.py new file mode 100644 index 0000000..1897706 --- /dev/null +++ b/src/opencode_github/comment_parser.py @@ -0,0 +1,47 @@ +"""Slash-command extraction from GitHub comment bodies. + +Uses ``utils.text`` for regex matching and ``utils.errors`` for parse +failures — no ad-hoc regex or exception boilerplate duplicated here. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + +from opencode_github.utils.text import extract_first_match, sanitize_input + +_TRIGGER_PATTERN = re.compile(r"^(/oc|/opencode)\b", re.MULTILINE) +_ARGS_PATTERN = re.compile(r"^(?:/oc|/opencode)\s+(.*)", re.MULTILINE) + + +@dataclass(frozen=True, slots=True) +class ParsedCommand: + """A parsed slash command extracted from a comment body.""" + + trigger: str + arguments: str + + +def parse_command(body: str) -> ParsedCommand | None: + """Extract the first slash command from a comment *body*. + + Returns ``None`` when *body* contains no recognised trigger. + """ + if not body or not body.strip(): + return None + + cleaned = sanitize_input(body) + trigger = extract_first_match(_TRIGGER_PATTERN, cleaned) + if trigger is None: + return None + + args_match = re.search(_ARGS_PATTERN, cleaned) + arguments = args_match.group(1).strip() if args_match else "" + + return ParsedCommand(trigger=trigger, arguments=arguments) + + +def is_trigger(body: str) -> bool: + """Return ``True`` if *body* contains a recognised slash-command trigger.""" + return parse_command(body) is not None diff --git a/src/opencode_github/config.py b/src/opencode_github/config.py new file mode 100644 index 0000000..f1c26b5 --- /dev/null +++ b/src/opencode_github/config.py @@ -0,0 +1,37 @@ +"""Environment-based configuration for OpenCode GitHub integration. + +Delegates all env-var loading to ``utils.env`` and uses ``utils.errors`` +for validation failures — no duplicated ``os.environ`` logic here. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from opencode_github.utils.env import get_optional_env, get_required_env + + +@dataclass(frozen=True, slots=True) +class Config: + """Validated runtime configuration.""" + + github_token: str + anthropic_api_key: str + webhook_secret: str + model: str + github_api_url: str + + @classmethod + def from_env(cls) -> Config: + """Load and validate configuration from environment variables.""" + return cls( + github_token=get_required_env("GITHUB_TOKEN"), + anthropic_api_key=get_required_env("ANTHROPIC_API_KEY"), + webhook_secret=get_required_env("WEBHOOK_SECRET"), + model=get_optional_env( + "OPENCODE_MODEL", "anthropic/claude-sonnet-4-20250514" + ), + github_api_url=get_optional_env( + "GITHUB_API_URL", "https://api.github.com" + ), + ) diff --git a/src/opencode_github/github_client.py b/src/opencode_github/github_client.py new file mode 100644 index 0000000..5a032b2 --- /dev/null +++ b/src/opencode_github/github_client.py @@ -0,0 +1,70 @@ +"""Asynchronous GitHub REST API client. + +Delegates HTTP mechanics to ``utils.http`` and uses ``utils.errors`` for +error handling — no duplicated httpx setup or header construction here. +""" + +from __future__ import annotations + +from typing import Any + +import httpx + +from opencode_github.utils.errors import GitHubAPIError +from opencode_github.utils.http import create_http_client, parse_json_response + + +class GitHubClient: + """Thin async wrapper around the GitHub REST API.""" + + def __init__( + self, token: str, *, base_url: str = "https://api.github.com" + ) -> None: + self._client: httpx.AsyncClient | None = None + self._token = token + self._base_url = base_url + + async def __aenter__(self) -> GitHubClient: + self._client = create_http_client( + token=self._token, base_url=self._base_url + ) + return self + + async def __aexit__(self, *exc: object) -> None: + if self._client: + await self._client.aclose() + self._client = None + + def _ensure_client(self) -> httpx.AsyncClient: + if self._client is None: + raise GitHubAPIError( + "Client not initialised; use 'async with' context manager" + ) + return self._client + + async def get_issue( + self, owner: str, repo: str, number: int + ) -> dict[str, Any]: + """Fetch an issue by owner/repo/number.""" + client = self._ensure_client() + resp = await client.get(f"/repos/{owner}/{repo}/issues/{number}") + return parse_json_response(resp) + + async def create_comment( + self, owner: str, repo: str, number: int, body: str + ) -> dict[str, Any]: + """Post a comment on an issue or pull request.""" + client = self._ensure_client() + resp = await client.post( + f"/repos/{owner}/{repo}/issues/{number}/comments", + json={"body": body}, + ) + return parse_json_response(resp) + + async def get_pull_request( + self, owner: str, repo: str, number: int + ) -> dict[str, Any]: + """Fetch a pull request by owner/repo/number.""" + client = self._ensure_client() + resp = await client.get(f"/repos/{owner}/{repo}/pulls/{number}") + return parse_json_response(resp) diff --git a/src/opencode_github/utils/__init__.py b/src/opencode_github/utils/__init__.py new file mode 100644 index 0000000..48718a0 --- /dev/null +++ b/src/opencode_github/utils/__init__.py @@ -0,0 +1,39 @@ +"""Shared utilities for OpenCode GitHub integration. + +This package centralises cross-cutting concerns — env loading, HTTP helpers, +cryptographic verification, text processing, and the exception hierarchy — +so that domain modules never duplicate this boilerplate. +""" + +from opencode_github.utils.crypto import compare_signatures, compute_hmac_sha256 +from opencode_github.utils.env import get_optional_env, get_required_env +from opencode_github.utils.errors import ( + CommandParseError, + ConfigError, + GitHubAPIError, + OpenCodeError, + WebhookValidationError, +) +from opencode_github.utils.http import ( + build_headers, + create_http_client, + parse_json_response, +) +from opencode_github.utils.text import extract_first_match, sanitize_input + +__all__ = [ + "compare_signatures", + "compute_hmac_sha256", + "get_optional_env", + "get_required_env", + "CommandParseError", + "ConfigError", + "GitHubAPIError", + "OpenCodeError", + "WebhookValidationError", + "build_headers", + "create_http_client", + "parse_json_response", + "extract_first_match", + "sanitize_input", +] diff --git a/src/opencode_github/utils/crypto.py b/src/opencode_github/utils/crypto.py new file mode 100644 index 0000000..f4d2cd1 --- /dev/null +++ b/src/opencode_github/utils/crypto.py @@ -0,0 +1,25 @@ +"""HMAC-SHA256 signature utilities. + +Used by ``webhook_handler`` for payload validation and potentially by any +future module that needs message authentication. Centralising the crypto +logic avoids duplicating hmac / hashlib boilerplate. +""" + +from __future__ import annotations + +import hashlib +import hmac + + +def compute_hmac_sha256(secret: str | bytes, payload: str | bytes) -> str: + """Compute an HMAC-SHA256 hex digest for *payload* using *secret*.""" + if isinstance(secret, str): + secret = secret.encode() + if isinstance(payload, str): + payload = payload.encode() + return hmac.new(secret, payload, hashlib.sha256).hexdigest() + + +def compare_signatures(expected: str, actual: str) -> bool: + """Constant-time comparison of two hex-encoded signatures.""" + return hmac.compare_digest(expected.lower(), actual.lower()) diff --git a/src/opencode_github/utils/env.py b/src/opencode_github/utils/env.py new file mode 100644 index 0000000..a27ce56 --- /dev/null +++ b/src/opencode_github/utils/env.py @@ -0,0 +1,31 @@ +"""Shared environment-variable loading and validation. + +Both ``config.py`` and any module that reads env vars (webhook secrets, +API keys) use these helpers instead of duplicating ``os.environ`` lookups +with ad-hoc validation scattered across the codebase. +""" + +from __future__ import annotations + +import os + +from opencode_github.utils.errors import ConfigError + + +def get_required_env(name: str) -> str: + """Return the value of a required environment variable. + + Raises ``ConfigError`` if the variable is unset or empty. + """ + value = os.environ.get(name, "").strip() + if not value: + raise ConfigError( + f"Required environment variable {name!r} is not set", + context={"variable": name}, + ) + return value + + +def get_optional_env(name: str, default: str = "") -> str: + """Return the value of an optional environment variable, or *default*.""" + return os.environ.get(name, default).strip() or default diff --git a/src/opencode_github/utils/errors.py b/src/opencode_github/utils/errors.py new file mode 100644 index 0000000..b642a1f --- /dev/null +++ b/src/opencode_github/utils/errors.py @@ -0,0 +1,45 @@ +"""Shared exception hierarchy for OpenCode GitHub integration. + +Without this module, each domain module (config, github_client, +webhook_handler, comment_parser) would define its own ad-hoc exception +classes, leading to duplicated error-handling boilerplate and inconsistent +error types. +""" + +from __future__ import annotations + + +class OpenCodeError(Exception): + """Base exception for all OpenCode errors.""" + + def __init__( + self, message: str, *, context: dict[str, object] | None = None + ) -> None: + super().__init__(message) + self.context = context or {} + + +class ConfigError(OpenCodeError): + """Raised when configuration is missing or invalid.""" + + +class GitHubAPIError(OpenCodeError): + """Raised when a GitHub API request fails.""" + + def __init__( + self, + message: str, + *, + status_code: int | None = None, + context: dict[str, object] | None = None, + ) -> None: + super().__init__(message, context=context) + self.status_code = status_code + + +class WebhookValidationError(OpenCodeError): + """Raised when webhook payload validation fails.""" + + +class CommandParseError(OpenCodeError): + """Raised when a slash command cannot be parsed.""" diff --git a/src/opencode_github/utils/http.py b/src/opencode_github/utils/http.py new file mode 100644 index 0000000..bb46236 --- /dev/null +++ b/src/opencode_github/utils/http.py @@ -0,0 +1,62 @@ +"""Shared HTTP helpers for GitHub API interactions. + +Both ``github_client`` and ``webhook_handler`` (and any future HTTP-facing +module) use these helpers for header construction, response parsing, and +client creation rather than duplicating httpx boilerplate. +""" + +from __future__ import annotations + +from typing import Any + +import httpx + +from opencode_github.utils.errors import GitHubAPIError + +_DEFAULT_ACCEPT = "application/vnd.github+json" +_API_VERSION = "2022-11-28" + + +def build_headers( + token: str, + *, + accept: str = _DEFAULT_ACCEPT, + extra: dict[str, str] | None = None, +) -> dict[str, str]: + """Build standard GitHub API request headers.""" + headers: dict[str, str] = { + "Accept": accept, + "Authorization": f"Bearer {token}", + "X-GitHub-Api-Version": _API_VERSION, + } + if extra: + headers.update(extra) + return headers + + +def parse_json_response(response: httpx.Response) -> Any: + """Parse a GitHub API JSON response, raising on error status codes.""" + if response.status_code >= 400: + raise GitHubAPIError( + f"GitHub API error: {response.status_code} {response.reason_phrase}", + status_code=response.status_code, + context={"url": str(response.url), "body": response.text[:500]}, + ) + if not response.content: + return None + return response.json() + + +def create_http_client( + *, + token: str | None = None, + base_url: str = "https://api.github.com", + timeout: float = 30.0, +) -> httpx.AsyncClient: + """Create a pre-configured async HTTP client for GitHub API calls.""" + headers = build_headers(token) if token else {} + return httpx.AsyncClient( + base_url=base_url, + headers=headers, + timeout=timeout, + ) diff --git a/src/opencode_github/utils/text.py b/src/opencode_github/utils/text.py new file mode 100644 index 0000000..4d31126 --- /dev/null +++ b/src/opencode_github/utils/text.py @@ -0,0 +1,23 @@ +"""Shared text / regex utilities. + +Used by ``comment_parser`` for slash-command extraction and by any module +that needs pattern matching or input sanitisation. Factoring these out +prevents each module from writing its own one-off regex helpers. +""" + +from __future__ import annotations + +import re + + +def extract_first_match(pattern: str | re.Pattern[str], text: str) -> str | None: + """Return the first regex match (group 0) in *text*, or ``None``.""" + compiled = re.compile(pattern) if isinstance(pattern, str) else pattern + match = compiled.search(text) + return match.group(0) if match else None + + +def sanitize_input(text: str, *, max_length: int = 10_000) -> str: + """Strip control characters and truncate *text* to *max_length*.""" + cleaned = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f]", "", text) + return cleaned[:max_length] diff --git a/src/opencode_github/webhook_handler.py b/src/opencode_github/webhook_handler.py new file mode 100644 index 0000000..29b5c95 --- /dev/null +++ b/src/opencode_github/webhook_handler.py @@ -0,0 +1,85 @@ +"""GitHub webhook payload validation and event normalisation. + +Delegates HMAC verification to ``utils.crypto``, input cleaning to +``utils.text``, and error types to ``utils.errors`` — avoiding duplicated +crypto / parsing boilerplate that would otherwise live in every handler. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import Any + +from opencode_github.utils.crypto import compare_signatures, compute_hmac_sha256 +from opencode_github.utils.errors import WebhookValidationError +from opencode_github.utils.text import sanitize_input + + +class EventType(Enum): + """Supported GitHub webhook event types.""" + + ISSUE_COMMENT = "issue_comment" + PR_REVIEW_COMMENT = "pull_request_review_comment" + UNKNOWN = "unknown" + + @classmethod + def from_header(cls, header: str) -> EventType: + """Map an ``X-GitHub-Event`` header value to an ``EventType``.""" + mapping: dict[str, EventType] = { + "issue_comment": cls.ISSUE_COMMENT, + "pull_request_review_comment": cls.PR_REVIEW_COMMENT, + } + return mapping.get(header.lower(), cls.UNKNOWN) + + +@dataclass(frozen=True, slots=True) +class WebhookEvent: + """Normalised webhook event.""" + + event_type: EventType + action: str + comment_body: str + repo_full_name: str + issue_number: int + sender: str + + +def verify_signature( + payload: str | bytes, secret: str, signature_header: str +) -> None: + """Verify the ``X-Hub-Signature-256`` header against *payload*. + + Raises ``WebhookValidationError`` on mismatch. + """ + expected = compute_hmac_sha256(secret, payload) + actual = signature_header.removeprefix("sha256=") + if not compare_signatures(expected, actual): + raise WebhookValidationError( + "Webhook signature verification failed", + context={"expected_prefix": expected[:8]}, + ) + + +def parse_event(event_header: str, payload: dict[str, Any]) -> WebhookEvent: + """Parse a raw webhook *payload* into a ``WebhookEvent``.""" + event_type = EventType.from_header(event_header) + action: str = payload.get("action", "") + comment: dict[str, Any] = payload.get("comment", {}) + repo: dict[str, Any] = payload.get("repository", {}) + + if "issue" in payload: + issue_number: int = payload["issue"].get("number", 0) + elif "pull_request" in payload: + issue_number = payload["pull_request"].get("number", 0) + else: + issue_number = 0 + + return WebhookEvent( + event_type=event_type, + action=action, + comment_body=sanitize_input(comment.get("body", "")), + repo_full_name=repo.get("full_name", ""), + issue_number=issue_number, + sender=comment.get("user", {}).get("login", ""), + ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_comment_parser.py b/tests/test_comment_parser.py new file mode 100644 index 0000000..bcca04b --- /dev/null +++ b/tests/test_comment_parser.py @@ -0,0 +1,47 @@ +"""Tests for the comment parser module.""" + +from __future__ import annotations + +from opencode_github.comment_parser import ParsedCommand, is_trigger, parse_command + + +class TestParseCommand: + def test_oc_trigger(self) -> None: + result = parse_command("/oc fix the bug") + assert result == ParsedCommand(trigger="/oc", arguments="fix the bug") + + def test_opencode_trigger(self) -> None: + result = parse_command("/opencode refactor this") + assert result == ParsedCommand(trigger="/opencode", arguments="refactor this") + + def test_trigger_without_args(self) -> None: + result = parse_command("/oc") + assert result == ParsedCommand(trigger="/oc", arguments="") + + def test_trigger_in_multiline(self) -> None: + body = "Some context\n/oc do something\nmore text" + result = parse_command(body) + assert result is not None + assert result.trigger == "/oc" + assert result.arguments == "do something" + + def test_no_trigger(self) -> None: + assert parse_command("just a normal comment") is None + + def test_empty_body(self) -> None: + assert parse_command("") is None + assert parse_command(" ") is None + + def test_trigger_not_at_start_of_line(self) -> None: + assert parse_command("inline /oc command") is None + + def test_partial_trigger_rejected(self) -> None: + assert parse_command("/ocean is not a command") is None + + +class TestIsTrigger: + def test_true_for_valid(self) -> None: + assert is_trigger("/oc hello") is True + + def test_false_for_invalid(self) -> None: + assert is_trigger("no command here") is False diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..47bd8f1 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,45 @@ +"""Tests for the Config dataclass.""" + +from __future__ import annotations + +import pytest + +from opencode_github.config import Config +from opencode_github.utils.errors import ConfigError + + +class TestConfigFromEnv: + def test_loads_all_required(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("GITHUB_TOKEN", "ghp_test") + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test") + monkeypatch.setenv("WEBHOOK_SECRET", "whsec_test") + monkeypatch.delenv("OPENCODE_MODEL", raising=False) + monkeypatch.delenv("GITHUB_API_URL", raising=False) + + cfg = Config.from_env() + + assert cfg.github_token == "ghp_test" + assert cfg.anthropic_api_key == "sk-ant-test" + assert cfg.webhook_secret == "whsec_test" + assert cfg.model == "anthropic/claude-sonnet-4-20250514" + assert cfg.github_api_url == "https://api.github.com" + + def test_overrides_optionals(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("GITHUB_TOKEN", "t") + monkeypatch.setenv("ANTHROPIC_API_KEY", "k") + monkeypatch.setenv("WEBHOOK_SECRET", "s") + monkeypatch.setenv("OPENCODE_MODEL", "custom/model") + monkeypatch.setenv("GITHUB_API_URL", "https://ghes.example.com/api/v3") + + cfg = Config.from_env() + + assert cfg.model == "custom/model" + assert cfg.github_api_url == "https://ghes.example.com/api/v3" + + def test_raises_on_missing_required(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("WEBHOOK_SECRET", raising=False) + + with pytest.raises(ConfigError, match="GITHUB_TOKEN"): + Config.from_env() diff --git a/tests/test_github_client.py b/tests/test_github_client.py new file mode 100644 index 0000000..fcdc3b3 --- /dev/null +++ b/tests/test_github_client.py @@ -0,0 +1,28 @@ +"""Tests for the GitHub client module.""" + +from __future__ import annotations + +import pytest + +from opencode_github.github_client import GitHubClient +from opencode_github.utils.errors import GitHubAPIError + + +class TestGitHubClientLifecycle: + @pytest.mark.asyncio + async def test_raises_without_context_manager(self) -> None: + client = GitHubClient(token="test-token") + with pytest.raises(GitHubAPIError, match="not initialised"): + await client.get_issue("owner", "repo", 1) + + @pytest.mark.asyncio + async def test_context_manager_initialises_client(self) -> None: + async with GitHubClient(token="test-token") as client: + assert client._client is not None + + @pytest.mark.asyncio + async def test_context_manager_closes_client(self) -> None: + client = GitHubClient(token="test-token") + async with client: + pass + assert client._client is None diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..5e55dd2 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,124 @@ +"""Tests for shared utility modules.""" + +from __future__ import annotations + +import pytest + +from opencode_github.utils.crypto import compare_signatures, compute_hmac_sha256 +from opencode_github.utils.env import get_optional_env, get_required_env +from opencode_github.utils.errors import ( + CommandParseError, + ConfigError, + GitHubAPIError, + OpenCodeError, + WebhookValidationError, +) +from opencode_github.utils.text import extract_first_match, sanitize_input + +# --- errors --- + + +class TestErrorHierarchy: + def test_all_errors_inherit_from_opencode_error(self) -> None: + error_classes = ( + ConfigError, + GitHubAPIError, + WebhookValidationError, + CommandParseError, + ) + for cls in error_classes: + assert issubclass(cls, OpenCodeError) + + def test_opencode_error_stores_context(self) -> None: + err = OpenCodeError("boom", context={"key": "val"}) + assert str(err) == "boom" + assert err.context == {"key": "val"} + + def test_github_api_error_stores_status_code(self) -> None: + err = GitHubAPIError("not found", status_code=404) + assert err.status_code == 404 + + +# --- env --- + + +class TestGetRequiredEnv: + def test_returns_value(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("TEST_VAR", "hello") + assert get_required_env("TEST_VAR") == "hello" + + def test_raises_on_missing(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("TEST_VAR", raising=False) + with pytest.raises(ConfigError, match="TEST_VAR"): + get_required_env("TEST_VAR") + + def test_raises_on_empty(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("TEST_VAR", " ") + with pytest.raises(ConfigError): + get_required_env("TEST_VAR") + + +class TestGetOptionalEnv: + def test_returns_value(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("OPT", "world") + assert get_optional_env("OPT", "fallback") == "world" + + def test_returns_default_when_missing( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("OPT", raising=False) + assert get_optional_env("OPT", "fallback") == "fallback" + + def test_returns_default_when_empty(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("OPT", " ") + assert get_optional_env("OPT", "fallback") == "fallback" + + +# --- crypto --- + + +class TestHmac: + def test_compute_and_compare(self) -> None: + sig = compute_hmac_sha256("secret", "payload") + assert isinstance(sig, str) + assert len(sig) == 64 # hex sha256 + assert compare_signatures(sig, sig) + + def test_compare_case_insensitive(self) -> None: + sig = compute_hmac_sha256("s", "p") + assert compare_signatures(sig.upper(), sig.lower()) + + def test_mismatch(self) -> None: + assert not compare_signatures("aabb", "ccdd") + + def test_accepts_bytes(self) -> None: + sig = compute_hmac_sha256(b"key", b"data") + assert len(sig) == 64 + + +# --- text --- + + +class TestExtractFirstMatch: + def test_finds_match(self) -> None: + assert extract_first_match(r"\d+", "abc 42 def") == "42" + + def test_returns_none_on_no_match(self) -> None: + assert extract_first_match(r"\d+", "no digits") is None + + def test_accepts_compiled_pattern(self) -> None: + import re + + pat = re.compile(r"hello", re.IGNORECASE) + assert extract_first_match(pat, "say HELLO world") == "HELLO" + + +class TestSanitizeInput: + def test_strips_control_chars(self) -> None: + assert sanitize_input("a\x00b\x01c") == "abc" + + def test_preserves_normal_whitespace(self) -> None: + assert sanitize_input("hello\nworld\ttab") == "hello\nworld\ttab" + + def test_truncates(self) -> None: + assert sanitize_input("abcdef", max_length=3) == "abc" diff --git a/tests/test_webhook_handler.py b/tests/test_webhook_handler.py new file mode 100644 index 0000000..0901809 --- /dev/null +++ b/tests/test_webhook_handler.py @@ -0,0 +1,88 @@ +"""Tests for the webhook handler module.""" + +from __future__ import annotations + +import pytest + +from opencode_github.utils.crypto import compute_hmac_sha256 +from opencode_github.utils.errors import WebhookValidationError +from opencode_github.webhook_handler import ( + EventType, + parse_event, + verify_signature, +) + + +class TestEventType: + def test_issue_comment(self) -> None: + assert EventType.from_header("issue_comment") is EventType.ISSUE_COMMENT + + def test_pr_review_comment(self) -> None: + assert ( + EventType.from_header("pull_request_review_comment") + is EventType.PR_REVIEW_COMMENT + ) + + def test_unknown(self) -> None: + assert EventType.from_header("push") is EventType.UNKNOWN + + def test_case_insensitive(self) -> None: + assert EventType.from_header("Issue_Comment") is EventType.ISSUE_COMMENT + + +class TestVerifySignature: + def test_valid_signature(self) -> None: + payload = '{"action": "created"}' + secret = "test-secret" + sig = compute_hmac_sha256(secret, payload) + verify_signature(payload, secret, f"sha256={sig}") + + def test_invalid_signature_raises(self) -> None: + with pytest.raises(WebhookValidationError, match="signature verification"): + verify_signature("payload", "secret", "sha256=badbeef") + + +class TestParseEvent: + def test_issue_comment_event(self) -> None: + payload = { + "action": "created", + "comment": { + "body": "/oc fix bug", + "user": {"login": "alice"}, + }, + "issue": {"number": 42}, + "repository": {"full_name": "org/repo"}, + } + + event = parse_event("issue_comment", payload) + + assert event.event_type is EventType.ISSUE_COMMENT + assert event.action == "created" + assert event.comment_body == "/oc fix bug" + assert event.repo_full_name == "org/repo" + assert event.issue_number == 42 + assert event.sender == "alice" + + def test_pr_review_comment_event(self) -> None: + payload = { + "action": "created", + "comment": { + "body": "/opencode review this", + "user": {"login": "bob"}, + }, + "pull_request": {"number": 7}, + "repository": {"full_name": "org/repo"}, + } + + event = parse_event("pull_request_review_comment", payload) + + assert event.event_type is EventType.PR_REVIEW_COMMENT + assert event.issue_number == 7 + + def test_missing_fields_default_gracefully(self) -> None: + event = parse_event("unknown_event", {}) + + assert event.event_type is EventType.UNKNOWN + assert event.action == "" + assert event.comment_body == "" + assert event.issue_number == 0