From bc1f7004e6c8fb1277f80e4b1bf7b93a9d7f65f8 Mon Sep 17 00:00:00 2001 From: Gopal Date: Sun, 28 Dec 2025 12:17:04 +0530 Subject: [PATCH 1/4] feat: Add --triggers-dir flag to serve command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the ability to load Trigger resources from a directory when starting the trigger server, matching the existing --agents-dir and --flows-dir flags. Changes: - Added --triggers-dir CLI argument to serve command - Added TriggersConfig struct for config file support - Load triggers from directory using TriggerRegistry - Auto-register platforms based on trigger types (e.g., GitHub) - Log command bindings from loaded triggers Usage: aofctl serve --triggers-dir ./triggers/ --agents-dir ./agents/ 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- crates/aofctl/src/cli.rs | 6 ++ crates/aofctl/src/commands/serve.rs | 99 +++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/crates/aofctl/src/cli.rs b/crates/aofctl/src/cli.rs index ab57477..6f183da 100644 --- a/crates/aofctl/src/cli.rs +++ b/crates/aofctl/src/cli.rs @@ -191,6 +191,10 @@ pub enum Commands { /// Directory containing AgentFlow YAML files for event-driven routing #[arg(long)] flows_dir: Option, + + /// Directory containing Trigger YAML files + #[arg(long)] + triggers_dir: Option, }, /// Manage agent fleets (multi-agent coordination) @@ -295,6 +299,7 @@ impl Cli { host, agents_dir, flows_dir, + triggers_dir, } => { commands::serve::execute( config.as_deref(), @@ -302,6 +307,7 @@ impl Cli { host.as_deref(), agents_dir.as_deref(), flows_dir.as_deref(), + triggers_dir.as_deref(), ) .await } diff --git a/crates/aofctl/src/commands/serve.rs b/crates/aofctl/src/commands/serve.rs index 76338c8..602d8f2 100644 --- a/crates/aofctl/src/commands/serve.rs +++ b/crates/aofctl/src/commands/serve.rs @@ -9,6 +9,7 @@ use std::net::SocketAddr; use std::path::PathBuf; use std::sync::Arc; +use aof_core::{TriggerRegistry, Registry, StandaloneTriggerType}; use aof_runtime::{Runtime, RuntimeOrchestrator}; use aof_triggers::{ TriggerHandler, TriggerHandlerConfig, TriggerServer, TriggerServerConfig, @@ -16,6 +17,7 @@ use aof_triggers::{ DiscordPlatform, PlatformConfig, TelegramPlatform, TelegramConfig, WhatsAppPlatform, WhatsAppConfig, + GitHubPlatform, GitHubConfig, flow::{FlowRegistry, FlowRouter}, }; use serde::{Deserialize, Serialize}; @@ -65,6 +67,10 @@ pub struct ServeSpec { #[serde(default)] pub flows: FlowsConfig, + /// Triggers directory (for loading Trigger resources) + #[serde(default)] + pub triggers: TriggersConfig, + /// Runtime settings #[serde(default)] pub runtime: RuntimeConfig, @@ -222,6 +228,17 @@ pub struct FlowsConfig { pub enabled: bool, } +/// Triggers configuration for loading Trigger resources +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct TriggersConfig { + /// Directory containing Trigger YAML files + pub directory: Option, + + /// Watch for changes and hot-reload + #[serde(default)] + pub watch: bool, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RuntimeConfig { /// Maximum concurrent tasks @@ -287,6 +304,7 @@ pub async fn execute( host: Option<&str>, agents_dir: Option<&str>, flows_dir: Option<&str>, + triggers_dir: Option<&str>, ) -> anyhow::Result<()> { // Load configuration let config = if let Some(config_path) = config_file { @@ -315,6 +333,10 @@ pub async fn execute( watch: false, enabled: true, }, + triggers: TriggersConfig { + directory: triggers_dir.map(PathBuf::from), + watch: false, + }, runtime: RuntimeConfig::default(), }, } @@ -490,6 +512,83 @@ pub async fn execute( } } + // Load Triggers from directory + let triggers_dir_path = triggers_dir + .map(PathBuf::from) + .or_else(|| config.spec.triggers.directory.clone()); + + if let Some(ref triggers_path) = triggers_dir_path { + info!("Loading Triggers from: {}", triggers_path.display()); + let mut trigger_registry = TriggerRegistry::new(); + + match trigger_registry.load_directory(triggers_path) { + Ok(count) => { + if count > 0 { + info!(" Loaded {} triggers: {:?}", count, trigger_registry.names()); + + // Register platforms for each trigger type + for trigger in trigger_registry.get_all() { + match trigger.spec.trigger_type { + StandaloneTriggerType::GitHub => { + // Register GitHub platform if we have a trigger for it + if let Some(ref secret) = trigger.spec.config.webhook_secret { + let github_config = GitHubConfig { + token: String::new(), // Token is optional for webhook-only mode + webhook_secret: secret.clone(), + bot_name: "aof-bot".to_string(), + api_url: "https://api.github.com".to_string(), + allowed_repos: None, + allowed_events: None, + allowed_users: None, + auto_approve_patterns: None, + enable_status_checks: true, + enable_reviews: true, + enable_comments: true, + }; + match GitHubPlatform::new(github_config) { + Ok(platform) => { + handler.register_platform(Arc::new(platform)); + info!(" Registered platform: github (from trigger '{}')", trigger.name()); + platforms_registered += 1; + } + Err(e) => { + warn!(" Failed to create GitHub platform: {}", e); + } + } + } else { + warn!(" GitHub trigger '{}' missing webhook_secret", trigger.name()); + } + } + // Other trigger types use platforms registered from config + _ => {} + } + + // Add command bindings from trigger + for (cmd, binding) in &trigger.spec.commands { + if let Some(ref agent) = binding.agent { + info!(" Registered command '{}' -> agent '{}'", cmd, agent); + } else if let Some(ref fleet) = binding.fleet { + info!(" Registered command '{}' -> fleet '{}'", cmd, fleet); + } else if let Some(ref flow) = binding.flow { + info!(" Registered command '{}' -> flow '{}'", cmd, flow); + } + } + + // Set default agent if specified in trigger + if let Some(ref default_agent) = trigger.spec.default_agent { + info!(" Default agent for trigger '{}': {}", trigger.name(), default_agent); + } + } + } else { + info!(" No Trigger files found in {}", triggers_path.display()); + } + } + Err(e) => { + warn!(" Failed to load triggers from {}: {}", triggers_path.display(), e); + } + } + } + if platforms_registered == 0 { warn!("No platforms registered! Server will start but won't process any webhooks."); warn!("Configure platforms in your config file or set environment variables."); From 557e74c19f0745426b55d794634dc7e227540d62 Mon Sep 17 00:00:00 2001 From: Gopal Date: Sun, 28 Dec 2025 12:46:13 +0530 Subject: [PATCH 2/4] fix: Use GITHUB_TOKEN env var for GitHub platform registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The GitHubPlatform requires both token and webhook_secret. Now reads GITHUB_TOKEN or GH_TOKEN from environment when registering GitHub platforms from triggers. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- crates/aofctl/src/commands/serve.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/aofctl/src/commands/serve.rs b/crates/aofctl/src/commands/serve.rs index 602d8f2..37a7b87 100644 --- a/crates/aofctl/src/commands/serve.rs +++ b/crates/aofctl/src/commands/serve.rs @@ -532,8 +532,17 @@ pub async fn execute( StandaloneTriggerType::GitHub => { // Register GitHub platform if we have a trigger for it if let Some(ref secret) = trigger.spec.config.webhook_secret { + // Get GitHub token from env or trigger config + let token = std::env::var("GITHUB_TOKEN") + .or_else(|_| std::env::var("GH_TOKEN")) + .unwrap_or_default(); + + if token.is_empty() { + warn!(" GitHub trigger '{}': GITHUB_TOKEN not set, API features disabled", trigger.name()); + } + let github_config = GitHubConfig { - token: String::new(), // Token is optional for webhook-only mode + token, webhook_secret: secret.clone(), bot_name: "aof-bot".to_string(), api_url: "https://api.github.com".to_string(), From f6f3794c6442312ea1878537e9fc19c123820f3c Mon Sep 17 00:00:00 2001 From: Gopal Date: Sun, 28 Dec 2025 13:34:39 +0530 Subject: [PATCH 3/4] fix: Enable GitHub slash commands in PR/issue comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This fixes the /review and other slash commands not being processed when users comment on PRs or issues. Changes: - Add register_command_binding() method to TriggerHandler for dynamic command registration - Export CommandBinding from aof-triggers crate - Update serve.rs to actually register command bindings from Trigger YAML files (was only logging them, not registering) - Fix GitHub issue_comment event handling to detect slash commands: - When comment starts with /, use raw comment body as message text - Add comment_body, comment_id, comment_html_url to metadata - Add is_pr_comment flag to detect if comment is on a PR vs issue - Fix workflow_tests.rs to include new command_bindings field Now when a user comments "/review" on a PR, the system will: 1. Parse the webhook and extract the slash command 2. Match it against registered command bindings from Trigger YAML 3. Route to the configured fleet/agent/flow 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- crates/aof-triggers/src/handler/mod.rs | 6 ++++ crates/aof-triggers/src/lib.rs | 2 +- crates/aof-triggers/src/platforms/github.rs | 34 +++++++++++++++++---- crates/aof-triggers/tests/workflow_tests.rs | 1 + crates/aofctl/src/commands/serve.rs | 13 ++++++++ 5 files changed, 49 insertions(+), 7 deletions(-) diff --git a/crates/aof-triggers/src/handler/mod.rs b/crates/aof-triggers/src/handler/mod.rs index 3f4b510..d7719a4 100644 --- a/crates/aof-triggers/src/handler/mod.rs +++ b/crates/aof-triggers/src/handler/mod.rs @@ -799,6 +799,12 @@ impl TriggerHandler { self.platforms.get(name) } + /// Register a command binding (maps slash command to agent/fleet/flow) + pub fn register_command_binding(&mut self, command: String, binding: CommandBinding) { + info!("Registering command binding: /{} -> {:?}", command, binding); + self.config.command_bindings.insert(command, binding); + } + /// Handle incoming message from platform pub async fn handle_message(&self, platform: &str, message: TriggerMessage) -> AofResult<()> { debug!( diff --git a/crates/aof-triggers/src/lib.rs b/crates/aof-triggers/src/lib.rs index 501a76a..70a6c0e 100644 --- a/crates/aof-triggers/src/lib.rs +++ b/crates/aof-triggers/src/lib.rs @@ -20,7 +20,7 @@ pub mod server; pub use command::{CommandContext, CommandType, TriggerCommand, TriggerTarget}; // Re-export main types from handler module -pub use handler::{TriggerHandler, TriggerHandlerConfig}; +pub use handler::{TriggerHandler, TriggerHandlerConfig, CommandBinding}; // Re-export main types from platforms module pub use platforms::{Platform, PlatformConfig}; diff --git a/crates/aof-triggers/src/platforms/github.rs b/crates/aof-triggers/src/platforms/github.rs index 85b1a37..ac8139c 100644 --- a/crates/aof-triggers/src/platforms/github.rs +++ b/crates/aof-triggers/src/platforms/github.rs @@ -916,12 +916,20 @@ impl GitHubPlatform { })?; let issue = payload.issue.as_ref(); let issue_num = issue.map(|i| i.number).unwrap_or(0); - format!( - "comment:{}:#{} {}", - action.unwrap_or(""), - issue_num, - comment.body.lines().next().unwrap_or("") - ) + let first_line = comment.body.lines().next().unwrap_or("").trim(); + + // If the comment starts with a slash command, use it directly as the text + // This enables command detection in issue comments (e.g., /review, /deploy) + if first_line.starts_with('/') { + first_line.to_string() + } else { + format!( + "comment:{}:#{} {}", + action.unwrap_or(""), + issue_num, + first_line + ) + } } "workflow_run" => { let run = payload.workflow_run.as_ref().ok_or_else(|| { @@ -993,6 +1001,20 @@ impl GitHubPlatform { metadata.insert("issue_html_url".to_string(), serde_json::json!(issue.html_url)); } + // Add comment-specific metadata + if let Some(ref comment) = payload.comment { + metadata.insert("comment_id".to_string(), serde_json::json!(comment.id)); + metadata.insert("comment_body".to_string(), serde_json::json!(comment.body)); + metadata.insert("comment_html_url".to_string(), serde_json::json!(comment.html_url)); + // Check if this is a PR comment (issue_comment on a PR has a pull_request URL in the issue) + if let Some(ref issue) = payload.issue { + // GitHub includes a pull_request field in the issue object if this is a PR + // Since we don't have that field parsed, we can check the html_url + let is_pr_comment = issue.html_url.contains("/pull/"); + metadata.insert("is_pr_comment".to_string(), serde_json::json!(is_pr_comment)); + } + } + // Add push-specific metadata if let Some(ref git_ref) = payload.git_ref { metadata.insert("ref".to_string(), serde_json::json!(git_ref)); diff --git a/crates/aof-triggers/tests/workflow_tests.rs b/crates/aof-triggers/tests/workflow_tests.rs index c16501e..f161d8d 100644 --- a/crates/aof-triggers/tests/workflow_tests.rs +++ b/crates/aof-triggers/tests/workflow_tests.rs @@ -188,6 +188,7 @@ fn test_custom_handler_config() { max_tasks_per_user: 5, command_timeout_secs: 600, default_agent: None, + command_bindings: HashMap::new(), }; let handler = create_handler_with_config(config.clone()); diff --git a/crates/aofctl/src/commands/serve.rs b/crates/aofctl/src/commands/serve.rs index 37a7b87..a02a462 100644 --- a/crates/aofctl/src/commands/serve.rs +++ b/crates/aofctl/src/commands/serve.rs @@ -18,6 +18,7 @@ use aof_triggers::{ TelegramPlatform, TelegramConfig, WhatsAppPlatform, WhatsAppConfig, GitHubPlatform, GitHubConfig, + CommandBinding as HandlerCommandBinding, flow::{FlowRegistry, FlowRouter}, }; use serde::{Deserialize, Serialize}; @@ -574,6 +575,18 @@ pub async fn execute( // Add command bindings from trigger for (cmd, binding) in &trigger.spec.commands { + // Convert core CommandBinding to handler CommandBinding + let handler_binding = HandlerCommandBinding { + agent: binding.agent.clone(), + fleet: binding.fleet.clone(), + flow: binding.flow.clone(), + description: binding.description.clone(), + }; + + // Strip leading slash if present for consistent lookup + let cmd_name = cmd.trim_start_matches('/').to_string(); + handler.register_command_binding(cmd_name.clone(), handler_binding); + if let Some(ref agent) = binding.agent { info!(" Registered command '{}' -> agent '{}'", cmd, agent); } else if let Some(ref fleet) = binding.fleet { From acdaaf804c1e6fca7a70266d22c6f469bf95cad9 Mon Sep 17 00:00:00 2001 From: Gopal Date: Sun, 28 Dec 2025 14:48:54 +0530 Subject: [PATCH 4/4] fix: Include issue/PR number in channel_id for response posting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix channel_id to include #number suffix (e.g., owner/repo#8) - Enables send_response to post comments back to PR/issue - Add context construction from PR metadata when command text is empty - Fixes /review without arguments receiving proper PR context Fixes #92 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- crates/aof-triggers/src/handler/mod.rs | 37 +++++++++++++++++++-- crates/aof-triggers/src/platforms/github.rs | 22 +++++++++--- 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/crates/aof-triggers/src/handler/mod.rs b/crates/aof-triggers/src/handler/mod.rs index d7719a4..f58ba21 100644 --- a/crates/aof-triggers/src/handler/mod.rs +++ b/crates/aof-triggers/src/handler/mod.rs @@ -859,9 +859,42 @@ impl TriggerHandler { if let Some(binding) = self.config.command_bindings.get(&cmd_name) { info!("Command '{}' matched binding: {:?}", cmd_name, binding); - // Create modified message with just the text (without the command) + // Create modified message with context from metadata if command text is empty let mut routed_message = message.clone(); - routed_message.text = command_text.clone().unwrap_or_default(); + let cmd_text = command_text.clone().unwrap_or_default(); + + // If command text is empty, construct context from metadata (for PR/issue commands) + if cmd_text.trim().is_empty() { + // Build context from metadata for commands like /review + let mut context_parts = Vec::new(); + + if let Some(pr_url) = message.metadata.get("pr_html_url").and_then(|v| v.as_str()) { + context_parts.push(format!("Review the PR at: {}", pr_url)); + } else if let Some(issue_url) = message.metadata.get("issue_html_url").and_then(|v| v.as_str()) { + context_parts.push(format!("Review the PR/issue at: {}", issue_url)); + } + + if let Some(pr_title) = message.metadata.get("pr_title").and_then(|v| v.as_str()) { + context_parts.push(format!("Title: {}", pr_title)); + } else if let Some(issue_title) = message.metadata.get("issue_title").and_then(|v| v.as_str()) { + context_parts.push(format!("Title: {}", issue_title)); + } + + if let Some(comment_body) = message.metadata.get("comment_body").and_then(|v| v.as_str()) { + if !comment_body.starts_with('/') { + context_parts.push(format!("Additional context: {}", comment_body)); + } + } + + routed_message.text = if context_parts.is_empty() { + format!("Execute {} command", cmd_name) + } else { + context_parts.join("\n") + }; + info!("Constructed context for command '{}': {}", cmd_name, routed_message.text); + } else { + routed_message.text = cmd_text; + } // Route to flow if specified (highest priority - complex workflows) if let Some(ref flow_name) = binding.flow { diff --git a/crates/aof-triggers/src/platforms/github.rs b/crates/aof-triggers/src/platforms/github.rs index ac8139c..cedca79 100644 --- a/crates/aof-triggers/src/platforms/github.rs +++ b/crates/aof-triggers/src/platforms/github.rs @@ -470,13 +470,18 @@ impl GitHubPlatform { /// Create new GitHub platform adapter /// /// # Errors - /// Returns error if token or webhook_secret is empty + /// Returns error if webhook_secret is empty (token is optional for receive-only mode) pub fn new(config: GitHubConfig) -> Result { - if config.token.is_empty() || config.webhook_secret.is_empty() { + // Webhook secret is required for signature verification + if config.webhook_secret.is_empty() { return Err(PlatformError::ParseError( - "GitHub token and webhook secret are required".to_string(), + "GitHub webhook secret is required for signature verification".to_string(), )); } + // Token is optional - if not provided, API features (posting comments) are disabled + if config.token.is_empty() { + tracing::warn!("GitHub token not provided - API features (posting comments, reviews) disabled"); + } let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(30)) @@ -957,8 +962,15 @@ impl GitHubPlatform { _ => format!("{}:{}", event_type, action.unwrap_or("")), }; - // Build channel_id from repo full name - let channel_id = repo.full_name.clone(); + // Build channel_id from repo full name and issue/PR number for response posting + // Format: owner/repo#number (allows send_response to post comments) + let channel_id = if let Some(ref pr) = payload.pull_request { + format!("{}#{}", repo.full_name, pr.number) + } else if let Some(ref issue) = payload.issue { + format!("{}#{}", repo.full_name, issue.number) + } else { + repo.full_name.clone() + }; // Build user let trigger_user = TriggerUser {