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
2 changes: 1 addition & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,5 @@ LICENSE
Thumbs.db

# Build artifacts / logs
node_modules
**/node_modules
*.log
24 changes: 24 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,30 @@ SENTRY_AUTH_TOKEN=
# Use a PAT with the scopes you need (typical: repo, read:org, workflow).
GH_TOKEN=

# === Optional: github-webhooks plugin ===
# This image ships a plugin (~/.config/opencode/plugins/github-webhooks.ts)
# that turns inbound GitHub webhooks into OpenCode agent sessions. A
# baseline config file is already baked in at
# ~/.config/opencode/webhooks.json — it wires `issues.assigned` to the
# bundled `github-issue-resolver` agent. Setting GITHUB_WEBHOOK_SECRET
# below activates the listener on port 5050 with that default trigger.

# HMAC secret matching what you configure in GitHub's webhook UI.
# Required to receive webhooks — without it the listener rejects every
# delivery with 503. Set the same value here and in GitHub's webhook UI.
GITHUB_WEBHOOK_SECRET=

# Override the bundled webhooks.json with one of your own. Default
# resolves to ~/.config/opencode/webhooks.json (the baked-in file).
# Point this at a path on the persistent ~/dev volume to customize
# triggers without rebuilding the image.
# WEBHOOKS_CONFIG=/home/developer/dev/.opencode/webhooks.json

# Port the plugin's webhook listener binds to. Defaults to 5050. Expose
# this separately from the opencode web UI port (4096 / $PORT) on your
# platform.
# WEBHOOK_PORT=5050

# === Optional: outbound proxy ===
# HTTPS_PROXY=
# HTTP_PROXY=
Expand Down
46 changes: 44 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,45 @@ COPY --chown=developer:developer \
opencode-user-config.json \
/home/developer/.config/opencode/opencode.json

# Bundled agents (e.g. github-issue-resolver). Copied into the user-level
# agents dir so they're discoverable from any session, including ones the
# webhook plugin spawns programmatically.
COPY --chown=developer:developer agents \
/home/developer/.config/opencode/agents

# Bundled plugins (e.g. github-webhooks). OpenCode auto-loads any
# .ts/.js file in this directory at startup. The sibling package.json
# declares the npm deps the plugins import (@opencode-ai/plugin); we
# `bun install` them once at build time so OpenCode doesn't have to do
# it on every container start.
#
# IMPORTANT: do NOT mount a runtime volume over /home/developer/.config/
# opencode — it would mask the baked-in node_modules and the plugin
# loader would fail at startup with `Cannot find module '@opencode-ai/
# plugin'`. Persistent state (sessions, auth) lives at ~/dev/.opencode
# already via the symlink set up below; that's the only directory you
# should attach a volume to.
COPY --chown=developer:developer plugins \
/home/developer/.config/opencode/plugins
COPY --chown=developer:developer opencode-config-package.json \
/home/developer/.config/opencode/package.json
COPY --chown=developer:developer opencode-config-bun.lock \
/home/developer/.config/opencode/bun.lock
RUN cd /home/developer/.config/opencode \
&& bun install --frozen-lockfile --production \
&& rm -rf ~/.bun/install/cache

# Default config for the github-webhooks plugin: one trigger that wires
# the `issues.assigned` event to the bundled `github-issue-resolver`
# agent. The plugin reads this on startup; without it, the listener
# stays off (no surprise port). Override per-deploy by setting
# WEBHOOKS_CONFIG to a path on your persistent volume (e.g.
# ~/dev/.opencode/webhooks.json) and putting your own file there. The
# HMAC secret is intentionally NOT in this file — set
# GITHUB_WEBHOOK_SECRET as an env var so it isn't baked into the image.
COPY --chown=developer:developer webhooks.json \
/home/developer/.config/opencode/webhooks.json

# Tiny entrypoint that mkdir's ~/dev/.opencode at runtime so a single
# Railway Volume mounted at ~/dev persists projects + OpenCode session/auth
# data together (~/.local/share/opencode is symlinked into it).
Expand All @@ -184,10 +223,13 @@ COPY --chmod=0755 docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
# No VOLUME directive — Railway rejects them. Attach a Railway Volume at
# /home/developer/dev (~/dev) via the dashboard for persistence; both
# projects you clone there and OpenCode session/auth data live in it.
EXPOSE 4096
# 4096 = opencode web UI; 5050 = plugin's webhook listener (only opens
# if WEBHOOKS_CONFIG points at a config file with at least one trigger).
EXPOSE 4096 5050
WORKDIR /home/developer/dev

# PORT lets PaaS platforms (Railway/Fly/Render) assign a port; falls back
# to 4096 locally.
# to 4096 locally. WEBHOOK_PORT (default 5050) is what the github-webhooks
# plugin binds its listener to.
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/docker-entrypoint.sh"]
CMD ["sh", "-c", "exec opencode web --hostname 0.0.0.0 --port ${PORT:-4096}"]
109 changes: 107 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ Self-hosted [OpenCode](https://opencode.ai) web UI in a Docker image, ready to d
- **OpenCode** built from source from the [`BYK/opencode`](https://github.com/BYK/opencode/tree/byk/cumulative) fork (`byk/cumulative` branch) — carries question-dock UX, plan-mode, and db perf fixes that aren't yet in upstream. Built fresh into the image; auto-update is effectively disabled because the fork has no release feed.
- [Sentry CLI](https://cli.sentry.dev), GitHub CLI, **nvm + Node 22 LTS** (`pnpm` / `yarn` via corepack), **Bun**, plus `git`, `ripgrep`, `fd`, `fzf`, `jq`, `yq`, and `build-essential`.
- No MCP servers preconfigured — add your own via a project-local `opencode.json` or by editing [`opencode-user-config.json`](./opencode-user-config.json) before building.
- **Bundled OpenCode plugin: [`github-webhooks`](./plugins/github-webhooks.ts)** — turns inbound GitHub webhook deliveries into OpenCode agent sessions running in the same `opencode` process. Ships with [`webhooks.json`](./webhooks.json) baked in (one default trigger: issue assigned → `github-issue-resolver`). Activates on container start once you set `GITHUB_WEBHOOK_SECRET` — the env var plus the bundled file are all you need. See [GitHub webhooks → agent sessions](#github-webhooks--agent-sessions).
- **Bundled agent: [`github-issue-resolver`](./agents/github-issue-resolver.md)** — autonomous "issue assigned → branch → plan → implement → PR" workflow, designed to be invoked by the webhook plugin or directly via `@github-issue-resolver`.
- Non-root `developer` user. OpenCode starts in `~/dev`. Mount a single persistent volume at `~/dev` (= `/home/developer/dev`) to keep your projects **and** OpenCode session/auth data across redeploys — `~/.local/share/opencode` is symlinked into `~/dev/.opencode`.

## Deploy on Railway
Expand Down Expand Up @@ -41,17 +43,120 @@ See [`.env.example`](./.env.example) for the full template.
| One of `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `GROQ_API_KEY`, `OPENROUTER_API_KEY` | **Required.** LLM provider key. |
| `SENTRY_AUTH_TOKEN`, `SENTRY_ORG`, `SENTRY_PROJECT`, `SENTRY_URL` | For the bundled `sentry` CLI. |
| `GH_TOKEN` | For the bundled `gh` CLI. PAT with the scopes you need. |
| `GITHUB_WEBHOOK_SECRET` | HMAC secret for the `github-webhooks` plugin. Required to receive webhooks. |
| `WEBHOOK_PORT`, `WEBHOOKS_CONFIG` | Optional plugin tuning. See [`.env.example`](./.env.example). |
| `PORT` | Set automatically by most PaaS providers. Defaults to `4096`. |

## GitHub webhooks → agent sessions

The bundled [`github-webhooks`](./plugins/github-webhooks.ts) plugin runs
**inside** the OpenCode server process — no sidecar, no second process to
supervise, no loopback HTTP. It opens its own listener on port `5050`
(configurable via `WEBHOOK_PORT`) and dispatches verified deliveries
into agent sessions via the in-process SDK client.

### Default behavior

The image ships with [`webhooks.json`](./webhooks.json) baked in at
`~/.config/opencode/webhooks.json`. It defines **one trigger**: when an
issue is assigned to someone, run the [`github-issue-resolver`](./agents/github-issue-resolver.md) agent against that
repo and issue.

Once `GITHUB_WEBHOOK_SECRET` is set in your environment, the plugin
boots its listener on port 5050 automatically. No further setup needed.

### Overriding the default config

The bundled file is fine for the "issue assigned → resolve it" flow
out of the box. To customize:

- **Edit before building** — change [`webhooks.json`](./webhooks.json) in
this repo and rebuild the image. Triggers stay version-controlled.
- **Override at runtime** — set `WEBHOOKS_CONFIG=/home/developer/dev/.opencode/webhooks.json`
(or any other path) and put your own file there. Handy for adding
per-deployment triggers without rebuilding.

The HMAC secret (`secret` field) is intentionally **not** baked into the
file — set `GITHUB_WEBHOOK_SECRET` as an env var instead.

### Config schema

The minimum-viable trigger:

```json
{
"triggers": [
{
"name": "issue-assigned",
"event": "issues",
"action": "assigned",
"agent": "github-issue-resolver",
"prompt_template": "Resolve issue #{{ payload.issue.number }} in {{ payload.repository.full_name }}."
}
]
}
```

The bundled [`webhooks.json`](./webhooks.json) is richer — its
`prompt_template` interpolates the issue title, body, assignee, author,
URL, and labels into a context-heavy prompt for the agent. Use that as
the working reference when writing your own trigger.

Field reference:

| Field | Required | What it does |
|---|---|---|
| `triggers[].name` | ✓ | Unique identifier; surfaces in plugin logs. |
| `triggers[].event` | ✓ | GitHub event header (`issues`, `pull_request`, `push`, ...). Use `"*"` to match anything. |
| `triggers[].action` | optional | If set, must match the payload's `action` exactly. Omit/`null` to match any action of this event. |
| `triggers[].agent` | ✓ | Agent name to invoke (built-in or from `agents/`). |
| `triggers[].prompt_template` | ✓ | Mustache-ish template. `{{ payload.foo.bar }}` looks up paths in the payload; missing paths render empty. |
| `triggers[].cwd` | optional | Override the session's working directory. Falls back to `default_cwd`, then to OpenCode's project root. |
| `port` | optional | Listener port; defaults to `5050` or `WEBHOOK_PORT`. |
| `secret` | optional | HMAC secret. Falls back to `GITHUB_WEBHOOK_SECRET`. |
| `max_concurrent` | optional | Cap on concurrent agent sessions across all triggers (default 2). |
| `timeout_ms` | optional | Per-session abort timeout (default 30 min). |
| `retention` | optional | Cap on persisted delivery rows for dedup (default 1000). |
| `default_cwd` | optional | Fallback `cwd` for triggers without one. |

In the GitHub webhook UI:

- **Payload URL**: `https://<your-domain>:5050/webhooks/github` (or however you route to that port).
- **Content type**: `application/json`.
- **Secret**: same value as `GITHUB_WEBHOOK_SECRET`.
- **Events**: pick what you need (`Issues`, `Pull request review`, etc.).

The plugin verifies `X-Hub-Signature-256`, dedups on `X-GitHub-Delivery`
(redeliveries are ack'd as `duplicate: true` and don't re-fire agents),
and parses each delivery's `action` for trigger matching. The dispatched
session itself is the system of record for everything that happens
afterward — view it in OpenCode's UI like any other session.

> **Railway note.** Railway only generates one HTTP domain per service. To
> reach `5050` you'll need a second Railway service pointing at the same
> image, a TCP proxy, or to route through Cloudflare. The opencode web UI
> on `4096`/`$PORT` and the plugin listener are independent — both speak
> plain HTTP on `0.0.0.0`.

### Health check

`GET http://<host>:5050/healthz` (the plugin's port, not OpenCode's
4096) returns `{ "ok": true, "plugin": "github-webhooks" }` once the
listener is up. No auth required.

## Local test

```bash
cp .env.example .env # edit, fill in the required values
docker build -t my-opencode .
docker run --rm -it -p 4096:4096 --env-file .env my-opencode
docker run --rm -it \
-p 4096:4096 -p 5050:5050 \
--env-file .env my-opencode
```

Open <http://localhost:4096>.
Open <http://localhost:4096> for OpenCode. Hit
<http://localhost:5050/healthz> if you've set up a `webhooks.json` and
want to verify the plugin loaded.

## Notes

Expand Down
123 changes: 123 additions & 0 deletions agents/github-issue-resolver.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
---
description: Resolves a GitHub issue end-to-end — clones the repo, branches, plans, implements, pushes, and opens a PR
mode: primary
temperature: 0.2
permission:
read: allow
edit: allow
glob: allow
grep: allow
list: allow
bash: allow
webfetch: allow
websearch: allow
task: allow
---

You are an autonomous engineer triggered by an inbound GitHub issue webhook.
Your job is to take an issue from "assigned" to "PR opened" without human
intervention, while staying conservative about scope.

## Inputs you'll receive in the prompt

- The issue's `repo` (owner/name), `number`, `title`, `body`, and `assignee`.
- The full webhook payload as JSON if more context is needed.

## Workflow

1. **Clone or update the repo** under `~/dev/<owner>/<repo>` using the
bundled `gh` CLI (it's authenticated via the `GH_TOKEN` env var). Use:
```sh
gh repo clone <owner>/<repo> ~/dev/<owner>/<repo> -- --depth=50
```
If the directory already exists, an earlier issue-resolution session
may have left it on a feature branch with uncommitted changes. Reset
defensively before doing anything else:
```sh
cd ~/dev/<owner>/<repo>
DEFAULT_BRANCH=$(gh repo view --json defaultBranchRef --jq .defaultBranchRef.name)
git fetch --all --prune
git reset --hard "origin/$DEFAULT_BRANCH" # discard local changes
git clean -fd # remove untracked files
git checkout "$DEFAULT_BRANCH"
```
This guarantees you start from a clean tree on the default branch.
If the repo had uncommitted work that mattered, that's the previous
run's bug — not yours to recover.

2. **Create a feature branch** named `issue-<number>-<short-slug>`:
```sh
git checkout -b issue-123-fix-thing
```

3. **Read the issue carefully**. Re-read the body. Look for linked
issues, code references (`file:line`), and acceptance criteria. If
the issue is ambiguous, lean toward the smallest interpretation that
plausibly resolves the user's stated problem — do NOT speculate
features.

4. **Explore the codebase** with `glob`, `grep`, and `read` before
touching anything. Identify:
- The specific files/functions the issue refers to.
- Existing tests that exercise the affected code.
- The project's coding style (look at neighbouring files).

5. **Plan**, then state the plan as a short bulleted list at the top of
your reply before implementing. If the change is more than ~5 files
or touches public APIs, stop. Post a comment on the issue via
`gh issue comment <number> --body "..."` asking for confirmation,
emit `BLOCKED: <reason>` as the final line of your reply, and
produce no PR.

6. **Implement** the smallest possible change. Update or add tests in
the same commit. Keep the diff focused — no opportunistic refactors.

7. **Verify**:
- Run the project's test suite if you can identify how (`npm test`,
`pnpm test`, `bun test`, `pytest`, `go test ./...`, `cargo test`).
If you can't determine the test command in 30 seconds, skip and
mention that in the PR body.
- Run `git diff` and self-review before committing.

8. **Commit + push** with a message body that:
- Subject line under 72 chars, imperative mood.
- References the issue: `Fixes #<number>`.
- One-paragraph "why" explaining the user-visible change.

9. **Open a PR** with `gh pr create`:
```sh
gh pr create --title "<subject>" --body "$(cat <<'EOF'
## Summary
<1-3 bullets>

## Why
<link to issue + paragraph>

## Testing
<what you ran, or "none — see note">

Closes #<number>
EOF
)"
```
Print the PR URL as the final line of your reply.

## Constraints

- Never push to `main`/`master` or whatever the default branch is.
Always work on a feature branch.
- Never `git push --force` to a remote branch you didn't create.
- Don't touch CI config, secrets, lockfile pinning, or package.json
versions unless the issue is *specifically* about that.
- If you can't make progress (auth error, missing context, the issue
is out of scope), post a comment on the issue explaining the blocker
via `gh issue comment <number>`, emit `BLOCKED: <reason>` as the
final line of your reply, and produce no PR.

## Output format

Your final assistant reply should be a short status line followed by:
- The PR URL (if created), or
- A clear `BLOCKED: <reason>` line and the issue comment URL you posted.

The host opencode server persists the full transcript; be terse here.
Loading
Loading