Imported from R44VC0RP/helper (
AGENTS.md). Install upstream withnpx skills add R44VC0RP/helper. Copyright stays with the author.
AGENTS.md - issue-management
Automated GitHub issue triage pipeline for anomalyco/opencode. Bun + Hono API server | Neon Postgres + Drizzle ORM | Daytona sandboxes | OpenCode agents
Build & Run Commands
bun install # Install dependencies (root + web/)
bun run dev # Turbo TUI: API + Vite + pgrok in split view
bun run dev:api # API server only (port 3000)
bun run dev:web # Vite dev server only (port 5173)
bun run dev:tunnel # pgrok tunnel (configure your own domain)
bun run build # Build API + web in parallel via turbo
bun run feed 12535 # Test: feed existing GitHub issues through pipeline
bun run db:generate # Generate Drizzle migration SQL from schema changes
bun run db:migrate # Run migrations against Neon Postgres
bun run db:studio # Open Drizzle visual DB explorer
No test framework is configured yet. No backend linter. Frontend has ESLint (bun run lint in web/).
Turbo uses bun workspaces (web/ is a workspace). Need "packageManager": "bun@x.x.x" in root package.json for turbo to work. Root tasks are //#taskname, workspace tasks are web#taskname. Set "ui": "tui" in turbo.json for tmux-like split view.
Architecture
POST /webhooks/github → verify signature → fire-and-forget pipeline:
1. resolveUser() → DB lookup/create, background profiling (non-blocking)
2. runTriage() → Daytona sandbox + OpenCode agent → structured JSON
3. processResult() → store triage, apply karma, queue actions for approval
Layer Map
| Layer | Directory | Pattern |
|---|---|---|
| Entrypoint | src/index.ts |
Hono app, middleware, route mounting |
| API Routes | src/api/*.ts |
One Hono router per resource, export default |
| Pipeline | src/pipeline/*.ts |
3-step orchestration, pure functions |
| Services | src/services/*.ts |
Business logic (karma, sandbox, indexer, notifier) |
| DB | src/db/*.ts |
Drizzle schema, singleton client, migration runner |
| GitHub | src/github/*.ts |
Octokit wrapper, webhook signature verification |
| Types | src/types/*.ts |
Domain types shared across modules |
| Agents | agents/*.md + opencode.json |
AI agent prompts deployed into sandboxes |
| Config | src/config.ts |
Zod-validated env vars |
Code Style
Formatting
- Semicolons: always
- Quotes: double quotes in backend
.ts - Trailing commas: yes, on multiline objects/arrays/params
- Indentation: 2 spaces
- Blank lines: single blank line between functions and logical sections
Imports
- External packages first (
hono,drizzle-orm,zod,@octokit/*,@daytonaio/sdk) - Internal imports (
../db/client,../services/karma) - Type-only imports use
import type { Foo }syntax
import { Hono } from "hono";
import { eq, desc } from "drizzle-orm";
import { getDb } from "../db/client";
import { users } from "../db/schema";
import type { WebhookEvent } from "../types/webhook";
Naming
| Element | Convention | Example |
|---|---|---|
| Files | kebab-case.ts |
run-triage.ts, config-store.ts |
| Variables/functions | camelCase |
triageResult, processWebhook() |
| Types/interfaces | PascalCase |
TriageResult, WebhookEvent |
| DB columns | snake_case SQL → camelCase TS |
github_id → githubId |
| DB tables | snake_case |
karma_events, pending_actions |
| JSON fields in types | snake_case |
is_user_specific, pr_merge_rate |
| Constants | UPPER_CASE for simple, camelCase for complex |
AGENTS_DIR, envSchema |
Types
interfacefor objects,typefor string unions- Shared types go in
src/types/, single-use types defined inline in the file - JSONB columns:
.$type<Record<string, unknown>>()in schema, cast withas unknown as Record<string, unknown>when writing - Drizzle row types:
type UserRow = typeof users.$inferSelect
Exports
- Route modules:
export default(Hono routers) - Everything else: named exports (functions, types, constants)
Error Handling
- Console logs use bracketed prefixes:
console.log("[pipeline] Starting...") - Background tasks use fire-and-forget:
.catch((err) => console.error(...)) - API routes return
c.json({ error: "message" }, statusCode)on failure - No custom error classes — use native
Error - Sandbox output parsing has a graceful fallback returning a safe default
TriageResult
Singletons
External clients use the lazy singleton pattern:
let _db: ReturnType<typeof createDb> | null = null;
export function getDb() {
if (!_db) { _db = createDb(); }
return _db;
}
Used by: getEnv(), getDb(), getOctokit(), getWebhooks()
DB Access
- Call
getDb()locally in each function (no DI) - Query style:
db.select().from(table).where(eq(...)).limit(1) - Check
.length === 0for not-found, returnc.json({ error }, 404) - Config values:
getConfigValue(key, defaultValue)reads fromconfigtable
Files That Must Change Together
New DB table: src/db/schema.ts → src/types/*.ts → run db:generate + db:migrate → service file → API route
New API route: src/api/new-route.ts → src/index.ts (import + app.route())
New agent: agents/new-agent.md → src/services/sandbox.ts (read + deploy file) → pipeline step
Triage output change: src/types/triage.ts → agents/triage.md (JSON schema + instructions) → agents/tools/write-triage-result.ts (tool arg schema) → src/services/sandbox.ts (fallback result) → consider: src/pipeline/process-result.ts, src/services/karma.ts, frontend if new field needs special handling
Env var change: .env + .env.example + src/config.ts (Zod schema)
Key Design Decisions
- Approval queue: Actions (labels, comments) go to
pending_actionstable, never auto-executed by default. Each triage can create multiple actions (e.g., one for labels, one for draft comment) — each is independently approvable. - Single GitHub token (
GITHUB_TOKEN): Used both server-side (Octokit) and in sandboxes (gh CLI). Fine-grained PAT with issues r/w, PRs read, contents read. - Agent is analysis-only: The triage agent never comments on issues. It returns structured JSON classification for human review.
- Sandbox isolation: Each triage runs in a disposable Daytona sandbox. Clone repo, run agent, parse result, destroy. Agent permissions controlled by
opencode.jsondeployed to sandbox. - Split user update paths: Environment extraction runs on EVERY webhook (
issue-environment.ts), but full GitHub profiling only runs when stale >7 days (user-indexer.ts). Environment data can be fresher than PR/commit stats. - OpenCode Zen: Single API key for multiple AI models. Auth written to
~/.local/share/opencode/auth.jsonin sandbox.
Non-Obvious Discoveries
Daytona Sandbox Environment
- Default TypeScript snapshots include
opencode-ai,bun,ts-nodepre-installed — but NOTghCLI - Use Daytona Declarative Builder to create custom images with
ghCLI:Image.base("node:22-bookworm-slim").runCommands("apt-get install gh")— cached for 24h - Sandbox user is
/home/daytonain default snapshots,/rootin custom images — discover$HOMEdynamically withecho $HOME /workspace/is not writable in some images — clone to$HOME/opencodeinstead- OpenCode Zen auth must be written to
~/.local/share/opencode/auth.json(not/root/.local) - When cloning a repo with its own
.opencode/directory, deploy your agent config to~/.config/opencode/(global) to avoid conflicts
OpenCode CLI in Non-Interactive Mode
opencode run --agent <name>via stdin does NOT load project config files (.opencode/agents/*.md) correctly- CRITICAL:
--format jsondisables ALL tools — agent has zero tools when this flag is used, cannot write files or run commands. Drop the flag to get bash, read, write, grep, glob, etc. - Reliable file I/O: Agent has full tools in plain
opencode runmode. Tell agent to use thewritetool to create/tmp/result.json, thencat /tmp/result.jsonto read it back. 100% reliable. - Custom tools in
.opencode/tools/do NOT load in non-interactive piped stdin mode, even withOPENCODE_CONFIG_DIRset. Use built-in tools instead. opencode serve+opencode run --attachfails with "No context found for instance" (as of 2026-02-06) — not viable yet- Inline the full agent prompt + context into the message instead of relying on file discovery (
--agentflag +.opencode/agents/) - Write opencode config to project root (
opencode.json) or useOPENCODE_CONFIG_CONTENTenv var with"permission": "allow"to enable all tools
GitHub API Quirks
- User IDs exceed 32-bit int:
github_idmust bebigint, notintegerin Postgres (user ID 3908667808 caused "out of range" error) - Fine-grained PATs cannot be created via
ghCLI or REST API — must use web UI atgithub.com/settings/tokens?type=beta - Public repo cloning (
git clone --depth=1) does not require auth, butghcommands need the token - GraphQL
contributionsCollectiongives last-year stats (commits, PRs, reviews, repos) — not available via REST API
Vite Dev Setup
- Vite HMR WebSocket cannot go through HTTP proxy — causes constant page refreshes
- Fix:
hmr: { clientPort: 5173 }invite.config.tsmakes HMR connect directly to Vite, bypassing the API proxy on port 3000 - Hono API on port 3000 proxies all non-
/api/*routes to Vite on 5173 for dev — single port for frontend + backend
Octokit Search API
octokit.rest.search.issuesAndPullRequests()triggers deprecation warnings but the API still works fine — suppress via custom log handler insrc/github/client.ts
Bun.serve Configuration
- Default idle timeout is 10 seconds — long-running requests (contributor score API: ~15s) will be killed mid-execution with "request timed out" error
- Fix: Add
idleTimeout: 120to the Bun.serve export object insrc/index.tsto allow 2-minute requests - Hono SPA pattern: Check
fs.existsSync("web/dist/index.html")at boot — serve static files viaserveStatic({ root: "./web/dist" })in production, proxy to Vite in dev
Chrome Extension Integration
- CORS required: Chrome extensions fetching from your API need
cors()middleware withorigin: ["https://github.com", "chrome-extension://*"] - GitHub Turbo navigation fires multiple events — both
turbo:loadandturbo:rendertrigger on page transitions. Debounce withsetTimeoutto prevent duplicate DOM injections. - GitHub's
.markdown-bodyclass overrides font-size to 16px — remove the class or usefont-size: 14px !importantto match GitHub comment styling
Contributor Scoring vs Karma
- Original karma system was punitive:
-2for questions,-1for config issues,-2for low-effort — good contributors got negative scores - Contributor score (
contributor-score.ts) is contribution-based: merged PRs (12 pts each), global reputation, commit volume - Grades: S (150+), A (80+), B (40+), C (15+), D (<15) — based on logarithmic weighting of PRs, commits, followers, account age
- Karma events still logged but only for confirmed bugs (+1) and duplicates (-1) — issue filing is neutral
- Trust level derived from score + merge rate, not issue quality
API List Endpoints and Counts
- Critical: List endpoints (
/api/issues,/api/users,/api/approvals) returnfiltered.lengthascount— capped bylimitparam (default 50) - Dashboard stats show capped counts, not true DB totals. Fix: separate
SELECT COUNT(*)query in parallel with data fetch (src/api/issues.ts:15-26,src/api/users.ts:15-29,src/api/approvals.ts:15-39) - Must import
countfromdrizzle-ormand use it explicitly — can't rely on array length for true counts - Status filtering happens in JS (
.filter()) after fetch, not in SQL — means count query needs a WHERE clause matching the filter
Approvals API and Issue Context
pending_actionstable stores onlyissueId(FK), not the GitHub issue number or title- Original
/api/approvalsdid no JOINs — frontend showed internal DB ID (action.issueId) instead of GitHub number - Fix requires LEFT JOIN to
issuestable to fetchtitle,githubNumber,triageResult(lines 15-32 insrc/api/approvals.ts) - Frontend
PendingActiontype must match the JOIN output — addissueTitle,issueGithubNumber,issueState,triageResultfields
Email Escalation Service
src/services/notifier.tswas a stub (console.log only) with// TODO: Integrate with Inbound email APIcomment- Pipeline still marked issues as
"escalated"in DB even though no email was sent (src/pipeline/process-result.ts:129) INBOUND_EMAIL_APIenv var existed but was never referenced in code — the notifier didn't use it- Real implementation:
POST https://api.inbound.new/v2/emailswith Bearer token auth,from/to/subject/htmlbody - Must wrap escalation call in try/catch — email failure shouldn't crash the pipeline after triage/karma already committed
Docker Static IP for Reverse Proxy
- Docker Compose assigns dynamic IPs by default — container gets a new IP on each rebuild/recreate
- NGINX reverse proxy configs become stale when pointing to old IPs (502 errors)
- Fix: specify
ipv4_addressundernetworksindocker-compose.ymlwith a static IP on the proxy network - Static IP survives
docker compose up -d --force-recreate— proxy config stays valid across deploys
Frontend Routing Pattern
- App uses
useState('dashboard')+ switch statement for page navigation, NOT React Router (web/src/App.tsx:11) - No URL routing, no deep linking, no browser back/forward in the original implementation
- nuqs integration: Use
nuqs/adapters/reactadapter (works with window.history directly, no router needed) parseAsStringLiteral(['page1','page2',...])for type-safe page navigation via URL query params- Keep
history: 'push'for page nav,history: 'replace'for filters to avoid polluting browser history
Drizzle ORM JSONB Queries
- Query builder doesn't support JSONB operators (
->,->>,?, etc.) — must use raw SQL - Aggregate queries on JSONB fields require
db.execute(sql\SELECT triage_result->>'type' as type, COUNT(*) FROM issues GROUP BY 1`)` - Return type is
{ rows: unknown[] }— cast with(result.rows as any[]).map(r => ({ field: r.field })) - Example: stats endpoint (
src/api/stats.ts:20-60) uses raw SQL for all JSONB GROUP BY queries - GIN indexes help if JSONB queries become slow:
CREATE INDEX idx_issues_triage ON issues USING gin(triage_result)
Post-Query Filtering Breaks Pagination
- Pattern to avoid: Fetching N rows, then filtering in JS with
.filter()— pagination offset/limit counts rows BEFORE the filter - Original
src/api/issues.tsandsrc/api/users.tsfiltered by status/trust_level after the DB query (line 22-23) — breaks when combined with offset - Fix: push ALL filters into SQL
WHEREclauses usingand()combinator from drizzle-orm - Applies to both the data query AND the count query — otherwise count doesn't match filtered results
Daytona Preview URL Authentication
- Preview URLs have format:
https://4096-xxx.proxy.daytona.works?tkn=abc123 - Broken pattern:
${previewUrl}/pathcreates...?tkn=abc/path(query param before path) - Correct: Parse URL, build path properly:
new URL(previewUrl); url.pathname += path; url.toString() - Token must be sent both as query param AND as
x-daytona-preview-tokenheader for reliability - Used by
discord-bot/src/sandbox/opencode-client.tsforopencode serveHTTP API calls
OpenCode Serve Agent Configuration
- Agent prompts in
.opencode/agents/default.mdrequire frontmatter (description,mode) to be recognized - For
opencode serve: Embed prompt directly inopencode.jsonunderagent.build.prompt(string value, not file reference) - The markdown file approach works for
opencode runbut is unreliable foropencode servesessions - Example:
discord-bot/src/sandbox/manager.ts:95-106writes config with embedded prompt
Daytona Sandbox Sleep Behavior
- Idle sandboxes are deallocated by Daytona infrastructure after ~15-30min (exact timing varies)
- Requests to sleeping sandboxes return
400: "no IP address found. Is the Sandbox started?" - No wake API exists — must detect the error, destroy the session, and recreate a fresh sandbox
- Pattern:
discord-bot/src/sandbox/manager.ts:211-223catches"no IP address found", throws recoverable error - Handler then recreates sandbox and retries the message (context lost, but conversation continues)
Discord Bot Thread Auto-Reply
message.mentions.has(client.user)returns true for@everyone— must filter withmessage.mentions.everyonecheck- In threads the bot created, respond to ALL messages without requiring @mentions
- Detection: check if
getSession(threadId)exists before filtering on mentions - Pattern:
discord-bot/src/discord/handlers/message-create.ts:31-39