From f05896fe6a0ffaccb61f3a0ab4f3f9128e6df6fd Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Thu, 12 Mar 2026 01:52:47 +0000 Subject: [PATCH] Migrate GitHub webhook normalization into github tool (#758) * Add event-triggered routines and workflow skill templates * Add generic host-verified webhook ingress for tools * Migrate GitHub webhook normalization into github tool * Bump github tool registry version * Stabilize trace E2E test rig and approval behavior * Add reusable gateway workflow harness with mock LLM server (#762) * Add reusable gateway workflow test harness with mock LLM server * Fix clippy issues in workflow harness * Stabilize trace E2E test rig and approval behavior * Address PR review feedback on gateway workflow harness - Extract shared TestChannelHandle into test_channel.rs with name override support, eliminating ~55 lines of duplication between test_rig.rs and gateway_workflow_harness.rs - Remove redundant RoutineEngine creation that was immediately overwritten by Agent::run() - Replace flaky sleep(500ms) with polling loop for routine run count check - Use components.context_manager instead of creating a fresh ContextManager for job tools, ensuring agent and tools share the same instance Co-Authored-By: Claude Opus 4.6 * Fix import ordering in gateway_workflow_harness Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 * Address PR #758 review feedback - Fix header_value to use fully case-insensitive lookup (iterate with to_ascii_lowercase) instead of checking only exact/lower/upper variants - Change comment_id from u32 to u64 to handle GitHub's billion-range IDs - Remove handle_webhook from LLM-facing JSON schema to prevent direct invocation bypassing HMAC verification - Rename enrichment keys from repository/sender to repository_name/ sender_login to preserve original JSON objects in webhook payloads - Remove put_string_normalized helper (no longer needed) - Replace no-op tests (test_validate_event_in_create_pr_review, test_validate_merge_method) with test_header_value_case_insensitive - Add README docs for 6 undocumented actions (list_issue_comments, create_issue_comment, list_pull_request_comments, reply_pull_request_comment, get_pull_request_reviews, get_combined_status) - Add comment explaining max_tool_calls <= 8 bound in e2e test - Fix gateway workflow harness: add webhook_capability with secret auth to MockGithubWebhookTool, matching staging's hardened webhook security - Fix merge artifacts: remove duplicate test function, orphaned code fragment in e2e_routine_heartbeat [skip-regression-check] Co-Authored-By: Claude Opus 4.6 * Fix formatting in gateway workflow harness Co-Authored-By: Claude Opus 4.6 * Address Copilot review: filter keys, pr_number fallback, feature gate, version alignment - Update SKILL.md and workflow-routines.md templates to use `repository_name` and `sender_login` (matching enriched payload field names) - Mark webhook HMAC secret as required in SKILL.md prerequisites - Fall back to `/issue/number` for `pr_number` on issue_comment PR webhooks - Gate `gateway_workflow_harness` module behind `#[cfg(feature = "libsql")]` - Align tool version to 0.2.1 in Cargo.toml and capabilities.json Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- Cargo.lock | 175 ++-- registry/tools/github.json | 2 +- .../ironclaw-workflow-orchestrator/SKILL.md | 8 +- .../references/workflow-routines.md | 14 +- tests/e2e_advanced_traces.rs | 2 + tests/e2e_routine_heartbeat.rs | 4 - tests/gateway_workflow_integration.rs | 150 ++++ tests/support/gateway_workflow_harness.rs | 528 +++++++++++ tests/support/mock_openai_server.rs | 300 +++++++ tests/support/mod.rs | 3 + tests/support/test_channel.rs | 76 ++ tests/support/test_rig.rs | 74 +- tools-src/github/Cargo.toml | 2 +- tools-src/github/README.md | 100 ++- .../github/github-tool.capabilities.json | 12 +- tools-src/github/src/lib.rs | 827 +++++++++++++++++- 16 files changed, 2096 insertions(+), 181 deletions(-) create mode 100644 tests/gateway_workflow_integration.rs create mode 100644 tests/support/gateway_workflow_harness.rs create mode 100644 tests/support/mock_openai_server.rs diff --git a/Cargo.lock b/Cargo.lock index 70a16a55..45d574e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -82,7 +82,7 @@ dependencies = [ "const-random", "once_cell", "version_check", - "zerocopy 0.8.39", + "zerocopy 0.8.42", ] [[package]] @@ -2654,20 +2654,20 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] [[package]] name = "getrandom" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] @@ -2843,9 +2843,9 @@ dependencies = [ [[package]] name = "html-to-markdown-rs" -version = "2.25.1" +version = "2.28.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c05335c6bf406653110ad8447c84461c6d0cda5e0aff9d3d3518f87502d30abe" +checksum = "3f9377e16af590b764fd98fd176027cf8831c5335f8964f3f643753e38913a4e" dependencies = [ "ahash 0.8.12", "astral-tl", @@ -3110,7 +3110,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.2", + "socket2 0.6.3", "system-configuration", "tokio", "tower-service", @@ -3334,9 +3334,9 @@ checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" [[package]] name = "ipnet" -version = "2.11.0" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "iri-string" @@ -3514,9 +3514,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.90" +version = "0.3.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14dc6f6450b3f6d4ed5b16327f38fed626d375a886159ca555bd7822c0c3a5a6" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" dependencies = [ "once_cell", "wasm-bindgen", @@ -3597,9 +3597,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.182" +version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[package]] name = "libloading" @@ -3619,13 +3619,14 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.12" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" dependencies = [ "bitflags 2.11.0", "libc", - "redox_syscall 0.7.2", + "plain", + "redox_syscall 0.7.3", ] [[package]] @@ -4574,18 +4575,18 @@ dependencies = [ [[package]] name = "pin-project" -version = "1.1.10" +version = "1.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.10" +version = "1.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" dependencies = [ "proc-macro2", "quote", @@ -4594,9 +4595,9 @@ dependencies = [ [[package]] name = "pin-project-lite" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pin-utils" @@ -4606,9 +4607,9 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "piper" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" dependencies = [ "atomic-waker", "fastrand", @@ -4631,6 +4632,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "polling" version = "3.11.0" @@ -4735,7 +4742,7 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ - "zerocopy 0.8.39", + "zerocopy 0.8.42", ] [[package]] @@ -4766,11 +4773,11 @@ dependencies = [ [[package]] name = "proc-macro-crate" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.23.10+spec-1.0.0", + "toml_edit 0.25.4+spec-1.1.0", ] [[package]] @@ -4859,7 +4866,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.1", "rustls 0.23.37", - "socket2 0.6.2", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tracing", @@ -4868,9 +4875,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.13" +version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ "bytes", "getrandom 0.3.4", @@ -4896,16 +4903,16 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.2", + "socket2 0.6.3", "tracing", "windows-sys 0.60.2", ] [[package]] name = "quote" -version = "1.0.44" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -4916,6 +4923,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "radium" version = "0.7.0" @@ -5055,9 +5068,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.7.2" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d94dd2f7cd932d4dc02cc8b2b50dfd38bd079a4e5d79198b99743d7fcf9a4b4" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" dependencies = [ "bitflags 2.11.0", ] @@ -5595,9 +5608,9 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.28" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ "windows-sys 0.61.2", ] @@ -6084,12 +6097,12 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6306,12 +6319,12 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tempfile" -version = "3.26.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.1", + "getrandom 0.4.2", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -6538,9 +6551,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.49.0" +version = "1.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" dependencies = [ "bytes", "libc", @@ -6548,7 +6561,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.2", + "socket2 0.6.3", "tokio-macros", "tracing", "windows-sys 0.61.2", @@ -6566,9 +6579,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" dependencies = [ "proc-macro2", "quote", @@ -6605,7 +6618,7 @@ dependencies = [ "postgres-protocol", "postgres-types", "rand 0.9.2", - "socket2 0.6.2", + "socket2 0.6.3", "tokio", "tokio-util", "whoami", @@ -6755,9 +6768,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.5+spec-1.1.0" +version = "1.0.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" dependencies = [ "serde_core", ] @@ -6778,12 +6791,12 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.23.10+spec-1.0.0" +version = "0.25.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" dependencies = [ "indexmap 2.13.0", - "toml_datetime 0.7.5+spec-1.1.0", + "toml_datetime 1.0.0+spec-1.1.0", "toml_parser", "winnow", ] @@ -7108,13 +7121,13 @@ checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" [[package]] name = "uds_windows" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" +checksum = "51b70b87d15e91f553711b40df3048faf27a7a04e01e0ddc0cf9309f0af7c2ca" dependencies = [ "memoffset", "tempfile", - "winapi", + "windows-sys 0.61.2", ] [[package]] @@ -7244,11 +7257,11 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.21.0" +version = "1.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" dependencies = [ - "getrandom 0.4.1", + "getrandom 0.4.2", "js-sys", "serde_core", "sha1_smol", @@ -7348,9 +7361,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.113" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60722a937f594b7fde9adb894d7c092fc1bb6612897c46368d18e7a20208eff2" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" dependencies = [ "cfg-if", "once_cell", @@ -7361,9 +7374,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.63" +version = "0.4.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a89f4650b770e4521aa6573724e2aed4704372151bd0de9d16a3bbabb87441a" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" dependencies = [ "cfg-if", "futures-util", @@ -7375,9 +7388,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.113" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fac8c6395094b6b91c4af293f4c79371c163f9a6f56184d2c9a85f5a95f3950" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -7385,9 +7398,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.113" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab3fabce6159dc20728033842636887e4877688ae94382766e00b180abac9d60" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" dependencies = [ "bumpalo", "proc-macro2", @@ -7398,9 +7411,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.113" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de0e091bdb824da87dc01d967388880d017a0a9bc4f3bdc0d86ee9f9336e3bb5" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" dependencies = [ "unicode-ident", ] @@ -7827,9 +7840,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.90" +version = "0.3.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "705eceb4ce901230f8625bd1d665128056ccbe4b7408faa625eec1ba80f59a97" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" dependencies = [ "js-sys", "wasm-bindgen", @@ -8299,9 +8312,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.7.14" +version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" dependencies = [ "memchr", ] @@ -8591,11 +8604,11 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.39" +version = "0.8.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" dependencies = [ - "zerocopy-derive 0.8.39", + "zerocopy-derive 0.8.42", ] [[package]] @@ -8611,9 +8624,9 @@ dependencies = [ [[package]] name = "zerocopy-derive" -version = "0.8.39" +version = "0.8.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" dependencies = [ "proc-macro2", "quote", diff --git a/registry/tools/github.json b/registry/tools/github.json index bf7af291..e36d702b 100644 --- a/registry/tools/github.json +++ b/registry/tools/github.json @@ -2,7 +2,7 @@ "name": "github", "display_name": "GitHub", "kind": "tool", - "version": "0.2.0", + "version": "0.2.1", "wit_version": "0.3.0", "description": "GitHub integration for issues, PRs, repos, and code search", "keywords": [ diff --git a/skills/ironclaw-workflow-orchestrator/SKILL.md b/skills/ironclaw-workflow-orchestrator/SKILL.md index 88d01441..6c38767f 100644 --- a/skills/ironclaw-workflow-orchestrator/SKILL.md +++ b/skills/ironclaw-workflow-orchestrator/SKILL.md @@ -28,7 +28,9 @@ Collect these values before creating routines: Before installing routines, verify: - Routines system enabled. - GitHub tool authenticated (for issue/PR/comment/status operations). -- Events are emitted via `event_emit` tool calls (a future HTTP webhook ingestion endpoint is planned but not yet available). +- GitHub webhook delivery configured to `POST /webhook/tools/github`. +- Webhook HMAC secret configured in the secrets store as `github_webhook_secret` (required for GitHub webhook delivery). +- Events can also be emitted via `event_emit` tool calls for testing or when webhook ingestion is not yet configured. ## Install Procedure 1. Open [`workflow-routines.md`](references/workflow-routines.md). @@ -51,8 +53,8 @@ Install these routines: ## Event Filters Prefer top-level filters for stability: -- `repository` (string) -- `sender` (string) +- `repository_name` (string, e.g. `owner/repo`) +- `sender_login` (string) - `issue_number` / `pr_number` - `ci_status`, `ci_conclusion` - `review_state`, `comment_author` diff --git a/skills/ironclaw-workflow-orchestrator/references/workflow-routines.md b/skills/ironclaw-workflow-orchestrator/references/workflow-routines.md index 74a5fb92..8afa857d 100644 --- a/skills/ironclaw-workflow-orchestrator/references/workflow-routines.md +++ b/skills/ironclaw-workflow-orchestrator/references/workflow-routines.md @@ -12,7 +12,7 @@ Replace `{{...}}` placeholders before use. "event_source": "github", "event_type": "issue.opened", "event_filters": { - "repository": "{{repository}}" + "repository_name": "{{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.", @@ -32,7 +32,7 @@ Trigger per-maintainer by creating one routine per handle, or maintain a shared "event_source": "github", "event_type": "pr.comment.created", "event_filters": { - "repository": "{{repository}}", + "repository_name": "{{repository}}", "comment_author": "{{maintainer}}" }, "action_type": "full_job", @@ -51,7 +51,7 @@ Trigger per-maintainer by creating one routine per handle, or maintain a shared "event_source": "github", "event_type": "pr.synchronize", "event_filters": { - "repository": "{{repository}}" + "repository_name": "{{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.", @@ -69,7 +69,7 @@ Trigger per-maintainer by creating one routine per handle, or maintain a shared "event_source": "github", "event_type": "ci.check_run.completed", "event_filters": { - "repository": "{{repository}}", + "repository_name": "{{repository}}", "ci_conclusion": "failure" }, "action_type": "full_job", @@ -102,7 +102,7 @@ Trigger per-maintainer by creating one routine per handle, or maintain a shared "event_source": "github", "event_type": "pr.closed", "event_filters": { - "repository": "{{repository}}", + "repository_name": "{{repository}}", "pr_merged": "true" }, "action_type": "full_job", @@ -118,9 +118,9 @@ Trigger per-maintainer by creating one routine per handle, or maintain a shared "source": "github", "event_type": "issue.opened", "payload": { - "repository": "{{repository}}", + "repository_name": "{{repository}}", "issue_number": 99999, - "sender": "test-bot" + "sender_login": "test-bot" } } ``` diff --git a/tests/e2e_advanced_traces.rs b/tests/e2e_advanced_traces.rs index 263e23c3..5a307cd8 100644 --- a/tests/e2e_advanced_traces.rs +++ b/tests/e2e_advanced_traces.rs @@ -251,6 +251,8 @@ mod advanced { assert!(!responses.is_empty(), "no response -- agent may have hung"); let started = rig.tool_calls_started(); + // Bound is 8 (not 4) because auto-approve lets the agent chain + // multiple tool calls per iteration without blocking on approval. assert!( started.len() <= 8, "expected <= 8 tool calls with max_tool_iterations=3, got {}: {started:?}", diff --git a/tests/e2e_routine_heartbeat.rs b/tests/e2e_routine_heartbeat.rs index a9ef086b..f245e656 100644 --- a/tests/e2e_routine_heartbeat.rs +++ b/tests/e2e_routine_heartbeat.rs @@ -398,10 +398,6 @@ mod tests { ); } - // ----------------------------------------------------------------------- - // Test 4: routine_cooldown - // ----------------------------------------------------------------------- - #[tokio::test] async fn routine_cooldown() { let (db, _tmp) = create_test_db().await; diff --git a/tests/gateway_workflow_integration.rs b/tests/gateway_workflow_integration.rs new file mode 100644 index 00000000..2f1353a5 --- /dev/null +++ b/tests/gateway_workflow_integration.rs @@ -0,0 +1,150 @@ +//! Live-ish gateway workflow integration using an in-process mock OpenAI server. +//! This exercises the same path as manual validation: +//! - chat send through gateway +//! - routine creation via tool call +//! - system-event emission via tool call +//! - webhook ingestion via generic tools webhook server +//! - status/runs checks via routines API + +#[cfg(feature = "libsql")] +mod support; + +#[cfg(feature = "libsql")] +mod tests { + use std::time::Duration; + + use crate::support::gateway_workflow_harness::GatewayWorkflowHarness; + use crate::support::mock_openai_server::{ + MockOpenAiResponse, MockOpenAiRule, MockOpenAiServerBuilder, MockToolCall, + }; + + #[tokio::test] + async fn gateway_workflow_harness_chat_and_webhook() { + let mock = MockOpenAiServerBuilder::new() + .with_rule(MockOpenAiRule::on_user_contains( + "create workflow routine", + MockOpenAiResponse::ToolCalls(vec![MockToolCall::new( + "call_create_1", + "routine_create", + serde_json::json!({ + "name": "wf-ci-webhook-demo", + "description": "CI webhook workflow demo", + "trigger_type": "system_event", + "event_source": "github", + "event_type": "issue.opened", + "event_filters": {"repository": "nearai/ironclaw"}, + "action_type": "lightweight", + "prompt": "Summarize webhook and report issue number" + }), + )]), + )) + .with_rule(MockOpenAiRule::on_user_contains( + "emit webhook event", + MockOpenAiResponse::ToolCalls(vec![MockToolCall::new( + "call_emit_1", + "event_emit", + serde_json::json!({ + "source": "github", + "event_type": "issue.opened", + "payload": { + "repository": "nearai/ironclaw", + "issue": {"number": 777, "title": "Infra test"} + } + }), + )]), + )) + .with_default_response(MockOpenAiResponse::Text("ack".to_string())) + .start() + .await; + + let harness = + GatewayWorkflowHarness::start_openai_compatible(&mock.openai_base_url(), "mock-model") + .await; + + let thread_id = harness.create_thread().await; + harness + .send_chat(&thread_id, "create workflow routine") + .await; + harness + .wait_for_turns(&thread_id, 1, Duration::from_secs(10)) + .await; + + let mut routine = None; + for _ in 0..30 { + routine = harness.routine_by_name("wf-ci-webhook-demo").await; + if routine.is_some() { + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + let routine = if let Some(r) = routine { + r + } else { + let history_dbg = harness.history(&thread_id).await; + let started_dbg = harness.test_channel.tool_calls_started(); + let requests_dbg = mock.requests().await; + panic!( + "routine not created; tool_calls_started={started_dbg:?}; history={history_dbg}; mock_requests={requests_dbg:?}" + ); + }; + let routine_id = routine["id"].as_str().expect("routine id missing"); + + harness.send_chat(&thread_id, "emit webhook event").await; + + let history = harness + .wait_for_turns(&thread_id, 2, Duration::from_secs(10)) + .await; + let turns = history["turns"].as_array().expect("turns array missing"); + assert!(turns.len() >= 2, "expected at least 2 turns"); + + let runs_before = harness.routine_runs(routine_id).await; + let before_count = runs_before["runs"] + .as_array() + .map(|a| a.len()) + .unwrap_or_default(); + + let hook = harness + .github_webhook( + "issues", + serde_json::json!({ + "action": "opened", + "repository": {"full_name": "nearai/ironclaw"}, + "issue": {"number": 778, "title": "Webhook endpoint test"} + }), + ) + .await; + + assert_eq!(hook["status"], "accepted"); + assert_eq!(hook["emitted_events"], 1); + assert!( + hook["fired_routines"].as_u64().unwrap_or(0) >= 1, + "expected webhook to fire at least one routine" + ); + + let mut after_count = before_count; + for _ in 0..50 { + let runs_after = harness.routine_runs(routine_id).await; + after_count = runs_after["runs"] + .as_array() + .map(|a| a.len()) + .unwrap_or_default(); + if after_count > before_count { + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + assert!( + after_count > before_count, + "expected routine runs to increase after webhook; before={before_count}, after={after_count}" + ); + + let requests = mock.requests().await; + assert!( + requests.len() >= 2, + "expected mock LLM server to receive requests" + ); + + harness.shutdown().await; + mock.shutdown().await; + } +} diff --git a/tests/support/gateway_workflow_harness.rs b/tests/support/gateway_workflow_harness.rs new file mode 100644 index 00000000..dd9e8643 --- /dev/null +++ b/tests/support/gateway_workflow_harness.rs @@ -0,0 +1,528 @@ +#![allow(dead_code)] + +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use async_trait::async_trait; +use secrecy::SecretString; +use tokio::sync::mpsc; +use tokio::sync::oneshot; + +use ironclaw::agent::routine_engine::RoutineEngine; +use ironclaw::agent::{Agent, AgentDeps, SessionManager as AgentSessionManager}; +use ironclaw::app::{AppBuilder, AppBuilderFlags}; +use ironclaw::channels::IncomingMessage; +use ironclaw::channels::web::log_layer::LogBroadcaster; +use ironclaw::channels::web::server::{GatewayState, RateLimiter, start_server}; +use ironclaw::channels::web::sse::SseManager; +use ironclaw::channels::web::ws::WsConnectionTracker; +use ironclaw::config::{Config, RegistryProviderConfig, RoutineConfig}; +use ironclaw::db::Database; +use ironclaw::db::libsql::LibSqlBackend; +use ironclaw::llm::registry::ProviderProtocol; +use ironclaw::llm::{ + SessionConfig as LlmSessionConfig, SessionManager as LlmSessionManager, create_llm_provider, +}; +use ironclaw::secrets::SecretsStore; +use ironclaw::tools::{Tool, ToolError, ToolOutput}; + +use crate::support::test_channel::{TestChannel, TestChannelHandle}; + +struct MockGithubWebhookTool; + +#[async_trait] +impl Tool for MockGithubWebhookTool { + fn name(&self) -> &str { + "github" + } + + fn description(&self) -> &str { + "Mock GitHub webhook parser for integration harness" + } + + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({"type":"object"}) + } + + async fn execute( + &self, + params: serde_json::Value, + _ctx: &ironclaw::context::JobContext, + ) -> Result { + let event = params + .pointer("/webhook/headers/x-github-event") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::InvalidParameters("missing x-github-event".to_string()))?; + + let action = params + .pointer("/webhook/body_json/action") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let mut payload = params + .pointer("/webhook/body_json") + .cloned() + .unwrap_or_else(|| serde_json::json!({})); + if payload.get("repository").and_then(|v| v.as_str()).is_none() + && let Some(full_name) = payload + .pointer("/repository/full_name") + .and_then(|v| v.as_str()) + { + payload["repository"] = serde_json::json!(full_name); + } + let event_type = format!( + "{}.{}", + if event == "issues" { "issue" } else { event }, + action + ); + + Ok(ToolOutput::success( + serde_json::json!({ + "emit_events": [{ + "source": "github", + "event_type": event_type, + "payload": payload + }] + }), + Duration::from_millis(1), + )) + } + + fn webhook_capability(&self) -> Option { + Some(ironclaw::tools::wasm::WebhookCapability { + secret_name: Some("github_webhook_secret".to_string()), + secret_header: Some("x-webhook-secret".to_string()), + ..Default::default() + }) + } +} + +pub struct GatewayWorkflowHarness { + pub addr: SocketAddr, + pub webhook_addr: SocketAddr, + pub auth_token: String, + pub client: reqwest::Client, + pub user_id: String, + pub test_channel: Arc, + pub db: Arc, + gateway_state: Arc, + agent_handle: Option>, + bridge_handle: Option>, + webhook_shutdown_tx: Option>, + webhook_handle: Option>, + _temp_dir: tempfile::TempDir, +} + +impl GatewayWorkflowHarness { + pub async fn start_openai_compatible(base_url: &str, model: &str) -> Self { + let temp_dir = tempfile::tempdir().expect("failed to create temp dir"); + let db_path = temp_dir.path().join("gateway_workflow_harness.db"); + let backend = LibSqlBackend::new_local(&db_path) + .await + .expect("failed to create test db"); + backend + .run_migrations() + .await + .expect("failed to run migrations"); + let db: Arc = Arc::new(backend); + + let skills_dir = temp_dir.path().join("skills"); + let installed_skills_dir = temp_dir.path().join("installed_skills"); + let _ = std::fs::create_dir_all(&skills_dir); + let _ = std::fs::create_dir_all(&installed_skills_dir); + let mut config = Config::for_testing(db_path, skills_dir, installed_skills_dir); + config.agent.auto_approve_tools = true; + config.routines.enabled = true; + config.routines.max_concurrent_routines = 4; + config.llm.backend = "openai_compatible".to_string(); + config.llm.provider = Some(RegistryProviderConfig { + protocol: ProviderProtocol::OpenAiCompletions, + provider_id: "openai_compatible".to_string(), + api_key: Some(SecretString::from("dummy".to_string())), + base_url: base_url.to_string(), + model: model.to_string(), + extra_headers: Vec::new(), + oauth_token: None, + cache_retention: Default::default(), + unsupported_params: Vec::new(), + }); + + let llm_session = Arc::new(LlmSessionManager::new(LlmSessionConfig::default())); + let llm = create_llm_provider(&config.llm, Arc::clone(&llm_session)) + .await + .expect("failed to create openai-compatible provider"); + + let log_broadcaster = Arc::new(LogBroadcaster::new()); + let mut app_builder = AppBuilder::new( + config, + AppBuilderFlags::default(), + None, + Arc::clone(&llm_session), + log_broadcaster, + ); + app_builder.with_database(Arc::clone(&db)); + app_builder.with_llm(llm); + + let components = app_builder + .build_all() + .await + .expect("failed to build app components"); + components + .tools + .register(Arc::new(MockGithubWebhookTool)) + .await; + + components.tools.register_job_tools( + Arc::clone(&components.context_manager), + None, + None, + components.db.clone(), + None, + None, + None, + None, + ); + + // Agent::run() creates its own RoutineEngine and populates this slot. + let routine_slot: Arc>>> = + Arc::new(tokio::sync::RwLock::new(None)); + + let test_channel = Arc::new(TestChannel::new()); + let handle = TestChannelHandle::with_name(Arc::clone(&test_channel), "gateway"); + let channel_manager = ironclaw::channels::ChannelManager::new(); + channel_manager.add(Box::new(handle)).await; + let channels = Arc::new(channel_manager); + + let user_id = "gateway-test-user".to_string(); + let (gw_tx, mut gw_rx) = mpsc::channel::(256); + let forward_channel = Arc::clone(&test_channel); + let bridge_handle = tokio::spawn(async move { + while let Some(msg) = gw_rx.recv().await { + forward_channel.send_incoming(msg).await; + } + }); + + let scheduler_slot: ironclaw::tools::builtin::SchedulerSlot = + Arc::new(tokio::sync::RwLock::new(None)); + let agent_session_manager = Arc::new(AgentSessionManager::new()); + + let gateway_state = Arc::new(GatewayState { + msg_tx: tokio::sync::RwLock::new(Some(gw_tx)), + sse: SseManager::new(), + workspace: components.workspace.clone(), + session_manager: Some(Arc::clone(&agent_session_manager)), + log_broadcaster: None, + log_level_handle: None, + extension_manager: components.extension_manager.clone(), + tool_registry: Some(Arc::clone(&components.tools)), + store: components.db.clone(), + job_manager: None, + prompt_queue: None, + scheduler: Some(scheduler_slot.clone()), + user_id: user_id.clone(), + shutdown_tx: tokio::sync::RwLock::new(None), + ws_tracker: Some(Arc::new(WsConnectionTracker::new())), + llm_provider: Some(Arc::clone(&components.llm)), + skill_registry: components.skill_registry.clone(), + skill_catalog: components.skill_catalog.clone(), + chat_rate_limiter: RateLimiter::new(120, 60), + oauth_rate_limiter: RateLimiter::new(10, 60), + registry_entries: Vec::new(), + cost_guard: Some(Arc::clone(&components.cost_guard)), + routine_engine: Arc::clone(&routine_slot), + startup_time: Instant::now(), + }); + + let mut agent = Agent::new( + components.config.agent.clone(), + AgentDeps { + store: components.db, + llm: components.llm, + cheap_llm: components.cheap_llm, + safety: components.safety, + tools: components.tools, + workspace: components.workspace, + extension_manager: components.extension_manager, + skill_registry: components.skill_registry, + skill_catalog: components.skill_catalog, + skills_config: components.config.skills.clone(), + hooks: components.hooks, + cost_guard: components.cost_guard, + sse_tx: Some(gateway_state.sse.sender()), + http_interceptor: None, + transcription: None, + document_extraction: None, + }, + channels, + None, + None, + Some(RoutineConfig { + enabled: true, + cron_check_interval_secs: 60, + max_concurrent_routines: 4, + default_cooldown_secs: 300, + max_lightweight_tokens: 4096, + lightweight_tools_enabled: true, + lightweight_max_iterations: 3, + }), + Some(Arc::clone(&components.context_manager)), + Some(Arc::clone(&agent_session_manager)), + ); + agent.set_routine_engine_slot(Arc::clone(&routine_slot)); + *scheduler_slot.write().await = Some(agent.scheduler()); + + let agent_handle = tokio::spawn(async move { + let _ = agent.run().await; + }); + + if let Some(rx) = test_channel.take_ready_rx().await { + let _ = tokio::time::timeout(Duration::from_secs(5), rx).await; + } + + let auth_token = "gateway-test-token".to_string(); + let addr = start_server( + "127.0.0.1:0".parse().expect("valid localhost addr"), + Arc::clone(&gateway_state), + auth_token.clone(), + ) + .await + .expect("failed to start gateway server"); + + let webhook_secrets = Arc::new(ironclaw::secrets::InMemorySecretsStore::new(Arc::new( + ironclaw::secrets::SecretsCrypto::new(SecretString::from( + "test-key-at-least-32-chars-long!!".to_string(), + )) + .expect("crypto"), + ))); + webhook_secrets + .create( + &user_id, + ironclaw::secrets::CreateSecretParams::new( + "github_webhook_secret", + "test-webhook-secret", + ), + ) + .await + .expect("store webhook secret"); + let webhook_state = ironclaw::webhooks::ToolWebhookState { + tools: Arc::clone(gateway_state.tool_registry.as_ref().expect("tool registry")), + routine_engine: Arc::clone(&routine_slot), + user_id: user_id.clone(), + secrets_store: Some( + webhook_secrets as Arc, + ), + }; + let webhook_app = ironclaw::webhooks::routes(webhook_state); + let webhook_listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("failed to bind webhook listener"); + let webhook_addr = webhook_listener.local_addr().expect("webhook local addr"); + let (webhook_shutdown_tx, webhook_shutdown_rx) = oneshot::channel(); + let webhook_handle = tokio::spawn(async move { + let _ = axum::serve(webhook_listener, webhook_app) + .with_graceful_shutdown(async { + let _ = webhook_shutdown_rx.await; + }) + .await; + }); + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .expect("failed to build reqwest client"); + + Self { + addr, + webhook_addr, + auth_token, + client, + user_id, + test_channel, + db, + gateway_state, + agent_handle: Some(agent_handle), + bridge_handle: Some(bridge_handle), + webhook_shutdown_tx: Some(webhook_shutdown_tx), + webhook_handle: Some(webhook_handle), + _temp_dir: temp_dir, + } + } + + pub fn base_url(&self) -> String { + format!("http://{}", self.addr) + } + + pub fn webhook_base_url(&self) -> String { + format!("http://{}", self.webhook_addr) + } + + pub async fn create_thread(&self) -> String { + let resp = self + .client + .post(format!("{}/api/chat/thread/new", self.base_url())) + .bearer_auth(&self.auth_token) + .send() + .await + .expect("create thread request failed") + .error_for_status() + .expect("create thread non-2xx") + .json::() + .await + .expect("invalid thread response"); + resp.get("id") + .and_then(|v| v.as_str()) + .expect("thread id missing") + .to_string() + } + + pub async fn send_chat(&self, thread_id: &str, content: &str) { + let _ = self + .client + .post(format!("{}/api/chat/send", self.base_url())) + .bearer_auth(&self.auth_token) + .json(&serde_json::json!({"thread_id": thread_id, "content": content})) + .send() + .await + .expect("chat send failed") + .error_for_status() + .expect("chat send non-2xx"); + } + + pub async fn history(&self, thread_id: &str) -> serde_json::Value { + self.client + .get(format!( + "{}/api/chat/history?thread_id={thread_id}", + self.base_url() + )) + .bearer_auth(&self.auth_token) + .send() + .await + .expect("history request failed") + .error_for_status() + .expect("history non-2xx") + .json::() + .await + .expect("invalid history response") + } + + pub async fn wait_for_turns( + &self, + thread_id: &str, + min_turns: usize, + timeout: Duration, + ) -> serde_json::Value { + let deadline = Instant::now() + timeout; + loop { + let history = self.history(thread_id).await; + let turns = history + .get("turns") + .and_then(|v| v.as_array()) + .map(|v| v.len()) + .unwrap_or_default(); + if turns >= min_turns { + return history; + } + assert!(Instant::now() < deadline, "timed out waiting for turns"); + tokio::time::sleep(Duration::from_millis(100)).await; + } + } + + pub async fn list_routines(&self) -> serde_json::Value { + self.client + .get(format!("{}/api/routines", self.base_url())) + .bearer_auth(&self.auth_token) + .send() + .await + .expect("routines request failed") + .error_for_status() + .expect("routines non-2xx") + .json::() + .await + .expect("invalid routines response") + } + + pub async fn routine_by_name(&self, name: &str) -> Option { + let routines = self.list_routines().await; + routines + .get("routines") + .and_then(|v| v.as_array()) + .and_then(|arr| { + arr.iter() + .find(|r| r.get("name").and_then(|v| v.as_str()) == Some(name)) + .cloned() + }) + } + + pub async fn routine_runs(&self, routine_id: &str) -> serde_json::Value { + self.client + .get(format!( + "{}/api/routines/{routine_id}/runs", + self.base_url() + )) + .bearer_auth(&self.auth_token) + .send() + .await + .expect("routine runs request failed") + .error_for_status() + .expect("routine runs non-2xx") + .json::() + .await + .expect("invalid routine runs response") + } + + pub async fn github_webhook( + &self, + event: &str, + payload: serde_json::Value, + ) -> serde_json::Value { + self.client + .post(format!("{}/webhook/tools/github", self.webhook_base_url())) + .header("x-github-event", event) + .header("x-webhook-secret", "test-webhook-secret") + .json(&payload) + .send() + .await + .expect("webhook request failed") + .error_for_status() + .expect("webhook non-2xx") + .json::() + .await + .expect("invalid webhook response") + } + + pub async fn shutdown(mut self) { + self.test_channel.signal_shutdown(); + + if let Some(tx) = self.gateway_state.shutdown_tx.write().await.take() { + let _ = tx.send(()); + } + if let Some(tx) = self.webhook_shutdown_tx.take() { + let _ = tx.send(()); + } + + if let Some(handle) = self.bridge_handle.take() { + handle.abort(); + } + if let Some(handle) = self.webhook_handle.take() { + let _ = handle.await; + } + if let Some(handle) = self.agent_handle.take() { + handle.abort(); + } + } +} + +impl Drop for GatewayWorkflowHarness { + fn drop(&mut self) { + self.test_channel.signal_shutdown(); + if let Some(handle) = self.bridge_handle.take() { + handle.abort(); + } + if let Some(handle) = self.webhook_handle.take() { + handle.abort(); + } + if let Some(handle) = self.agent_handle.take() { + handle.abort(); + } + } +} diff --git a/tests/support/mock_openai_server.rs b/tests/support/mock_openai_server.rs new file mode 100644 index 00000000..2a64c018 --- /dev/null +++ b/tests/support/mock_openai_server.rs @@ -0,0 +1,300 @@ +#![allow(dead_code)] + +use std::net::SocketAddr; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +use axum::extract::State; +use axum::http::StatusCode; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use serde_json::{Value, json}; +use tokio::net::TcpListener; +use tokio::sync::{Mutex, oneshot}; + +#[derive(Clone)] +pub struct MockOpenAiRule { + contains: String, + response: MockOpenAiResponse, +} + +impl MockOpenAiRule { + pub fn on_user_contains(contains: impl Into, response: MockOpenAiResponse) -> Self { + Self { + contains: contains.into(), + response, + } + } +} + +#[derive(Clone)] +pub enum MockOpenAiResponse { + Text(String), + ToolCalls(Vec), + Raw(Value), +} + +#[derive(Clone)] +pub struct MockToolCall { + pub id: String, + pub name: String, + pub arguments: Value, +} + +impl MockToolCall { + pub fn new(id: impl Into, name: impl Into, arguments: Value) -> Self { + Self { + id: id.into(), + name: name.into(), + arguments, + } + } +} + +#[derive(Default)] +pub struct MockOpenAiServerBuilder { + models: Vec, + rules: Vec, + default_response: Option, +} + +impl MockOpenAiServerBuilder { + pub fn new() -> Self { + Self { + models: vec!["mock-model".to_string()], + ..Self::default() + } + } + + pub fn with_models(mut self, models: Vec) -> Self { + self.models = models; + self + } + + pub fn with_rule(mut self, rule: MockOpenAiRule) -> Self { + self.rules.push(rule); + self + } + + pub fn with_default_response(mut self, response: MockOpenAiResponse) -> Self { + self.default_response = Some(response); + self + } + + pub async fn start(self) -> MockOpenAiServer { + let state = Arc::new(MockOpenAiState { + models: self.models, + rules: self.rules, + default_response: self + .default_response + .unwrap_or_else(|| MockOpenAiResponse::Text("OK".to_string())), + requests: Mutex::new(Vec::new()), + response_counter: AtomicU64::new(1), + }); + + let app = Router::new() + .route("/v1/models", get(models_handler)) + .route("/v1/chat/completions", post(chat_completions_handler)) + .with_state(Arc::clone(&state)); + + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("failed to bind mock openai server"); + let addr = listener.local_addr().expect("failed to read bound addr"); + + let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); + let handle = tokio::spawn(async move { + let _ = axum::serve(listener, app) + .with_graceful_shutdown(async { + let _ = shutdown_rx.await; + }) + .await; + }); + + MockOpenAiServer { + addr, + state, + shutdown_tx: Some(shutdown_tx), + server_task: Some(handle), + } + } +} + +pub struct MockOpenAiServer { + addr: SocketAddr, + state: Arc, + shutdown_tx: Option>, + server_task: Option>, +} + +impl MockOpenAiServer { + pub fn base_url(&self) -> String { + format!("http://{}", self.addr) + } + + pub fn openai_base_url(&self) -> String { + format!("{}/v1", self.base_url()) + } + + pub async fn requests(&self) -> Vec { + self.state.requests.lock().await.clone() + } + + pub async fn shutdown(mut self) { + if let Some(tx) = self.shutdown_tx.take() { + let _ = tx.send(()); + } + if let Some(handle) = self.server_task.take() { + let _ = handle.await; + } + } +} + +impl Drop for MockOpenAiServer { + fn drop(&mut self) { + if let Some(tx) = self.shutdown_tx.take() { + let _ = tx.send(()); + } + if let Some(handle) = self.server_task.take() { + handle.abort(); + } + } +} + +struct MockOpenAiState { + models: Vec, + rules: Vec, + default_response: MockOpenAiResponse, + requests: Mutex>, + response_counter: AtomicU64, +} + +async fn models_handler(State(state): State>) -> Json { + Json(json!({ + "object": "list", + "data": state + .models + .iter() + .map(|id| json!({"id": id, "object": "model"})) + .collect::>() + })) +} + +async fn chat_completions_handler( + State(state): State>, + Json(body): Json, +) -> Result, (StatusCode, String)> { + state.requests.lock().await.push(body.clone()); + + let model = body + .get("model") + .and_then(|v| v.as_str()) + .unwrap_or("mock-model"); + let last_role = body + .pointer("/messages") + .and_then(|m| m.as_array()) + .and_then(|arr| arr.last()) + .and_then(|v| v.get("role")) + .and_then(|r| r.as_str()) + .unwrap_or_default(); + + fn extract_text_content(msg: &Value) -> Option { + let content = msg.get("content")?; + if let Some(s) = content.as_str() { + return Some(s.to_string()); + } + if let Some(parts) = content.as_array() { + let mut out = String::new(); + for part in parts { + if part.get("type").and_then(|v| v.as_str()) == Some("text") + && let Some(text) = part.get("text").and_then(|v| v.as_str()) + { + if !out.is_empty() { + out.push(' '); + } + out.push_str(text); + } + } + if !out.is_empty() { + return Some(out); + } + } + None + } + + let latest_user = body + .pointer("/messages") + .and_then(|m| m.as_array()) + .and_then(|arr| { + arr.iter().rev().find_map(|msg| { + if msg.get("role").and_then(|r| r.as_str()) == Some("user") { + extract_text_content(msg) + } else { + None + } + }) + }) + .unwrap_or_default(); + + let selected = if last_role == "user" { + let latest_user_lower = latest_user.to_ascii_lowercase(); + state + .rules + .iter() + .find(|r| latest_user_lower.contains(&r.contains.to_ascii_lowercase())) + .map(|r| r.response.clone()) + .unwrap_or_else(|| state.default_response.clone()) + } else { + state.default_response.clone() + }; + + let n = state.response_counter.fetch_add(1, Ordering::Relaxed); + let response = match selected { + MockOpenAiResponse::Text(content) => json!({ + "id": format!("chatcmpl-mock-{n}"), + "object": "chat.completion", + "created": 0, + "model": model, + "choices": [{ + "index": 0, + "message": {"role": "assistant", "content": content}, + "finish_reason": "stop" + }], + "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15} + }), + MockOpenAiResponse::ToolCalls(tool_calls) => { + let calls = tool_calls + .iter() + .map(|tc| { + json!({ + "id": tc.id, + "type": "function", + "function": { + "name": tc.name, + "arguments": tc.arguments.to_string() + } + }) + }) + .collect::>(); + json!({ + "id": format!("chatcmpl-mock-{n}"), + "object": "chat.completion", + "created": 0, + "model": model, + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": serde_json::Value::Null, + "tool_calls": calls + }, + "finish_reason": "tool_calls" + }], + "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15} + }) + } + MockOpenAiResponse::Raw(v) => v, + }; + + Ok(Json(response)) +} diff --git a/tests/support/mod.rs b/tests/support/mod.rs index e1ce4866..91321a30 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -1,7 +1,10 @@ pub mod assertions; pub mod cleanup; +#[cfg(feature = "libsql")] +pub mod gateway_workflow_harness; pub mod instrumented_llm; pub mod metrics; +pub mod mock_openai_server; pub mod test_channel; pub mod test_rig; pub mod trace_llm; diff --git a/tests/support/test_channel.rs b/tests/support/test_channel.rs index 12f45532..d7d8a28c 100644 --- a/tests/support/test_channel.rs +++ b/tests/support/test_channel.rs @@ -198,6 +198,82 @@ impl TestChannel { } } +// --------------------------------------------------------------------------- +// TestChannelHandle -- wraps Arc as Box +// --------------------------------------------------------------------------- + +/// A thin wrapper around `Arc` that implements `Channel`. +/// +/// This lets us hand a `Box` to `ChannelManager::add()` while +/// keeping an `Arc` in the test rig for sending messages and +/// reading captures. The `name_override` allows different test harnesses +/// to present the channel under different names (e.g. "gateway" vs "test"). +pub struct TestChannelHandle { + inner: Arc, + name: String, +} + +impl TestChannelHandle { + /// Create a handle that delegates `name()` to the inner `TestChannel`. + pub fn new(inner: Arc) -> Self { + Self { + name: inner.name().to_string(), + inner, + } + } + + /// Create a handle with a custom channel name. + pub fn with_name(inner: Arc, name: impl Into) -> Self { + Self { + inner, + name: name.into(), + } + } +} + +#[async_trait] +impl Channel for TestChannelHandle { + fn name(&self) -> &str { + &self.name + } + + async fn start(&self) -> Result { + self.inner.start().await + } + + async fn respond( + &self, + msg: &IncomingMessage, + response: OutgoingResponse, + ) -> Result<(), ChannelError> { + self.inner.respond(msg, response).await + } + + async fn send_status( + &self, + status: StatusUpdate, + metadata: &serde_json::Value, + ) -> Result<(), ChannelError> { + self.inner.send_status(status, metadata).await + } + + async fn broadcast( + &self, + user_id: &str, + response: OutgoingResponse, + ) -> Result<(), ChannelError> { + self.inner.broadcast(user_id, response).await + } + + async fn health_check(&self) -> Result<(), ChannelError> { + self.inner.health_check().await + } + + fn conversation_context(&self, metadata: &serde_json::Value) -> HashMap { + self.inner.conversation_context(metadata) + } +} + // --------------------------------------------------------------------------- // Channel trait implementation // --------------------------------------------------------------------------- diff --git a/tests/support/test_rig.rs b/tests/support/test_rig.rs index 14e4ffbf..87782f00 100644 --- a/tests/support/test_rig.rs +++ b/tests/support/test_rig.rs @@ -6,95 +6,25 @@ #![allow(dead_code)] // Public API consumed by later test modules (Task 4+). -use std::collections::HashMap; use std::sync::Arc; use std::time::{Duration, Instant}; -use async_trait::async_trait; - use ironclaw::agent::{Agent, AgentDeps}; use ironclaw::app::{AppBuilder, AppBuilderFlags}; use ironclaw::channels::web::log_layer::LogBroadcaster; -use ironclaw::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate}; +use ironclaw::channels::{OutgoingResponse, StatusUpdate}; use ironclaw::config::Config; use ironclaw::db::Database; -use ironclaw::error::ChannelError; use ironclaw::llm::{LlmProvider, SessionConfig, SessionManager}; use ironclaw::tools::Tool; use crate::support::instrumented_llm::InstrumentedLlm; use crate::support::metrics::{ToolInvocation, TraceMetrics}; -use crate::support::test_channel::TestChannel; +use crate::support::test_channel::{TestChannel, TestChannelHandle}; use crate::support::trace_llm::{LlmTrace, TraceLlm}; use ironclaw::llm::recording::{HttpExchange, ReplayingHttpInterceptor}; -// --------------------------------------------------------------------------- -// TestChannelHandle -- wraps Arc as Box -// --------------------------------------------------------------------------- - -/// A thin wrapper around `Arc` that implements `Channel`. -/// -/// This lets us hand a `Box` to `ChannelManager::add()` while -/// keeping an `Arc` in the `TestRig` for sending messages and -/// reading captures. -struct TestChannelHandle { - inner: Arc, -} - -impl TestChannelHandle { - fn new(inner: Arc) -> Self { - Self { inner } - } -} - -#[async_trait] -impl Channel for TestChannelHandle { - fn name(&self) -> &str { - self.inner.name() - } - - async fn start(&self) -> Result { - self.inner.start().await - } - - async fn respond( - &self, - msg: &IncomingMessage, - response: OutgoingResponse, - ) -> Result<(), ChannelError> { - self.inner.respond(msg, response).await - } - - async fn send_status( - &self, - status: StatusUpdate, - metadata: &serde_json::Value, - ) -> Result<(), ChannelError> { - self.inner.send_status(status, metadata).await - } - - async fn broadcast( - &self, - user_id: &str, - response: OutgoingResponse, - ) -> Result<(), ChannelError> { - self.inner.broadcast(user_id, response).await - } - - async fn health_check(&self) -> Result<(), ChannelError> { - self.inner.health_check().await - } - - fn conversation_context(&self, metadata: &serde_json::Value) -> HashMap { - self.inner.conversation_context(metadata) - } - - async fn shutdown(&self) -> Result<(), ChannelError> { - self.inner.shutdown().await - } -} - // --------------------------------------------------------------------------- // TestRig // --------------------------------------------------------------------------- diff --git a/tools-src/github/Cargo.toml b/tools-src/github/Cargo.toml index 7f1c2630..1ae5bbde 100644 --- a/tools-src/github/Cargo.toml +++ b/tools-src/github/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "github-tool" -version = "0.2.0" +version = "0.2.1" edition = "2021" description = "GitHub integration tool for IronClaw (WASM component)" license = "MIT OR Apache-2.0" diff --git a/tools-src/github/README.md b/tools-src/github/README.md index fbde6c61..2d03af28 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,102 @@ 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" +} +``` + +### List Issue Comments + +```json +{ + "action": "list_issue_comments", + "owner": "nearai", + "repo": "ironclaw", + "issue_number": 42, + "limit": 10 +} +``` + +### Add Issue Comment + +```json +{ + "action": "create_issue_comment", + "owner": "nearai", + "repo": "ironclaw", + "issue_number": 42, + "body": "Thanks for reporting this!" +} +``` + +### List PR Review Comments + +```json +{ + "action": "list_pull_request_comments", + "owner": "nearai", + "repo": "ironclaw", + "pr_number": 42, + "limit": 30 +} +``` + +### Reply to PR Review Comment + +```json +{ + "action": "reply_pull_request_comment", + "owner": "nearai", + "repo": "ironclaw", + "comment_id": 123456789, + "body": "Fixed in the latest commit." +} +``` + +### Get PR Reviews + +```json +{ + "action": "get_pull_request_reviews", + "owner": "nearai", + "repo": "ironclaw", + "pr_number": 42 +} +``` + +### Get Combined Status + +```json +{ + "action": "get_combined_status", + "owner": "nearai", + "repo": "ironclaw", + "ref": "main" +} +``` + ### Get File Content ```json diff --git a/tools-src/github/github-tool.capabilities.json b/tools-src/github/github-tool.capabilities.json index 48c53dbf..61bbd55f 100644 --- a/tools-src/github/github-tool.capabilities.json +++ b/tools-src/github/github-tool.capabilities.json @@ -1,7 +1,12 @@ { - "version": "0.2.0", + "version": "0.2.1", "wit_version": "0.3.0", "capabilities": { + "webhook": { + "hmac_secret_name": "github_webhook_secret", + "hmac_signature_header": "x-hub-signature-256", + "hmac_prefix": "sha256=" + }, "http": { "allowlist": [ { @@ -9,7 +14,8 @@ "path_prefix": "/", "methods": [ "GET", - "POST" + "POST", + "PUT" ] } ], @@ -56,4 +62,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..322bbaf6 100644 --- a/tools-src/github/src/lib.rs +++ b/tools-src/github/src/lib.rs @@ -18,7 +18,9 @@ wit_bindgen::generate!({ path: "../../wit/tool.wit", }); -use serde::Deserialize; +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; const MAX_TEXT_LENGTH: usize = 65536; @@ -93,6 +95,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 +118,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 +148,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: u64, + 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, @@ -150,6 +215,29 @@ enum GitHubAction { page: Option, limit: Option, }, + #[serde(rename = "handle_webhook")] + HandleWebhook { webhook: GitHubWebhookRequest }, +} + +#[derive(Debug, Deserialize)] +struct GitHubWebhookRequest { + #[serde(default)] + headers: HashMap, + #[serde(default)] + body_json: Option, +} + +#[derive(Debug, Serialize)] +struct ToolWebhookResponse { + accepted: bool, + emit_events: Vec, +} + +#[derive(Debug, Serialize)] +struct SystemEventIntent { + source: String, + event_type: String, + payload: serde_json::Value, } impl exports::near::agent::tool::Guest for GitHubTool { @@ -208,6 +296,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 +316,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 +350,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, @@ -257,6 +413,7 @@ fn execute_inner(params: &str) -> Result { page, limit, } => get_workflow_runs(&owner, &repo, workflow_id.as_deref(), page, limit), + GitHubAction::HandleWebhook { webhook } => handle_webhook(webhook), } } @@ -451,6 +608,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 +678,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 +777,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: u64, + 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()); @@ -681,6 +1041,239 @@ fn get_workflow_runs( github_request("GET", &path, None) } +fn header_value<'a>(headers: &'a HashMap, key: &str) -> Option<&'a str> { + let lower = key.to_ascii_lowercase(); + headers + .iter() + .find(|(k, _)| k.to_ascii_lowercase() == lower) + .map(|(_, v)| v.as_str()) +} + +fn handle_webhook(webhook: GitHubWebhookRequest) -> Result { + let event = header_value(&webhook.headers, "x-github-event") + .map(str::trim) + .filter(|v| !v.is_empty()) + .ok_or_else(|| "Missing X-GitHub-Event header".to_string())?; + + let payload = webhook + .body_json + .ok_or_else(|| "Missing webhook.body_json".to_string())?; + + let event_type = github_event_type(event, &payload); + let enriched_payload = github_enriched_payload(event, &webhook.headers, &payload, &event_type); + + let resp = ToolWebhookResponse { + accepted: true, + emit_events: vec![SystemEventIntent { + source: "github".to_string(), + event_type, + payload: enriched_payload, + }], + }; + serde_json::to_string(&resp).map_err(|e| format!("Failed to encode webhook response: {e}")) +} + +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()) { + if !action.is_empty() { + return format!("{base}.{action}"); + } + } + + base.to_string() +} + +fn github_enriched_payload( + raw_event: &str, + headers: &HashMap, + 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) { + if let Some(v) = val { + obj.insert(key.to_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", + header_value(headers, "x-github-delivery") + .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_if_missing( + &mut obj, + "repository_name", + payload + .pointer("/repository/full_name") + .and_then(|v| v.as_str()) + .map(|s| serde_json::Value::String(s.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_if_missing( + &mut obj, + "sender_login", + payload + .pointer("/sender/login") + .and_then(|v| v.as_str()) + .map(|s| serde_json::Value::String(s.to_string())), + ); + put_if_missing( + &mut obj, + "issue_number", + payload.pointer("/issue/number").cloned(), + ); + // For `issue_comment` webhooks on PRs, `/pull_request/number` is absent but + // `/issue/number` is present and `/issue/pull_request` exists. Fall back to + // `/issue/number` so PR-comment events carry `pr_number`. + let pr_number = payload + .pointer("/pull_request/number") + .cloned() + .or_else(|| { + if payload.pointer("/issue/pull_request").is_some() { + payload.pointer("/issue/number").cloned() + } else { + None + } + }); + put_if_missing(&mut obj, "pr_number", pr_number); + 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) +} + const SCHEMA: &str = r#"{ "type": "object", "required": ["action"], @@ -723,6 +1316,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 +1347,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 +1389,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" }, @@ -827,12 +1507,13 @@ mod tests { } #[test] - fn test_validate_event_in_create_pr_review() { - let valid = ["APPROVE", "REQUEST_CHANGES", "COMMENT"]; - // Ensure valid inputs are accepted - for event in valid { - assert!(valid.contains(&event)); - } + fn test_header_value_case_insensitive() { + let mut headers = HashMap::new(); + headers.insert("X-Github-Event".to_string(), "push".to_string()); + assert_eq!(header_value(&headers, "x-github-event"), Some("push")); + assert_eq!(header_value(&headers, "X-GITHUB-EVENT"), Some("push")); + assert_eq!(header_value(&headers, "X-Github-Event"), Some("push")); + assert_eq!(header_value(&headers, "x-nonexistent"), None); } #[test] @@ -842,4 +1523,136 @@ mod tests { let long = "a".repeat(MAX_TEXT_LENGTH + 1); assert!(validate_input_length(&long, "test").is_err()); } + + #[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( + "issue_comment", + &serde_json::json!({ + "action": "created", + "issue": { "pull_request": { "url": "https://api.github.com/repos/org/repo/pulls/1" } } + }) + ), + "pr.comment.created" + ); + } + + #[test] + fn test_github_enriched_payload_extracts_common_fields() { + let headers = HashMap::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_name").and_then(|v| v.as_str()), + Some("nearai/ironclaw") + ); + // Original repository object is preserved + assert!(enriched + .get("repository") + .and_then(|v| v.as_object()) + .is_some()); + assert_eq!( + enriched.get("issue_number").and_then(|v| v.as_i64()), + Some(77) + ); + assert_eq!( + enriched.get("comment_body").and_then(|v| v.as_str()), + Some("Please update the implementation plan") + ); + } + + #[test] + fn test_enriched_payload_pr_number_from_issue_comment() { + let headers = HashMap::new(); + let payload = serde_json::json!({ + "action": "created", + "issue": { + "number": 42, + "pull_request": { "url": "https://api.github.com/repos/nearai/ironclaw/pulls/42" } + }, + "comment": { "body": "LGTM", "user": { "login": "reviewer" } }, + "repository": { "full_name": "nearai/ironclaw", "owner": { "login": "nearai" } }, + "sender": { "login": "reviewer" } + }); + + let enriched = + github_enriched_payload("issue_comment", &headers, &payload, "pr.comment.created"); + // pr_number should fall back to issue.number when issue.pull_request exists + assert_eq!( + enriched.get("pr_number").and_then(|v| v.as_i64()), + Some(42), + "pr_number should be set from issue.number for issue_comment on a PR" + ); + } + + #[test] + fn test_handle_webhook_requires_event_header() { + let err = handle_webhook(GitHubWebhookRequest { + headers: HashMap::new(), + body_json: Some(serde_json::json!({"action":"opened"})), + }) + .expect_err("expected header validation error"); + assert!(err.contains("X-GitHub-Event")); + } + + #[test] + fn test_handle_webhook_emits_event_intent() { + let mut headers = HashMap::new(); + headers.insert("x-github-event".to_string(), "issues".to_string()); + headers.insert("x-github-delivery".to_string(), "abc-123".to_string()); + + let out = handle_webhook(GitHubWebhookRequest { + headers, + body_json: Some(serde_json::json!({ + "action":"opened", + "issue":{"number":42}, + "repository":{"full_name":"nearai/ironclaw"}, + "sender":{"login":"maintainer1"} + })), + }) + .expect("webhook handled"); + + let json: serde_json::Value = serde_json::from_str(&out).expect("json"); + assert_eq!( + json.pointer("/emit_events/0/source") + .and_then(|v| v.as_str()), + Some("github") + ); + assert_eq!( + json.pointer("/emit_events/0/event_type") + .and_then(|v| v.as_str()), + Some("issue.opened") + ); + assert_eq!( + json.pointer("/emit_events/0/payload/issue_number") + .and_then(|v| v.as_i64()), + Some(42) + ); + } }