mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
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 <[email protected]> * Fix import ordering in gateway_workflow_harness Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]> * 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 <[email protected]> * Fix formatting in gateway workflow harness Co-Authored-By: Claude Opus 4.6 <[email protected]> * 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 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
febed1e12e
commit
f05896fe6a
Generated
+94
-81
@@ -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",
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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:?}",
|
||||
|
||||
@@ -398,10 +398,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 4: routine_cooldown
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn routine_cooldown() {
|
||||
let (db, _tmp) = create_test_db().await;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<ToolOutput, ToolError> {
|
||||
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<ironclaw::tools::wasm::WebhookCapability> {
|
||||
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<TestChannel>,
|
||||
pub db: Arc<dyn Database>,
|
||||
gateway_state: Arc<GatewayState>,
|
||||
agent_handle: Option<tokio::task::JoinHandle<()>>,
|
||||
bridge_handle: Option<tokio::task::JoinHandle<()>>,
|
||||
webhook_shutdown_tx: Option<oneshot::Sender<()>>,
|
||||
webhook_handle: Option<tokio::task::JoinHandle<()>>,
|
||||
_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<dyn Database> = 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<tokio::sync::RwLock<Option<Arc<RoutineEngine>>>> =
|
||||
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::<IncomingMessage>(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<dyn ironclaw::secrets::SecretsStore + Send + Sync>,
|
||||
),
|
||||
};
|
||||
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::<serde_json::Value>()
|
||||
.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::<serde_json::Value>()
|
||||
.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::<serde_json::Value>()
|
||||
.await
|
||||
.expect("invalid routines response")
|
||||
}
|
||||
|
||||
pub async fn routine_by_name(&self, name: &str) -> Option<serde_json::Value> {
|
||||
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::<serde_json::Value>()
|
||||
.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::<serde_json::Value>()
|
||||
.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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String>, response: MockOpenAiResponse) -> Self {
|
||||
Self {
|
||||
contains: contains.into(),
|
||||
response,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum MockOpenAiResponse {
|
||||
Text(String),
|
||||
ToolCalls(Vec<MockToolCall>),
|
||||
Raw(Value),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct MockToolCall {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub arguments: Value,
|
||||
}
|
||||
|
||||
impl MockToolCall {
|
||||
pub fn new(id: impl Into<String>, name: impl Into<String>, arguments: Value) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
name: name.into(),
|
||||
arguments,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct MockOpenAiServerBuilder {
|
||||
models: Vec<String>,
|
||||
rules: Vec<MockOpenAiRule>,
|
||||
default_response: Option<MockOpenAiResponse>,
|
||||
}
|
||||
|
||||
impl MockOpenAiServerBuilder {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
models: vec!["mock-model".to_string()],
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_models(mut self, models: Vec<String>) -> 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<MockOpenAiState>,
|
||||
shutdown_tx: Option<oneshot::Sender<()>>,
|
||||
server_task: Option<tokio::task::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
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<Value> {
|
||||
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<String>,
|
||||
rules: Vec<MockOpenAiRule>,
|
||||
default_response: MockOpenAiResponse,
|
||||
requests: Mutex<Vec<Value>>,
|
||||
response_counter: AtomicU64,
|
||||
}
|
||||
|
||||
async fn models_handler(State(state): State<Arc<MockOpenAiState>>) -> Json<Value> {
|
||||
Json(json!({
|
||||
"object": "list",
|
||||
"data": state
|
||||
.models
|
||||
.iter()
|
||||
.map(|id| json!({"id": id, "object": "model"}))
|
||||
.collect::<Vec<_>>()
|
||||
}))
|
||||
}
|
||||
|
||||
async fn chat_completions_handler(
|
||||
State(state): State<Arc<MockOpenAiState>>,
|
||||
Json(body): Json<Value>,
|
||||
) -> Result<Json<Value>, (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<String> {
|
||||
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::<Vec<_>>();
|
||||
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))
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -198,6 +198,82 @@ impl TestChannel {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TestChannelHandle -- wraps Arc<TestChannel> as Box<dyn Channel>
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A thin wrapper around `Arc<TestChannel>` that implements `Channel`.
|
||||
///
|
||||
/// This lets us hand a `Box<dyn Channel>` to `ChannelManager::add()` while
|
||||
/// keeping an `Arc<TestChannel>` 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<TestChannel>,
|
||||
name: String,
|
||||
}
|
||||
|
||||
impl TestChannelHandle {
|
||||
/// Create a handle that delegates `name()` to the inner `TestChannel`.
|
||||
pub fn new(inner: Arc<TestChannel>) -> Self {
|
||||
Self {
|
||||
name: inner.name().to_string(),
|
||||
inner,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a handle with a custom channel name.
|
||||
pub fn with_name(inner: Arc<TestChannel>, name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
name: name.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Channel for TestChannelHandle {
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
async fn start(&self) -> Result<MessageStream, ChannelError> {
|
||||
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<String, String> {
|
||||
self.inner.conversation_context(metadata)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Channel trait implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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<TestChannel> as Box<dyn Channel>
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A thin wrapper around `Arc<TestChannel>` that implements `Channel`.
|
||||
///
|
||||
/// This lets us hand a `Box<dyn Channel>` to `ChannelManager::add()` while
|
||||
/// keeping an `Arc<TestChannel>` in the `TestRig` for sending messages and
|
||||
/// reading captures.
|
||||
struct TestChannelHandle {
|
||||
inner: Arc<TestChannel>,
|
||||
}
|
||||
|
||||
impl TestChannelHandle {
|
||||
fn new(inner: Arc<TestChannel>) -> Self {
|
||||
Self { inner }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Channel for TestChannelHandle {
|
||||
fn name(&self) -> &str {
|
||||
self.inner.name()
|
||||
}
|
||||
|
||||
async fn start(&self) -> Result<MessageStream, ChannelError> {
|
||||
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<String, String> {
|
||||
self.inner.conversation_context(metadata)
|
||||
}
|
||||
|
||||
async fn shutdown(&self) -> Result<(), ChannelError> {
|
||||
self.inner.shutdown().await
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TestRig
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+820
-7
@@ -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<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 +118,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 +148,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: u64,
|
||||
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,
|
||||
@@ -150,6 +215,29 @@ enum GitHubAction {
|
||||
page: Option<u32>,
|
||||
limit: Option<u32>,
|
||||
},
|
||||
#[serde(rename = "handle_webhook")]
|
||||
HandleWebhook { webhook: GitHubWebhookRequest },
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GitHubWebhookRequest {
|
||||
#[serde(default)]
|
||||
headers: HashMap<String, String>,
|
||||
#[serde(default)]
|
||||
body_json: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ToolWebhookResponse {
|
||||
accepted: bool,
|
||||
emit_events: Vec<SystemEventIntent>,
|
||||
}
|
||||
|
||||
#[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<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 +316,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 +350,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,
|
||||
@@ -257,6 +413,7 @@ fn execute_inner(params: &str) -> Result<String, String> {
|
||||
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<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 +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<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 +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<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: u64,
|
||||
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());
|
||||
@@ -681,6 +1041,239 @@ fn get_workflow_runs(
|
||||
github_request("GET", &path, None)
|
||||
}
|
||||
|
||||
fn header_value<'a>(headers: &'a HashMap<String, String>, 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<String, String> {
|
||||
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<String, String>,
|
||||
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) {
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user