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
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,30 @@
# OpenCode GitHub Integration

Repository for OpenCode GitHub Actions integration.
Python helpers and a GitHub Actions workflow for triggering [OpenCode](https://github.com/anomalyco/opencode) from issue and PR comments.

## Quick start

1. Add the `ANTHROPIC_API_KEY` secret to your repository (Settings → Secrets and variables → Actions).
2. Copy `.github/workflows/opencode.yml` into your repo.
3. Comment `/oc <instruction>` or `/opencode <instruction>` on any issue or PR.

Only repository **owners**, **members**, and **collaborators** can trigger the bot.

## Configuration

| Environment variable | Default | Description |
|---|---|---|
| `ANTHROPIC_API_KEY` | *(required)* | Anthropic API key |
| `GITHUB_TOKEN` | *(auto-provided)* | GitHub token for API calls |
| `OPENCODE_MODEL` | `anthropic/claude-sonnet-4-20250514` | Model identifier |
| `GITHUB_API_URL` | `https://api.github.com` | GitHub API base URL (for GHES) |
| `OPENCODE_COMMANDS` | `/oc,/opencode` | Comma-separated trigger prefixes |
| `OPENCODE_TIMEOUT` | `30` | HTTP request timeout in seconds (1–300) |

## Development

```bash
pip install -e ".[dev]"
pytest
ruff check .
```
7 changes: 6 additions & 1 deletion src/opencode_github/comment_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ def extract_commands(body: str, allowed_triggers: list[str]) -> list[ParsedComma

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*$",
r"^\s*(" + "|".join(escaped) + r")(?=\s|$)\s*(.*?)\s*$",
re.MULTILINE,
)

Expand All @@ -63,8 +63,13 @@ def is_command_comment(body: str, allowed_triggers: list[str]) -> bool:
def split_arguments(arguments: str) -> list[str]:
"""Split an argument string into tokens respecting double-quoted groups.

Unmatched quotes are treated as literal characters — the remaining text
is collected into the final token.

>>> split_arguments('fix bug --verbose "hello world"')
['fix', 'bug', '--verbose', 'hello world']
>>> split_arguments('fix "unterminated')
['fix', 'unterminated']
"""
tokens: list[str] = []
current: list[str] = []
Expand Down
25 changes: 23 additions & 2 deletions src/opencode_github/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,16 @@

from __future__ import annotations

import logging
import os
from dataclasses import dataclass, field

logger = logging.getLogger(__name__)

MIN_TIMEOUT = 1
MAX_TIMEOUT = 300
DEFAULT_TIMEOUT = 30


@dataclass(frozen=True)
class Config:
Expand Down Expand Up @@ -47,11 +54,25 @@ def from_env(cls, environ: dict[str, str] | None = None) -> Config:
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()
timeout_raw = env.get("OPENCODE_TIMEOUT", str(DEFAULT_TIMEOUT)).strip()
try:
request_timeout = int(timeout_raw)
except ValueError:
request_timeout = 30
logger.warning(
"Invalid OPENCODE_TIMEOUT value %r, falling back to %d",
timeout_raw,
DEFAULT_TIMEOUT,
)
request_timeout = DEFAULT_TIMEOUT

if request_timeout < MIN_TIMEOUT or request_timeout > MAX_TIMEOUT:
logger.warning(
"OPENCODE_TIMEOUT=%d out of range [%d, %d], clamping",
request_timeout,
MIN_TIMEOUT,
MAX_TIMEOUT,
)
request_timeout = max(MIN_TIMEOUT, min(request_timeout, MAX_TIMEOUT))

return cls(
github_token=github_token,
Expand Down
58 changes: 47 additions & 11 deletions src/opencode_github/github_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@

from __future__ import annotations

import logging
from dataclasses import dataclass
from typing import Any

import httpx

logger = logging.getLogger(__name__)


@dataclass(frozen=True)
class PullRequest:
Expand Down Expand Up @@ -67,6 +70,7 @@ def __init__(
"X-GitHub-Api-Version": "2022-11-28",
},
timeout=timeout,
follow_redirects=False,
)

async def close(self) -> None:
Expand All @@ -82,9 +86,20 @@ 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.is_redirect or resp.status_code >= 300:
raise GitHubAPIError(
resp.status_code,
f"Unexpected redirect to {resp.headers.get('location', '?')}",
)
if resp.status_code == 204:
return None
return resp.json()
try:
return resp.json()
except ValueError as exc:
raise GitHubAPIError(
resp.status_code,
f"Invalid JSON in response body: {exc}",
) from exc

async def get_pull_request(self, owner: str, repo: str, number: int) -> PullRequest:
data = await self._request("GET", f"/repos/{owner}/{repo}/pulls/{number}")
Expand All @@ -97,18 +112,39 @@ async def get_pull_request(self, owner: str, repo: str, number: int) -> PullRequ
)

async def list_issue_comments(
self, owner: str, repo: str, issue_number: int
self, owner: str, repo: str, issue_number: int, *, max_pages: int = 10
) -> 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"],
"""Fetch all comments for an issue, following pagination.

Parameters
----------
max_pages:
Safety limit to prevent runaway pagination. Defaults to 10
(up to 1000 comments at 100 per page).
"""
comments: list[IssueComment] = []
page = 1
while page <= max_pages:
data = await self._request(
"GET",
f"/repos/{owner}/{repo}/issues/{issue_number}/comments",
params={"per_page": 100, "page": page},
)
if not data:
break
comments.extend(
IssueComment(
id=c["id"],
body=c.get("body") or "",
user_login=c["user"]["login"],
html_url=c["html_url"],
)
for c in data
)
for c in data
]
if len(data) < 100:
break
page += 1
return comments

async def create_issue_comment(
self, owner: str, repo: str, issue_number: int, body: str
Expand Down
10 changes: 8 additions & 2 deletions src/opencode_github/webhook_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,12 +102,18 @@ def parse_payload(event_type: EventType, payload: dict[str, Any]) -> WebhookEven
elif event_type == EventType.PR_REVIEW_COMMENT:
issue_number = payload.get("pull_request", {}).get("number", 0)

comment_id = comment.get("id", 0)
sender_login = comment.get("user", {}).get("login", "")

if not comment_id or not sender_login or not issue_number:
return None

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", ""),
comment_id=comment_id,
sender_login=sender_login,
repo_owner=owner_data.get("login", ""),
repo_name=repo_data.get("name", ""),
issue_number=issue_number,
Expand Down
24 changes: 24 additions & 0 deletions tests/test_comment_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,16 @@ def test_trigger_prefix_not_partial_word(self) -> None:
"""/ocean should NOT match /oc."""
assert extract_commands("/ocean voyage", TRIGGERS) == []

def test_none_body(self) -> None:
"""Passing None-ish empty string should return empty."""
assert extract_commands("", TRIGGERS) == []

def test_trigger_with_special_regex_chars(self) -> None:
"""Triggers with regex metacharacters are escaped properly."""
cmds = extract_commands("/oc+ hello", ["/oc+"])
assert len(cmds) == 1
assert cmds[0].trigger == "/oc+"


class TestIsCommandComment:
def test_true_for_matching(self) -> None:
Expand Down Expand Up @@ -94,3 +104,17 @@ def test_adjacent_quotes(self) -> None:

def test_no_quotes(self) -> None:
assert split_arguments("a b c") == ["a", "b", "c"]

def test_unmatched_quote(self) -> None:
"""Unmatched opening quote collects remaining text as one token."""
result = split_arguments('fix "hello world')
assert result == ["fix", "hello world"]

def test_empty_quoted_string(self) -> None:
"""Empty quotes produce no token (quotes stripped, nothing inside)."""
result = split_arguments('a "" b')
assert result == ["a", "b"]

def test_consecutive_spaces(self) -> None:
result = split_arguments("a b c")
assert result == ["a", "b", "c"]
29 changes: 27 additions & 2 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import pytest

from opencode_github.config import Config
from opencode_github.config import DEFAULT_TIMEOUT, MAX_TIMEOUT, MIN_TIMEOUT, Config


class TestConfigFromEnv:
Expand Down Expand Up @@ -42,7 +42,32 @@ def test_custom_timeout(self, minimal_env: dict[str, str]) -> None:
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
assert cfg.request_timeout == DEFAULT_TIMEOUT

def test_negative_timeout_clamped(self, minimal_env: dict[str, str]) -> None:
minimal_env["OPENCODE_TIMEOUT"] = "-5"
cfg = Config.from_env(minimal_env)
assert cfg.request_timeout == MIN_TIMEOUT

def test_zero_timeout_clamped(self, minimal_env: dict[str, str]) -> None:
minimal_env["OPENCODE_TIMEOUT"] = "0"
cfg = Config.from_env(minimal_env)
assert cfg.request_timeout == MIN_TIMEOUT

def test_excessive_timeout_clamped(self, minimal_env: dict[str, str]) -> None:
minimal_env["OPENCODE_TIMEOUT"] = "9999"
cfg = Config.from_env(minimal_env)
assert cfg.request_timeout == MAX_TIMEOUT

def test_boundary_min_timeout(self, minimal_env: dict[str, str]) -> None:
minimal_env["OPENCODE_TIMEOUT"] = str(MIN_TIMEOUT)
cfg = Config.from_env(minimal_env)
assert cfg.request_timeout == MIN_TIMEOUT

def test_boundary_max_timeout(self, minimal_env: dict[str, str]) -> None:
minimal_env["OPENCODE_TIMEOUT"] = str(MAX_TIMEOUT)
cfg = Config.from_env(minimal_env)
assert cfg.request_timeout == MAX_TIMEOUT

def test_whitespace_stripped(self, minimal_env: dict[str, str]) -> None:
minimal_env["GITHUB_TOKEN"] = " token_with_spaces "
Expand Down
71 changes: 71 additions & 0 deletions tests/test_github_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,52 @@ async def test_success(self, mock_router: respx.MockRouter, client: GitHubClient
)
assert comments[1].body == ""

async def test_empty_response(
self, mock_router: respx.MockRouter, client: GitHubClient
) -> None:
mock_router.get("/repos/owner/repo/issues/1/comments").mock(
return_value=httpx.Response(200, json=[])
)
comments = await client.list_issue_comments("owner", "repo", 1)
assert comments == []

async def test_pagination(self, mock_router: respx.MockRouter, client: GitHubClient) -> None:
"""Verify the client follows pagination when a full page is returned."""
page1 = [
{
"id": i,
"body": f"comment {i}",
"user": {"login": "user"},
"html_url": f"https://github.com/o/r/issues/1#issuecomment-{i}",
}
for i in range(100)
]
page2 = [
{
"id": 200,
"body": "last",
"user": {"login": "user"},
"html_url": "https://github.com/o/r/issues/1#issuecomment-200",
}
]

call_count = 0

def side_effect(request: httpx.Request) -> httpx.Response:
nonlocal call_count
call_count += 1
page = int(request.url.params.get("page", "1"))
if page == 1:
return httpx.Response(200, json=page1)
return httpx.Response(200, json=page2)

mock_router.get("/repos/o/r/issues/1/comments").mock(side_effect=side_effect)

comments = await client.list_issue_comments("o", "r", 1)
assert len(comments) == 101
assert comments[-1].body == "last"
assert call_count == 2


class TestCreateIssueComment:
async def test_success(self, mock_router: respx.MockRouter, client: GitHubClient) -> None:
Expand Down Expand Up @@ -153,3 +199,28 @@ class TestContextManager:
async def test_async_with(self) -> None:
async with GitHubClient(token="tok") as c:
assert c._token == "tok"


class TestRedirectHandling:
async def test_3xx_raises(self, mock_router: respx.MockRouter, client: GitHubClient) -> None:
mock_router.get("/repos/owner/repo").mock(
return_value=httpx.Response(
301, headers={"location": "https://api.github.com/repos/new-owner/repo"}
)
)
with pytest.raises(GitHubAPIError) as exc_info:
await client.get_repo("owner", "repo")
assert exc_info.value.status_code == 301
assert "redirect" in exc_info.value.detail.lower()


class TestInvalidJsonResponse:
async def test_invalid_json_raises(
self, mock_router: respx.MockRouter, client: GitHubClient
) -> None:
mock_router.get("/repos/owner/repo").mock(
return_value=httpx.Response(200, text="not json at all")
)
with pytest.raises(GitHubAPIError) as exc_info:
await client.get_repo("owner", "repo")
assert "Invalid JSON" in exc_info.value.detail
Loading