diff --git a/registry/tools/github.json b/registry/tools/github.json index bb35125960..5af2452360 100644 --- a/registry/tools/github.json +++ b/registry/tools/github.json @@ -4,13 +4,15 @@ "kind": "tool", "version": "0.2.2", "wit_version": "0.3.0", - "description": "GitHub integration for issues, PRs, repos, and code search", + "description": "GitHub integration for repositories, issues, pull requests, search, branches, file writes, releases, and workflows", "keywords": [ "git", "code", "issues", "pull-requests", - "repositories" + "repositories", + "search", + "releases" ], "source": { "dir": "tools-src/github", diff --git a/src/tools/registry.rs b/src/tools/registry.rs index 8c08633bbd..ce6e3bf4c9 100644 --- a/src/tools/registry.rs +++ b/src/tools/registry.rs @@ -25,7 +25,7 @@ use crate::tools::builtin::{ ToolUpgradeTool, WriteFileTool, }; use crate::tools::rate_limiter::RateLimiter; -use crate::tools::tool::{ApprovalRequirement, Tool, ToolDomain}; +use crate::tools::tool::{ApprovalRequirement, Tool, ToolDiscoverySummary, ToolDomain}; use crate::tools::wasm::{ Capabilities, OAuthRefreshConfig, ResourceLimits, SharedCredentialRegistry, WasmError, WasmStorageError, WasmToolRuntime, WasmToolStore, WasmToolWrapper, @@ -674,6 +674,9 @@ impl ToolRegistry { if let Some(s) = reg.schema { wrapper = wrapper.with_schema(s); } + if let Some(summary) = reg.discovery_summary { + wrapper = wrapper.with_discovery_summary(summary); + } if let Some(store) = reg.secrets_store { wrapper = wrapper.with_secrets_store(store); } @@ -748,6 +751,7 @@ impl ToolRegistry { limits: None, description: Some(&tool_with_binary.tool.description), schema: Some(tool_with_binary.tool.parameters_schema.clone()), + discovery_summary: None, secrets_store: self.secrets_store.clone(), oauth_refresh: None, }) @@ -791,6 +795,8 @@ pub struct WasmToolRegistration<'a> { pub description: Option<&'a str>, /// Optional parameter schema override. pub schema: Option, + /// Optional curated discovery guidance for `tool_info(detail: "summary")`. + pub discovery_summary: Option, /// Secrets store for credential injection at request time. pub secrets_store: Option>, /// OAuth refresh configuration for auto-refreshing expired tokens. diff --git a/src/tools/wasm/capabilities_schema.rs b/src/tools/wasm/capabilities_schema.rs index 8ac7806ca5..7f3cbb0810 100644 --- a/src/tools/wasm/capabilities_schema.rs +++ b/src/tools/wasm/capabilities_schema.rs @@ -33,6 +33,7 @@ use std::time::Duration; use serde::{Deserialize, Serialize}; use crate::secrets::{CredentialLocation, CredentialMapping}; +use crate::tools::tool::ToolDiscoverySummary; use crate::tools::wasm::{ Capabilities, EndpointPattern, HttpCapability, RateLimitConfig, SecretsCapability, ToolInvokeCapability, WebhookCapability, WorkspaceCapability, @@ -47,6 +48,10 @@ pub struct CapabilitiesFile { #[serde(default)] pub description: Option, + /// Optional curated guidance surfaced by `tool_info(detail: "summary")`. + #[serde(default)] + pub discovery_summary: Option, + /// Extension version (semver). #[serde(default)] pub version: Option, @@ -154,6 +159,7 @@ impl CapabilitiesFile { if let Some(inner) = self.capabilities.take() { let inner = inner.resolve_nested_inner(depth + 1); self.description = self.description.or(inner.description); + self.discovery_summary = self.discovery_summary.or(inner.discovery_summary); self.http = self.http.or(inner.http); self.secrets = self.secrets.or(inner.secrets); self.tool_invoke = self.tool_invoke.or(inner.tool_invoke); @@ -1531,6 +1537,28 @@ mod tests { ); } + #[test] + fn test_discovery_summary_promoted_from_nested_capabilities() { + let json = r#"{ + "capabilities": { + "discovery_summary": { + "always_required": ["action"], + "notes": ["Use tool_info for full schema"] + } + } + }"#; + + let caps = CapabilitiesFile::from_json(json).unwrap(); + let summary = caps + .discovery_summary + .expect("discovery summary should be promoted"); + assert_eq!(summary.always_required, vec!["action".to_string()]); + assert_eq!( + summary.notes, + vec!["Use tool_info for full schema".to_string()] + ); + } + /// Regression test for issue #974: deeply nested capabilities wrappers /// must not cause stack overflow. resolve_nested should stop at /// MAX_NESTED_DEPTH and return gracefully. diff --git a/src/tools/wasm/loader.rs b/src/tools/wasm/loader.rs index 680abf939b..8aa0ba472e 100644 --- a/src/tools/wasm/loader.rs +++ b/src/tools/wasm/loader.rs @@ -128,48 +128,50 @@ impl WasmToolLoader { // capabilities file — it is auto-derived from the WASM module's // schema() export at prepare time (see WasmToolSchemas::compact_schema), // so no schema override is needed here. - let (capabilities, oauth_refresh, description) = if let Some(cap_path) = capabilities_path { - if cap_path.exists() { - let cap_bytes = fs::read(cap_path).await?; - let cap_file = CapabilitiesFile::from_bytes(&cap_bytes) - .map_err(|e| WasmLoadError::InvalidCapabilities(e.to_string()))?; - cap_file.validate(name); + let (capabilities, oauth_refresh, description, discovery_summary) = + if let Some(cap_path) = capabilities_path { + if cap_path.exists() { + let cap_bytes = fs::read(cap_path).await?; + let cap_file = CapabilitiesFile::from_bytes(&cap_bytes) + .map_err(|e| WasmLoadError::InvalidCapabilities(e.to_string()))?; + cap_file.validate(name); - // Check WIT version compatibility - check_wit_version_compat( - name, - cap_file.wit_version.as_deref(), - crate::tools::wasm::WIT_TOOL_VERSION, - )?; + // Check WIT version compatibility + check_wit_version_compat( + name, + cap_file.wit_version.as_deref(), + crate::tools::wasm::WIT_TOOL_VERSION, + )?; - let caps = cap_file.to_capabilities(); - let oauth = resolve_oauth_refresh_config(&cap_file); - let desc = cap_file.description.clone(); - if desc.is_none() { + let caps = cap_file.to_capabilities(); + let oauth = resolve_oauth_refresh_config(&cap_file); + let desc = cap_file.description.clone(); + let summary = cap_file.discovery_summary.clone(); + if desc.is_none() { + tracing::warn!( + tool = name, + path = %cap_path.display(), + "Capabilities file missing \"description\" field; \ + tool will use generic fallback description" + ); + } + (caps, oauth, desc, summary) + } else { tracing::warn!( tool = name, path = %cap_path.display(), - "Capabilities file missing \"description\" field; \ - tool will use generic fallback description" + "Capabilities file not found, using default (no permissions)" ); + (Capabilities::default(), None, None, None) } - (caps, oauth, desc) } else { tracing::warn!( tool = name, - path = %cap_path.display(), - "Capabilities file not found, using default (no permissions)" - ); - (Capabilities::default(), None, None) - } - } else { - tracing::warn!( - tool = name, - "No capabilities file for WASM tool; \ + "No capabilities file for WASM tool; \ tool will use generic fallback description" - ); - (Capabilities::default(), None, None) - }; + ); + (Capabilities::default(), None, None, None) + }; // Register the tool self.registry @@ -181,6 +183,7 @@ impl WasmToolLoader { limits: None, description: description.as_deref(), schema: None, + discovery_summary, secrets_store: self.secrets_store.clone(), oauth_refresh, }) diff --git a/src/tools/wasm/wrapper.rs b/src/tools/wasm/wrapper.rs index f8f69ba435..ed2994fa42 100644 --- a/src/tools/wasm/wrapper.rs +++ b/src/tools/wasm/wrapper.rs @@ -20,7 +20,7 @@ use crate::context::JobContext; use crate::llm::recording::{HttpExchangeRequest, HttpExchangeResponse, HttpInterceptor}; use crate::safety::LeakDetector; use crate::secrets::{DecryptedSecret, SecretsStore}; -use crate::tools::tool::{Tool, ToolError, ToolOutput}; +use crate::tools::tool::{Tool, ToolDiscoverySummary, ToolError, ToolOutput}; use crate::tools::wasm::capabilities::Capabilities; use crate::tools::wasm::credential_injector::{ InjectedCredentials, host_matches_pattern, inject_credential, @@ -585,6 +585,8 @@ pub struct WasmToolWrapper { description: String, /// Compact and discovery schemas for this tool. schemas: WasmToolSchemas, + /// Optional curated discovery guidance surfaced by `tool_info`. + discovery_summary: Option, /// Injected credentials for HTTP requests (e.g., OAuth tokens). /// Keys are placeholder names like "GOOGLE_ACCESS_TOKEN". credentials: HashMap, @@ -836,6 +838,7 @@ impl WasmToolWrapper { Self { description: prepared.description.clone(), schemas: WasmToolSchemas::new(prepared.schema.clone()), + discovery_summary: None, runtime, prepared, capabilities, @@ -882,6 +885,12 @@ impl WasmToolWrapper { self } + /// Override the curated discovery summary. + pub fn with_discovery_summary(mut self, summary: ToolDiscoverySummary) -> Self { + self.discovery_summary = Some(summary); + self + } + /// Set credentials for HTTP request placeholder injection. pub fn with_credentials(mut self, credentials: HashMap) -> Self { self.credentials = credentials; @@ -1098,6 +1107,10 @@ impl Tool for WasmToolWrapper { self.schemas.discovery() } + fn discovery_summary(&self) -> Option { + self.discovery_summary.clone() + } + /// Compose the tool schema for LLM function calling. /// /// When the advertised schema is permissive (no typed properties), appends @@ -1149,6 +1162,7 @@ impl Tool for WasmToolWrapper { let capabilities = self.capabilities.clone(); let description = self.description.clone(); let schemas = self.schemas.clone(); + let discovery_summary = self.discovery_summary.clone(); let credentials = self.credentials.clone(); // Execute in blocking task with timeout @@ -1159,6 +1173,7 @@ impl Tool for WasmToolWrapper { capabilities, description, schemas, + discovery_summary, credentials, secrets_store: None, // Not needed in blocking task oauth_refresh: None, // Already used above for pre-refresh @@ -3058,6 +3073,27 @@ mod tests { assert_eq!(wrapper.discovery_schema(), typed_schema); // safety: test-only assertion } + #[tokio::test] + async fn test_wrapper_returns_curated_discovery_summary() { + let runtime = Arc::new(WasmToolRuntime::new(WasmRuntimeConfig::for_testing()).unwrap()); // safety: test-only setup + let prepared = runtime + .prepare("github", b"\0asm\x0d\0\x01\0", None) + .await + .unwrap(); // safety: test-only setup + + let summary = crate::tools::tool::ToolDiscoverySummary { + always_required: vec!["action".into()], + notes: vec!["Use tool_info for the full schema".into()], + ..crate::tools::tool::ToolDiscoverySummary::default() + }; + + let wrapper = + super::WasmToolWrapper::new(Arc::clone(&runtime), prepared, Capabilities::default()) + .with_discovery_summary(summary.clone()); + + assert_eq!(wrapper.discovery_summary(), Some(summary)); + } + #[test] fn test_build_tool_usage_hint_detects_nullable_container_properties() { let schema = serde_json::json!({ diff --git a/tests/e2e_wasm_github_coercion.rs b/tests/e2e_wasm_github_coercion.rs index 5277ea91bd..04e54ff1cf 100644 --- a/tests/e2e_wasm_github_coercion.rs +++ b/tests/e2e_wasm_github_coercion.rs @@ -46,6 +46,37 @@ mod tests { } } + fn github_exchange( + method: &str, + url: &str, + body: Option, + response_body: &str, + ) -> HttpExchange { + HttpExchange { + request: HttpExchangeRequest { + method: method.to_string(), + url: url.to_string(), + headers: vec![], + body, + }, + response: github_ok(response_body), + } + } + + async fn run_trace(trace: LlmTrace, prompt: &str) { + let rig = TestRigBuilder::new() + .with_trace(trace.clone()) + .with_wasm_tool("github", GITHUB_WASM, Some(GITHUB_CAPS.into())) + .build() + .await; + + rig.send_message(prompt).await; + let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await; + rig.verify_trace_expects(&trace, &responses); + + rig.shutdown(); + } + /// LLM sends `limit: "50"` (string) to `list_issues`. Coercion converts it /// to integer, and the WASM tool must call `GET /repos/.../issues?...&per_page=50`. #[tokio::test] @@ -110,18 +141,7 @@ mod tests { steps: Vec::new(), }; - let rig = TestRigBuilder::new() - .with_trace(trace.clone()) - .with_wasm_tool("github", GITHUB_WASM, Some(GITHUB_CAPS.into())) - .build() - .await; - - rig.send_message("List issues in nearai/ironclaw with limit 50") - .await; - let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await; - rig.verify_trace_expects(&trace, &responses); - - rig.shutdown(); + run_trace(trace, "List issues in nearai/ironclaw with limit 50").await; } /// LLM sends `issue_number: "42"` (string) to `get_issue`. Coercion converts @@ -186,17 +206,7 @@ mod tests { steps: Vec::new(), }; - let rig = TestRigBuilder::new() - .with_trace(trace.clone()) - .with_wasm_tool("github", GITHUB_WASM, Some(GITHUB_CAPS.into())) - .build() - .await; - - rig.send_message("Get issue 42 from nearai/ironclaw").await; - let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await; - rig.verify_trace_expects(&trace, &responses); - - rig.shutdown(); + run_trace(trace, "Get issue 42 from nearai/ironclaw").await; } /// LLM sends `limit: "25"` (string) to `list_pull_requests`. URL must @@ -262,16 +272,449 @@ mod tests { steps: Vec::new(), }; - let rig = TestRigBuilder::new() - .with_trace(trace.clone()) - .with_wasm_tool("github", GITHUB_WASM, Some(GITHUB_CAPS.into())) - .build() - .await; + run_trace(trace, "List PRs in nearai/ironclaw").await; + } - rig.send_message("List PRs in nearai/ironclaw").await; - let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await; - rig.verify_trace_expects(&trace, &responses); + /// LLM sends pagination as strings to `search_code`. Coercion converts them + /// to integers, and the tool must construct the expected search query. + #[tokio::test] + #[ignore] // requires pre-compiled WASM binary + async fn wasm_github_search_code_coerces_string_pagination() { + let expected_url = "https://api.github.com/search/code?q=repo%3Anearai%2Fironclaw%20path%3Asrc%20Tool&per_page=10&page=2&sort=indexed&order=desc"; - rig.shutdown(); + let trace = LlmTrace { + model_name: "test-wasm-coercion-search-code".to_string(), + turns: vec![crate::support::trace_llm::TraceTurn { + user_input: "Search code in nearai/ironclaw".to_string(), + steps: vec![ + TraceStep { + request_hint: None, + response: TraceResponse::ToolCalls { + tool_calls: vec![TraceToolCall { + id: "call_gh_4".to_string(), + name: "github".to_string(), + arguments: json!({ + "action": "search_code", + "query": "repo:nearai/ironclaw path:src Tool", + "limit": "10", + "page": "2", + "sort": "indexed", + "order": "desc" + }), + }], + input_tokens: 120, + output_tokens: 40, + }, + expected_tool_results: Vec::new(), + }, + TraceStep { + request_hint: None, + response: TraceResponse::Text { + content: "Found matching code results.".to_string(), + input_tokens: 180, + output_tokens: 12, + }, + expected_tool_results: Vec::new(), + }, + ], + expects: TraceExpects::default(), + }], + memory_snapshot: Vec::new(), + http_exchanges: vec![github_exchange( + "GET", + expected_url, + None, + r#"{"total_count":1,"items":[{"name":"lib.rs","path":"src/lib.rs"}]}"#, + )], + expects: TraceExpects { + tools_used: vec!["github".to_string()], + all_tools_succeeded: Some(true), + max_tool_calls: Some(1), + min_responses: Some(1), + ..Default::default() + }, + steps: Vec::new(), + }; + + run_trace(trace, "Search code in nearai/ironclaw").await; + } + + /// `create_repo` must target the org repos endpoint and send the expected + /// JSON body for repository creation. + #[tokio::test] + #[ignore] // requires pre-compiled WASM binary + async fn wasm_github_create_repo_posts_expected_payload() { + let expected_url = "https://api.github.com/orgs/nearai/repos"; + let expected_body = json!({ + "name": "github-tool-replay", + "private": true, + "auto_init": true, + "description": "Replay-created repo", + "gitignore_template": "Rust", + "license_template": "mit" + }) + .to_string(); + + let trace = LlmTrace { + model_name: "test-wasm-create-repo".to_string(), + turns: vec![crate::support::trace_llm::TraceTurn { + user_input: "Create a private repo for nearai".to_string(), + steps: vec![ + TraceStep { + request_hint: None, + response: TraceResponse::ToolCalls { + tool_calls: vec![TraceToolCall { + id: "call_gh_5".to_string(), + name: "github".to_string(), + arguments: json!({ + "action": "create_repo", + "name": "github-tool-replay", + "description": "Replay-created repo", + "private": true, + "auto_init": true, + "gitignore_template": "Rust", + "license_template": "mit", + "org": "nearai" + }), + }], + input_tokens: 110, + output_tokens: 40, + }, + expected_tool_results: Vec::new(), + }, + TraceStep { + request_hint: None, + response: TraceResponse::Text { + content: "Repository created.".to_string(), + input_tokens: 150, + output_tokens: 12, + }, + expected_tool_results: Vec::new(), + }, + ], + expects: TraceExpects::default(), + }], + memory_snapshot: Vec::new(), + http_exchanges: vec![github_exchange( + "POST", + expected_url, + Some(expected_body), + r#"{"name":"github-tool-replay","private":true}"#, + )], + expects: TraceExpects { + tools_used: vec!["github".to_string()], + all_tools_succeeded: Some(true), + max_tool_calls: Some(1), + min_responses: Some(1), + ..Default::default() + }, + steps: Vec::new(), + }; + + run_trace(trace, "Create a private repo for nearai").await; + } + + /// `create_branch` should fetch the source ref SHA and then create the new + /// branch ref in a second request. + #[tokio::test] + #[ignore] // requires pre-compiled WASM binary + async fn wasm_github_create_branch_replays_two_step_ref_flow() { + let source_ref_url = "https://api.github.com/repos/nearai/ironclaw/git/ref/heads/main"; + let create_ref_url = "https://api.github.com/repos/nearai/ironclaw/git/refs"; + let create_ref_body = json!({ + "ref": "refs/heads/feature/replay-test", + "sha": "abc123def4567890abc123def4567890abc123de" + }) + .to_string(); + + let trace = LlmTrace { + model_name: "test-wasm-create-branch".to_string(), + turns: vec![crate::support::trace_llm::TraceTurn { + user_input: "Create a replay branch from main".to_string(), + steps: vec![ + TraceStep { + request_hint: None, + response: TraceResponse::ToolCalls { + tool_calls: vec![TraceToolCall { + id: "call_gh_6".to_string(), + name: "github".to_string(), + arguments: json!({ + "action": "create_branch", + "owner": "nearai", + "repo": "ironclaw", + "branch": "feature/replay-test", + "from_ref": "main" + }), + }], + input_tokens: 100, + output_tokens: 35, + }, + expected_tool_results: Vec::new(), + }, + TraceStep { + request_hint: None, + response: TraceResponse::Text { + content: "Branch created.".to_string(), + input_tokens: 145, + output_tokens: 10, + }, + expected_tool_results: Vec::new(), + }, + ], + expects: TraceExpects::default(), + }], + memory_snapshot: Vec::new(), + http_exchanges: vec![ + github_exchange( + "GET", + source_ref_url, + None, + r#"{"ref":"refs/heads/main","object":{"sha":"abc123def4567890abc123def4567890abc123de"}}"#, + ), + github_exchange( + "POST", + create_ref_url, + Some(create_ref_body), + r#"{"ref":"refs/heads/feature/replay-test"}"#, + ), + ], + expects: TraceExpects { + tools_used: vec!["github".to_string()], + all_tools_succeeded: Some(true), + max_tool_calls: Some(1), + min_responses: Some(1), + ..Default::default() + }, + steps: Vec::new(), + }; + + run_trace(trace, "Create a replay branch from main").await; + } + + /// `create_or_update_file` must target the contents API and send base64 + /// encoded file contents in the request body. + #[tokio::test] + #[ignore] // requires pre-compiled WASM binary + async fn wasm_github_create_or_update_file_puts_contents_payload() { + let expected_url = "https://api.github.com/repos/nearai/ironclaw/contents/docs/replay.md"; + let expected_body = json!({ + "message": "Add replay doc", + "content": "IyBSZXBsYXkgZG9jCg==", + "branch": "feature/replay-test", + "committer": { + "name": "IronClaw Bot", + "email": "bot@example.com" + } + }) + .to_string(); + + let trace = LlmTrace { + model_name: "test-wasm-create-or-update-file".to_string(), + turns: vec![crate::support::trace_llm::TraceTurn { + user_input: "Write docs/replay.md".to_string(), + steps: vec![ + TraceStep { + request_hint: None, + response: TraceResponse::ToolCalls { + tool_calls: vec![TraceToolCall { + id: "call_gh_7".to_string(), + name: "github".to_string(), + arguments: json!({ + "action": "create_or_update_file", + "owner": "nearai", + "repo": "ironclaw", + "path": "docs/replay.md", + "message": "Add replay doc", + "content": "# Replay doc\n", + "branch": "feature/replay-test", + "committer": { + "name": "IronClaw Bot", + "email": "bot@example.com" + } + }), + }], + input_tokens: 120, + output_tokens: 40, + }, + expected_tool_results: Vec::new(), + }, + TraceStep { + request_hint: None, + response: TraceResponse::Text { + content: "File written.".to_string(), + input_tokens: 160, + output_tokens: 10, + }, + expected_tool_results: Vec::new(), + }, + ], + expects: TraceExpects::default(), + }], + memory_snapshot: Vec::new(), + http_exchanges: vec![github_exchange( + "PUT", + expected_url, + Some(expected_body), + r#"{"content":{"path":"docs/replay.md"},"commit":{"sha":"deadbeef"}}"#, + )], + expects: TraceExpects { + tools_used: vec!["github".to_string()], + all_tools_succeeded: Some(true), + max_tool_calls: Some(1), + min_responses: Some(1), + ..Default::default() + }, + steps: Vec::new(), + }; + + run_trace(trace, "Write docs/replay.md").await; + } + + /// `delete_file` must call DELETE on the contents endpoint with the blob SHA + /// and commit metadata in the request body. + #[tokio::test] + #[ignore] // requires pre-compiled WASM binary + async fn wasm_github_delete_file_deletes_contents_with_sha() { + let expected_url = "https://api.github.com/repos/nearai/ironclaw/contents/docs/replay.md"; + let expected_body = json!({ + "message": "Remove replay doc", + "sha": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + "branch": "feature/replay-test" + }) + .to_string(); + + let trace = LlmTrace { + model_name: "test-wasm-delete-file".to_string(), + turns: vec![crate::support::trace_llm::TraceTurn { + user_input: "Delete docs/replay.md".to_string(), + steps: vec![ + TraceStep { + request_hint: None, + response: TraceResponse::ToolCalls { + tool_calls: vec![TraceToolCall { + id: "call_gh_8".to_string(), + name: "github".to_string(), + arguments: json!({ + "action": "delete_file", + "owner": "nearai", + "repo": "ironclaw", + "path": "docs/replay.md", + "message": "Remove replay doc", + "sha": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + "branch": "feature/replay-test" + }), + }], + input_tokens: 110, + output_tokens: 36, + }, + expected_tool_results: Vec::new(), + }, + TraceStep { + request_hint: None, + response: TraceResponse::Text { + content: "File deleted.".to_string(), + input_tokens: 150, + output_tokens: 10, + }, + expected_tool_results: Vec::new(), + }, + ], + expects: TraceExpects::default(), + }], + memory_snapshot: Vec::new(), + http_exchanges: vec![github_exchange( + "DELETE", + expected_url, + Some(expected_body), + r#"{"commit":{"sha":"feedface"}}"#, + )], + expects: TraceExpects { + tools_used: vec!["github".to_string()], + all_tools_succeeded: Some(true), + max_tool_calls: Some(1), + min_responses: Some(1), + ..Default::default() + }, + steps: Vec::new(), + }; + + run_trace(trace, "Delete docs/replay.md").await; + } + + /// `create_release` should post release metadata to the releases endpoint. + #[tokio::test] + #[ignore] // requires pre-compiled WASM binary + async fn wasm_github_create_release_posts_expected_payload() { + let expected_url = "https://api.github.com/repos/nearai/ironclaw/releases"; + let expected_body = json!({ + "tag_name": "v1.2.3", + "draft": false, + "prerelease": true, + "generate_release_notes": true, + "target_commitish": "main", + "name": "Replay Release", + "body": "Generated during replay testing" + }) + .to_string(); + + let trace = LlmTrace { + model_name: "test-wasm-create-release".to_string(), + turns: vec![crate::support::trace_llm::TraceTurn { + user_input: "Create a prerelease".to_string(), + steps: vec![ + TraceStep { + request_hint: None, + response: TraceResponse::ToolCalls { + tool_calls: vec![TraceToolCall { + id: "call_gh_9".to_string(), + name: "github".to_string(), + arguments: json!({ + "action": "create_release", + "owner": "nearai", + "repo": "ironclaw", + "tag_name": "v1.2.3", + "target_commitish": "main", + "name": "Replay Release", + "body": "Generated during replay testing", + "draft": false, + "prerelease": true, + "generate_release_notes": true + }), + }], + input_tokens: 125, + output_tokens: 40, + }, + expected_tool_results: Vec::new(), + }, + TraceStep { + request_hint: None, + response: TraceResponse::Text { + content: "Release created.".to_string(), + input_tokens: 170, + output_tokens: 10, + }, + expected_tool_results: Vec::new(), + }, + ], + expects: TraceExpects::default(), + }], + memory_snapshot: Vec::new(), + http_exchanges: vec![github_exchange( + "POST", + expected_url, + Some(expected_body), + r#"{"id":1,"tag_name":"v1.2.3"}"#, + )], + expects: TraceExpects { + tools_used: vec!["github".to_string()], + all_tools_succeeded: Some(true), + max_tool_calls: Some(1), + min_responses: Some(1), + ..Default::default() + }, + steps: Vec::new(), + }; + + run_trace(trace, "Create a prerelease").await; } } diff --git a/tools-src/github/Cargo.toml b/tools-src/github/Cargo.toml index 1ae5bbdeb4..4c8ae6ecc9 100644 --- a/tools-src/github/Cargo.toml +++ b/tools-src/github/Cargo.toml @@ -1,12 +1,13 @@ [package] name = "github-tool" -version = "0.2.1" +version = "0.2.2" edition = "2021" description = "GitHub integration tool for IronClaw (WASM component)" license = "MIT OR Apache-2.0" publish = false [dependencies] +base64 = "0.22" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" wit-bindgen = "0.41.0" diff --git a/tools-src/github/README.md b/tools-src/github/README.md index 2d03af28d1..bcfa94a7b7 100644 --- a/tools-src/github/README.md +++ b/tools-src/github/README.md @@ -1,13 +1,17 @@ # GitHub Tool for IronClaw -WASM tool for GitHub integration - manage repos, issues, PRs, and workflows. +WASM tool for GitHub integration. It covers repositories, issues, pull requests, +search, branches, file reads and writes, releases, and workflows. ## Features -- **Repository Info** - Get repo details, list user repos +- **Repositories** - Get repo details, list user repos, create repositories +- **Search** - Search repositories, code, and issues/PRs +- **Branches** - List branches and create new branches from an existing ref - **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 +- **File Content** - Read files and create/update/delete repository files +- **Releases** - List releases and create new releases - **Workflows** - Trigger GitHub Actions, check run status ## Setup @@ -32,6 +36,18 @@ WASM tool for GitHub integration - manage repos, issues, PRs, and workflows. } ``` +### Create Repository + +```json +{ + "action": "create_repo", + "name": "infra-playground", + "description": "Scratch repo for release automation", + "private": true, + "auto_init": true +} +``` + ### List Open Issues ```json @@ -69,6 +85,26 @@ WASM tool for GitHub integration - manage repos, issues, PRs, and workflows. } ``` +### Search Code + +```json +{ + "action": "search_code", + "query": "repo:nearai/ironclaw tool_info", + "limit": 5 +} +``` + +### Search Issues and Pull Requests + +```json +{ + "action": "search_issues_pull_requests", + "query": "repo:nearai/ironclaw is:pr label:bug", + "limit": 10 +} +``` + ### Review PR ```json @@ -190,6 +226,81 @@ WASM tool for GitHub integration - manage repos, issues, PRs, and workflows. } ``` +### Create or Update a File + +```json +{ + "action": "create_or_update_file", + "owner": "nearai", + "repo": "ironclaw", + "path": "docs/example.txt", + "message": "docs: add example", + "content": "Hello from IronClaw" +} +``` + +When updating an existing file, include the current blob `sha`. + +### Delete a File + +```json +{ + "action": "delete_file", + "owner": "nearai", + "repo": "ironclaw", + "path": "docs/example.txt", + "message": "docs: remove example", + "sha": "0123456789abcdef0123456789abcdef01234567" +} +``` + +### List Branches + +```json +{ + "action": "list_branches", + "owner": "nearai", + "repo": "ironclaw", + "limit": 20 +} +``` + +### Create Branch + +```json +{ + "action": "create_branch", + "owner": "nearai", + "repo": "ironclaw", + "branch": "feature/github-tool-audit", + "from_ref": "main" +} +``` + +### List Releases + +```json +{ + "action": "list_releases", + "owner": "nearai", + "repo": "ironclaw", + "limit": 10 +} +``` + +### Create Release + +```json +{ + "action": "create_release", + "owner": "nearai", + "repo": "ironclaw", + "tag_name": "v1.2.3", + "name": "v1.2.3", + "generate_release_notes": true +} +``` + ### Trigger Workflow ```json diff --git a/tools-src/github/github-tool.capabilities.json b/tools-src/github/github-tool.capabilities.json index 773705100c..ee86b01e28 100644 --- a/tools-src/github/github-tool.capabilities.json +++ b/tools-src/github/github-tool.capabilities.json @@ -1,7 +1,45 @@ { - "version": "0.2.1", + "version": "0.2.2", "wit_version": "0.3.0", - "description": "Manage GitHub repositories, issues, pull requests, reviews, and workflows. Supports listing, creating, commenting, merging PRs, and triggering GitHub Actions.", + "description": "Manage GitHub repositories, issues, pull requests, search, branches, file reads and writes, releases, and workflows.", + "discovery_summary": { + "always_required": [ + "action" + ], + "conditional_requirements": [ + "Repository-scoped actions require `owner` and `repo`.", + "Search actions require `query`.", + "File write actions require `path` and `message`; updates additionally need `sha`.", + "Workflow dispatch requires `workflow_id` and `ref`.", + "Branch creation requires `branch` and `from_ref`." + ], + "notes": [ + "Use `tool_info(name: \"github\", detail: \"schema\")` for the full action schema before guessing fields.", + "Supported families: repositories, issues, pull requests, reviews/comments, search, branches, code reads, file writes, releases, workflow dispatch/runs, and webhook normalization.", + "Not supported yet: forks, labels, milestones, projects, org/team admin, GraphQL, release asset uploads, and repository deletion." + ], + "examples": [ + { + "action": "create_repo", + "name": "infra-playground", + "private": true, + "auto_init": true + }, + { + "action": "search_code", + "query": "repo:nearai/ironclaw tool_info", + "limit": 5 + }, + { + "action": "create_or_update_file", + "owner": "nearai", + "repo": "ironclaw", + "path": "docs/example.txt", + "message": "docs: add example", + "content": "Hello from IronClaw" + } + ] + }, "capabilities": { "webhook": { "hmac_secret_name": "github_webhook_secret", @@ -16,7 +54,8 @@ "methods": [ "GET", "POST", - "PUT" + "PUT", + "DELETE" ] } ], diff --git a/tools-src/github/src/lib.rs b/tools-src/github/src/lib.rs index 322bbaf6fd..600f9fc809 100644 --- a/tools-src/github/src/lib.rs +++ b/tools-src/github/src/lib.rs @@ -20,6 +20,8 @@ wit_bindgen::generate!({ use std::collections::HashMap; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use base64::Engine as _; use serde::{Deserialize, Serialize}; const MAX_TEXT_LENGTH: usize = 65536; @@ -63,7 +65,129 @@ fn url_encode_query(s: &str) -> String { /// Validate that a path segment doesn't contain dangerous characters. /// Returns true if the segment is safe to use. fn validate_path_segment(s: &str) -> bool { - !s.is_empty() && !s.contains('/') && !s.contains("..") && !s.contains('?') && !s.contains('#') + !s.is_empty() + && !s.contains('/') + && !s.contains("..") + && !s.contains('?') + && !s.contains('#') + && !s.chars().any(|c| c.is_control() || c.is_whitespace()) +} + +fn validate_repo_path(path: &str) -> Result<(), String> { + validate_input_length(path, "path")?; + for segment in path.split('/') { + if segment == ".." { + return Err("Invalid path: path traversal not allowed".into()); + } + if segment.is_empty() { + return Err("Invalid path: empty segment not allowed".into()); + } + } + Ok(()) +} + +fn encode_repo_path(path: &str) -> String { + path.split('/') + .map(url_encode_path) + .collect::>() + .join("/") +} + +fn validate_git_ref(ref_name: &str, field_name: &str) -> Result<(), String> { + if ref_name.is_empty() { + return Err(format!("Invalid {field_name}: cannot be empty")); + } + if ref_name.contains("..") + || ref_name.contains(':') + || ref_name.contains('?') + || ref_name.contains('[') + || ref_name.contains('\\') + || ref_name.contains('^') + || ref_name.contains('~') + || ref_name.contains("@{") + || ref_name.contains("//") + || ref_name.starts_with('/') + || ref_name.ends_with('/') + || ref_name.starts_with('.') + || ref_name.ends_with('.') + || ref_name.ends_with(".lock") + || ref_name.chars().any(|c| c.is_control() || c == ' ') + { + return Err(format!( + "Invalid {field_name}: must be a valid branch, tag, or ref name" + )); + } + Ok(()) +} + +fn normalize_ref_lookup(ref_name: &str) -> Result { + validate_git_ref(ref_name, "from_ref")?; + if let Some(stripped) = ref_name.strip_prefix("refs/heads/") { + return Ok(format!("heads/{stripped}")); + } + if let Some(stripped) = ref_name.strip_prefix("refs/tags/") { + return Ok(format!("tags/{stripped}")); + } + if ref_name.starts_with("refs/") { + return Err( + "Unsupported from_ref: only refs/heads/* and refs/tags/* are supported".to_string(), + ); + } + if ref_name.starts_with("heads/") || ref_name.starts_with("tags/") { + return Ok(ref_name.to_string()); + } + Ok(format!("heads/{ref_name}")) +} + +fn normalize_branch_ref(branch: &str) -> Result { + validate_git_ref(branch, "branch")?; + if branch.starts_with("refs/heads/") { + return Ok(branch.to_string()); + } + if branch.starts_with("refs/") { + return Err("Invalid branch ref: only refs/heads/* is allowed".to_string()); + } + let branch = branch.strip_prefix("heads/").unwrap_or(branch); + if branch.starts_with("tags/") { + return Err("Invalid branch ref: tags/* is not a branch".to_string()); + } + Ok(format!("refs/heads/{branch}")) +} + +fn append_search_params( + path: &mut String, + page: Option, + sort: Option<&str>, + order: Option<&str>, +) -> Result<(), String> { + if let Some(p) = page { + path.push_str(&format!("&page={p}")); + } + if let Some(sort) = sort { + validate_input_length(sort, "sort")?; + path.push_str("&sort="); + path.push_str(&url_encode_query(sort)); + } + if let Some(order) = order { + if !matches!(order, "asc" | "desc") { + return Err("Invalid order: must be 'asc' or 'desc'".into()); + } + path.push_str("&order="); + path.push_str(order); + } + Ok(()) +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +struct GitCommitIdentity { + name: String, + email: String, +} + +fn validate_commit_identity(identity: &GitCommitIdentity, field_name: &str) -> Result<(), String> { + validate_input_length(&identity.name, &format!("{field_name}.name"))?; + validate_input_length(&identity.email, &format!("{field_name}.email"))?; + Ok(()) } struct GitHubTool; @@ -73,6 +197,16 @@ struct GitHubTool; enum GitHubAction { #[serde(rename = "get_repo")] GetRepo { owner: String, repo: String }, + #[serde(rename = "create_repo")] + CreateRepo { + name: String, + description: Option, + private: Option, + auto_init: Option, + gitignore_template: Option, + license_template: Option, + org: Option, + }, #[serde(rename = "list_issues")] ListIssues { owner: String, @@ -192,6 +326,45 @@ enum GitHubAction { page: Option, limit: Option, }, + #[serde(rename = "search_repositories")] + SearchRepositories { + query: String, + page: Option, + limit: Option, + sort: Option, + order: Option, + }, + #[serde(rename = "search_code")] + SearchCode { + query: String, + page: Option, + limit: Option, + sort: Option, + order: Option, + }, + #[serde(rename = "search_issues_pull_requests")] + SearchIssuesPullRequests { + query: String, + page: Option, + limit: Option, + sort: Option, + order: Option, + }, + #[serde(rename = "list_branches")] + ListBranches { + owner: String, + repo: String, + protected: Option, + page: Option, + limit: Option, + }, + #[serde(rename = "create_branch")] + CreateBranch { + owner: String, + repo: String, + branch: String, + from_ref: String, + }, #[serde(rename = "get_file_content")] GetFileContent { owner: String, @@ -199,6 +372,48 @@ enum GitHubAction { path: String, r#ref: Option, }, + #[serde(rename = "create_or_update_file")] + CreateOrUpdateFile { + owner: String, + repo: String, + path: String, + message: String, + content: String, + sha: Option, + branch: Option, + committer: Option, + author: Option, + }, + #[serde(rename = "delete_file")] + DeleteFile { + owner: String, + repo: String, + path: String, + message: String, + sha: String, + branch: Option, + committer: Option, + author: Option, + }, + #[serde(rename = "list_releases")] + ListReleases { + owner: String, + repo: String, + page: Option, + limit: Option, + }, + #[serde(rename = "create_release")] + CreateRelease { + owner: String, + repo: String, + tag_name: String, + target_commitish: Option, + name: Option, + body: Option, + draft: Option, + prerelease: Option, + generate_release_notes: Option, + }, #[serde(rename = "trigger_workflow")] TriggerWorkflow { owner: String, @@ -259,9 +474,8 @@ impl exports::near::agent::tool::Guest for GitHubTool { } fn description() -> String { - "GitHub integration for managing repositories, issues, pull requests, \ - and workflows. Supports reading repo info, listing/creating issues, \ - reviewing PRs, and triggering GitHub Actions. \ + "GitHub integration for repositories, issues, pull requests, search, \ + branches, file reads and writes, releases, and workflows. \ Authentication is handled via the 'github_token' secret injected by the host." .to_string() } @@ -277,6 +491,23 @@ fn execute_inner(params: &str) -> Result { match action { GitHubAction::GetRepo { owner, repo } => get_repo(&owner, &repo), + GitHubAction::CreateRepo { + name, + description, + private, + auto_init, + gitignore_template, + license_template, + org, + } => create_repo( + &name, + description.as_deref(), + private.unwrap_or(false), + auto_init.unwrap_or(false), + gitignore_template.as_deref(), + license_template.as_deref(), + org.as_deref(), + ), GitHubAction::ListIssues { owner, repo, @@ -393,12 +624,113 @@ fn execute_inner(params: &str) -> Result { page, limit, } => list_repos(&username, page, limit), + GitHubAction::SearchRepositories { + query, + page, + limit, + sort, + order, + } => search_repositories(&query, page, limit, sort.as_deref(), order.as_deref()), + GitHubAction::SearchCode { + query, + page, + limit, + sort, + order, + } => search_code(&query, page, limit, sort.as_deref(), order.as_deref()), + GitHubAction::SearchIssuesPullRequests { + query, + page, + limit, + sort, + order, + } => search_issues_pull_requests(&query, page, limit, sort.as_deref(), order.as_deref()), + GitHubAction::ListBranches { + owner, + repo, + protected, + page, + limit, + } => list_branches(&owner, &repo, protected, page, limit), + GitHubAction::CreateBranch { + owner, + repo, + branch, + from_ref, + } => create_branch(&owner, &repo, &branch, &from_ref), GitHubAction::GetFileContent { owner, repo, path, r#ref, } => get_file_content(&owner, &repo, &path, r#ref.as_deref()), + GitHubAction::CreateOrUpdateFile { + owner, + repo, + path, + message, + content, + sha, + branch, + committer, + author, + } => create_or_update_file( + &owner, + &repo, + &path, + &message, + &content, + sha.as_deref(), + branch.as_deref(), + committer, + author, + ), + GitHubAction::DeleteFile { + owner, + repo, + path, + message, + sha, + branch, + committer, + author, + } => delete_file( + &owner, + &repo, + &path, + &message, + &sha, + branch.as_deref(), + committer, + author, + ), + GitHubAction::ListReleases { + owner, + repo, + page, + limit, + } => list_releases(&owner, &repo, page, limit), + GitHubAction::CreateRelease { + owner, + repo, + tag_name, + target_commitish, + name, + body, + draft, + prerelease, + generate_release_notes, + } => create_release( + &owner, + &repo, + &tag_name, + target_commitish.as_deref(), + name.as_deref(), + body.as_deref(), + draft.unwrap_or(false), + prerelease.unwrap_or(false), + generate_release_notes.unwrap_or(false), + ), GitHubAction::TriggerWorkflow { owner, repo, @@ -435,7 +767,7 @@ fn github_request(method: &str, path: &str, body: Option) -> Result Result { ) } +fn create_repo( + name: &str, + description: Option<&str>, + private: bool, + auto_init: bool, + gitignore_template: Option<&str>, + license_template: Option<&str>, + org: Option<&str>, +) -> Result { + if !validate_path_segment(name) { + return Err("Invalid repository name".into()); + } + validate_input_length(name, "name")?; + if let Some(description) = description { + validate_input_length(description, "description")?; + } + if let Some(template) = gitignore_template { + validate_input_length(template, "gitignore_template")?; + } + if let Some(template) = license_template { + validate_input_length(template, "license_template")?; + } + if let Some(org) = org { + if !validate_path_segment(org) { + return Err("Invalid org name".into()); + } + } + + let path = if let Some(org) = org { + format!("/orgs/{}/repos", url_encode_path(org)) + } else { + "/user/repos".to_string() + }; + + let mut req_body = serde_json::json!({ + "name": name, + "private": private, + "auto_init": auto_init, + }); + if let Some(description) = description { + req_body["description"] = serde_json::json!(description); + } + if let Some(template) = gitignore_template { + req_body["gitignore_template"] = serde_json::json!(template); + } + if let Some(template) = license_template { + req_body["license_template"] = serde_json::json!(template); + } + + github_request("POST", &path, Some(req_body.to_string())) +} + fn list_issues( owner: &str, repo: &str, @@ -916,6 +1300,119 @@ fn list_repos(username: &str, page: Option, limit: Option) -> Result, + limit: Option, + sort: Option<&str>, + order: Option<&str>, +) -> Result { + validate_input_length(query, "query")?; + let limit = limit.unwrap_or(30).min(100); + let mut path = format!( + "/search/repositories?q={}&per_page={}", + url_encode_query(query), + limit + ); + append_search_params(&mut path, page, sort, order)?; + github_request("GET", &path, None) +} + +fn search_code( + query: &str, + page: Option, + limit: Option, + sort: Option<&str>, + order: Option<&str>, +) -> Result { + validate_input_length(query, "query")?; + let limit = limit.unwrap_or(30).min(100); + let mut path = format!( + "/search/code?q={}&per_page={}", + url_encode_query(query), + limit + ); + append_search_params(&mut path, page, sort, order)?; + github_request("GET", &path, None) +} + +fn search_issues_pull_requests( + query: &str, + page: Option, + limit: Option, + sort: Option<&str>, + order: Option<&str>, +) -> Result { + validate_input_length(query, "query")?; + let limit = limit.unwrap_or(30).min(100); + let mut path = format!( + "/search/issues?q={}&per_page={}", + url_encode_query(query), + limit + ); + append_search_params(&mut path, page, sort, order)?; + github_request("GET", &path, None) +} + +fn list_branches( + owner: &str, + repo: &str, + protected: Option, + page: Option, + limit: Option, +) -> Result { + if !validate_path_segment(owner) || !validate_path_segment(repo) { + return Err("Invalid owner or repo name".into()); + } + let encoded_owner = url_encode_path(owner); + let encoded_repo = url_encode_path(repo); + let limit = limit.unwrap_or(30).min(100); + let mut path = format!( + "/repos/{}/{}/branches?per_page={}", + encoded_owner, encoded_repo, limit + ); + if let Some(protected) = protected { + path.push_str("&protected="); + path.push_str(if protected { "true" } else { "false" }); + } + if let Some(page) = page { + path.push_str(&format!("&page={page}")); + } + github_request("GET", &path, None) +} + +fn create_branch(owner: &str, repo: &str, branch: &str, from_ref: &str) -> Result { + if !validate_path_segment(owner) || !validate_path_segment(repo) { + return Err("Invalid owner or repo name".into()); + } + validate_input_length(branch, "branch")?; + validate_input_length(from_ref, "from_ref")?; + + let encoded_owner = url_encode_path(owner); + let encoded_repo = url_encode_path(repo); + let source_ref = normalize_ref_lookup(from_ref)?; + let source_path = format!( + "/repos/{}/{}/git/ref/{}", + encoded_owner, + encoded_repo, + encode_repo_path(&source_ref) + ); + let source_ref_resp = github_request("GET", &source_path, None)?; + let source_ref_json: serde_json::Value = serde_json::from_str(&source_ref_resp) + .map_err(|e| format!("Invalid GitHub response for source ref: {e}"))?; + let sha = source_ref_json + .pointer("/object/sha") + .and_then(|v| v.as_str()) + .ok_or_else(|| "Source ref response missing object.sha".to_string())?; + + let req_body = serde_json::json!({ + "ref": normalize_branch_ref(branch)?, + "sha": sha, + }); + let path = format!("/repos/{}/{}/git/refs", encoded_owner, encoded_repo); + github_request("POST", &path, Some(req_body.to_string())) +} + fn get_file_content( owner: &str, repo: &str, @@ -925,29 +1422,14 @@ fn get_file_content( if !validate_path_segment(owner) || !validate_path_segment(repo) { return Err("Invalid owner or repo name".into()); } - // Validate path segments - reject path traversal attempts and empty segments - for segment in path.split('/') { - if segment == ".." { - return Err("Invalid path: path traversal not allowed".into()); - } - if segment.is_empty() { - return Err("Invalid path: empty segment not allowed".into()); - } - } + validate_repo_path(path)?; // Validate ref if provided if let Some(r#ref) = r#ref { - if r#ref.contains("..") || r#ref.contains(':') { - return Err("Invalid ref: must be a valid branch, tag, or commit SHA".into()); - } + validate_git_ref(r#ref, "ref")?; } let encoded_owner = url_encode_path(owner); let encoded_repo = url_encode_path(repo); - // Path can contain slashes, so we encode each segment separately - let encoded_path = path - .split('/') - .map(url_encode_path) - .collect::>() - .join("/"); + let encoded_path = encode_repo_path(path); let url_path = if let Some(r#ref) = r#ref { let encoded_ref = url_encode_query(r#ref); @@ -964,6 +1446,186 @@ fn get_file_content( github_request("GET", &url_path, None) } +fn create_or_update_file( + owner: &str, + repo: &str, + path: &str, + message: &str, + content: &str, + sha: Option<&str>, + branch: Option<&str>, + committer: Option, + author: Option, +) -> Result { + if !validate_path_segment(owner) || !validate_path_segment(repo) { + return Err("Invalid owner or repo name".into()); + } + validate_repo_path(path)?; + validate_input_length(message, "message")?; + validate_input_length(content, "content")?; + if let Some(branch) = branch { + validate_git_ref(branch, "branch")?; + } + if let Some(sha) = sha { + validate_input_length(sha, "sha")?; + } + if let Some(committer) = &committer { + validate_commit_identity(committer, "committer")?; + } + if let Some(author) = &author { + validate_commit_identity(author, "author")?; + } + + let encoded_owner = url_encode_path(owner); + let encoded_repo = url_encode_path(repo); + let encoded_path = encode_repo_path(path); + let mut req_body = serde_json::json!({ + "message": message, + "content": BASE64_STANDARD.encode(content.as_bytes()), + }); + if let Some(sha) = sha { + req_body["sha"] = serde_json::json!(sha); + } + if let Some(branch) = branch { + req_body["branch"] = serde_json::json!(branch); + } + if let Some(committer) = committer { + req_body["committer"] = + serde_json::to_value(committer).map_err(|e| format!("Invalid committer: {e}"))?; + } + if let Some(author) = author { + req_body["author"] = + serde_json::to_value(author).map_err(|e| format!("Invalid author: {e}"))?; + } + + let path = format!( + "/repos/{}/{}/contents/{}", + encoded_owner, encoded_repo, encoded_path + ); + github_request("PUT", &path, Some(req_body.to_string())) +} + +fn delete_file( + owner: &str, + repo: &str, + path: &str, + message: &str, + sha: &str, + branch: Option<&str>, + committer: Option, + author: Option, +) -> Result { + if !validate_path_segment(owner) || !validate_path_segment(repo) { + return Err("Invalid owner or repo name".into()); + } + validate_repo_path(path)?; + validate_input_length(message, "message")?; + validate_input_length(sha, "sha")?; + if let Some(branch) = branch { + validate_git_ref(branch, "branch")?; + } + if let Some(committer) = &committer { + validate_commit_identity(committer, "committer")?; + } + if let Some(author) = &author { + validate_commit_identity(author, "author")?; + } + + let encoded_owner = url_encode_path(owner); + let encoded_repo = url_encode_path(repo); + let encoded_path = encode_repo_path(path); + let mut req_body = serde_json::json!({ + "message": message, + "sha": sha, + }); + if let Some(branch) = branch { + req_body["branch"] = serde_json::json!(branch); + } + if let Some(committer) = committer { + req_body["committer"] = + serde_json::to_value(committer).map_err(|e| format!("Invalid committer: {e}"))?; + } + if let Some(author) = author { + req_body["author"] = + serde_json::to_value(author).map_err(|e| format!("Invalid author: {e}"))?; + } + + let path = format!( + "/repos/{}/{}/contents/{}", + encoded_owner, encoded_repo, encoded_path + ); + github_request("DELETE", &path, Some(req_body.to_string())) +} + +fn list_releases( + owner: &str, + repo: &str, + page: Option, + limit: Option, +) -> Result { + if !validate_path_segment(owner) || !validate_path_segment(repo) { + return Err("Invalid owner or repo name".into()); + } + let encoded_owner = url_encode_path(owner); + let encoded_repo = url_encode_path(repo); + let limit = limit.unwrap_or(30).min(100); + let mut path = format!( + "/repos/{}/{}/releases?per_page={}", + encoded_owner, encoded_repo, limit + ); + if let Some(page) = page { + path.push_str(&format!("&page={page}")); + } + github_request("GET", &path, None) +} + +fn create_release( + owner: &str, + repo: &str, + tag_name: &str, + target_commitish: Option<&str>, + name: Option<&str>, + body: Option<&str>, + draft: bool, + prerelease: bool, + generate_release_notes: bool, +) -> Result { + if !validate_path_segment(owner) || !validate_path_segment(repo) { + return Err("Invalid owner or repo name".into()); + } + validate_git_ref(tag_name, "tag_name")?; + if let Some(target_commitish) = target_commitish { + validate_git_ref(target_commitish, "target_commitish")?; + } + if let Some(name) = name { + validate_input_length(name, "name")?; + } + if let Some(body) = body { + validate_input_length(body, "body")?; + } + + let encoded_owner = url_encode_path(owner); + let encoded_repo = url_encode_path(repo); + let path = format!("/repos/{}/{}/releases", encoded_owner, encoded_repo); + let mut req_body = serde_json::json!({ + "tag_name": tag_name, + "draft": draft, + "prerelease": prerelease, + "generate_release_notes": generate_release_notes, + }); + if let Some(target_commitish) = target_commitish { + req_body["target_commitish"] = serde_json::json!(target_commitish); + } + if let Some(name) = name { + req_body["name"] = serde_json::json!(name); + } + if let Some(body) = body { + req_body["body"] = serde_json::json!(body); + } + + github_request("POST", &path, Some(req_body.to_string())) +} + fn trigger_workflow( owner: &str, repo: &str, @@ -1286,6 +1948,19 @@ const SCHEMA: &str = r#"{ }, "required": ["action", "owner", "repo"] }, + { + "properties": { + "action": { "const": "create_repo" }, + "name": { "type": "string", "description": "New repository name" }, + "description": { "type": "string" }, + "private": { "type": "boolean", "default": false }, + "auto_init": { "type": "boolean", "default": false }, + "gitignore_template": { "type": "string" }, + "license_template": { "type": "string" }, + "org": { "type": "string", "description": "Optional organization name; omit to create under the authenticated user" } + }, + "required": ["action", "name"] + }, { "properties": { "action": { "const": "list_issues" }, @@ -1446,10 +2121,65 @@ const SCHEMA: &str = r#"{ "properties": { "action": { "const": "list_repos" }, "username": { "type": "string" }, + "page": { "type": "integer" }, "limit": { "type": "integer", "default": 30 } }, "required": ["action", "username"] }, + { + "properties": { + "action": { "const": "search_repositories" }, + "query": { "type": "string", "description": "GitHub repository search query" }, + "page": { "type": "integer" }, + "limit": { "type": "integer", "default": 30 }, + "sort": { "type": "string" }, + "order": { "type": "string", "enum": ["asc", "desc"] } + }, + "required": ["action", "query"] + }, + { + "properties": { + "action": { "const": "search_code" }, + "query": { "type": "string", "description": "GitHub code search query" }, + "page": { "type": "integer" }, + "limit": { "type": "integer", "default": 30 }, + "sort": { "type": "string" }, + "order": { "type": "string", "enum": ["asc", "desc"] } + }, + "required": ["action", "query"] + }, + { + "properties": { + "action": { "const": "search_issues_pull_requests" }, + "query": { "type": "string", "description": "GitHub issue/PR search query" }, + "page": { "type": "integer" }, + "limit": { "type": "integer", "default": 30 }, + "sort": { "type": "string" }, + "order": { "type": "string", "enum": ["asc", "desc"] } + }, + "required": ["action", "query"] + }, + { + "properties": { + "action": { "const": "list_branches" }, + "owner": { "type": "string" }, + "repo": { "type": "string" }, + "protected": { "type": "boolean" }, + "page": { "type": "integer" }, + "limit": { "type": "integer", "default": 30 } + }, + "required": ["action", "owner", "repo"] + }, + { + "properties": { + "action": { "const": "create_branch" }, + "owner": { "type": "string" }, + "repo": { "type": "string" }, + "branch": { "type": "string", "description": "New branch name" }, + "from_ref": { "type": "string", "description": "Source branch or tag to branch from" } + }, + "required": ["action", "owner", "repo", "branch", "from_ref"] + }, { "properties": { "action": { "const": "get_file_content" }, @@ -1460,6 +2190,88 @@ const SCHEMA: &str = r#"{ }, "required": ["action", "owner", "repo", "path"] }, + { + "properties": { + "action": { "const": "create_or_update_file" }, + "owner": { "type": "string" }, + "repo": { "type": "string" }, + "path": { "type": "string", "description": "File path in repo" }, + "message": { "type": "string", "description": "Commit message" }, + "content": { "type": "string", "description": "Raw UTF-8 file content; the tool base64-encodes it for GitHub" }, + "sha": { "type": "string", "description": "Required when updating an existing file" }, + "branch": { "type": "string" }, + "committer": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "email": { "type": "string" } + }, + "required": ["name", "email"] + }, + "author": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "email": { "type": "string" } + }, + "required": ["name", "email"] + } + }, + "required": ["action", "owner", "repo", "path", "message", "content"] + }, + { + "properties": { + "action": { "const": "delete_file" }, + "owner": { "type": "string" }, + "repo": { "type": "string" }, + "path": { "type": "string", "description": "File path in repo" }, + "message": { "type": "string", "description": "Commit message" }, + "sha": { "type": "string", "description": "Blob SHA of the file to delete" }, + "branch": { "type": "string" }, + "committer": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "email": { "type": "string" } + }, + "required": ["name", "email"] + }, + "author": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "email": { "type": "string" } + }, + "required": ["name", "email"] + } + }, + "required": ["action", "owner", "repo", "path", "message", "sha"] + }, + { + "properties": { + "action": { "const": "list_releases" }, + "owner": { "type": "string" }, + "repo": { "type": "string" }, + "page": { "type": "integer" }, + "limit": { "type": "integer", "default": 30 } + }, + "required": ["action", "owner", "repo"] + }, + { + "properties": { + "action": { "const": "create_release" }, + "owner": { "type": "string" }, + "repo": { "type": "string" }, + "tag_name": { "type": "string" }, + "target_commitish": { "type": "string" }, + "name": { "type": "string" }, + "body": { "type": "string" }, + "draft": { "type": "boolean", "default": false }, + "prerelease": { "type": "boolean", "default": false }, + "generate_release_notes": { "type": "boolean", "default": false } + }, + "required": ["action", "owner", "repo", "tag_name"] + }, { "properties": { "action": { "const": "trigger_workflow" }, @@ -1480,6 +2292,26 @@ const SCHEMA: &str = r#"{ "limit": { "type": "integer", "default": 30 } }, "required": ["action", "owner", "repo"] + }, + { + "properties": { + "action": { "const": "handle_webhook" }, + "webhook": { + "type": "object", + "properties": { + "headers": { + "type": "object", + "additionalProperties": { "type": "string" } + }, + "body_json": { + "type": "object", + "description": "Parsed GitHub webhook JSON payload" + } + }, + "required": ["headers", "body_json"] + } + }, + "required": ["action", "webhook"] } ] }"#; @@ -1490,6 +2322,61 @@ export!(GitHubTool); mod tests { use super::*; + fn schema_actions() -> std::collections::HashSet { + let schema: serde_json::Value = + serde_json::from_str(SCHEMA).expect("schema should be valid JSON"); + schema["oneOf"] + .as_array() + .expect("schema.oneOf should be an array") + .iter() + .filter_map(|variant| { + variant + .pointer("/properties/action/const") + .and_then(|v| v.as_str()) + .map(str::to_string) + }) + .collect() + } + + fn supported_actions() -> std::collections::HashSet { + [ + "get_repo", + "create_repo", + "list_issues", + "create_issue", + "get_issue", + "list_issue_comments", + "create_issue_comment", + "list_pull_requests", + "create_pull_request", + "get_pull_request", + "get_pull_request_files", + "create_pr_review", + "list_pull_request_comments", + "reply_pull_request_comment", + "get_pull_request_reviews", + "get_combined_status", + "merge_pull_request", + "list_repos", + "search_repositories", + "search_code", + "search_issues_pull_requests", + "list_branches", + "create_branch", + "get_file_content", + "create_or_update_file", + "delete_file", + "list_releases", + "create_release", + "trigger_workflow", + "get_workflow_runs", + "handle_webhook", + ] + .into_iter() + .map(str::to_string) + .collect() + } + #[test] fn test_url_encode_path() { assert_eq!(url_encode_path("foo-bar_123.baz"), "foo-bar_123.baz"); @@ -1500,10 +2387,12 @@ mod tests { #[test] fn test_validate_path_segment() { assert!(validate_path_segment("foo")); + assert!(validate_path_segment("foo-bar_123.baz")); assert!(!validate_path_segment("")); assert!(!validate_path_segment("foo/bar")); assert!(!validate_path_segment("..")); - // Empty segments are handled in get_file_content logic, not here + assert!(!validate_path_segment("foo bar")); + assert!(!validate_path_segment("foo\nbar")); } #[test] @@ -1655,4 +2544,150 @@ mod tests { Some(42) ); } + + #[test] + fn test_validate_git_ref_rejects_bad_names() { + assert!(validate_git_ref("feature/test", "branch").is_ok()); + assert!(validate_git_ref("release/v1.2.3", "branch").is_ok()); + assert!(validate_git_ref("bad ref", "branch").is_err()); + assert!(validate_git_ref("../main", "branch").is_err()); + assert!(validate_git_ref("refs/heads/main.lock", "branch").is_err()); + } + + #[test] + fn test_normalize_ref_lookup_and_branch_ref() { + assert_eq!( + normalize_ref_lookup("main").expect("main should normalize"), + "heads/main" + ); + assert_eq!( + normalize_ref_lookup("refs/tags/v1.0.0").expect("tag ref should normalize"), + "tags/v1.0.0" + ); + assert_eq!( + normalize_branch_ref("feature/github-tool-audit").expect("branch ref should normalize"), + "refs/heads/feature/github-tool-audit" + ); + assert_eq!( + normalize_branch_ref("refs/heads/main").expect("qualified branch ref should pass"), + "refs/heads/main" + ); + assert!(normalize_ref_lookup("refs/pull/123/head").is_err()); + assert!(normalize_branch_ref("refs/tags/v1.0.0").is_err()); + assert!(normalize_branch_ref("tags/v1.0.0").is_err()); + } + + #[test] + fn test_validate_repo_path_enforces_length_limit() { + let long_path = format!("dir/{}", "a".repeat(MAX_TEXT_LENGTH)); + assert!(validate_repo_path("docs/readme.md").is_ok()); + assert!(validate_repo_path(&long_path).is_err()); + } + + #[test] + fn test_validate_commit_identity_enforces_length_limit() { + let identity = GitCommitIdentity { + name: "IronClaw Bot".to_string(), + email: "bot@example.com".to_string(), + }; + assert!(validate_commit_identity(&identity, "committer").is_ok()); + + let too_long = GitCommitIdentity { + name: "a".repeat(MAX_TEXT_LENGTH + 1), + email: "bot@example.com".to_string(), + }; + assert!(validate_commit_identity(&too_long, "committer").is_err()); + } + + #[test] + fn test_schema_includes_new_core_actions() { + let actions = schema_actions(); + for action in [ + "create_repo", + "search_repositories", + "search_code", + "search_issues_pull_requests", + "list_branches", + "create_branch", + "create_or_update_file", + "delete_file", + "list_releases", + "create_release", + ] { + assert!( + actions.contains(action), + "schema should include action {action}" + ); + } + } + + #[test] + fn test_schema_matches_supported_action_set() { + assert_eq!(schema_actions(), supported_actions()); + } + + #[test] + fn test_readme_examples_only_reference_supported_actions() { + let actions = schema_actions(); + let readme = include_str!("../README.md"); + let mut referenced = Vec::new(); + + for line in readme.lines() { + let Some((_, rhs)) = line.split_once("\"action\":") else { + continue; + }; + let rhs = rhs.trim(); + let Some(rest) = rhs.strip_prefix('"') else { + continue; + }; + let Some(action) = rest.split('"').next() else { + continue; + }; + referenced.push(action.to_string()); + } + + assert!( + !referenced.is_empty(), + "README should contain action examples" + ); + for action in referenced { + assert!( + actions.contains(&action), + "README references unsupported action {action}" + ); + } + } + + #[test] + fn test_registry_description_claims_are_supported() { + let actions = schema_actions(); + let manifest: serde_json::Value = + serde_json::from_str(include_str!("../../../registry/tools/github.json")) + .expect("registry manifest should parse"); + let description = manifest["description"] + .as_str() + .expect("registry manifest should have a description") + .to_ascii_lowercase(); + + if description.contains("search") { + assert!( + actions.contains("search_repositories") + && actions.contains("search_code") + && actions.contains("search_issues_pull_requests"), + "registry search claim should map to implemented search actions" + ); + } + if description.contains("releases") { + assert!( + actions.contains("list_releases") && actions.contains("create_release"), + "registry releases claim should map to implemented release actions" + ); + } + if description.contains("file writes") { + assert!( + actions.contains("create_or_update_file") && actions.contains("delete_file"), + "registry file write claim should map to implemented content actions" + ); + } + } }