| .gitignore | ||
| LICENSE | ||
| README.md | ||
forgejo-agent
A service that receives Forgejo webhook events and dispatches them to AI agents configured to perform automated actions — triage issues, reproduce bugs, review PRs, and more.
Overview
┌──────────────┐
│ Forgejo │
│ (self-host) │
└──────┬───────┘
│ webhook (HTTPS)
▼
┌──────────────┐
│ Caddy │
│ (TLS term) │
└──────┬───────┘
│ HTTP :8080
▼
┌────────────────────────┐
│ forgejo-agent │
│ │
│ ┌──────────────────┐ │
│ │ Webhook Receiver │ │
│ └────────┬─────────┘ │
│ │ │
│ ┌────────▼─────────┐ │
│ │ Rule Engine │ │
│ └────────┬─────────┘ │
│ │ │
│ ┌────────▼─────────┐ │
│ │ Agent Executor │──┼──▶ LLM Provider
│ │ (langchaingo) │ │ (OpenAI/Anthropic/...)
│ └────────┬─────────┘ │
│ │ │
│ ┌────────▼─────────┐ │
│ │ Tool Runner │ │
│ │ │──┼──▶ Forgejo API
│ │ ┌────────────┐ │ │
│ │ │ boxlite │──┼──▶ Sandbox (run_command)
│ │ └────────────┘ │ │
│ └──────────────────┘ │
│ │
│ ┌──────────────────┐ │
│ │ Web UI │ │
│ │ (basic auth) │ │
│ └──────────────────┘ │
│ │
│ ┌──────────────────┐ │
│ │ Postgres │ │
│ └──────────────────┘ │
└────────────────────────┘
Architecture
1. Webhook Receiver (POST /api/webhook)
- Accepts Forgejo webhook payloads (JSON, content-type
application/json). - Validates the webhook secret (HMAC-SHA256) against a global secret configured in the credentials store.
- Persists the raw webhook to the
webhookstable. - Hands off to the Rule Engine asynchronously (goroutine + worker pool).
- Returns
200 OKimmediately to Forgejo to avoid timeouts.
2. Rule Engine
- A rule is a user-configured mapping of
(repo_pattern, event_type)→agent_config. repo_patternsupports globs (e.g.owner/*) to match multiple repositories.- Built-in event types mirror the Forgejo webhook events:
issues,issue_comment,pull_request,pull_request_comment,push,create,delete,release. - Each rule carries:
- The system prompt / persona for the LLM.
- Which tools the agent has access to.
- An explicit allow-list of actions the agent is permitted to take (e.g. "may post comments", "may add labels").
- Token rate limit (tokens per hour, default 1M).
- Enabled/disabled toggle.
- Rules are evaluated in order by priority; the first matching rule wins.
- A global token rate limit is enforced across all agents (configurable via env var).
3. Agent Executor (langchaingo)
- Uses langchaingo for LLM interaction, tool calling, and the agent loop.
- The executor constructs a conversation context from the webhook payload and the rule's system prompt.
- langchaingo's agent framework drives the LLM interaction, handling the function-calling / tool-use loop automatically.
- All tool invocations are logged to the
actionstable along with inputs and results. - Support for any provider langchaingo supports: OpenAI, Anthropic, Ollama, Google AI, AWS Bedrock, Azure OpenAI, and others.
4. Tools (what agents can do)
Available tools are registered per rule. Implemented as Go functions using
langchaingo's tool.Tool interface:
| Tool | Description |
|---|---|
post_issue_comment |
Add a comment on an issue or pull request |
edit_issue |
Change title, body, or assignee of an issue |
add_labels |
Add labels to an issue or pull request |
close_issue |
Close an issue or pull request |
fetch_issue |
Get full details of an issue |
fetch_diff |
Retrieve the diff for a pull request |
fetch_file |
Read a file at a given path/ref from the repository |
search_code |
Search repository code via Forgejo's API |
get_commit |
Retrieve details of a specific commit |
run_command* |
Execute a shell command inside a boxlite sandbox (opt-in) |
*
run_commandis isolated via boxlite's Go SDK. Each command invocation creates a lightweight VM sandbox running an OCI container with the configured image (e.g.python:slim,ubuntu:latest). The sandbox is ephemeral — state is not preserved between calls. CPU/memory limits and a timeout are enforced. Must be explicitly enabled per rule.
5. Database Schema (Postgres)
Schema migrations are managed by goose.
SQL queries are built using jet, which generates
type-safe Go code from a live database. The generated code lives under internal/db/gen/
and is committed to the repository.
-- Incoming webhook log
CREATE TABLE webhooks (
id BIGSERIAL PRIMARY KEY,
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
event_type TEXT NOT NULL, -- e.g. 'issues', 'pull_request'
repo_path TEXT NOT NULL, -- e.g. 'owner/repo'
payload JSONB NOT NULL, -- raw webhook body
signature TEXT, -- HMAC signature received
matched_rule_id BIGINT REFERENCES rules(id),
status TEXT NOT NULL DEFAULT 'pending' -- pending | processing | completed | failed
);
-- Action taken by an agent
CREATE TABLE actions (
id BIGSERIAL PRIMARY KEY,
webhook_id BIGINT NOT NULL REFERENCES webhooks(id),
rule_id BIGINT REFERENCES rules(id),
tool_name TEXT NOT NULL, -- which tool was invoked
tool_input JSONB, -- arguments the LLM passed
tool_output JSONB, -- result from the tool
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Agent behavior rules
CREATE TABLE rules (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
repo_pattern TEXT NOT NULL, -- glob, e.g. 'owner/*'
event_type TEXT NOT NULL, -- Forgejo event type
enabled BOOLEAN NOT NULL DEFAULT true,
llm_provider TEXT NOT NULL, -- e.g. 'openai', 'anthropic', 'ollama'
llm_model TEXT NOT NULL,
system_prompt TEXT NOT NULL, -- agent persona / instructions
allowed_tools TEXT[] NOT NULL, -- e.g. '{post_issue_comment, add_labels}'
max_tokens_per_hour BIGINT NOT NULL DEFAULT 1000000, -- per-agent rate limit
priority INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Encrypted credentials (Forgejo API tokens, LLM API keys, etc.)
CREATE TABLE credentials (
id BIGSERIAL PRIMARY KEY,
key TEXT NOT NULL UNIQUE, -- e.g. 'forgejo_api_token', 'openai_api_key'
value TEXT NOT NULL, -- AES-256-GCM encrypted value
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Secrets encryption at rest: Credential values are encrypted with AES-256-GCM. The encryption key is derived from the
ENCRYPTION_KEYenvironment variable (a 256-bit hex string). If unset, the process exits with an error.
6. Authentication & Web UI
Web UI authentication is entirely in-memory. Credentials are never written to the database.
- Basic auth — the browser sends an
Authorizationheader on every request. The server checks it against in-memory credentials. No sessions, no cookies. - Admin credentials are set via environment variables:
AUTH_USERNAME— admin username.AUTH_PASSWORD— admin password.
- If neither is set, forgejo-agent generates a random username + password on startup and prints them to stdout. These are held in memory for the lifetime of the process only.
- The auto-generated credentials are printed at
INFOlevel so they appear in both console and structured log output.
Web UI pages (server-rendered with Go html/template):
| Page | Description |
|---|---|
| Dashboard | Recent webhook events, action counts, agent status |
| Rules | CRUD list of rules with ordering, enable/disable toggle |
| Event Log | Searchable, filterable, paginated table of webhooks. Click to expand payload, matched rule, actions taken |
| Credentials | Set or update Forgejo API token, LLM API keys, webhook secret |
7. Rate Limiting
- Per-agent limit: Each rule has a
max_tokens_per_hourfield (default 1,000,000). When an agent hits its limit, the webhook is logged asfailedwith a rate-limit reason. Limits reset on a rolling window. - Global limit:
GLOBAL_MAX_TOKENS_PER_HOURenv var (default 10,000,000) caps total token consumption across all agents. When hit, new webhooks are queued until the window rolls forward or rejected if the queue is full. - Token counting uses the LLM provider's reported usage from each API response for accurate metering.
8. Deployment
┌──────────────────────────────────────────────────────┐
│ Host / VM │
│ │
│ caddy (TLS termination, reverse proxy to :8080) │
│ forgejo-agent (single Go binary, listens :8080) │
│ postgres (or external connection string) │
└──────────────────────────────────────────────────────┘
- Caddyfile example:
agent.example.com {
reverse_proxy localhost:8080
}
- forgejo-agent is configured via environment variables:
| Env var | Description | Default |
|---|---|---|
DATABASE_URL |
Postgres connection string | postgres://localhost:5432/forgejo_agent |
LISTEN_ADDR |
HTTP listen address | :8080 |
BASE_URL |
Public base URL (used in generated links) | http://localhost:8080 |
LOG_LEVEL |
zerolog level (debug, info, warn, error, fatal) |
info |
LOG_FORMAT |
Log format (json, console) |
json (auto-detects TTY) |
AUTH_USERNAME |
Admin username for basic auth | auto-generated if unset |
AUTH_PASSWORD |
Admin password for basic auth | auto-generated if unset |
ENCRYPTION_KEY |
256-bit hex key for credential encryption at rest | (required) |
MAX_CONCURRENT_AGENTS |
Agent worker pool size | 4 |
GLOBAL_MAX_TOKENS_PER_HOUR |
Global token rate limit | 10000000 |
WEBHOOK_QUEUE_SIZE |
Pending webhook buffer size | 100 |
Development Workflow
Schema changes
- Write a new SQL migration file in
migrations/using goose conventions (e.g.00002_add_rate_limits.sql). - Apply with
goose -dir migrations postgres "$DATABASE_URL" up. - Regenerate jet types:
jet -dsn="$DATABASE_URL" -schema=public -path=./internal/db/gen. - Commit both the migration file and the regenerated Go code.
The generated code under
internal/db/gen/must always be in sync with the current migration state. CI should verify this (runjetand check for a cleangit diff).
Implementation Plan
Phase 1 — Skeleton
- Project layout following standard Go conventions:
cmd/,internal/. - CLI entry point using cobra. Root command starts the server; future subcommands can handle credential rotation, health checks, etc.
- zerolog for structured logging:
- JSON output to stderr by default.
zerolog.ConsoleWriterwith colorized output whenLOG_FORMAT=consoleor when running on an interactive terminal (isattyauto-detection).
- Auto-run goose migrations on startup.
- Basic HTTP router using
net/http+ chi. - Caddy in front for TLS during development.
Phase 2 — Webhook Ingestion
- Webhook handler: verify HMAC signature, parse JSON, insert into
webhookstable using jet-generated query builders. - Rule matching engine: load rules from DB (using jet), match
repo_pathglobevent_type, resolve first match by priority.
- Worker pool: N goroutines pull from a buffered channel, process webhooks.
Phase 3 — Agent Loop with langchaingo
- Integrate langchaingo's agent package. Configure LLM providers via langchaingo's built-in backends (OpenAI, Anthropic, Ollama, etc.).
- Define tools as langchaingo
tool.Toolimplementations. - Agent loop: langchaingo manages the conversation, tool calling, and response handling internally.
Phase 4 — Tools
- Implement Forgejo API tools (
post_issue_comment,edit_issue,add_labels,close_issue,fetch_issue,fetch_diff,fetch_file,search_code,get_commit). Each tool reads the Forgejo API token from the credentials table (decrypted at load time). - Implement
run_commandusing the boxlite Go SDK:- Create ephemeral boxlite Box from a configurable OCI image.
- Execute the command inside with a timeout and resource limits.
- Capture stdout/stderr and return them to the LLM.
- Destroy the box after execution.
Phase 5 — Web UI
- Server-rendered HTML using Go
html/template. Minimal CSS. - HTTP basic auth middleware checks credentials on every request against
in-memory values (set via
AUTH_USERNAME/AUTH_PASSWORDenv vars or auto-generated at startup). - Pages: Dashboard, Rules CRUD, Event Log (paginated), Credentials management.
- Auto-generate admin credentials on startup if not provided, log them.
Phase 6 — Rate Limiting & Operations
- Token-based rate limiting: per-rule and global, using a rolling-window counter keyed by rule ID (in-memory, backed by atomic counters).
- Prometheus metrics endpoint (
/metrics). - Graceful shutdown with webhook drain.
- Health check endpoint (
/health). - CLI subcommand to rotate the encryption key.
Project Layout
forgejo-agent/
├── cmd/
│ └── forgejo-agent/
│ └── main.go # cobra root command, server startup
├── internal/
│ ├── webhook/ # webhook handler, HMAC validation
│ ├── rules/ # rule engine, matching logic
│ ├── agent/ # agent executor (langchaingo integration)
│ │ └── tools/ # tool implementations (Forgejo API + boxlite)
│ ├── db/
│ │ ├── gen/ # jet-generated types and query builders (committed)
│ │ └── migrations.go # goose migration runner
│ ├── auth/ # basic auth middleware
│ ├── ui/ # HTML templates, handlers
│ │ └── templates/
│ ├── ratelimit/ # token rate limiter
│ └── config/ # configuration loading
├── migrations/ # goose SQL migration files
│ └── 00001_initial.sql
├── scripts/ # helper scripts (dev caddy, etc.)
├── Dockerfile
├── go.mod
├── go.sum
└── README.md
Dependencies
| Library | Purpose |
|---|---|
github.com/spf13/cobra |
CLI framework |
github.com/go-chi/chi/v5 |
HTTP router and middleware |
github.com/rs/zerolog |
Structured logging with colorized console output |
github.com/jackc/pgx/v5 |
Postgres driver |
github.com/pressly/goose/v3 |
Database migration engine |
github.com/go-jet/jet/v2 |
Type-safe SQL query builder (code generation from DB) |
github.com/tmc/langchaingo |
LLM integration, agent framework, tool calling |
github.com/boxlite-ai/boxlite/sdks/go |
Sandboxed command execution for agents |
github.com/gobwas/glob |
Glob matching for repo patterns |
golang.org/x/crypto |
bcrypt (not needed for auth; AES-256-GCM for credential encryption) |
golang.org/x/term |
Terminal detection for zerolog console writer auto-enable |
Open Questions
-
Boxlite image management — The
run_commandtool needs an OCI image to boot. Should each rule configure its own image (e.g.python:slimfor issue reproduction,ubuntu:latestfor build checks), or use a single global default? A per-rule default with a global fallback seems most flexible. -
Repository access for run_command — To reproduce bugs, the sandbox needs a clone of the repository. Options: (a) shallow-clone on every
run_commandcall inside the boxlite VM, (b) use a read-only volume mount of a pre-cloned repo. Option (a) is simpler; option (b) is faster for repos with large histories. Start with (a) and add (b) if needed. -
Webhook retry / durability — If forgejo-agent is down when a webhook is sent, Forgejo will retry according to its own retry policy. Is that sufficient, or do we need an out-of-process queue (e.g. Redis)? The in-process channel + Postgres write-before-process should be sufficient for single-node deployments.