diff --git a/Dockerfile b/Dockerfile index 6c21fd45..af590192 100644 --- a/Dockerfile +++ b/Dockerfile @@ -55,6 +55,41 @@ RUN mkdir -p /run/postgresql && chown -R postgres:postgres /run/postgresql \ && su postgres -c "psql -c \"ALTER USER postgres WITH PASSWORD 'postgres';\"" \ && su postgres -c "/usr/lib/postgresql/*/bin/pg_ctl stop -D /var/lib/postgresql/data" +# Install and configure Redis for local development use by agents +RUN apt-get update && apt-get install -y \ + redis-server \ + redis-tools \ + && rm -rf /var/lib/apt/lists/* + +# Configure Redis +# - Port: 6379 (standard) +# - Bind: localhost only for security +# - Persistence: AOF enabled +# - Memory: 256MB limit with LRU eviction +RUN mkdir -p /var/lib/redis /var/log/redis /var/run/redis && \ + chown -R redis:redis /var/lib/redis /var/log/redis /var/run/redis && \ + { \ + echo "# Redis Configuration for CASCADE Agents"; \ + echo "bind 127.0.0.1"; \ + echo "port 6379"; \ + echo "daemonize no"; \ + echo "supervised systemd"; \ + echo "pidfile /var/run/redis/redis-server.pid"; \ + echo "loglevel notice"; \ + echo "logfile /var/log/redis/redis-server.log"; \ + echo "dir /var/lib/redis"; \ + echo "# Persistence"; \ + echo "appendonly yes"; \ + echo "appendfilename \"appendonly.aof\""; \ + echo "appendfsync everysec"; \ + echo "# Memory Management"; \ + echo "maxmemory 256mb"; \ + echo "maxmemory-policy allkeys-lru"; \ + echo "# Disable protected mode for local dev"; \ + echo "protected-mode no"; \ + } > /etc/redis/redis.conf && \ + chown redis:redis /etc/redis/redis.conf + # Install ast-grep RUN ARCH=$(dpkg --print-architecture) && \ if [ "$ARCH" = "amd64" ]; then AST_ARCH="x86_64"; else AST_ARCH="aarch64"; fi && \ diff --git a/README.md b/README.md index b7024add..3999ba1c 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,10 @@ Multi-project Trello-to-code automation platform. CASCADE reacts to Trello card - **Multi-project support** - Single deployment handles multiple repos/Trello boards - **Extensible trigger system** - Easy to add new triggers (card moved, label added, PR ready, etc.) -- **AI-powered agents** - Briefing, planning, and implementation agents using llmist +- **AI-powered agents** - Briefing, planning, implementation, review, and debug agents using llmist - **Git workflow** - Automatic branch creation, commits, and PR creation - **Trello integration** - Full card management (labels, comments, attachments) +- **GitHub integration** - PR review webhooks, automatic card movement, CI check monitoring ## Getting Started @@ -270,6 +271,30 @@ curl -X POST "https://api.trello.com/1/webhooks" \ -d "description=Cascade webhook" ``` +### GitHub Webhook Setup + +Set up GitHub webhooks for your repository to enable PR review triggers: + +1. Go to your repository settings: `https://github.com/owner/repo/settings/hooks` +2. Click "Add webhook" +3. Configure: + - **Payload URL**: `https://cascade.fly.dev/github/webhook` + - **Content type**: `application/json` + - **Secret**: (optional, not currently validated) + - **Events**: Select individual events: + - Pull request review comments + - Pull request reviews + - Check suites +4. Click "Add webhook" + +**Supported GitHub Triggers**: +- **PR Review Comments**: Triggers review agent when someone comments on a PR review +- **PR Review Submissions**: Triggers review agent when someone submits a PR review (approve/request changes) +- **Check Suite Failures**: Triggers review agent to fix failed CI checks +- **PR Ready to Merge**: Auto-moves card to DONE when all checks pass and PR is approved + +**Note**: GitHub webhooks only trigger for PRs that have a Trello card URL in their description. + ## API Endpoints | Endpoint | Method | Description | @@ -278,6 +303,8 @@ curl -X POST "https://api.trello.com/1/webhooks" \ | `/health` | HEAD | Health check (no body) | | `/trello/webhooks` | POST | Trello webhook receiver | | `/trello/webhooks` | HEAD | Trello webhook verification | +| `/github/webhook` | POST | GitHub webhook receiver | +| `/github/webhook` | GET | GitHub webhook verification | ## License diff --git a/config/projects.json b/config/projects.json index 184e6332..2ba5691c 100644 --- a/config/projects.json +++ b/config/projects.json @@ -5,7 +5,8 @@ "agentIterations": { "implementation": 65 }, - "selfDestructTimeoutMs": 1800000 + "selfDestructTimeoutMs": 1800000, + "postJobGracePeriodMs": 45000 }, "projects": [ { diff --git a/src/agents/base.ts b/src/agents/base.ts index bad7595d..8e97305b 100644 --- a/src/agents/base.ts +++ b/src/agents/base.ts @@ -30,6 +30,7 @@ import { installDependencies, readContextFiles, startPostgres, + startRedis, warmTypeScriptCache, } from './utils/index.js'; import { createAgentLogger } from './utils/logging.js'; @@ -63,6 +64,9 @@ async function setupRepository( // Start PostgreSQL if available (for local database testing) await startPostgres(); + // Start Redis if available (for caching, queues, session storage) + await startRedis(); + // Clone repo to temp directory const repoDir = createTempDir(project.id); cloneRepo(project, repoDir); diff --git a/src/agents/prompts/templates/partials/environment.eta b/src/agents/prompts/templates/partials/environment.eta index 20ac381b..4533bcc5 100644 --- a/src/agents/prompts/templates/partials/environment.eta +++ b/src/agents/prompts/templates/partials/environment.eta @@ -7,6 +7,21 @@ - **Stop**: `su postgres -c 'pg_ctl stop -D /var/lib/postgresql/data'` - **Status**: `su postgres -c 'pg_ctl status -D /var/lib/postgresql/data'` - **Create database**: `psql -U postgres -h localhost -c 'CREATE DATABASE mydb;'` +- **Redis**: In-memory data store running on port 6379 + - **Connection**: `redis://localhost:6379` (no password) + - **Connect via CLI**: `redis-cli` + - **Common commands**: + - `redis-cli ping` - Check if Redis is running + - `redis-cli SET key value` - Set a key + - `redis-cli GET key` - Get a key's value + - `redis-cli KEYS "*"` - List all keys (use sparingly) + - `redis-cli FLUSHALL` - Clear all data (use with caution) + - `redis-cli INFO` - Get server statistics + - **Start**: `su redis -c 'redis-server /etc/redis/redis.conf --daemonize yes'` + - **Stop**: `redis-cli SHUTDOWN` + - **Monitor**: `redis-cli MONITOR` - Watch all commands in real-time + - **Data persistence**: AOF enabled (data survives restarts) + - **Memory limit**: 256MB with LRU eviction - **Node.js 22**: With npm, pnpm, yarn, bun - **Git & GitHub CLI**: `git` and `gh` commands available - **Search tools** (use via Tmux for fast codebase exploration): diff --git a/src/agents/review.ts b/src/agents/review.ts index 2d9215e4..7fa8dd04 100644 --- a/src/agents/review.ts +++ b/src/agents/review.ts @@ -25,6 +25,7 @@ import { installDependencies, readContextFiles, startPostgres, + startRedis, } from './utils/index.js'; import { createAgentLogger } from './utils/logging.js'; @@ -57,6 +58,9 @@ async function setupRepository( // Start PostgreSQL if available (for local database testing) await startPostgres(); + // Start Redis if available (for caching, queues, session storage) + await startRedis(); + // Clone repo to temp directory const repoDir = createTempDir(project.id); cloneRepo(project, repoDir); diff --git a/src/agents/utils/index.ts b/src/agents/utils/index.ts index be42c02d..7724f729 100644 --- a/src/agents/utils/index.ts +++ b/src/agents/utils/index.ts @@ -2,6 +2,7 @@ export { LOG_LEVELS, getLogLevel, startPostgres, + startRedis, generateDirectoryListing, type ContextFile, readContextFiles, diff --git a/src/agents/utils/setup.ts b/src/agents/utils/setup.ts index 52db41bf..1331c7eb 100644 --- a/src/agents/utils/setup.ts +++ b/src/agents/utils/setup.ts @@ -95,6 +95,100 @@ export async function startPostgres(): Promise { logger.info('PostgreSQL started successfully'); } +// ============================================================================ +// Redis Startup +// ============================================================================ + +let redisStarted = false; + +export async function startRedis(): Promise { + if (redisStarted) return; + + const REDIS_CONF = '/etc/redis/redis.conf'; + const REDIS_LOG = '/var/log/redis/redis-server.log'; + + try { + // Check if Redis is already running + const statusResult = await execCommand('redis-cli', ['ping'], '/'); + + if (statusResult.stdout.trim() === 'PONG') { + logger.info('Redis already running'); + redisStarted = true; + return; + } + } catch { + // Not running, continue to start it + } + + // Start Redis as redis user + logger.info('Starting Redis...'); + + // Ensure runtime directories exist (may not persist across container restarts) + try { + await execCommand('mkdir', ['-p', '/var/run/redis'], '/'); + await execCommand('chown', ['redis:redis', '/var/run/redis'], '/'); + await execCommand('mkdir', ['-p', '/var/lib/redis'], '/'); + await execCommand('chown', ['redis:redis', '/var/lib/redis'], '/'); + await execCommand('mkdir', ['-p', '/var/log/redis'], '/'); + await execCommand('chown', ['redis:redis', '/var/log/redis'], '/'); + } catch (err) { + logger.warn('Failed to create Redis directories', { error: String(err) }); + } + + try { + // Start Redis in background using redis-server + // We use su to run as redis user, and redirect stderr to capture startup errors + const startResult = await execCommand( + 'su', + ['redis', '-c', `redis-server ${REDIS_CONF} --daemonize yes 2>&1`], + '/', + ); + logger.debug('redis-server start output', { + stdout: startResult.stdout, + stderr: startResult.stderr, + }); + } catch (err) { + // Try to read the log file for more details + try { + const logResult = await execCommand('cat', [REDIS_LOG], '/'); + logger.error('Redis log contents', { log: logResult.stdout }); + } catch { + // Ignore if log file doesn't exist + } + logger.error('Failed to start Redis', { error: String(err) }); + throw new Error(`Redis failed to start. Check ${REDIS_LOG} for details. Error: ${err}`); + } + + // Wait a moment for Redis to initialize + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Verify it's actually running + try { + const verifyResult = await execCommand('redis-cli', ['ping'], '/'); + logger.debug('redis-cli ping output', { + stdout: verifyResult.stdout, + stderr: verifyResult.stderr, + }); + + if (verifyResult.stdout.trim() !== 'PONG') { + // Try to read the log file for more details + try { + const logResult = await execCommand('cat', [REDIS_LOG], '/'); + logger.error('Redis log contents', { log: logResult.stdout }); + } catch { + // Ignore if log file doesn't exist + } + throw new Error(`Redis ping check failed. Output: ${verifyResult.stdout}`); + } + } catch (err) { + logger.error('Redis ping check failed after start', { error: String(err) }); + throw new Error(`Redis failed to start properly: ${err}`); + } + + redisStarted = true; + logger.info('Redis started successfully'); +} + // ============================================================================ // Log Level Configuration // ============================================================================ diff --git a/src/triggers/github/index.ts b/src/triggers/github/index.ts index acf6eb91..152967f2 100644 --- a/src/triggers/github/index.ts +++ b/src/triggers/github/index.ts @@ -1,6 +1,7 @@ export { CheckSuiteFailureTrigger } from './check-suite-failure.js'; export { PRReadyToMergeTrigger } from './pr-ready-to-merge.js'; export { PRReviewCommentTrigger } from './pr-review-comment.js'; +export { PRReviewSubmittedTrigger } from './pr-review-submitted.js'; export { processGitHubWebhook } from './webhook-handler.js'; export * from './types.js'; export * from './utils.js'; diff --git a/src/triggers/github/pr-review-submitted.ts b/src/triggers/github/pr-review-submitted.ts new file mode 100644 index 00000000..79979b37 --- /dev/null +++ b/src/triggers/github/pr-review-submitted.ts @@ -0,0 +1,61 @@ +import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; +import { logger } from '../../utils/logging.js'; +import { isGitHubPullRequestReviewPayload } from './types.js'; +import { extractTrelloCardId, hasTrelloCardUrl } from './utils.js'; + +export class PRReviewSubmittedTrigger implements TriggerHandler { + name = 'pr-review-submitted'; + description = 'Triggers review agent when a PR review is submitted'; + + matches(ctx: TriggerContext): boolean { + if (ctx.source !== 'github') return false; + if (!isGitHubPullRequestReviewPayload(ctx.payload)) return false; + + // Only trigger on submitted reviews, not edits or dismissals + return ctx.payload.action === 'submitted'; + } + + async handle(ctx: TriggerContext): Promise { + // Type assertion since we validated in matches() + const reviewPayload = ctx.payload as { + pull_request: { number: number; body: string | null; head: { ref: string } }; + repository: { full_name: string }; + review: { id: number; body: string | null; html_url: string; state: string }; + }; + + const prNumber = reviewPayload.pull_request.number; + + // Check if PR has Trello card URL in body + const prBody = reviewPayload.pull_request.body || ''; + if (!hasTrelloCardUrl(prBody)) { + logger.info('PR does not have Trello card URL, skipping review submission trigger', { + prNumber, + reviewState: reviewPayload.review.state, + }); + return null; + } + + const cardId = extractTrelloCardId(prBody); + + logger.info('PR review submitted, triggering review agent', { + prNumber, + reviewState: reviewPayload.review.state, + cardId, + }); + + return { + agentType: 'review', + agentInput: { + prNumber, + prBranch: reviewPayload.pull_request.head.ref, + repoFullName: reviewPayload.repository.full_name, + triggerCommentId: reviewPayload.review.id, + triggerCommentBody: reviewPayload.review.body || `Review: ${reviewPayload.review.state}`, + triggerCommentPath: '', // Reviews don't have a specific file path + triggerCommentUrl: reviewPayload.review.html_url, + }, + prNumber, + cardId: cardId || undefined, + }; + } +} diff --git a/src/triggers/github/webhook-handler.ts b/src/triggers/github/webhook-handler.ts index a7813d54..c6d8a3db 100644 --- a/src/triggers/github/webhook-handler.ts +++ b/src/triggers/github/webhook-handler.ts @@ -63,6 +63,18 @@ async function executeGitHubAgent( ); } + // Move to in-review if implementation and PR was created + if (cardId && result.agentType === 'implementation' && agentResult.prUrl) { + await safeOperation(() => trelloClient.moveCardToList(cardId, project.trello.lists.inReview), { + action: 'move card to in-review', + cardId, + }); + await safeOperation(() => trelloClient.addComment(cardId, `PR created: ${agentResult.prUrl}`), { + action: 'add PR comment', + cardId, + }); + } + logger.info('GitHub agent completed', { agentType: result.agentType, prNumber: result.prNumber, @@ -74,13 +86,15 @@ async function executeGitHubAgent( function processNextQueuedGitHubWebhook(config: CascadeConfig, registry: TriggerRegistry): void { const next = dequeueWebhook(); if (next) { - logger.info('Processing queued GitHub webhook', { queueLength: getQueueLength() }); + const eventType = next.eventType || 'pull_request_review_comment'; // Fallback for backward compatibility + logger.info('Processing queued GitHub webhook', { + queueLength: getQueueLength(), + eventType, + }); setImmediate(() => { - processGitHubWebhook(next.payload, 'pull_request_review_comment', config, registry).catch( - (err) => { - logger.error('Failed to process queued GitHub webhook', { error: String(err) }); - }, - ); + processGitHubWebhook(next.payload, eventType, config, registry).catch((err) => { + logger.error('Failed to process queued GitHub webhook', { error: String(err) }); + }); }); } else if (process.env.FLY_APP_NAME) { scheduleShutdownAfterJob(config.defaults.postJobGracePeriodMs); @@ -106,9 +120,12 @@ export async function processGitHubWebhook( } if (isCurrentlyProcessing()) { - const queued = enqueueWebhook(payload); + const queued = enqueueWebhook(payload, eventType); if (queued) { - logger.info('Currently processing, GitHub webhook queued', { queueLength: getQueueLength() }); + logger.info('Currently processing, GitHub webhook queued', { + queueLength: getQueueLength(), + eventType, + }); } else { logger.warn('Queue full, GitHub webhook rejected', { queueLength: getQueueLength() }); } diff --git a/src/triggers/index.ts b/src/triggers/index.ts index 8fb4d313..77090a6d 100644 --- a/src/triggers/index.ts +++ b/src/triggers/index.ts @@ -1,6 +1,7 @@ import { CheckSuiteFailureTrigger } from './github/check-suite-failure.js'; import { PRReadyToMergeTrigger } from './github/pr-ready-to-merge.js'; import { PRReviewCommentTrigger } from './github/pr-review-comment.js'; +import { PRReviewSubmittedTrigger } from './github/pr-review-submitted.js'; import type { TriggerRegistry } from './registry.js'; import { AttachmentAddedTrigger } from './trello/attachment-added.js'; import { @@ -36,6 +37,9 @@ export function registerBuiltInTriggers(registry: TriggerRegistry): void { // GitHub: PR review comment trigger registry.register(new PRReviewCommentTrigger()); + // GitHub: PR review submission trigger (when someone submits a review) + registry.register(new PRReviewSubmittedTrigger()); + // GitHub: Check suite failure trigger (runs review agent to fix) registry.register(new CheckSuiteFailureTrigger()); diff --git a/src/utils/webhookQueue.ts b/src/utils/webhookQueue.ts index e5cadb05..9f2b546a 100644 --- a/src/utils/webhookQueue.ts +++ b/src/utils/webhookQueue.ts @@ -4,12 +4,13 @@ const MAX_QUEUE_SIZE = 10; interface QueuedWebhook { payload: unknown; + eventType?: string; // Optional for backward compatibility (Trello doesn't need it) receivedAt: Date; } const queue: QueuedWebhook[] = []; -export function enqueueWebhook(payload: unknown): boolean { +export function enqueueWebhook(payload: unknown, eventType?: string): boolean { if (queue.length >= MAX_QUEUE_SIZE) { logger.warn('Webhook queue full, rejecting', { queueLength: queue.length, @@ -20,6 +21,7 @@ export function enqueueWebhook(payload: unknown): boolean { queue.push({ payload, + eventType, receivedAt: new Date(), }); diff --git a/tmp-test.sh b/tmp-test.sh new file mode 100755 index 00000000..6f57262d --- /dev/null +++ b/tmp-test.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +# docker build -t cascade . && docker run -it cascade /tmp/tmp-test.sh + +mkdir /tmp/p +cd /tmp/p +env GH_TOKEN=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 gh repo clone https://github.com/zbigniewsobiecki/niu.git +cd niu +pnpm install