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
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
__pycache__/
*.pyc
*.egg-info/
.venv/
.pytest_cache/
.ruff_cache/
dist/
build/
46 changes: 45 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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
```
26 changes: 26 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
1 change: 1 addition & 0 deletions src/opencode_github/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""OpenCode GitHub integration library."""
47 changes: 47 additions & 0 deletions src/opencode_github/comment_parser.py
Original file line number Diff line number Diff line change
@@ -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
37 changes: 37 additions & 0 deletions src/opencode_github/config.py
Original file line number Diff line number Diff line change
@@ -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"
),
)
70 changes: 70 additions & 0 deletions src/opencode_github/github_client.py
Original file line number Diff line number Diff line change
@@ -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)
39 changes: 39 additions & 0 deletions src/opencode_github/utils/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
25 changes: 25 additions & 0 deletions src/opencode_github/utils/crypto.py
Original file line number Diff line number Diff line change
@@ -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())
31 changes: 31 additions & 0 deletions src/opencode_github/utils/env.py
Original file line number Diff line number Diff line change
@@ -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
45 changes: 45 additions & 0 deletions src/opencode_github/utils/errors.py
Original file line number Diff line number Diff line change
@@ -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."""
Loading