From df920b96516e96ea7296a722154e02dc5d72e3b0 Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Sun, 8 Mar 2026 10:21:52 -0700 Subject: [PATCH] Add event-driven workflow orchestration skill, webhook flow, and trace coverage --- Cargo.lock | 174 +++++- Cargo.toml | 1 + FEATURE_PARITY.md | 3 +- .../ironclaw-workflow-orchestrator/SKILL.md | 81 +++ .../agents/openai.yaml | 4 + .../references/workflow-routines.md | 128 +++++ src/agent/routine.rs | 79 +++ src/agent/routine_engine.rs | 115 +++- src/channels/web/handlers/routines.rs | 6 + src/channels/web/server.rs | 533 +++++++++++++++++- src/db/libsql/routines.rs | 2 +- src/history/store.rs | 2 +- src/tools/builtin/mod.rs | 4 +- src/tools/builtin/routine.rs | 145 ++++- src/tools/registry.rs | 8 +- src/tools/schema_validator.rs | 22 +- tests/e2e_builtin_tool_coverage.rs | 110 +++- tests/e2e_routine_heartbeat.rs | 99 ++++ .../tools/routine_system_event_emit.json | 42 ++ .../skill_install_routine_webhook_sim.json | 100 ++++ tests/support/test_rig.rs | 39 +- tools-src/github/README.md | 30 +- .../github/github-tool.capabilities.json | 5 +- tools-src/github/src/lib.rs | 428 ++++++++++++++ 24 files changed, 2111 insertions(+), 49 deletions(-) create mode 100644 skills/ironclaw-workflow-orchestrator/SKILL.md create mode 100644 skills/ironclaw-workflow-orchestrator/agents/openai.yaml create mode 100644 skills/ironclaw-workflow-orchestrator/references/workflow-routines.md create mode 100644 tests/fixtures/llm_traces/tools/routine_system_event_emit.json create mode 100644 tests/fixtures/llm_traces/tools/skill_install_routine_webhook_sim.json diff --git a/Cargo.lock b/Cargo.lock index c6ad733a..a77b1b99 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -628,13 +628,13 @@ dependencies = [ "http-body-util", "hyper 1.8.1", "hyper-named-pipe", - "hyper-rustls", + "hyper-rustls 0.27.7", "hyper-util", "hyperlocal", "log", "pin-project-lite", - "rustls", - "rustls-native-certs", + "rustls 0.23.37", + "rustls-native-certs 0.8.3", "rustls-pemfile", "rustls-pki-types", "serde", @@ -2558,6 +2558,24 @@ dependencies = [ "winapi", ] +[[package]] +name = "hyper-rustls" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "399c78f9338483cb7e630c8474b07268983c6bd5acee012e4211f9f7bb21b070" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.32", + "log", + "rustls 0.22.4", + "rustls-native-certs 0.7.3", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.25.0", + "webpki-roots 0.26.11", +] + [[package]] name = "hyper-rustls" version = "0.27.7" @@ -2567,11 +2585,11 @@ dependencies = [ "http 1.4.0", "hyper 1.8.1", "hyper-util", - "rustls", - "rustls-native-certs", + "rustls 0.23.37", + "rustls-native-certs 0.8.3", "rustls-pki-types", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tower-service", ] @@ -2905,14 +2923,15 @@ dependencies = [ "regex", "reqwest", "rig-core", + "rust-analyzer", "rust_decimal", "rust_decimal_macros", - "rustls", - "rustls-native-certs", + "rustls 0.23.37", + "rustls-native-certs 0.8.3", "rustyline", "secrecy", "secret-service", - "security-framework", + "security-framework 3.7.0", "semver", "serde", "serde_json", @@ -3130,6 +3149,7 @@ dependencies = [ "anyhow", "async-stream", "async-trait", + "base64 0.21.7", "bincode", "bitflags 2.11.0", "bytes", @@ -3137,14 +3157,18 @@ dependencies = [ "futures", "http 0.2.12", "hyper 0.14.32", + "hyper-rustls 0.25.0", + "libsql-hrana", "libsql-sqlite3-parser", "libsql-sys", "libsql_replication", "parking_lot", "serde", + "serde_json", "thiserror 1.0.69", "tokio", "tokio-stream", + "tokio-util", "tonic", "tonic-web", "tower 0.4.13", @@ -3164,6 +3188,18 @@ dependencies = [ "cc", ] +[[package]] +name = "libsql-hrana" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeaf5d19e365465e1c23d687a28c805d7462531b3f619f0ba49d3cf369890a3e" +dependencies = [ + "base64 0.21.7", + "bytes", + "prost", + "serde", +] + [[package]] name = "libsql-rusqlite" version = "0.33.0" @@ -3487,10 +3523,10 @@ dependencies = [ "libc", "log", "openssl", - "openssl-probe", + "openssl-probe 0.2.1", "openssl-sys", "schannel", - "security-framework", + "security-framework 3.7.0", "security-framework-sys", "tempfile", ] @@ -3737,6 +3773,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + [[package]] name = "openssl-probe" version = "0.2.1" @@ -4261,7 +4303,7 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash 2.1.1", - "rustls", + "rustls 0.23.37", "socket2 0.6.2", "thiserror 2.0.18", "tokio", @@ -4281,7 +4323,7 @@ dependencies = [ "rand 0.9.2", "ring", "rustc-hash 2.1.1", - "rustls", + "rustls 0.23.37", "rustls-pki-types", "slab", "thiserror 2.0.18", @@ -4620,7 +4662,7 @@ dependencies = [ "http-body 1.0.1", "http-body-util", "hyper 1.8.1", - "hyper-rustls", + "hyper-rustls 0.27.7", "hyper-tls", "hyper-util", "js-sys", @@ -4631,8 +4673,8 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls", - "rustls-native-certs", + "rustls 0.23.37", + "rustls-native-certs 0.8.3", "rustls-pki-types", "serde", "serde_json", @@ -4640,7 +4682,7 @@ dependencies = [ "sync_wrapper 1.0.2", "tokio", "tokio-native-tls", - "tokio-rustls", + "tokio-rustls 0.26.4", "tokio-util", "tower 0.5.3", "tower-http 0.6.8", @@ -4727,6 +4769,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "rust-analyzer" +version = "0.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11707871ffa56ce568d4f15dd34c2f891a2aa5e4b3435b99b8f99938492525c3" + [[package]] name = "rust_decimal" version = "1.40.0" @@ -4817,6 +4865,20 @@ dependencies = [ "rustix 1.1.4", ] +[[package]] +name = "rustls" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432" +dependencies = [ + "log", + "ring", + "rustls-pki-types", + "rustls-webpki 0.102.8", + "subtle", + "zeroize", +] + [[package]] name = "rustls" version = "0.23.37" @@ -4826,21 +4888,34 @@ dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki", + "rustls-webpki 0.103.9", "subtle", "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" +dependencies = [ + "openssl-probe 0.1.6", + "rustls-pemfile", + "rustls-pki-types", + "schannel", + "security-framework 2.11.1", +] + [[package]] name = "rustls-native-certs" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ - "openssl-probe", + "openssl-probe 0.2.1", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.7.0", ] [[package]] @@ -4862,6 +4937,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-webpki" +version = "0.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustls-webpki" version = "0.103.9" @@ -5030,6 +5116,19 @@ dependencies = [ "zbus", ] +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -5931,20 +6030,31 @@ checksum = "27d684bad428a0f2481f42241f821db42c54e2dc81d8c00db8536c506b0a0144" dependencies = [ "const-oid", "ring", - "rustls", + "rustls 0.23.37", "tokio", "tokio-postgres", - "tokio-rustls", + "tokio-rustls 0.26.4", "x509-cert", ] +[[package]] +name = "tokio-rustls" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f" +dependencies = [ + "rustls 0.22.4", + "rustls-pki-types", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls", + "rustls 0.23.37", "tokio", ] @@ -7136,6 +7246,24 @@ dependencies = [ "string_cache_codegen", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" diff --git a/Cargo.toml b/Cargo.toml index 75d42f63..122d880d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -166,6 +166,7 @@ html-to-markdown-rs = { version = "2.3", optional = true } readabilityrs = { version = "0.1.2", optional = true } ed25519-dalek = { version = "2.2.0", features = ["std"] } hex = "0.4.3" +rust-analyzer = "0.0.1" # macOS keychain [target.'cfg(target_os = "macos")'.dependencies] diff --git a/FEATURE_PARITY.md b/FEATURE_PARITY.md index 368dcc4d..b2859077 100644 --- a/FEATURE_PARITY.md +++ b/FEATURE_PARITY.md @@ -433,6 +433,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O | `before_agent_start` hook | ✅ | ❌ | P2 | Model/provider override | | `before_message_write` hook | ✅ | ❌ | P2 | Pre-write interception | | `onMessage` hook | ✅ | ✅ | - | Routines with event trigger | +| Structured system-event routines | ✅ | ✅ | P2 | `system_event` trigger + `event_emit` tool for event-driven automation | | `onSessionStart` hook | ✅ | ✅ | P2 | | | `onSessionEnd` hook | ✅ | ✅ | P2 | | | `transcribeAudio` hook | ✅ | ❌ | P3 | | @@ -551,7 +552,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O - ❌ Media handling (images, PDFs) - ✅ Ollama/local model support (via rig::providers::ollama) - ❌ Configuration hot-reload -- ❌ Webhook trigger endpoint in web gateway +- ✅ Webhook trigger endpoint in web gateway (`/api/webhooks/github` -> `system_event` routines) - ❌ Channel health monitor with auto-restart - ❌ Partial output preservation on abort diff --git a/skills/ironclaw-workflow-orchestrator/SKILL.md b/skills/ironclaw-workflow-orchestrator/SKILL.md new file mode 100644 index 00000000..d0eb5db0 --- /dev/null +++ b/skills/ironclaw-workflow-orchestrator/SKILL.md @@ -0,0 +1,81 @@ +--- +name: ironclaw-workflow-orchestrator +description: "Install and operate a full GitHub issue-to-merge workflow in IronClaw using event-driven and cron routines. Use when setting up or tuning autonomous project orchestration: issue intake, planning, maintainer feedback handling, branch/PR execution, CI/comment follow-up, batched staging review every 8 hours, and memory updates from merge outcomes." +--- + +# IronClaw Workflow Orchestrator + +## Overview +Use this skill to install and maintain a complete project workflow as routines, not core code changes. It maps GitHub webhook events plus scheduled checks into plan/update/implement/review/merge loops with explicit staging-batch analysis. + +## Workflow +1. Gather workflow parameters. +2. Verify runtime prerequisites. +3. Install or update routine set from templates. +4. Run a dry test with `event_emit`. +5. Monitor outcomes and tune prompts/filters. + +## Parameters +Collect these values before creating routines: +- `repository`: `owner/repo` (required) +- `maintainers`: GitHub handles allowed to trigger implement/replan actions +- `staging_branch`: default `staging` +- `main_branch`: default `main` +- `batch_interval_hours`: default `8` +- `implementation_label`: default `autonomous-impl` + +## Prerequisites +Before installing routines, verify: +- Routines system enabled. +- GitHub tool authenticated (for issue/PR/comment/status operations). +- GitHub webhook delivery configured to `POST /api/webhooks/github`. +- Optional webhook secret configured (`GITHUB_WEBHOOK_SECRET` or gateway setting `github.webhook_secret`). + +## Install Procedure +1. Open [`workflow-routines.md`](references/workflow-routines.md). +2. For each template block: +- replace placeholders (`{{repository}}`, `{{maintainers}}`, branch names) +- call `routine_create` +3. If a routine already exists: +- use `routine_update` instead of creating duplicates +- keep names stable so long-lived metrics/history stay intact +4. Confirm install with `routine_list` and `routine_history`. + +## Routine Set +Install these routines: +- `wf-issue-plan`: on `issue.opened` or `issue.reopened`, generate implementation plan comment/checklist. +- `wf-maintainer-comment-gate`: on maintainer comments, decide update-plan vs start implementation. +- `wf-pr-monitor-loop`: on PR open/sync/review-comment/review, address feedback and refresh branch. +- `wf-ci-fix-loop`: on CI status/check failures, apply fixes and push updates. +- `wf-staging-batch-review`: every 8h, review ready PRs, merge into staging, run deep batch correctness analysis, fix findings, then merge staging -> main. +- `wf-learning-memory`: on merged PRs, extract mistakes/lessons and write to shared memory. + +## Event Filters +Prefer top-level filters for stability: +- `repository` (string) +- `sender` (string) +- `issue_number` / `pr_number` +- `ci_status`, `ci_conclusion` +- `review_state`, `comment_author` + +Use narrow filters to avoid accidental triggers across repos. + +## Operating Rules +- All implementation work must occur on non-main branches. +- PR loop must resolve both human and AI review comments. +- On conflicts with `origin/main`, refresh branch before continuing. +- Staging-batch routine is the only path for bulk correctness verification before mainline merge. +- Memory update routine runs only after successful merge. + +## Validation +After install, run: +1. `event_emit` with a synthetic `issue.opened` payload for the target repo. +2. Confirm at least one routine fired. +3. Check corresponding `routine_history` entries. +4. Confirm no unrelated routines fired. + +## When To Update Templates +Update this skill when: +- GitHub event names/payload fields change. +- Team review policy changes (e.g., staging cadence, maintainer gates). +- New CI policy requires different failure routing. diff --git a/skills/ironclaw-workflow-orchestrator/agents/openai.yaml b/skills/ironclaw-workflow-orchestrator/agents/openai.yaml new file mode 100644 index 00000000..3febe0ff --- /dev/null +++ b/skills/ironclaw-workflow-orchestrator/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "IronClaw Workflow Orchestrator" + short_description: "Install and run event-driven GitHub workflow routines" + default_prompt: "Set up the full issue-to-merge workflow using routines and event triggers." diff --git a/skills/ironclaw-workflow-orchestrator/references/workflow-routines.md b/skills/ironclaw-workflow-orchestrator/references/workflow-routines.md new file mode 100644 index 00000000..74a5fb92 --- /dev/null +++ b/skills/ironclaw-workflow-orchestrator/references/workflow-routines.md @@ -0,0 +1,128 @@ +# Workflow Routine Templates + +Replace `{{...}}` placeholders before use. + +## 1) Issue -> Plan + +```json +{ + "name": "wf-issue-plan", + "description": "Create implementation plan when a new issue arrives", + "trigger_type": "system_event", + "event_source": "github", + "event_type": "issue.opened", + "event_filters": { + "repository": "{{repository}}" + }, + "action_type": "full_job", + "prompt": "For issue #{{issue_number}} in {{repository}}, produce a concrete implementation plan with milestones, edge cases, and tests. Post/update an issue comment with the plan.", + "cooldown_secs": 30 +} +``` + +## 2) Maintainer Comment Gate (Update Plan vs Implement) + +Trigger per-maintainer by creating one routine per handle, or maintain a shared author convention. + +```json +{ + "name": "wf-maintainer-comment-gate-{{maintainer}}", + "description": "React to maintainer guidance comments on issues/PRs", + "trigger_type": "system_event", + "event_source": "github", + "event_type": "pr.comment.created", + "event_filters": { + "repository": "{{repository}}", + "comment_author": "{{maintainer}}" + }, + "action_type": "full_job", + "prompt": "Read the maintainer comment and decide: update plan or start/continue implementation. If plan changes are requested, edit the plan artifact first. If implementation is requested, continue on the feature branch and update PR status/comment.", + "cooldown_secs": 20 +} +``` + +## 3) PR Monitor Loop + +```json +{ + "name": "wf-pr-monitor-loop", + "description": "Keep PR healthy: address review comments and refresh branch", + "trigger_type": "system_event", + "event_source": "github", + "event_type": "pr.synchronize", + "event_filters": { + "repository": "{{repository}}" + }, + "action_type": "full_job", + "prompt": "For PR #{{pr_number}}, collect open review comments and unresolved threads, apply fixes, push branch updates, and summarize remaining blockers. If conflict with {{main_branch}}, rebase/merge from origin/{{main_branch}} and resolve safely.", + "cooldown_secs": 20 +} +``` + +## 4) CI Failure Fix Loop + +```json +{ + "name": "wf-ci-fix-loop", + "description": "Fix failing CI checks on active PRs", + "trigger_type": "system_event", + "event_source": "github", + "event_type": "ci.check_run.completed", + "event_filters": { + "repository": "{{repository}}", + "ci_conclusion": "failure" + }, + "action_type": "full_job", + "prompt": "Find failing check details for PR #{{pr_number}}, implement minimal safe fixes, rerun or await CI, and post concise status updates. Prioritize deterministic and test-backed fixes.", + "cooldown_secs": 20 +} +``` + +## 5) Staging Batch Review (Every 8h) + +```json +{ + "name": "wf-staging-batch-review", + "description": "Batch correctness review through staging, then merge to main", + "trigger_type": "cron", + "schedule": "0 0 */{{batch_interval_hours}} * * *", + "action_type": "full_job", + "prompt": "Every cycle: list ready PRs, merge ready ones into {{staging_branch}}, run deep correctness analysis in batch, fix discovered issues on affected branches, ensure CI green, then merge {{staging_branch}} into {{main_branch}} if clean.", + "cooldown_secs": 120 +} +``` + +## 6) Post-Merge Learning -> Common Memory + +```json +{ + "name": "wf-learning-memory", + "description": "Capture merge learnings into shared memory", + "trigger_type": "system_event", + "event_source": "github", + "event_type": "pr.closed", + "event_filters": { + "repository": "{{repository}}", + "pr_merged": "true" + }, + "action_type": "full_job", + "prompt": "From merged PR #{{pr_number}}, extract preventable mistakes, reviewer themes, CI failure causes, and successful patterns. Write/update a shared memory doc with actionable rules to reduce cycle time and regressions.", + "cooldown_secs": 30 +} +``` + +## Optional: Synthetic Event Test + +```json +{ + "source": "github", + "event_type": "issue.opened", + "payload": { + "repository": "{{repository}}", + "issue_number": 99999, + "sender": "test-bot" + } +} +``` + +Use with `event_emit` after routine install. diff --git a/src/agent/routine.rs b/src/agent/routine.rs index 4cf691be..e24c12f0 100644 --- a/src/agent/routine.rs +++ b/src/agent/routine.rs @@ -65,6 +65,16 @@ pub enum Trigger { /// Regex pattern to match against message content. pattern: String, }, + /// Fire when a structured system event is emitted. + SystemEvent { + /// Event source namespace (e.g. "github", "workflow", "tool"). + source: String, + /// Event type within the source (e.g. "issue.opened"). + event_type: String, + /// Optional exact-match filters against payload top-level fields. + #[serde(default)] + filters: std::collections::HashMap, + }, /// Fire on incoming webhook POST to /hooks/routine/{id}. Webhook { /// Optional webhook path suffix (defaults to routine id). @@ -82,6 +92,7 @@ impl Trigger { match self { Trigger::Cron { .. } => "cron", Trigger::Event { .. } => "event", + Trigger::SystemEvent { .. } => "system_event", Trigger::Webhook { .. } => "webhook", Trigger::Manual => "manual", } @@ -116,6 +127,38 @@ impl Trigger { .map(String::from); Ok(Trigger::Event { channel, pattern }) } + "system_event" => { + let source = config + .get("source") + .and_then(|v| v.as_str()) + .ok_or_else(|| RoutineError::MissingField { + context: "system_event trigger".into(), + field: "source".into(), + })? + .to_string(); + let event_type = config + .get("event_type") + .and_then(|v| v.as_str()) + .ok_or_else(|| RoutineError::MissingField { + context: "system_event trigger".into(), + field: "event_type".into(), + })? + .to_string(); + let filters = config + .get("filters") + .and_then(|v| v.as_object()) + .map(|m| { + m.iter() + .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) + .collect() + }) + .unwrap_or_default(); + Ok(Trigger::SystemEvent { + source, + event_type, + filters, + }) + } "webhook" => { let path = config .get("path") @@ -142,6 +185,15 @@ impl Trigger { "pattern": pattern, "channel": channel, }), + Trigger::SystemEvent { + source, + event_type, + filters, + } => serde_json::json!({ + "source": source, + "event_type": event_type, + "filters": filters, + }), Trigger::Webhook { path, secret } => serde_json::json!({ "path": path, "secret": secret, @@ -451,6 +503,24 @@ mod tests { if channel == Some("telegram".to_string()) && pattern == r"deploy\s+\w+")); } + #[test] + fn test_system_event_trigger_roundtrip() { + let mut filters = std::collections::HashMap::new(); + filters.insert("repo".to_string(), "nearai/ironclaw".to_string()); + filters.insert("action".to_string(), "opened".to_string()); + let trigger = Trigger::SystemEvent { + source: "github".to_string(), + event_type: "issue".to_string(), + filters: filters.clone(), + }; + let json = trigger.to_config_json(); + let parsed = Trigger::from_db("system_event", json).expect("parse system_event"); + assert!( + matches!(parsed, Trigger::SystemEvent { source, event_type, filters: f } + if source == "github" && event_type == "issue" && f == filters) + ); + } + #[test] fn test_action_lightweight_roundtrip() { let action = RoutineAction::Lightweight { @@ -552,6 +622,15 @@ mod tests { .type_tag(), "webhook" ); + assert_eq!( + Trigger::SystemEvent { + source: String::new(), + event_type: String::new(), + filters: std::collections::HashMap::new(), + } + .type_tag(), + "system_event" + ); assert_eq!(Trigger::Manual.type_tag(), "manual"); } } diff --git a/src/agent/routine_engine.rs b/src/agent/routine_engine.rs index da22ffc1..5f2ef30b 100644 --- a/src/agent/routine_engine.rs +++ b/src/agent/routine_engine.rs @@ -31,6 +31,11 @@ use crate::llm::{ChatMessage, CompletionRequest, FinishReason, LlmProvider}; use crate::tools::ApprovalContext; use crate::workspace::Workspace; +enum EventMatcher { + Message { routine: Routine, regex: Regex }, + System { routine: Routine }, +} + /// The routine execution engine. pub struct RoutineEngine { config: RoutineConfig, @@ -41,8 +46,8 @@ pub struct RoutineEngine { notify_tx: mpsc::Sender, /// Currently running routine count (across all routines). running_count: Arc, - /// Compiled event regex cache: routine_id -> compiled regex. - event_cache: Arc>>, + /// Cached matchers for all event-driven routines. + event_cache: Arc>>, /// Scheduler for dispatching jobs (FullJob mode). scheduler: Option>, } @@ -74,9 +79,12 @@ impl RoutineEngine { Ok(routines) => { let mut cache = Vec::new(); for routine in routines { - if let Trigger::Event { ref pattern, .. } = routine.trigger { - match Regex::new(pattern) { - Ok(re) => cache.push((routine.id, routine.clone(), re)), + match &routine.trigger { + Trigger::Event { pattern, .. } => match Regex::new(pattern) { + Ok(re) => cache.push(EventMatcher::Message { + routine: routine.clone(), + regex: re, + }), Err(e) => { tracing::warn!( routine = %routine.name, @@ -84,7 +92,13 @@ impl RoutineEngine { pattern, e ); } + }, + Trigger::SystemEvent { .. } => { + cache.push(EventMatcher::System { + routine: routine.clone(), + }); } + _ => {} } } let count = cache.len(); @@ -105,7 +119,11 @@ impl RoutineEngine { let cache = self.event_cache.read().await; let mut fired = 0; - for (_, routine, re) in cache.iter() { + for matcher in cache.iter() { + let (routine, re) = match matcher { + EventMatcher::Message { routine, regex } => (routine, regex), + EventMatcher::System { .. } => continue, + }; // Channel filter if let Trigger::Event { channel: Some(ch), .. @@ -146,6 +164,82 @@ impl RoutineEngine { fired } + /// Emit a structured event to system-event routines. + /// + /// Returns the number of routines that were fired. + pub async fn emit_system_event( + &self, + source: &str, + event_type: &str, + payload: &serde_json::Value, + user_id: Option<&str>, + ) -> usize { + let cache = self.event_cache.read().await; + let mut fired = 0; + + for matcher in cache.iter() { + let routine = match matcher { + EventMatcher::System { routine } => routine, + EventMatcher::Message { .. } => continue, + }; + + let Trigger::SystemEvent { + source: expected_source, + event_type: expected_event, + filters, + } = &routine.trigger + else { + continue; + }; + + if expected_source != source || expected_event != event_type { + continue; + } + + if let Some(uid) = user_id + && routine.user_id != uid + { + continue; + } + + let mut matched = true; + for (key, expected) in filters { + let Some(actual) = payload.get(key).and_then(json_value_as_string) else { + matched = false; + break; + }; + if actual != *expected { + matched = false; + break; + } + } + if !matched { + continue; + } + + if !self.check_cooldown(routine) { + tracing::debug!(routine = %routine.name, "Skipped: cooldown active"); + continue; + } + + if !self.check_concurrent(routine).await { + tracing::debug!(routine = %routine.name, "Skipped: max concurrent reached"); + continue; + } + + if self.running_count.load(Ordering::Relaxed) >= self.config.max_concurrent_routines { + tracing::warn!(routine = %routine.name, "Skipped: global max concurrent reached"); + continue; + } + + let detail = truncate(&format!("{source}:{event_type}"), 200); + self.spawn_fire(routine.clone(), "system_event", Some(detail)); + fired += 1; + } + + fired + } + /// Check all due cron routines and fire them. Called by the cron ticker. pub async fn check_cron_triggers(&self) { let routines = match self.store.list_due_cron_routines().await { @@ -725,6 +819,15 @@ fn truncate(s: &str, max: usize) -> String { } } +fn json_value_as_string(v: &serde_json::Value) -> Option { + match v { + serde_json::Value::String(s) => Some(s.clone()), + serde_json::Value::Number(n) => Some(n.to_string()), + serde_json::Value::Bool(b) => Some(b.to_string()), + _ => None, + } +} + #[cfg(test)] mod tests { use crate::agent::routine::{NotifyConfig, RunStatus}; diff --git a/src/channels/web/handlers/routines.rs b/src/channels/web/handlers/routines.rs index 88abfc68..d436b3c5 100644 --- a/src/channels/web/handlers/routines.rs +++ b/src/channels/web/handlers/routines.rs @@ -273,6 +273,12 @@ fn routine_to_info(r: &crate::agent::routine::Routine) -> RoutineInfo { let ch = channel.as_deref().unwrap_or("any"); ("event".to_string(), format!("on {} /{}/", ch, pattern)) } + crate::agent::routine::Trigger::SystemEvent { + source, event_type, .. + } => ( + "system_event".to_string(), + format!("event: {}.{}", source, event_type), + ), crate::agent::routine::Trigger::Webhook { path, .. } => { let p = path.as_deref().unwrap_or("/"); ("webhook".to_string(), format!("webhook: {}", p)) diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs index 2f3b2a5b..0fbfa4ff 100644 --- a/src/channels/web/server.rs +++ b/src/channels/web/server.rs @@ -10,7 +10,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use axum::{ Json, Router, extract::{DefaultBodyLimit, Path, Query, State, WebSocketUpgrade}, - http::{StatusCode, header}, + http::{HeaderMap, StatusCode, header}, middleware, response::{ IntoResponse, @@ -200,6 +200,7 @@ pub async fn start_server( // Public routes (no auth) let public = Router::new() .route("/api/health", get(health_handler)) + .route("/api/webhooks/github", post(github_webhook_handler)) .route("/oauth/callback", get(oauth_callback_handler)); // Protected routes (require auth) @@ -432,6 +433,335 @@ async fn health_handler() -> Json { }) } +#[derive(serde::Serialize)] +struct GithubWebhookResponse { + status: &'static str, + source: &'static str, + event_type: String, + fired_routines: usize, +} + +/// PUBLIC webhook ingress for GitHub events. +/// +/// Expects: +/// - `X-GitHub-Event` header +/// - JSON body payload +/// - Optional `X-Hub-Signature-256` HMAC when secret is configured +/// +/// Secret lookup order: +/// 1. `GITHUB_WEBHOOK_SECRET` env var +/// 2. Gateway setting `github.webhook_secret` +async fn github_webhook_handler( + State(state): State>, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result<(StatusCode, Json), (StatusCode, String)> { + let event = headers + .get("x-github-event") + .and_then(|v| v.to_str().ok()) + .map(str::trim) + .filter(|v| !v.is_empty()) + .ok_or(( + StatusCode::BAD_REQUEST, + "Missing X-GitHub-Event header".to_string(), + ))?; + + let payload: serde_json::Value = serde_json::from_slice(&body).map_err(|e| { + ( + StatusCode::BAD_REQUEST, + format!("Invalid JSON payload: {}", e), + ) + })?; + + if let Some(secret) = github_webhook_secret(&state).await { + let sig = headers + .get("x-hub-signature-256") + .and_then(|v| v.to_str().ok()) + .ok_or(( + StatusCode::UNAUTHORIZED, + "Missing X-Hub-Signature-256 header".to_string(), + ))?; + + if !verify_github_signature(&secret, &body, sig) { + return Err((StatusCode::UNAUTHORIZED, "Invalid signature".to_string())); + } + } + + let engine = { + let guard = state.routine_engine.read().await; + guard.as_ref().cloned().ok_or(( + StatusCode::SERVICE_UNAVAILABLE, + "Routine engine not available".to_string(), + ))? + }; + + let event_type = github_event_type(event, &payload); + let enriched_payload = github_enriched_payload(event, &headers, &payload, &event_type); + let fired = engine + .emit_system_event( + "github", + &event_type, + &enriched_payload, + Some(&state.user_id), + ) + .await; + + Ok(( + StatusCode::ACCEPTED, + Json(GithubWebhookResponse { + status: "accepted", + source: "github", + event_type, + fired_routines: fired, + }), + )) +} + +async fn github_webhook_secret(state: &GatewayState) -> Option { + if let Ok(secret) = std::env::var("GITHUB_WEBHOOK_SECRET") + && !secret.trim().is_empty() + { + return Some(secret); + } + + let store = state.store.as_ref()?; + let value = store + .get_setting(&state.user_id, "github.webhook_secret") + .await + .ok() + .flatten()?; + value.as_str().map(ToString::to_string) +} + +fn verify_github_signature(secret: &str, payload: &[u8], signature_header: &str) -> bool { + use hmac::Mac; + use subtle::ConstantTimeEq; + + let Some(provided) = signature_header.strip_prefix("sha256=") else { + return false; + }; + + let mut mac = match hmac::Hmac::::new_from_slice(secret.as_bytes()) { + Ok(m) => m, + Err(_) => return false, + }; + mac.update(payload); + let expected = hex::encode(mac.finalize().into_bytes()); + + expected + .as_bytes() + .ct_eq(provided.to_ascii_lowercase().as_bytes()) + .into() +} + +fn github_event_type(event: &str, payload: &serde_json::Value) -> String { + let base = match event { + "issues" => "issue", + "pull_request" => "pr", + "issue_comment" => { + if payload.pointer("/issue/pull_request").is_some() { + "pr.comment" + } else { + "issue.comment" + } + } + "pull_request_review" => "pr.review", + "pull_request_review_comment" => "pr.review_comment", + "pull_request_review_thread" => "pr.review_thread", + "check_suite" => "ci.check_suite", + "check_run" => "ci.check_run", + "status" => "ci.status", + other => other, + }; + + if let Some(action) = payload.get("action").and_then(|v| v.as_str()) + && !action.is_empty() + { + return format!("{base}.{action}"); + } + + base.to_string() +} + +fn github_enriched_payload( + raw_event: &str, + headers: &HeaderMap, + payload: &serde_json::Value, + event_type: &str, +) -> serde_json::Value { + fn put_if_missing( + obj: &mut serde_json::Map, + key: &str, + val: Option, + ) { + if !obj.contains_key(key) + && let Some(v) = val + { + obj.insert(key.to_string(), v); + } + } + + fn put_string_normalized( + obj: &mut serde_json::Map, + key: &str, + val: Option, + ) { + let should_set = match obj.get(key) { + None => true, + Some(existing) => !existing.is_string(), + }; + if should_set && let Some(v) = val { + obj.insert(key.to_string(), serde_json::Value::String(v)); + } + } + + let mut obj = payload + .as_object() + .cloned() + .unwrap_or_else(serde_json::Map::new); + + put_if_missing( + &mut obj, + "event", + Some(serde_json::Value::String(raw_event.to_string())), + ); + put_if_missing( + &mut obj, + "event_type", + Some(serde_json::Value::String(event_type.to_string())), + ); + put_if_missing( + &mut obj, + "delivery_id", + headers + .get("x-github-delivery") + .and_then(|v| v.to_str().ok()) + .map(|s| serde_json::Value::String(s.to_string())), + ); + put_if_missing( + &mut obj, + "action", + payload + .get("action") + .and_then(|v| v.as_str()) + .map(|s| serde_json::Value::String(s.to_string())), + ); + put_string_normalized( + &mut obj, + "repository", + payload + .pointer("/repository/full_name") + .and_then(|v| v.as_str()) + .map(ToString::to_string), + ); + put_if_missing( + &mut obj, + "repository_owner", + payload + .pointer("/repository/owner/login") + .and_then(|v| v.as_str()) + .map(|s| serde_json::Value::String(s.to_string())), + ); + put_string_normalized( + &mut obj, + "sender", + payload + .pointer("/sender/login") + .and_then(|v| v.as_str()) + .map(ToString::to_string), + ); + put_if_missing( + &mut obj, + "issue_number", + payload.pointer("/issue/number").cloned(), + ); + put_if_missing( + &mut obj, + "pr_number", + payload.pointer("/pull_request/number").cloned(), + ); + put_if_missing( + &mut obj, + "comment_author", + payload + .pointer("/comment/user/login") + .and_then(|v| v.as_str()) + .map(|s| serde_json::Value::String(s.to_string())), + ); + put_if_missing( + &mut obj, + "comment_body", + payload + .pointer("/comment/body") + .and_then(|v| v.as_str()) + .map(|s| serde_json::Value::String(s.to_string())), + ); + put_if_missing( + &mut obj, + "review_state", + payload + .pointer("/review/state") + .and_then(|v| v.as_str()) + .map(|s| serde_json::Value::String(s.to_string())), + ); + put_if_missing( + &mut obj, + "pr_state", + payload + .pointer("/pull_request/state") + .and_then(|v| v.as_str()) + .map(|s| serde_json::Value::String(s.to_string())), + ); + put_if_missing( + &mut obj, + "pr_merged", + payload.pointer("/pull_request/merged").cloned(), + ); + put_if_missing( + &mut obj, + "pr_draft", + payload.pointer("/pull_request/draft").cloned(), + ); + put_if_missing( + &mut obj, + "base_branch", + payload + .pointer("/pull_request/base/ref") + .and_then(|v| v.as_str()) + .map(|s| serde_json::Value::String(s.to_string())), + ); + put_if_missing( + &mut obj, + "head_branch", + payload + .pointer("/pull_request/head/ref") + .and_then(|v| v.as_str()) + .map(|s| serde_json::Value::String(s.to_string())), + ); + put_if_missing( + &mut obj, + "ci_status", + payload + .pointer("/check_run/status") + .or_else(|| payload.pointer("/check_suite/status")) + .or_else(|| payload.pointer("/status")) + .and_then(|v| v.as_str()) + .map(|s| serde_json::Value::String(s.to_string())), + ); + put_if_missing( + &mut obj, + "ci_conclusion", + payload + .pointer("/check_run/conclusion") + .or_else(|| payload.pointer("/check_suite/conclusion")) + .or_else(|| payload.pointer("/state")) + .and_then(|v| v.as_str()) + .map(|s| serde_json::Value::String(s.to_string())), + ); + + serde_json::Value::Object(obj) +} + /// Return an OAuth error landing page response. fn oauth_error_page(label: &str) -> axum::response::Response { let html = crate::cli::oauth_defaults::landing_html(label, false); @@ -2124,6 +2454,12 @@ fn routine_to_info(r: &crate::agent::routine::Routine) -> RoutineInfo { let ch = channel.as_deref().unwrap_or("any"); ("event".to_string(), format!("on {} /{}/", ch, pattern)) } + crate::agent::routine::Trigger::SystemEvent { + source, event_type, .. + } => ( + "system_event".to_string(), + format!("event: {}.{}", source, event_type), + ), crate::agent::routine::Trigger::Webhook { path, .. } => { let p = path.as_deref().unwrap_or("/"); ("webhook".to_string(), format!("webhook: {}", p)) @@ -2474,6 +2810,12 @@ mod tests { .with_state(state) } + fn test_github_webhook_router(state: Arc) -> Router { + Router::new() + .route("/api/webhooks/github", post(github_webhook_handler)) + .with_state(state) + } + #[tokio::test] async fn test_oauth_callback_missing_params() { use axum::body::Body; @@ -2779,4 +3121,193 @@ mod tests { .is_none() ); } + + #[test] + fn test_github_event_type_normalization() { + assert_eq!( + github_event_type("issues", &serde_json::json!({"action": "opened"})), + "issue.opened" + ); + assert_eq!( + github_event_type( + "pull_request", + &serde_json::json!({"action": "synchronize"}) + ), + "pr.synchronize" + ); + assert_eq!( + github_event_type("push", &serde_json::json!({})), + "push".to_string() + ); + assert_eq!( + github_event_type( + "issue_comment", + &serde_json::json!({ + "action": "created", + "issue": { "pull_request": { "url": "https://api.github.com/repos/org/repo/pulls/1" } } + }) + ), + "pr.comment.created" + ); + assert_eq!( + github_event_type("check_run", &serde_json::json!({"action": "completed"})), + "ci.check_run.completed" + ); + } + + #[test] + fn test_verify_github_signature_valid_and_invalid() { + use hmac::Mac; + + let secret = "test-secret"; + let payload = br#"{"action":"opened"}"#; + + let mut mac = + hmac::Hmac::::new_from_slice(secret.as_bytes()).expect("hmac key"); + mac.update(payload); + let digest = hex::encode(mac.finalize().into_bytes()); + let sig = format!("sha256={digest}"); + + assert!(verify_github_signature(secret, payload, &sig)); + assert!(!verify_github_signature(secret, payload, "sha256=deadbeef")); + assert!(!verify_github_signature(secret, payload, "invalid-format")); + } + + #[tokio::test] + async fn test_github_webhook_missing_event_header_rejected() { + use axum::body::Body; + use tower::ServiceExt; + + let state = test_gateway_state(None); + let app = test_github_webhook_router(state); + + let req = axum::http::Request::builder() + .method("POST") + .uri("/api/webhooks/github") + .header("content-type", "application/json") + .body(Body::from(r#"{"action":"opened"}"#)) + .expect("request"); + + let resp = ServiceExt::>::oneshot(app, req) + .await + .expect("response"); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn test_github_webhook_without_engine_returns_service_unavailable() { + use axum::body::Body; + use tower::ServiceExt; + + let state = test_gateway_state(None); + let app = test_github_webhook_router(state); + + let req = axum::http::Request::builder() + .method("POST") + .uri("/api/webhooks/github") + .header("content-type", "application/json") + .header("x-github-event", "issues") + .body(Body::from(r#"{"action":"opened"}"#)) + .expect("request"); + + let resp = ServiceExt::>::oneshot(app, req) + .await + .expect("response"); + assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); + } + + #[cfg(feature = "libsql")] + #[tokio::test] + async fn test_github_webhook_accepts_without_secret_when_engine_present() { + use axum::body::Body; + use tower::ServiceExt; + + let state = test_gateway_state(None); + + let (db, _tmp) = crate::testing::test_db().await; + let ws = Arc::new(crate::workspace::Workspace::new_with_db("test", db.clone())); + let llm = Arc::new(crate::testing::StubLlm::new("ok")); + let (notify_tx, _notify_rx) = tokio::sync::mpsc::channel(8); + let engine = Arc::new(crate::agent::routine_engine::RoutineEngine::new( + crate::config::RoutineConfig::default(), + db, + llm, + ws, + notify_tx, + None, + )); + *state.routine_engine.write().await = Some(engine); + + let app = test_github_webhook_router(state); + let req = axum::http::Request::builder() + .method("POST") + .uri("/api/webhooks/github") + .header("content-type", "application/json") + .header("x-github-event", "issues") + .body(Body::from(r#"{"action":"opened"}"#)) + .expect("request"); + + let resp = ServiceExt::>::oneshot(app, req) + .await + .expect("response"); + assert_eq!(resp.status(), StatusCode::ACCEPTED); + + let body = axum::body::to_bytes(resp.into_body(), 1024 * 64) + .await + .expect("body"); + let json: serde_json::Value = serde_json::from_slice(&body).expect("json"); + assert_eq!( + json.get("event_type").and_then(|v| v.as_str()), + Some("issue.opened") + ); + } + + #[test] + fn test_github_enriched_payload_extracts_common_fields() { + let headers = HeaderMap::new(); + let payload = serde_json::json!({ + "action": "created", + "repository": { + "full_name": "nearai/ironclaw", + "owner": { "login": "nearai" } + }, + "sender": { "login": "maintainer1" }, + "issue": { "number": 77 }, + "comment": { + "body": "Please update the implementation plan", + "user": { "login": "maintainer1" } + } + }); + + let enriched = + github_enriched_payload("issue_comment", &headers, &payload, "issue.comment.created"); + assert_eq!( + enriched.get("repository").and_then(|v| v.as_str()), + Some("nearai/ironclaw") + ); + assert_eq!( + enriched.get("repository_owner").and_then(|v| v.as_str()), + Some("nearai") + ); + assert_eq!( + enriched.get("sender").and_then(|v| v.as_str()), + Some("maintainer1") + ); + assert_eq!( + enriched.get("issue_number").and_then(|v| v.as_i64()), + Some(77) + ); + assert_eq!( + enriched.get("comment_author").and_then(|v| v.as_str()), + Some("maintainer1") + ); + assert_eq!( + enriched.get("comment_body").and_then(|v| v.as_str()), + Some("Please update the implementation plan") + ); + assert_eq!( + enriched.get("event_type").and_then(|v| v.as_str()), + Some("issue.comment.created") + ); + } } diff --git a/src/db/libsql/routines.rs b/src/db/libsql/routines.rs index f85ba0e3..3f2629ea 100644 --- a/src/db/libsql/routines.rs +++ b/src/db/libsql/routines.rs @@ -167,7 +167,7 @@ impl RoutineStore for LibSqlBackend { let mut rows = conn .query( &format!( - "SELECT {} FROM routines WHERE enabled = 1 AND trigger_type = 'event'", + "SELECT {} FROM routines WHERE enabled = 1 AND trigger_type IN ('event', 'system_event')", ROUTINE_COLUMNS ), (), diff --git a/src/history/store.rs b/src/history/store.rs index 2a46aaea..6e732e81 100644 --- a/src/history/store.rs +++ b/src/history/store.rs @@ -1081,7 +1081,7 @@ impl Store { let conn = self.conn().await?; let rows = conn .query( - "SELECT * FROM routines WHERE enabled AND trigger_type = 'event'", + "SELECT * FROM routines WHERE enabled AND trigger_type IN ('event', 'system_event')", &[], ) .await?; diff --git a/src/tools/builtin/mod.rs b/src/tools/builtin/mod.rs index bbbc7056..c52915a1 100644 --- a/src/tools/builtin/mod.rs +++ b/src/tools/builtin/mod.rs @@ -32,8 +32,8 @@ pub use memory::{MemoryReadTool, MemorySearchTool, MemoryTreeTool, MemoryWriteTo pub use message::MessageTool; pub use restart::RestartTool; pub use routine::{ - RoutineCreateTool, RoutineDeleteTool, RoutineFireTool, RoutineHistoryTool, RoutineListTool, - RoutineUpdateTool, + EventEmitTool, RoutineCreateTool, RoutineDeleteTool, RoutineFireTool, RoutineHistoryTool, + RoutineListTool, RoutineUpdateTool, }; pub use secrets_tools::{SecretDeleteTool, SecretListTool}; pub use shell::ShellTool; diff --git a/src/tools/builtin/routine.rs b/src/tools/builtin/routine.rs index 2fddec29..2bb55afa 100644 --- a/src/tools/builtin/routine.rs +++ b/src/tools/builtin/routine.rs @@ -1,12 +1,13 @@ //! LLM-facing tools for managing routines. //! -//! Six tools let the agent manage routines conversationally: +//! Seven tools let the agent manage routines conversationally: //! - `routine_create` - Create a new routine //! - `routine_list` - List all routines with status //! - `routine_update` - Modify or toggle a routine //! - `routine_delete` - Remove a routine //! - `routine_fire` - Manually trigger a routine //! - `routine_history` - View past runs +//! - `event_emit` - Emit a structured event to event-driven routines use std::sync::Arc; use std::time::Duration; @@ -62,7 +63,7 @@ impl Tool for RoutineCreateTool { }, "trigger_type": { "type": "string", - "enum": ["cron", "event", "webhook", "manual"], + "enum": ["cron", "event", "system_event", "webhook", "manual"], "description": "When the routine fires" }, "schedule": { @@ -77,6 +78,19 @@ impl Tool for RoutineCreateTool { "type": "string", "description": "Optional channel filter for event trigger (e.g. 'telegram')" }, + "event_source": { + "type": "string", + "description": "Event source for system_event triggers (e.g. 'github')" + }, + "event_type": { + "type": "string", + "description": "Event type for system_event triggers (e.g. 'issue.opened')" + }, + "event_filters": { + "type": "object", + "additionalProperties": { "type": "string" }, + "description": "Optional exact-match filters against payload fields for system_event triggers" + }, "prompt": { "type": "string", "description": "The prompt/instructions for the routine" @@ -172,6 +186,38 @@ impl Tool for RoutineCreateTool { pattern: pattern.to_string(), } } + "system_event" => { + let source = params + .get("event_source") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + ToolError::InvalidParameters( + "system_event trigger requires 'event_source'".to_string(), + ) + })?; + let event_type = params + .get("event_type") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + ToolError::InvalidParameters( + "system_event trigger requires 'event_type'".to_string(), + ) + })?; + let filters = params + .get("event_filters") + .and_then(|v| v.as_object()) + .map(|obj| { + obj.iter() + .filter_map(|(k, v)| v.as_str().map(|s| (k.to_string(), s.to_string()))) + .collect::>() + }) + .unwrap_or_default(); + Trigger::SystemEvent { + source: source.to_string(), + event_type: event_type.to_string(), + filters, + } + } "webhook" => Trigger::Webhook { path: None, secret: None, @@ -274,7 +320,10 @@ impl Tool for RoutineCreateTool { .map_err(|e| ToolError::ExecutionFailed(format!("failed to create routine: {e}")))?; // Refresh event cache if this is an event trigger - if routine.trigger.type_tag() == "event" { + if matches!( + routine.trigger, + Trigger::Event { .. } | Trigger::SystemEvent { .. } + ) { self.engine.refresh_event_cache().await; } @@ -648,6 +697,96 @@ pub struct RoutineHistoryTool { store: Arc, } +// ==================== event_emit ==================== + +pub struct EventEmitTool { + engine: Arc, +} + +impl EventEmitTool { + pub fn new(engine: Arc) -> Self { + Self { engine } + } +} + +#[async_trait] +impl Tool for EventEmitTool { + fn name(&self) -> &str { + "event_emit" + } + + fn description(&self) -> &str { + "Emit a structured event to event-driven routines. \ + Use this to trigger routines from tool workflows without waiting for cron." + } + + fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement { + ApprovalRequirement::Never + } + + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "source": { + "type": "string", + "description": "Event source (e.g. 'github', 'workflow', 'tool')" + }, + "event_type": { + "type": "string", + "description": "Event type (e.g. 'issue.opened', 'pr.ready')" + }, + "payload": { + "type": "object", + "description": "Structured event payload" + }, + "user_id": { + "type": "string", + "description": "Optional target user id; defaults to current user" + } + }, + "required": ["source", "event_type"] + }) + } + + async fn execute( + &self, + params: serde_json::Value, + ctx: &JobContext, + ) -> Result { + let start = std::time::Instant::now(); + + let source = require_str(¶ms, "source")?; + let event_type = require_str(¶ms, "event_type")?; + let payload = params + .get("payload") + .cloned() + .unwrap_or_else(|| serde_json::json!({})); + let user_id = params + .get("user_id") + .and_then(|v| v.as_str()) + .unwrap_or(&ctx.user_id); + + let fired = self + .engine + .emit_system_event(source, event_type, &payload, Some(user_id)) + .await; + + let result = serde_json::json!({ + "source": source, + "event_type": event_type, + "target_user_id": user_id, + "fired_routines": fired, + }); + + Ok(ToolOutput::success(result, start.elapsed())) + } + + fn requires_sanitization(&self) -> bool { + false + } +} + impl RoutineHistoryTool { pub fn new(store: Arc) -> Self { Self { store } diff --git a/src/tools/registry.rs b/src/tools/registry.rs index 498d1d58..1eb6d299 100644 --- a/src/tools/registry.rs +++ b/src/tools/registry.rs @@ -65,6 +65,7 @@ const PROTECTED_TOOL_NAMES: &[&str] = &[ "routine_delete", "routine_fire", "routine_history", + "event_emit", "skill_list", "skill_search", "skill_install", @@ -425,8 +426,8 @@ impl ToolRegistry { engine: Arc, ) { use crate::tools::builtin::{ - RoutineCreateTool, RoutineDeleteTool, RoutineFireTool, RoutineHistoryTool, - RoutineListTool, RoutineUpdateTool, + EventEmitTool, RoutineCreateTool, RoutineDeleteTool, RoutineFireTool, + RoutineHistoryTool, RoutineListTool, RoutineUpdateTool, }; self.register_sync(Arc::new(RoutineCreateTool::new( Arc::clone(&store), @@ -446,7 +447,8 @@ impl ToolRegistry { Arc::clone(&engine), ))); self.register_sync(Arc::new(RoutineHistoryTool::new(store))); - tracing::info!("Registered 6 routine management tools"); + self.register_sync(Arc::new(EventEmitTool::new(engine))); + tracing::info!("Registered 7 routine management tools"); } /// Register message tool for sending messages to channels. diff --git a/src/tools/schema_validator.rs b/src/tools/schema_validator.rs index 8da0b613..91ed4798 100644 --- a/src/tools/schema_validator.rs +++ b/src/tools/schema_validator.rs @@ -565,12 +565,19 @@ mod tests { "description": { "type": "string", "description": "What it does" }, "trigger_type": { "type": "string", - "enum": ["cron", "event", "webhook", "manual"], + "enum": ["cron", "event", "system_event", "webhook", "manual"], "description": "When the routine fires" }, "schedule": { "type": "string", "description": "Cron expression" }, "event_pattern": { "type": "string", "description": "Regex pattern" }, "event_channel": { "type": "string", "description": "Channel filter" }, + "event_source": { "type": "string", "description": "System event source" }, + "event_type": { "type": "string", "description": "System event type" }, + "event_filters": { + "type": "object", + "additionalProperties": { "type": "string" }, + "description": "Exact-match payload filters" + }, "prompt": { "type": "string", "description": "Instructions" }, "context_paths": { "type": "array", @@ -647,6 +654,19 @@ mod tests { "required": ["name"] }), ), + ( + "event_emit", + serde_json::json!({ + "type": "object", + "properties": { + "source": { "type": "string", "description": "Event source" }, + "event_type": { "type": "string", "description": "Event type" }, + "payload": { "type": "object", "description": "Event payload" }, + "user_id": { "type": "string", "description": "Optional target user id" } + }, + "required": ["source", "event_type"] + }), + ), // Job tools with complex deps ( "job_events", diff --git a/tests/e2e_builtin_tool_coverage.rs b/tests/e2e_builtin_tool_coverage.rs index 2143d7a9..2df2ca6e 100644 --- a/tests/e2e_builtin_tool_coverage.rs +++ b/tests/e2e_builtin_tool_coverage.rs @@ -27,6 +27,8 @@ mod tests { let rig = TestRigBuilder::new() .with_trace(trace.clone()) + .with_auto_approve_tools(true) + .with_skills() .build() .await; @@ -60,6 +62,8 @@ mod tests { let rig = TestRigBuilder::new() .with_trace(trace.clone()) + .with_auto_approve_tools(true) + .with_skills() .build() .await; @@ -97,6 +101,8 @@ mod tests { let rig = TestRigBuilder::new() .with_trace(trace.clone()) + .with_auto_approve_tools(true) + .with_skills() .build() .await; @@ -197,7 +203,107 @@ mod tests { } // ----------------------------------------------------------------------- - // Test 6: job_create_status + // Test 6: routine_system_event_emit + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn routine_system_event_emit() { + let trace = LlmTrace::from_file(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/llm_traces/tools/routine_system_event_emit.json" + )) + .expect("failed to load routine_system_event_emit.json"); + + let rig = TestRigBuilder::new() + .with_trace(trace.clone()) + .build() + .await; + + rig.send_message("Create a system-event routine and emit an event") + .await; + let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await; + + rig.verify_trace_expects(&trace, &responses); + + let completed = rig.tool_calls_completed(); + assert!( + completed.iter().any(|(n, ok)| n == "event_emit" && *ok), + "event_emit should succeed: {completed:?}" + ); + + let results = rig.tool_results(); + let emit_result = results + .iter() + .find(|(n, _)| n == "event_emit") + .expect("event_emit result missing"); + assert!( + emit_result.1.contains("fired_routines"), + "event_emit should report fired routine count: {:?}", + emit_result.1 + ); + + rig.shutdown(); + } + + // ----------------------------------------------------------------------- + // Test 7: skill_install_routine_webhook_sim + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn skill_install_routine_webhook_sim() { + let trace = LlmTrace::from_file(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/llm_traces/tools/skill_install_routine_webhook_sim.json" + )) + .expect("failed to load skill_install_routine_webhook_sim.json"); + + let rig = TestRigBuilder::new() + .with_trace(trace.clone()) + .build() + .await; + + rig.send_message("Install the workflow skill template and simulate a webhook routine run") + .await; + // `skill_install` is approval-gated in the interactive loop. + // Approve once so the trace can proceed through the remaining steps. + tokio::time::sleep(Duration::from_millis(500)).await; + rig.send_message("always").await; + let responses = rig.wait_for_responses(1, Duration::from_secs(20)).await; + rig.verify_trace_expects(&trace, &responses); + + let completed = rig.tool_calls_completed(); + assert!( + completed.iter().any(|(n, _)| n == "skill_install"), + "skill_install should be called: {completed:?}" + ); + for tool in &["routine_create", "event_emit", "routine_history"] { + assert!( + completed.iter().any(|(n, ok)| n == tool && *ok), + "{tool} should succeed: {completed:?}" + ); + } + + let results = rig.tool_results(); + let emit_result = results + .iter() + .find(|(n, _)| n == "event_emit") + .expect("event_emit result missing"); + assert!( + emit_result.1.contains("fired_routines"), + "event_emit should include fired_routines: {:?}", + emit_result.1 + ); + + let _history_result = results + .iter() + .find(|(n, _)| n == "routine_history") + .expect("routine_history result missing"); + + rig.shutdown(); + } + + // ----------------------------------------------------------------------- + // Test 8: job_create_status // ----------------------------------------------------------------------- // Uses {{call_cj_1.job_id}} template to forward the dynamic UUID from // create_job's result into job_status's arguments. @@ -256,7 +362,7 @@ mod tests { } // ----------------------------------------------------------------------- - // Test 7: job_list_cancel + // Test 9: job_list_cancel // ----------------------------------------------------------------------- // Uses {{call_cj_lc.job_id}} template to forward the dynamic UUID from // create_job into cancel_job. diff --git a/tests/e2e_routine_heartbeat.rs b/tests/e2e_routine_heartbeat.rs index 1e65fb3d..ae455217 100644 --- a/tests/e2e_routine_heartbeat.rs +++ b/tests/e2e_routine_heartbeat.rs @@ -234,6 +234,105 @@ mod tests { // Test 3: routine_cooldown // ----------------------------------------------------------------------- + #[tokio::test] + async fn system_event_trigger_matches_and_filters() { + let (db, _tmp) = create_test_db().await; + let ws = create_workspace(&db); + + let trace = LlmTrace::single_turn( + "test-system-event-match", + "event", + vec![TraceStep { + request_hint: None, + response: TraceResponse::Text { + content: "System event handled".to_string(), + input_tokens: 40, + output_tokens: 8, + }, + expected_tool_results: vec![], + }], + ); + let llm = Arc::new(TraceLlm::from_trace(trace)); + let (notify_tx, _notify_rx) = tokio::sync::mpsc::channel(16); + + let engine = Arc::new(RoutineEngine::new( + RoutineConfig::default(), + db.clone(), + llm, + ws, + notify_tx, + None, + )); + + let mut filters = std::collections::HashMap::new(); + filters.insert("repository".to_string(), "nearai/ironclaw".to_string()); + + let routine = make_routine( + "github-issue-opened", + Trigger::SystemEvent { + source: "github".to_string(), + event_type: "issue.opened".to_string(), + filters, + }, + "Summarize the issue and propose an implementation plan.", + ); + db.create_routine(&routine).await.expect("create_routine"); + engine.refresh_event_cache().await; + + // Matching event should fire. + let fired = engine + .emit_system_event( + "github", + "issue.opened", + &serde_json::json!({ + "repository": "nearai/ironclaw", + "issue_number": 42 + }), + Some("default"), + ) + .await; + assert_eq!(fired, 1, "Expected one routine to fire for matching event"); + + tokio::time::sleep(Duration::from_millis(300)).await; + + let runs = db + .list_routine_runs(routine.id, 10) + .await + .expect("list runs"); + assert!( + !runs.is_empty(), + "Expected run history after matching event" + ); + + // Wrong event type should not fire. + let fired_wrong_type = engine + .emit_system_event( + "github", + "issue.closed", + &serde_json::json!({"repository": "nearai/ironclaw"}), + Some("default"), + ) + .await; + assert_eq!( + fired_wrong_type, 0, + "Expected no routine for wrong event type" + ); + + // Wrong filter value should not fire. + let fired_wrong_filter = engine + .emit_system_event( + "github", + "issue.opened", + &serde_json::json!({"repository": "other/repo"}), + Some("default"), + ) + .await; + assert_eq!( + fired_wrong_filter, 0, + "Expected no routine for filter mismatch" + ); + } + #[tokio::test] async fn routine_cooldown() { let (db, _tmp) = create_test_db().await; diff --git a/tests/fixtures/llm_traces/tools/routine_system_event_emit.json b/tests/fixtures/llm_traces/tools/routine_system_event_emit.json new file mode 100644 index 00000000..07b848ab --- /dev/null +++ b/tests/fixtures/llm_traces/tools/routine_system_event_emit.json @@ -0,0 +1,42 @@ +{ + "model_name": "test-routine-system-event-emit", + "expects": { + "tools_used": ["event_emit"], + "all_tools_succeeded": true, + "tool_results_contain": { + "event_emit": "fired_routines" + } + }, + "steps": [ + { + "response": { + "type": "tool_calls", + "tool_calls": [ + { + "id": "call_ee_1", + "name": "event_emit", + "arguments": { + "source": "github", + "event_type": "issue.opened", + "payload": { + "repository": "nearai/ironclaw", + "issue_number": 123, + "title": "Support event-driven project workflow" + } + } + } + ], + "input_tokens": 90, + "output_tokens": 28 + } + }, + { + "response": { + "type": "text", + "content": "Emitted a GitHub system event successfully.", + "input_tokens": 140, + "output_tokens": 14 + } + } + ] +} diff --git a/tests/fixtures/llm_traces/tools/skill_install_routine_webhook_sim.json b/tests/fixtures/llm_traces/tools/skill_install_routine_webhook_sim.json new file mode 100644 index 00000000..14e070c6 --- /dev/null +++ b/tests/fixtures/llm_traces/tools/skill_install_routine_webhook_sim.json @@ -0,0 +1,100 @@ +{ + "model_name": "test-skill-install-routine-webhook-sim", + "expects": { + "tools_used": ["skill_install", "routine_create", "event_emit", "routine_history"], + "tool_results_contain": { + "event_emit": "fired_routines" + } + }, + "steps": [ + { + "response": { + "type": "tool_calls", + "tool_calls": [ + { + "id": "call_skill_install_1", + "name": "skill_install", + "arguments": { + "name": "wf-orchestrator-trace-install-1", + "content": "---\nname: wf-orchestrator-trace-install-1\ndescription: Minimal workflow skill for trace install validation\nactivation:\n keywords: [\"workflow\", \"orchestrator\"]\n---\n\nYou are a minimal workflow skill used for trace install validation.\n" + } + } + ], + "input_tokens": 120, + "output_tokens": 32 + } + }, + { + "response": { + "type": "tool_calls", + "tool_calls": [ + { + "id": "call_routine_create_1", + "name": "routine_create", + "arguments": { + "name": "wf-webhook-sim-trace", + "description": "Trace routine to simulate webhook event flow", + "trigger_type": "system_event", + "event_source": "github", + "event_type": "issue.opened", + "event_filters": { + "repository": "nearai/ironclaw" + }, + "action_type": "full_job", + "prompt": "When issue webhook event arrives, start implementation loop and create branch/PR updates." + } + } + ], + "input_tokens": 170, + "output_tokens": 36 + } + }, + { + "response": { + "type": "tool_calls", + "tool_calls": [ + { + "id": "call_event_emit_1", + "name": "event_emit", + "arguments": { + "source": "github", + "event_type": "issue.opened", + "payload": { + "repository": "nearai/ironclaw", + "issue_number": 4242, + "sender": "trace-bot" + } + } + } + ], + "input_tokens": 210, + "output_tokens": 28 + } + }, + { + "response": { + "type": "tool_calls", + "tool_calls": [ + { + "id": "call_routine_history_1", + "name": "routine_history", + "arguments": { + "name": "wf-webhook-sim-trace", + "limit": 5 + } + } + ], + "input_tokens": 240, + "output_tokens": 22 + } + }, + { + "response": { + "type": "text", + "content": "Installed the skill template, created a system-event routine, emitted a webhook-equivalent event, and verified the routine run history.", + "input_tokens": 280, + "output_tokens": 25 + } + } + ] +} diff --git a/tests/support/test_rig.rs b/tests/support/test_rig.rs index 0073741e..3bb8162c 100644 --- a/tests/support/test_rig.rs +++ b/tests/support/test_rig.rs @@ -379,6 +379,8 @@ pub struct TestRigBuilder { llm: Option>, max_tool_iterations: usize, injection_check: bool, + auto_approve_tools: Option, + enable_skills: bool, enable_routines: bool, http_exchanges: Vec, extra_tools: Vec>, @@ -392,6 +394,8 @@ impl TestRigBuilder { llm: None, max_tool_iterations: 10, injection_check: false, + auto_approve_tools: None, + enable_skills: false, enable_routines: false, http_exchanges: Vec::new(), extra_tools: Vec::new(), @@ -432,6 +436,18 @@ impl TestRigBuilder { self } + /// Override agent-level automatic approval of `UnlessAutoApproved` tools. + pub fn with_auto_approve_tools(mut self, enable: bool) -> Self { + self.auto_approve_tools = Some(enable); + self + } + + /// Enable skill discovery and registration for this test rig. + pub fn with_skills(mut self) -> Self { + self.enable_skills = true; + self + } + /// Enable the routines system so the scheduler is wired with a `RoutineEngine`, /// allowing routine jobs to actually execute. Routine tools are always registered /// but require the engine to dispatch jobs. @@ -466,6 +482,8 @@ impl TestRigBuilder { llm, max_tool_iterations, injection_check, + auto_approve_tools, + enable_skills, enable_routines, http_exchanges: explicit_http_exchanges, extra_tools, @@ -491,6 +509,10 @@ impl TestRigBuilder { let mut config = Config::for_testing(db_path, skills_dir, installed_skills_dir); config.agent.max_tool_iterations = max_tool_iterations; config.safety.injection_check_enabled = injection_check; + config.skills.enabled = enable_skills; + if let Some(v) = auto_approve_tools { + config.agent.auto_approve_tools = v; + } // 3. Create SessionManager + LogBroadcaster. let session = Arc::new(SessionManager::new(SessionConfig::default())); @@ -540,7 +562,7 @@ impl TestRigBuilder { ); builder.with_database(Arc::clone(&db)); builder.with_llm(llm); - let components = builder + let mut components = builder .build_all() .await .expect("AppBuilder::build_all() failed in test rig"); @@ -583,6 +605,21 @@ impl TestRigBuilder { .register_routine_tools(Arc::clone(db_arc), engine); } + // Skills tools: ensure tests use temp skill dirs (sandbox-safe) even if + // AppBuilder did not wire them for this environment. + if enable_skills { + let registry = Arc::new(std::sync::RwLock::new( + ironclaw::skills::SkillRegistry::new(temp_dir.path().join("skills")) + .with_installed_dir(temp_dir.path().join("installed_skills")), + )); + let catalog = ironclaw::skills::catalog::shared_catalog(); + components + .tools + .register_skill_tools(Arc::clone(®istry), Arc::clone(&catalog)); + components.skill_registry = Some(registry); + components.skill_catalog = Some(catalog); + } + // Register any extra test-specific tools. for tool in extra_tools { components.tools.register(tool).await; diff --git a/tools-src/github/README.md b/tools-src/github/README.md index fbde6c61..a66eba40 100644 --- a/tools-src/github/README.md +++ b/tools-src/github/README.md @@ -5,8 +5,8 @@ WASM tool for GitHub integration - manage repos, issues, PRs, and workflows. ## Features - **Repository Info** - Get repo details, list user repos -- **Issues** - List, create, and get issue details -- **Pull Requests** - List PRs, get PR details, review files, create reviews +- **Issues** - List/create/get issues, list/add issue comments +- **Pull Requests** - List/create/get PRs, review files, create reviews, list/reply review comments, merge PRs - **File Content** - Read files from repos - **Workflows** - Trigger GitHub Actions, check run status @@ -82,6 +82,32 @@ WASM tool for GitHub integration - manage repos, issues, PRs, and workflows. } ``` +### Create Pull Request + +```json +{ + "action": "create_pull_request", + "owner": "nearai", + "repo": "ironclaw", + "title": "feat: add event-driven routines", + "head": "feat/event-routines", + "base": "main", + "body": "Implements system_event trigger + event_emit tool." +} +``` + +### Merge Pull Request + +```json +{ + "action": "merge_pull_request", + "owner": "nearai", + "repo": "ironclaw", + "pr_number": 42, + "merge_method": "squash" +} +``` + ### Get File Content ```json diff --git a/tools-src/github/github-tool.capabilities.json b/tools-src/github/github-tool.capabilities.json index 48c53dbf..b19afe1e 100644 --- a/tools-src/github/github-tool.capabilities.json +++ b/tools-src/github/github-tool.capabilities.json @@ -9,7 +9,8 @@ "path_prefix": "/", "methods": [ "GET", - "POST" + "POST", + "PUT" ] } ], @@ -56,4 +57,4 @@ "default_limit": 30, "max_limit": 100 } -} \ No newline at end of file +} diff --git a/tools-src/github/src/lib.rs b/tools-src/github/src/lib.rs index c8c780cb..1128fd8a 100644 --- a/tools-src/github/src/lib.rs +++ b/tools-src/github/src/lib.rs @@ -93,6 +93,21 @@ enum GitHubAction { repo: String, issue_number: u32, }, + #[serde(rename = "list_issue_comments")] + ListIssueComments { + owner: String, + repo: String, + issue_number: u32, + page: Option, + limit: Option, + }, + #[serde(rename = "create_issue_comment")] + CreateIssueComment { + owner: String, + repo: String, + issue_number: u32, + body: String, + }, #[serde(rename = "list_pull_requests")] ListPullRequests { owner: String, @@ -101,6 +116,16 @@ enum GitHubAction { page: Option, limit: Option, }, + #[serde(rename = "create_pull_request")] + CreatePullRequest { + owner: String, + repo: String, + title: String, + head: String, + base: String, + body: Option, + draft: Option, + }, #[serde(rename = "get_pull_request")] GetPullRequest { owner: String, @@ -121,6 +146,44 @@ enum GitHubAction { body: String, event: String, }, + #[serde(rename = "list_pull_request_comments")] + ListPullRequestComments { + owner: String, + repo: String, + pr_number: u32, + page: Option, + limit: Option, + }, + #[serde(rename = "reply_pull_request_comment")] + ReplyPullRequestComment { + owner: String, + repo: String, + comment_id: u32, + body: String, + }, + #[serde(rename = "get_pull_request_reviews")] + GetPullRequestReviews { + owner: String, + repo: String, + pr_number: u32, + page: Option, + limit: Option, + }, + #[serde(rename = "get_combined_status")] + GetCombinedStatus { + owner: String, + repo: String, + r#ref: String, + }, + #[serde(rename = "merge_pull_request")] + MergePullRequest { + owner: String, + repo: String, + pr_number: u32, + commit_title: Option, + commit_message: Option, + merge_method: Option, + }, #[serde(rename = "list_repos")] ListRepos { username: String, @@ -208,6 +271,19 @@ fn execute_inner(params: &str) -> Result { repo, issue_number, } => get_issue(&owner, &repo, issue_number), + GitHubAction::ListIssueComments { + owner, + repo, + issue_number, + page, + limit, + } => list_issue_comments(&owner, &repo, issue_number, page, limit), + GitHubAction::CreateIssueComment { + owner, + repo, + issue_number, + body, + } => create_issue_comment(&owner, &repo, issue_number, &body), GitHubAction::ListPullRequests { owner, repo, @@ -215,6 +291,23 @@ fn execute_inner(params: &str) -> Result { page, limit, } => list_pull_requests(&owner, &repo, state.as_deref(), page, limit), + GitHubAction::CreatePullRequest { + owner, + repo, + title, + head, + base, + body, + draft, + } => create_pull_request( + &owner, + &repo, + &title, + &head, + &base, + body.as_deref(), + draft.unwrap_or(false), + ), GitHubAction::GetPullRequest { owner, repo, @@ -232,6 +325,44 @@ fn execute_inner(params: &str) -> Result { body, event, } => create_pr_review(&owner, &repo, pr_number, &body, &event), + GitHubAction::ListPullRequestComments { + owner, + repo, + pr_number, + page, + limit, + } => list_pull_request_comments(&owner, &repo, pr_number, page, limit), + GitHubAction::ReplyPullRequestComment { + owner, + repo, + comment_id, + body, + } => reply_pull_request_comment(&owner, &repo, comment_id, &body), + GitHubAction::GetPullRequestReviews { + owner, + repo, + pr_number, + page, + limit, + } => get_pull_request_reviews(&owner, &repo, pr_number, page, limit), + GitHubAction::GetCombinedStatus { owner, repo, r#ref } => { + get_combined_status(&owner, &repo, &r#ref) + } + GitHubAction::MergePullRequest { + owner, + repo, + pr_number, + commit_title, + commit_message, + merge_method, + } => merge_pull_request( + &owner, + &repo, + pr_number, + commit_title.as_deref(), + commit_message.as_deref(), + merge_method.as_deref(), + ), GitHubAction::ListRepos { username, page, @@ -451,6 +582,49 @@ fn get_issue(owner: &str, repo: &str, issue_number: u32) -> Result, + limit: Option, +) -> Result { + if !validate_path_segment(owner) || !validate_path_segment(repo) { + return Err("Invalid owner or repo name".into()); + } + let encoded_owner = url_encode_path(owner); + let encoded_repo = url_encode_path(repo); + let limit = limit.unwrap_or(30).min(100); + let mut path = format!( + "/repos/{}/{}/issues/{}/comments?per_page={}", + encoded_owner, encoded_repo, issue_number, limit + ); + if let Some(p) = page { + path.push_str(&format!("&page={}", p)); + } + github_request("GET", &path, None) +} + +fn create_issue_comment( + owner: &str, + repo: &str, + issue_number: u32, + body: &str, +) -> Result { + if !validate_path_segment(owner) || !validate_path_segment(repo) { + return Err("Invalid owner or repo name".into()); + } + validate_input_length(body, "body")?; + let encoded_owner = url_encode_path(owner); + let encoded_repo = url_encode_path(repo); + let path = format!( + "/repos/{}/{}/issues/{}/comments", + encoded_owner, encoded_repo, issue_number + ); + let req_body = serde_json::json!({ "body": body }); + github_request("POST", &path, Some(req_body.to_string())) +} + fn list_pull_requests( owner: &str, repo: &str, @@ -478,6 +652,40 @@ fn list_pull_requests( github_request("GET", &path, None) } +fn create_pull_request( + owner: &str, + repo: &str, + title: &str, + head: &str, + base: &str, + body: Option<&str>, + draft: bool, +) -> Result { + if !validate_path_segment(owner) || !validate_path_segment(repo) { + return Err("Invalid owner or repo name".into()); + } + validate_input_length(title, "title")?; + validate_input_length(head, "head")?; + validate_input_length(base, "base")?; + if let Some(b) = body { + validate_input_length(b, "body")?; + } + + let encoded_owner = url_encode_path(owner); + let encoded_repo = url_encode_path(repo); + let path = format!("/repos/{}/{}/pulls", encoded_owner, encoded_repo); + let mut req_body = serde_json::json!({ + "title": title, + "head": head, + "base": base, + "draft": draft, + }); + if let Some(body) = body { + req_body["body"] = serde_json::json!(body); + } + github_request("POST", &path, Some(req_body.to_string())) +} + fn get_pull_request(owner: &str, repo: &str, pr_number: u32) -> Result { if !validate_path_segment(owner) || !validate_path_segment(repo) { return Err("Invalid owner or repo name".into()); @@ -543,6 +751,132 @@ fn create_pr_review( github_request("POST", &path, Some(req_body.to_string())) } +fn list_pull_request_comments( + owner: &str, + repo: &str, + pr_number: u32, + page: Option, + limit: Option, +) -> Result { + if !validate_path_segment(owner) || !validate_path_segment(repo) { + return Err("Invalid owner or repo name".into()); + } + let encoded_owner = url_encode_path(owner); + let encoded_repo = url_encode_path(repo); + let limit = limit.unwrap_or(30).min(100); + let mut path = format!( + "/repos/{}/{}/pulls/{}/comments?per_page={}", + encoded_owner, encoded_repo, pr_number, limit + ); + if let Some(p) = page { + path.push_str(&format!("&page={}", p)); + } + github_request("GET", &path, None) +} + +fn reply_pull_request_comment( + owner: &str, + repo: &str, + comment_id: u32, + body: &str, +) -> Result { + if !validate_path_segment(owner) || !validate_path_segment(repo) { + return Err("Invalid owner or repo name".into()); + } + validate_input_length(body, "body")?; + let encoded_owner = url_encode_path(owner); + let encoded_repo = url_encode_path(repo); + let path = format!( + "/repos/{}/{}/pulls/comments/{}/replies", + encoded_owner, encoded_repo, comment_id + ); + let req_body = serde_json::json!({ "body": body }); + github_request("POST", &path, Some(req_body.to_string())) +} + +fn get_pull_request_reviews( + owner: &str, + repo: &str, + pr_number: u32, + page: Option, + limit: Option, +) -> Result { + if !validate_path_segment(owner) || !validate_path_segment(repo) { + return Err("Invalid owner or repo name".into()); + } + let encoded_owner = url_encode_path(owner); + let encoded_repo = url_encode_path(repo); + let limit = limit.unwrap_or(30).min(100); + let mut path = format!( + "/repos/{}/{}/pulls/{}/reviews?per_page={}", + encoded_owner, encoded_repo, pr_number, limit + ); + if let Some(p) = page { + path.push_str(&format!("&page={}", p)); + } + github_request("GET", &path, None) +} + +fn get_combined_status(owner: &str, repo: &str, r#ref: &str) -> Result { + if !validate_path_segment(owner) || !validate_path_segment(repo) { + return Err("Invalid owner or repo name".into()); + } + validate_input_length(r#ref, "ref")?; + let encoded_owner = url_encode_path(owner); + let encoded_repo = url_encode_path(repo); + let encoded_ref = url_encode_path(r#ref); + let path = format!( + "/repos/{}/{}/commits/{}/status", + encoded_owner, encoded_repo, encoded_ref + ); + github_request("GET", &path, None) +} + +fn merge_pull_request( + owner: &str, + repo: &str, + pr_number: u32, + commit_title: Option<&str>, + commit_message: Option<&str>, + merge_method: Option<&str>, +) -> Result { + if !validate_path_segment(owner) || !validate_path_segment(repo) { + return Err("Invalid owner or repo name".into()); + } + if let Some(v) = commit_title { + validate_input_length(v, "commit_title")?; + } + if let Some(v) = commit_message { + validate_input_length(v, "commit_message")?; + } + let method = merge_method.unwrap_or("merge"); + let valid_methods = ["merge", "squash", "rebase"]; + if !valid_methods.contains(&method) { + return Err(format!( + "Invalid merge_method: '{}'. Must be one of: {}", + method, + valid_methods.join(", ") + )); + } + + let encoded_owner = url_encode_path(owner); + let encoded_repo = url_encode_path(repo); + let path = format!( + "/repos/{}/{}/pulls/{}/merge", + encoded_owner, encoded_repo, pr_number + ); + let mut req_body = serde_json::json!({ + "merge_method": method, + }); + if let Some(v) = commit_title { + req_body["commit_title"] = serde_json::json!(v); + } + if let Some(v) = commit_message { + req_body["commit_message"] = serde_json::json!(v); + } + github_request("PUT", &path, Some(req_body.to_string())) +} + fn list_repos(username: &str, page: Option, limit: Option) -> Result { if !validate_path_segment(username) { return Err("Invalid username".into()); @@ -723,6 +1057,27 @@ const SCHEMA: &str = r#"{ }, "required": ["action", "owner", "repo", "issue_number"] }, + { + "properties": { + "action": { "const": "list_issue_comments" }, + "owner": { "type": "string" }, + "repo": { "type": "string" }, + "issue_number": { "type": "integer" }, + "page": { "type": "integer" }, + "limit": { "type": "integer", "default": 30 } + }, + "required": ["action", "owner", "repo", "issue_number"] + }, + { + "properties": { + "action": { "const": "create_issue_comment" }, + "owner": { "type": "string" }, + "repo": { "type": "string" }, + "issue_number": { "type": "integer" }, + "body": { "type": "string" } + }, + "required": ["action", "owner", "repo", "issue_number", "body"] + }, { "properties": { "action": { "const": "list_pull_requests" }, @@ -733,6 +1088,19 @@ const SCHEMA: &str = r#"{ }, "required": ["action", "owner", "repo"] }, + { + "properties": { + "action": { "const": "create_pull_request" }, + "owner": { "type": "string" }, + "repo": { "type": "string" }, + "title": { "type": "string" }, + "head": { "type": "string" }, + "base": { "type": "string" }, + "body": { "type": "string" }, + "draft": { "type": "boolean", "default": false } + }, + "required": ["action", "owner", "repo", "title", "head", "base"] + }, { "properties": { "action": { "const": "get_pull_request" }, @@ -762,6 +1130,59 @@ const SCHEMA: &str = r#"{ }, "required": ["action", "owner", "repo", "pr_number", "body", "event"] }, + { + "properties": { + "action": { "const": "list_pull_request_comments" }, + "owner": { "type": "string" }, + "repo": { "type": "string" }, + "pr_number": { "type": "integer" }, + "page": { "type": "integer" }, + "limit": { "type": "integer", "default": 30 } + }, + "required": ["action", "owner", "repo", "pr_number"] + }, + { + "properties": { + "action": { "const": "reply_pull_request_comment" }, + "owner": { "type": "string" }, + "repo": { "type": "string" }, + "comment_id": { "type": "integer" }, + "body": { "type": "string" } + }, + "required": ["action", "owner", "repo", "comment_id", "body"] + }, + { + "properties": { + "action": { "const": "get_pull_request_reviews" }, + "owner": { "type": "string" }, + "repo": { "type": "string" }, + "pr_number": { "type": "integer" }, + "page": { "type": "integer" }, + "limit": { "type": "integer", "default": 30 } + }, + "required": ["action", "owner", "repo", "pr_number"] + }, + { + "properties": { + "action": { "const": "get_combined_status" }, + "owner": { "type": "string" }, + "repo": { "type": "string" }, + "ref": { "type": "string" } + }, + "required": ["action", "owner", "repo", "ref"] + }, + { + "properties": { + "action": { "const": "merge_pull_request" }, + "owner": { "type": "string" }, + "repo": { "type": "string" }, + "pr_number": { "type": "integer" }, + "commit_title": { "type": "string" }, + "commit_message": { "type": "string" }, + "merge_method": { "type": "string", "enum": ["merge", "squash", "rebase"], "default": "merge" } + }, + "required": ["action", "owner", "repo", "pr_number"] + }, { "properties": { "action": { "const": "list_repos" }, @@ -835,6 +1256,13 @@ mod tests { } } + #[test] + fn test_validate_merge_method() { + let valid = ["merge", "squash", "rebase"]; + assert!(valid.contains(&"merge")); + assert!(!valid.contains(&"invalid")); + } + #[test] fn test_input_length_validation() { assert!(validate_input_length("short", "test").is_ok());