Add event-driven workflow orchestration skill, webhook flow, and trace coverage

This commit is contained in:
Illia Polosukhin
2026-03-08 10:21:52 -07:00
parent 3b57d5bec9
commit df920b9651
24 changed files with 2111 additions and 49 deletions
Generated
+151 -23
View File
@@ -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"
+1
View File
@@ -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]
+2 -1
View File
@@ -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
@@ -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.
@@ -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."
@@ -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.
+79
View File
@@ -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<String, String>,
},
/// 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");
}
}
+109 -6
View File
@@ -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<OutgoingResponse>,
/// Currently running routine count (across all routines).
running_count: Arc<AtomicUsize>,
/// Compiled event regex cache: routine_id -> compiled regex.
event_cache: Arc<RwLock<Vec<(Uuid, Routine, Regex)>>>,
/// Cached matchers for all event-driven routines.
event_cache: Arc<RwLock<Vec<EventMatcher>>>,
/// Scheduler for dispatching jobs (FullJob mode).
scheduler: Option<Arc<Scheduler>>,
}
@@ -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<String> {
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};
+6
View File
@@ -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))
+532 -1
View File
@@ -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<HealthResponse> {
})
}
#[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<Arc<GatewayState>>,
headers: HeaderMap,
body: axum::body::Bytes,
) -> Result<(StatusCode, Json<GithubWebhookResponse>), (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<String> {
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::<sha2::Sha256>::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<String, serde_json::Value>,
key: &str,
val: Option<serde_json::Value>,
) {
if !obj.contains_key(key)
&& let Some(v) = val
{
obj.insert(key.to_string(), v);
}
}
fn put_string_normalized(
obj: &mut serde_json::Map<String, serde_json::Value>,
key: &str,
val: Option<String>,
) {
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<GatewayState>) -> 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::<sha2::Sha256>::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::<axum::http::Request<Body>>::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::<axum::http::Request<Body>>::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::<axum::http::Request<Body>>::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")
);
}
}
+1 -1
View File
@@ -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
),
(),
+1 -1
View File
@@ -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?;
+2 -2
View File
@@ -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;
+142 -3
View File
@@ -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::<std::collections::HashMap<String, String>>()
})
.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<dyn Database>,
}
// ==================== event_emit ====================
pub struct EventEmitTool {
engine: Arc<RoutineEngine>,
}
impl EventEmitTool {
pub fn new(engine: Arc<RoutineEngine>) -> 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<ToolOutput, ToolError> {
let start = std::time::Instant::now();
let source = require_str(&params, "source")?;
let event_type = require_str(&params, "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<dyn Database>) -> Self {
Self { store }
+5 -3
View File
@@ -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<crate::agent::routine_engine::RoutineEngine>,
) {
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.
+21 -1
View File
@@ -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",
+108 -2
View File
@@ -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.
+99
View File
@@ -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;
@@ -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
}
}
]
}
@@ -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
}
}
]
}
+38 -1
View File
@@ -379,6 +379,8 @@ pub struct TestRigBuilder {
llm: Option<Arc<dyn LlmProvider>>,
max_tool_iterations: usize,
injection_check: bool,
auto_approve_tools: Option<bool>,
enable_skills: bool,
enable_routines: bool,
http_exchanges: Vec<HttpExchange>,
extra_tools: Vec<Arc<dyn Tool>>,
@@ -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(&registry), 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;
+28 -2
View File
@@ -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
@@ -9,7 +9,8 @@
"path_prefix": "/",
"methods": [
"GET",
"POST"
"POST",
"PUT"
]
}
],
@@ -56,4 +57,4 @@
"default_limit": 30,
"max_limit": 100
}
}
}
+428
View File
@@ -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<u32>,
limit: Option<u32>,
},
#[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<u32>,
limit: Option<u32>,
},
#[serde(rename = "create_pull_request")]
CreatePullRequest {
owner: String,
repo: String,
title: String,
head: String,
base: String,
body: Option<String>,
draft: Option<bool>,
},
#[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<u32>,
limit: Option<u32>,
},
#[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<u32>,
limit: Option<u32>,
},
#[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<String>,
commit_message: Option<String>,
merge_method: Option<String>,
},
#[serde(rename = "list_repos")]
ListRepos {
username: String,
@@ -208,6 +271,19 @@ fn execute_inner(params: &str) -> Result<String, String> {
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<String, String> {
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<String, String> {
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<String, Strin
)
}
fn list_issue_comments(
owner: &str,
repo: &str,
issue_number: u32,
page: Option<u32>,
limit: Option<u32>,
) -> Result<String, String> {
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<String, String> {
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<String, String> {
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<String, String> {
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<u32>,
limit: Option<u32>,
) -> Result<String, String> {
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<String, String> {
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<u32>,
limit: Option<u32>,
) -> Result<String, String> {
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<String, String> {
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<String, String> {
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<u32>, limit: Option<u32>) -> Result<String, String> {
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());