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
35 changes: 35 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 && \
Expand Down
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 |
Expand All @@ -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

Expand Down
3 changes: 2 additions & 1 deletion config/projects.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
"agentIterations": {
"implementation": 65
},
"selfDestructTimeoutMs": 1800000
"selfDestructTimeoutMs": 1800000,
"postJobGracePeriodMs": 45000
},
"projects": [
{
Expand Down
4 changes: 4 additions & 0 deletions src/agents/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
installDependencies,
readContextFiles,
startPostgres,
startRedis,
warmTypeScriptCache,
} from './utils/index.js';
import { createAgentLogger } from './utils/logging.js';
Expand Down Expand Up @@ -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);
Expand Down
15 changes: 15 additions & 0 deletions src/agents/prompts/templates/partials/environment.eta
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
4 changes: 4 additions & 0 deletions src/agents/review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
installDependencies,
readContextFiles,
startPostgres,
startRedis,
} from './utils/index.js';
import { createAgentLogger } from './utils/logging.js';

Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions src/agents/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export {
LOG_LEVELS,
getLogLevel,
startPostgres,
startRedis,
generateDirectoryListing,
type ContextFile,
readContextFiles,
Expand Down
94 changes: 94 additions & 0 deletions src/agents/utils/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,100 @@ export async function startPostgres(): Promise<void> {
logger.info('PostgreSQL started successfully');
}

// ============================================================================
// Redis Startup
// ============================================================================

let redisStarted = false;

export async function startRedis(): Promise<void> {
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
// ============================================================================
Expand Down
1 change: 1 addition & 0 deletions src/triggers/github/index.ts
Original file line number Diff line number Diff line change
@@ -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';
61 changes: 61 additions & 0 deletions src/triggers/github/pr-review-submitted.ts
Original file line number Diff line number Diff line change
@@ -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<TriggerResult | null> {
// 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,
};
}
}
33 changes: 25 additions & 8 deletions src/triggers/github/webhook-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
Expand All @@ -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() });
}
Expand Down
Loading