Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
__pycache__/
*.pyc
*.egg-info/
.coverage
.pytest_cache/
dist/
build/
39 changes: 39 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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
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 helpers."""
86 changes: 86 additions & 0 deletions src/opencode_github/comment_parser.py
Original file line number Diff line number Diff line change
@@ -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
63 changes: 63 additions & 0 deletions src/opencode_github/config.py
Original file line number Diff line number Diff line change
@@ -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,
)
138 changes: 138 additions & 0 deletions src/opencode_github/github_client.py
Original file line number Diff line number Diff line change
@@ -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
]
Comment on lines +99 to +111

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 list_issue_comments does not handle pagination

The list_issue_comments method at src/opencode_github/github_client.py:99-111 makes a single GET request without pagination parameters. GitHub's API defaults to 30 items per page, so issues/PRs with more than 30 comments will silently return only the first page. This is fine for an initial implementation but could cause subtle data loss in production if the caller assumes all comments are returned.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


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}")
Loading
Loading