@@ -27,4 +27,5 @@ hex = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
metrics = { workspace = true }
|
||||
metrics-exporter-prometheus = { workspace = true }
|
||||
devcontainer-rs = { path = "../devcontainer-rs" }
|
||||
devcontainer-rs = { path = "../devcontainer-rs" }
|
||||
tempfile = "3"
|
||||
@@ -2,6 +2,7 @@ use crate::{
|
||||
gitea::{GiteaAPI, WebhookType},
|
||||
metrics,
|
||||
open_router::OpenRouterClient,
|
||||
sandbox::SandboxConfig,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use std::{collections::HashSet, sync::Arc};
|
||||
@@ -31,6 +32,7 @@ pub struct Bot {
|
||||
http_client: reqwest::Client,
|
||||
max_concurrent: usize,
|
||||
open_router_model: String,
|
||||
sandbox: SandboxConfig,
|
||||
actions_handled: Arc<Mutex<HashSet<u64>>>,
|
||||
}
|
||||
|
||||
@@ -42,6 +44,7 @@ impl Bot {
|
||||
http_client: reqwest::Client,
|
||||
max_concurrent: usize,
|
||||
open_router_model: String,
|
||||
sandbox: SandboxConfig,
|
||||
) -> Self {
|
||||
Self {
|
||||
bot_name,
|
||||
@@ -50,6 +53,7 @@ impl Bot {
|
||||
http_client,
|
||||
max_concurrent,
|
||||
open_router_model,
|
||||
sandbox,
|
||||
actions_handled: Arc::new(Mutex::new(HashSet::new())),
|
||||
}
|
||||
}
|
||||
@@ -113,12 +117,16 @@ impl Bot {
|
||||
}
|
||||
};
|
||||
|
||||
let tools = crate::sandbox::tools::for_webhook(&webhook);
|
||||
|
||||
let exec_result = match webhook {
|
||||
WebhookType::Review(review_payload) => crate::bot_actions::review::exec_review(
|
||||
&self.gitea_api,
|
||||
&self.open_router_client,
|
||||
&self.http_client,
|
||||
&self.open_router_model,
|
||||
&self.sandbox,
|
||||
tools,
|
||||
review_payload,
|
||||
),
|
||||
}
|
||||
|
||||
@@ -1,22 +1,33 @@
|
||||
use futures_util::stream::TryStreamExt;
|
||||
use openrouter_rs::types::Tool;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio_util::io::StreamReader;
|
||||
use tracing::instrument;
|
||||
use tracing::{info, instrument, warn};
|
||||
|
||||
use crate::{
|
||||
bot::ReviewResult,
|
||||
consts::{BOT_PROCESS_MSG, MAX_DIFF_SIZE, REVIEW_PROMPT},
|
||||
consts::{BOT_PROCESS_MSG, MAX_DIFF_SIZE, REVIEW_PROMPT, SANDBOX_SYSTEM_PROMPT},
|
||||
gitea::{GiteaAPI, ReviewPayload},
|
||||
metrics,
|
||||
open_router::OpenRouterClient,
|
||||
sandbox::{Sandbox, SandboxConfig, agent},
|
||||
};
|
||||
|
||||
#[instrument(skip(gitea_api, open_router_client, http_client, review_payload))]
|
||||
#[instrument(skip(
|
||||
gitea_api,
|
||||
open_router_client,
|
||||
http_client,
|
||||
sandbox_config,
|
||||
tools,
|
||||
review_payload
|
||||
))]
|
||||
pub async fn exec_review(
|
||||
gitea_api: &GiteaAPI,
|
||||
open_router_client: &OpenRouterClient,
|
||||
http_client: &reqwest::Client,
|
||||
model: &str,
|
||||
sandbox_config: &SandboxConfig,
|
||||
tools: Vec<Tool>,
|
||||
review_payload: ReviewPayload,
|
||||
) -> anyhow::Result<()> {
|
||||
tracing::info!(
|
||||
@@ -45,10 +56,24 @@ pub async fn exec_review(
|
||||
.replace("{comment}", &review_payload.comment.body)
|
||||
.replace("{diff}", &diff_for_llm);
|
||||
|
||||
let chat_result = open_router_client.chat(&bot_request).await?;
|
||||
let mut review_result = serde_json::from_str::<ReviewResult>(&chat_result.message)?;
|
||||
let (message, cost) = if sandbox_config.enabled {
|
||||
run_sandboxed_review(
|
||||
gitea_api,
|
||||
open_router_client,
|
||||
sandbox_config,
|
||||
tools,
|
||||
&review_payload,
|
||||
&bot_request,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
let chat_result = open_router_client.chat(&bot_request).await?;
|
||||
(chat_result.message, chat_result.cost)
|
||||
};
|
||||
|
||||
review_result.cost = chat_result.cost;
|
||||
let mut review_result = serde_json::from_str::<ReviewResult>(&message)?;
|
||||
|
||||
review_result.cost = cost;
|
||||
if let Some(cost) = review_result.cost {
|
||||
metrics::openrouter_cost_usd(cost);
|
||||
}
|
||||
@@ -86,6 +111,50 @@ pub async fn exec_review(
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the review inside a sandbox container, letting the model explore the
|
||||
/// repository with tools before answering.
|
||||
async fn run_sandboxed_review(
|
||||
gitea_api: &GiteaAPI,
|
||||
open_router_client: &OpenRouterClient,
|
||||
sandbox_config: &SandboxConfig,
|
||||
tools: Vec<Tool>,
|
||||
review_payload: &ReviewPayload,
|
||||
bot_request: &str,
|
||||
) -> anyhow::Result<(String, Option<f64>)> {
|
||||
let repo_url = gitea_api.repo_clone_url(&review_payload.repository.full_name);
|
||||
|
||||
let sandbox = Sandbox::create(
|
||||
&sandbox_config.runtime,
|
||||
&repo_url,
|
||||
gitea_api.token(),
|
||||
review_payload.pull_request.number,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let result = agent::run(
|
||||
open_router_client,
|
||||
&sandbox,
|
||||
tools,
|
||||
SANDBOX_SYSTEM_PROMPT,
|
||||
bot_request,
|
||||
sandbox_config.max_iterations,
|
||||
)
|
||||
.await;
|
||||
|
||||
if let Err(err) = sandbox.cleanup().await {
|
||||
warn!(%err, "Failed to clean up sandbox container");
|
||||
}
|
||||
|
||||
let result = result?;
|
||||
info!(
|
||||
iterations = result.iterations,
|
||||
cost = ?result.cost,
|
||||
"Sandboxed review finished"
|
||||
);
|
||||
|
||||
Ok((result.message, result.cost))
|
||||
}
|
||||
|
||||
fn review_result_to_markdown(review_result: &ReviewResult) -> String {
|
||||
if review_result.reviews.is_empty() {
|
||||
return String::from("No issues found. ✅");
|
||||
|
||||
@@ -8,6 +8,16 @@ pub const BOT_PROCESS_MSG: &str = "
|
||||
Review in progress with the model \"{model}\"...
|
||||
";
|
||||
|
||||
pub const SANDBOX_SYSTEM_PROMPT: &str = "
|
||||
You are a senior software engineer reviewing a pull request.
|
||||
|
||||
The repository is checked out in your working directory. Use the provided
|
||||
tools (ls, read_file, grep, find) to explore the code and gather the context
|
||||
you need before answering. Paths are relative to the repository root.
|
||||
|
||||
When you have enough information, answer with the requested JSON only.
|
||||
";
|
||||
|
||||
pub const REVIEW_PROMPT: &str = "
|
||||
You are a senior software engineer reviewing code changes.
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@ pub struct EnvConfig {
|
||||
pub gitea_token: String,
|
||||
pub gitea_timeout: u64,
|
||||
pub metrics_bind_addr: Option<String>,
|
||||
pub container_runtime: String,
|
||||
pub sandbox_enabled: bool,
|
||||
pub sandbox_max_iterations: usize,
|
||||
}
|
||||
|
||||
pub fn load_config() -> anyhow::Result<EnvConfig> {
|
||||
@@ -25,6 +28,15 @@ pub fn load_config() -> anyhow::Result<EnvConfig> {
|
||||
let gitea_token = try_get_env("GITEA_TOKEN")?;
|
||||
let gitea_timeout = try_get_env("GITEA_TIMEOUT")?.parse()?;
|
||||
let metrics_bind_addr = std::env::var("METRICS_BIND_ADDR").ok();
|
||||
let container_runtime =
|
||||
std::env::var("CONTAINER_RUNTIME").unwrap_or_else(|_| "docker".to_string());
|
||||
let sandbox_enabled = std::env::var("SANDBOX_ENABLED")
|
||||
.map(|value| matches!(value.as_str(), "1" | "true" | "yes"))
|
||||
.unwrap_or(false);
|
||||
let sandbox_max_iterations = std::env::var("SANDBOX_MAX_ITERATIONS")
|
||||
.ok()
|
||||
.and_then(|value| value.parse().ok())
|
||||
.unwrap_or(8);
|
||||
|
||||
Ok(EnvConfig {
|
||||
http_port,
|
||||
@@ -37,6 +49,9 @@ pub fn load_config() -> anyhow::Result<EnvConfig> {
|
||||
gitea_token,
|
||||
gitea_timeout,
|
||||
metrics_bind_addr,
|
||||
container_runtime,
|
||||
sandbox_enabled,
|
||||
sandbox_max_iterations,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::{bot::ReviewResult, errors::AppError};
|
||||
#[derive(Clone)]
|
||||
pub struct GiteaAPI {
|
||||
base_url: String,
|
||||
token: String,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
@@ -22,6 +23,7 @@ impl GiteaAPI {
|
||||
|
||||
Ok(Self {
|
||||
base_url: String::from(base_url),
|
||||
token: String::from(token),
|
||||
client: reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(timeout))
|
||||
.default_headers(default_headers)
|
||||
@@ -29,6 +31,20 @@ impl GiteaAPI {
|
||||
})
|
||||
}
|
||||
|
||||
/// API token used to authenticate against Gitea.
|
||||
pub fn token(&self) -> &str {
|
||||
&self.token
|
||||
}
|
||||
|
||||
/// HTTPS clone URL for a repository, suitable for `git clone`.
|
||||
pub fn repo_clone_url(&self, full_name: &str) -> String {
|
||||
format!(
|
||||
"{}/{}.git",
|
||||
self.base_url.trim_end_matches('/'),
|
||||
full_name.trim_start_matches('/')
|
||||
)
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
pub async fn get_authorized_user(&self) -> anyhow::Result<User> {
|
||||
let url = format!("{}/api/v1/user", self.base_url);
|
||||
|
||||
@@ -2,6 +2,7 @@ use crate::{
|
||||
bot::Bot,
|
||||
gitea::{GiteaAPI, WebhookType},
|
||||
open_router::OpenRouterClient,
|
||||
sandbox::SandboxConfig,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
@@ -20,6 +21,7 @@ mod errors;
|
||||
mod gitea;
|
||||
mod metrics;
|
||||
mod open_router;
|
||||
mod sandbox;
|
||||
mod state;
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
@@ -76,6 +78,19 @@ async fn run() -> anyhow::Result<()> {
|
||||
|
||||
let shutdown = CancellationToken::new();
|
||||
|
||||
let sandbox = SandboxConfig {
|
||||
enabled: config.sandbox_enabled,
|
||||
runtime: devcontainer_rs::ContainerRuntime::new(config.container_runtime.clone()),
|
||||
max_iterations: config.sandbox_max_iterations,
|
||||
};
|
||||
|
||||
if sandbox.enabled && !sandbox.runtime.available().await {
|
||||
warn!(
|
||||
runtime = sandbox.runtime.program(),
|
||||
"Sandbox is enabled but the container runtime is not available"
|
||||
);
|
||||
}
|
||||
|
||||
let bot = Bot::new(
|
||||
gitea_user.login,
|
||||
gitea_api,
|
||||
@@ -83,6 +98,7 @@ async fn run() -> anyhow::Result<()> {
|
||||
reqwest::Client::new(),
|
||||
config.bot_max_concurrent,
|
||||
config.open_router_model.clone(),
|
||||
sandbox,
|
||||
);
|
||||
|
||||
let (tx, rx) = tokio::sync::mpsc::channel::<WebhookType>(config.bot_max_concurrent * 2);
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use openrouter_rs::{Message, api::chat::ChatCompletionRequest};
|
||||
use openrouter_rs::{
|
||||
Message,
|
||||
api::chat::ChatCompletionRequest,
|
||||
types::{Tool, ToolCall},
|
||||
};
|
||||
use tracing::instrument;
|
||||
|
||||
pub struct ChatResult {
|
||||
@@ -8,6 +12,12 @@ pub struct ChatResult {
|
||||
pub cost: Option<f64>,
|
||||
}
|
||||
|
||||
pub struct ToolChatResult {
|
||||
pub message: Option<String>,
|
||||
pub tool_calls: Vec<ToolCall>,
|
||||
pub cost: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct OpenRouterClient {
|
||||
client: openrouter_rs::OpenRouterClient,
|
||||
@@ -47,4 +57,36 @@ impl OpenRouterClient {
|
||||
cost: response.usage.and_then(|u| u.cost),
|
||||
})
|
||||
}
|
||||
|
||||
/// Sends a conversation with tool definitions and returns either a final
|
||||
/// message or the tool calls requested by the model.
|
||||
#[instrument(skip(self, messages, tools), err)]
|
||||
pub async fn chat_with_tools(
|
||||
&self,
|
||||
messages: Vec<Message>,
|
||||
tools: Vec<Tool>,
|
||||
) -> anyhow::Result<ToolChatResult> {
|
||||
let request = ChatCompletionRequest::builder()
|
||||
.model(&self.model)
|
||||
.enable_reasoning()
|
||||
.messages(messages)
|
||||
.tools(tools)
|
||||
.tool_choice_auto()
|
||||
.build()?;
|
||||
|
||||
let response = self.client.chat().create(&request).await?;
|
||||
let choice = response
|
||||
.choices
|
||||
.first()
|
||||
.ok_or_else(|| anyhow::anyhow!("No choices in response"))?;
|
||||
|
||||
Ok(ToolChatResult {
|
||||
message: choice.content().map(String::from),
|
||||
tool_calls: choice
|
||||
.tool_calls()
|
||||
.map(<[ToolCall]>::to_vec)
|
||||
.unwrap_or_default(),
|
||||
cost: response.usage.and_then(|u| u.cost),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
//! Tool-calling loop driving the model against a [`Sandbox`].
|
||||
//!
|
||||
//! The loop sends the conversation and the available tools to OpenRouter,
|
||||
//! executes any requested tool call inside the sandbox and feeds the results
|
||||
//! back, until the model produces a final message or the iteration budget is
|
||||
//! exhausted.
|
||||
|
||||
use anyhow::Context;
|
||||
use openrouter_rs::{
|
||||
Message,
|
||||
types::{Role, Tool, ToolCall},
|
||||
};
|
||||
use serde_json::Value;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::{
|
||||
open_router::OpenRouterClient,
|
||||
sandbox::{Sandbox, tools},
|
||||
};
|
||||
|
||||
/// Final output of an agent run.
|
||||
pub struct AgentResult {
|
||||
pub message: String,
|
||||
pub cost: Option<f64>,
|
||||
pub iterations: usize,
|
||||
}
|
||||
|
||||
/// Runs the tool-calling loop until the model answers or `max_iterations` is
|
||||
/// reached.
|
||||
///
|
||||
/// `tool_definitions` is the set of tools the model may call; it is selected by
|
||||
/// the caller based on the webhook action (see [`tools::for_webhook`]).
|
||||
pub async fn run(
|
||||
open_router: &OpenRouterClient,
|
||||
sandbox: &Sandbox,
|
||||
tool_definitions: Vec<Tool>,
|
||||
system_prompt: &str,
|
||||
user_prompt: &str,
|
||||
max_iterations: usize,
|
||||
) -> anyhow::Result<AgentResult> {
|
||||
let mut messages = vec![
|
||||
Message::new(Role::System, system_prompt),
|
||||
Message::new(Role::User, user_prompt),
|
||||
];
|
||||
|
||||
let mut total_cost = 0.0_f64;
|
||||
let mut has_cost = false;
|
||||
|
||||
for iteration in 1..=max_iterations {
|
||||
let response = open_router
|
||||
.chat_with_tools(messages.clone(), tool_definitions.clone())
|
||||
.await?;
|
||||
|
||||
if let Some(cost) = response.cost {
|
||||
total_cost += cost;
|
||||
has_cost = true;
|
||||
}
|
||||
|
||||
if response.tool_calls.is_empty() {
|
||||
return Ok(AgentResult {
|
||||
message: response.message.unwrap_or_default(),
|
||||
cost: has_cost.then_some(total_cost),
|
||||
iterations: iteration,
|
||||
});
|
||||
}
|
||||
|
||||
messages.push(Message::assistant_with_tool_calls(
|
||||
response.message.unwrap_or_default(),
|
||||
response.tool_calls.clone(),
|
||||
));
|
||||
|
||||
for call in &response.tool_calls {
|
||||
debug!(tool = call.name(), "Executing tool call");
|
||||
let content = execute(sandbox, call).await;
|
||||
messages.push(Message::tool_response(call.id(), content));
|
||||
}
|
||||
}
|
||||
|
||||
warn!(max_iterations, "Agent reached the iteration limit");
|
||||
anyhow::bail!("agent exceeded the maximum number of iterations ({max_iterations})")
|
||||
}
|
||||
|
||||
async fn execute(sandbox: &Sandbox, call: &ToolCall) -> String {
|
||||
let args = match parse_args(call) {
|
||||
Ok(args) => args,
|
||||
Err(err) => return format!("error: {err}"),
|
||||
};
|
||||
|
||||
match tools::dispatch(sandbox, call.name(), &args).await {
|
||||
Ok(output) => output,
|
||||
Err(err) => format!("error: {err}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_args(call: &ToolCall) -> anyhow::Result<Value> {
|
||||
let raw = call.arguments_json().trim();
|
||||
if raw.is_empty() {
|
||||
return Ok(Value::Object(serde_json::Map::new()));
|
||||
}
|
||||
|
||||
serde_json::from_str(raw)
|
||||
.with_context(|| format!("invalid arguments for tool `{}`", call.name()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_args_accepts_empty_arguments() {
|
||||
let call = ToolCall::new("id", "ls", "");
|
||||
assert_eq!(
|
||||
parse_args(&call).unwrap(),
|
||||
Value::Object(serde_json::Map::new())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_args_parses_json_object() {
|
||||
let call = ToolCall::new("id", "ls", r#"{"path":"src"}"#);
|
||||
assert_eq!(parse_args(&call).unwrap()["path"], "src");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_args_rejects_invalid_json() {
|
||||
let call = ToolCall::new("id", "ls", "not json");
|
||||
assert!(parse_args(&call).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
//! Sandboxed tool execution for the AI bot.
|
||||
//!
|
||||
//! A [`Sandbox`] clones a pull request into a temporary directory, builds and
|
||||
//! starts its devcontainer (via `devcontainer-rs`) and exposes command
|
||||
//! execution inside the resulting container. The [`tools`] module maps model
|
||||
//! tool calls to commands run in that container, and [`agent`] drives the
|
||||
//! tool-calling loop against OpenRouter.
|
||||
|
||||
pub mod agent;
|
||||
pub mod tools;
|
||||
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
process::Stdio,
|
||||
};
|
||||
|
||||
use anyhow::Context;
|
||||
use devcontainer_rs::{Container, ContainerRuntime, ExecOutput};
|
||||
use tempfile::TempDir;
|
||||
use tracing::{info, instrument};
|
||||
|
||||
/// Devcontainer locations recognized within a repository, in priority order.
|
||||
const DEVCONTAINER_PATHS: [&str; 2] = [".devcontainer/devcontainer.json", ".devcontainer.json"];
|
||||
|
||||
/// Sandbox-related runtime configuration.
|
||||
#[derive(Clone)]
|
||||
pub struct SandboxConfig {
|
||||
/// Whether the bot should run its tools inside a sandbox container.
|
||||
pub enabled: bool,
|
||||
/// Container runtime binary to drive (e.g. `docker`, `podman`).
|
||||
pub runtime: ContainerRuntime,
|
||||
/// Maximum number of tool-calling iterations per agent run.
|
||||
pub max_iterations: usize,
|
||||
}
|
||||
|
||||
/// A cloned repository running inside an ephemeral devcontainer.
|
||||
pub struct Sandbox {
|
||||
// Owns the temporary directory; dropping it cleans up the clone.
|
||||
_workspace: TempDir,
|
||||
container: Container,
|
||||
}
|
||||
|
||||
impl Sandbox {
|
||||
/// Clones the pull request head, builds the devcontainer and starts it.
|
||||
///
|
||||
/// The clone is PR-aware: it fetches `refs/pull/<number>/head`, which works
|
||||
/// for both same-repository and forked pull requests.
|
||||
#[instrument(skip(runtime, token), fields(pr = pull_request_number))]
|
||||
pub async fn create(
|
||||
runtime: &ContainerRuntime,
|
||||
repo_url: &str,
|
||||
token: &str,
|
||||
pull_request_number: u64,
|
||||
) -> anyhow::Result<Self> {
|
||||
let workspace = tempfile::tempdir().context("failed to create sandbox workspace")?;
|
||||
let repo_dir = workspace.path().join("repo");
|
||||
|
||||
clone_pull_request(repo_url, token, pull_request_number, &repo_dir).await?;
|
||||
|
||||
let devcontainer_path = find_devcontainer(&repo_dir)
|
||||
.with_context(|| format!("no devcontainer found in `{repo_url}`"))?;
|
||||
|
||||
let devcontainer = devcontainer_rs::parse(&devcontainer_path).await?;
|
||||
|
||||
info!(image = %devcontainer.image_tag(), "Building and starting sandbox container");
|
||||
let container = devcontainer.up(runtime, &repo_dir).await?;
|
||||
|
||||
Ok(Self {
|
||||
_workspace: workspace,
|
||||
container,
|
||||
})
|
||||
}
|
||||
|
||||
/// Executes a command in the container as an argv vector (no shell).
|
||||
pub async fn exec(&self, cmd: &[&str]) -> anyhow::Result<ExecOutput> {
|
||||
Ok(self.container.exec(cmd).await?)
|
||||
}
|
||||
|
||||
/// Path of the repository inside the container.
|
||||
pub fn workspace_folder(&self) -> &str {
|
||||
self.container.workspace_folder()
|
||||
}
|
||||
|
||||
/// Stops and removes the container. The temporary clone is removed on drop.
|
||||
pub async fn cleanup(self) -> anyhow::Result<()> {
|
||||
self.container.remove().await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn find_devcontainer(repo_dir: &Path) -> Option<PathBuf> {
|
||||
DEVCONTAINER_PATHS
|
||||
.iter()
|
||||
.map(|relative| repo_dir.join(relative))
|
||||
.find(|candidate| candidate.is_file())
|
||||
}
|
||||
|
||||
async fn clone_pull_request(
|
||||
repo_url: &str,
|
||||
token: &str,
|
||||
pull_request_number: u64,
|
||||
dest: &Path,
|
||||
) -> anyhow::Result<()> {
|
||||
let dest = dest.display().to_string();
|
||||
|
||||
run_git(
|
||||
token,
|
||||
&[
|
||||
"clone".to_string(),
|
||||
"--depth".to_string(),
|
||||
"1".to_string(),
|
||||
repo_url.to_string(),
|
||||
dest.clone(),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
run_git(
|
||||
token,
|
||||
&[
|
||||
"-C".to_string(),
|
||||
dest.clone(),
|
||||
"fetch".to_string(),
|
||||
"--depth".to_string(),
|
||||
"1".to_string(),
|
||||
"origin".to_string(),
|
||||
format!("refs/pull/{pull_request_number}/head"),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
run_git(
|
||||
token,
|
||||
&[
|
||||
"-C".to_string(),
|
||||
dest,
|
||||
"checkout".to_string(),
|
||||
"FETCH_HEAD".to_string(),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Runs git with the token injected through `http.extraHeader`, keeping the
|
||||
/// secret out of the process arguments.
|
||||
async fn run_git(token: &str, args: &[String]) -> anyhow::Result<()> {
|
||||
let output = tokio::process::Command::new("git")
|
||||
.args(args)
|
||||
.env("GIT_CONFIG_COUNT", "1")
|
||||
.env("GIT_CONFIG_KEY_0", "http.extraHeader")
|
||||
.env(
|
||||
"GIT_CONFIG_VALUE_0",
|
||||
format!("Authorization: token {token}"),
|
||||
)
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
.await
|
||||
.context("failed to spawn git")?;
|
||||
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"git {} failed: {}",
|
||||
args.join(" "),
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn find_devcontainer_prefers_dot_devcontainer_dir() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let nested = dir.path().join(".devcontainer");
|
||||
std::fs::create_dir(&nested).unwrap();
|
||||
std::fs::write(nested.join("devcontainer.json"), "{}").unwrap();
|
||||
std::fs::write(dir.path().join(".devcontainer.json"), "{}").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
find_devcontainer(dir.path()),
|
||||
Some(nested.join("devcontainer.json"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_devcontainer_returns_none_when_absent() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert_eq!(find_devcontainer(dir.path()), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
//! Tool definitions exposed to the model and their execution inside a
|
||||
//! [`Sandbox`].
|
||||
//!
|
||||
//! Every tool is read-only and confined to the repository workspace: paths are
|
||||
//! resolved relative to the container workspace folder and rejected if they
|
||||
//! escape it. Commands are executed as argv vectors (never through a shell),
|
||||
//! so tool arguments cannot be used for shell injection.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, bail};
|
||||
use devcontainer_rs::{ExecOutput, normalize};
|
||||
use openrouter_rs::types::Tool;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::Sandbox;
|
||||
use crate::gitea::WebhookType;
|
||||
|
||||
/// Tools available to the model for a given webhook action.
|
||||
///
|
||||
/// The match is exhaustive on [`WebhookType`], so adding a new action forces a
|
||||
/// decision here about which tools that action may use.
|
||||
pub fn for_webhook(webhook: &WebhookType) -> Vec<Tool> {
|
||||
match webhook {
|
||||
WebhookType::Review(_) => review_tools(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only tools used to explore a repository during a review.
|
||||
fn review_tools() -> Vec<Tool> {
|
||||
vec![
|
||||
Tool::new(
|
||||
"ls",
|
||||
"List the entries of a directory inside the repository.",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Directory path relative to the repository root. Defaults to the repository root."
|
||||
}
|
||||
}
|
||||
}),
|
||||
),
|
||||
Tool::new(
|
||||
"read_file",
|
||||
"Read the content of a text file inside the repository.",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "File path relative to the repository root."
|
||||
},
|
||||
"start_line": {
|
||||
"type": "integer",
|
||||
"description": "First line to read (1-based, inclusive). Defaults to the first line."
|
||||
},
|
||||
"end_line": {
|
||||
"type": "integer",
|
||||
"description": "Last line to read (1-based, inclusive). Defaults to the last line."
|
||||
}
|
||||
},
|
||||
"required": ["path"]
|
||||
}),
|
||||
),
|
||||
Tool::new(
|
||||
"grep",
|
||||
"Search for a regular expression across the repository files.",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pattern": {
|
||||
"type": "string",
|
||||
"description": "Extended regular expression to search for."
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "File or directory to search in, relative to the repository root. Defaults to the repository root."
|
||||
}
|
||||
},
|
||||
"required": ["pattern"]
|
||||
}),
|
||||
),
|
||||
Tool::new(
|
||||
"find",
|
||||
"Find files by name pattern inside the repository.",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pattern": {
|
||||
"type": "string",
|
||||
"description": "Glob pattern matched against file names, e.g. `*.rs`."
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Directory to search in, relative to the repository root. Defaults to the repository root."
|
||||
}
|
||||
},
|
||||
"required": ["pattern"]
|
||||
}),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Executes a tool call and returns its textual result.
|
||||
///
|
||||
/// Errors are returned as `Err` so the caller can decide whether to surface
|
||||
/// them to the model or abort.
|
||||
pub async fn dispatch(sandbox: &Sandbox, name: &str, args: &Value) -> anyhow::Result<String> {
|
||||
match name {
|
||||
"ls" => ls(sandbox, args).await,
|
||||
"read_file" => read_file(sandbox, args).await,
|
||||
"grep" => grep(sandbox, args).await,
|
||||
"find" => find(sandbox, args).await,
|
||||
other => bail!("unknown tool `{other}`"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn ls(sandbox: &Sandbox, args: &Value) -> anyhow::Result<String> {
|
||||
let path = resolve(sandbox, optional_str(args, "path").unwrap_or("."))?;
|
||||
let output = sandbox.exec(&["ls", "-la", "--", &path]).await?;
|
||||
into_stdout(output)
|
||||
}
|
||||
|
||||
async fn read_file(sandbox: &Sandbox, args: &Value) -> anyhow::Result<String> {
|
||||
let path = resolve(sandbox, required_str(args, "path")?)?;
|
||||
|
||||
let start = args.get("start_line").and_then(Value::as_u64);
|
||||
let end = args.get("end_line").and_then(Value::as_u64);
|
||||
|
||||
let output = if start.is_none() && end.is_none() {
|
||||
sandbox.exec(&["cat", "--", &path]).await?
|
||||
} else {
|
||||
let start = start.unwrap_or(1);
|
||||
let end = end
|
||||
.map(|line| line.to_string())
|
||||
.unwrap_or_else(|| "$".to_string());
|
||||
let range = format!("{start},{end}p");
|
||||
sandbox.exec(&["sed", "-n", &range, "--", &path]).await?
|
||||
};
|
||||
|
||||
into_stdout(output)
|
||||
}
|
||||
|
||||
async fn grep(sandbox: &Sandbox, args: &Value) -> anyhow::Result<String> {
|
||||
let pattern = required_str(args, "pattern")?;
|
||||
let path = resolve(sandbox, optional_str(args, "path").unwrap_or("."))?;
|
||||
|
||||
let output = sandbox
|
||||
.exec(&["grep", "-rn", "-E", "--", pattern, &path])
|
||||
.await?;
|
||||
|
||||
// grep exits with 1 when there is no match, which is not an error.
|
||||
match output.status {
|
||||
0 | 1 => Ok(output.stdout),
|
||||
_ => bail!("grep failed: {}", output.stderr.trim()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn find(sandbox: &Sandbox, args: &Value) -> anyhow::Result<String> {
|
||||
let pattern = required_str(args, "pattern")?;
|
||||
let path = resolve(sandbox, optional_str(args, "path").unwrap_or("."))?;
|
||||
|
||||
let output = sandbox
|
||||
.exec(&["find", &path, "-type", "f", "-name", pattern])
|
||||
.await?;
|
||||
|
||||
into_stdout(output)
|
||||
}
|
||||
|
||||
/// Resolves a tool path against the container workspace folder, rejecting any
|
||||
/// path that escapes it.
|
||||
fn resolve(sandbox: &Sandbox, path: &str) -> anyhow::Result<String> {
|
||||
let workspace = sandbox.workspace_folder();
|
||||
let candidate = Path::new(workspace).join(path);
|
||||
|
||||
let normalized =
|
||||
normalize(&candidate).with_context(|| format!("path `{path}` escapes the workspace"))?;
|
||||
|
||||
if !normalized.starts_with(workspace) {
|
||||
bail!("path `{path}` escapes the workspace");
|
||||
}
|
||||
|
||||
Ok(normalized.display().to_string())
|
||||
}
|
||||
|
||||
fn required_str<'a>(args: &'a Value, key: &str) -> anyhow::Result<&'a str> {
|
||||
args.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.with_context(|| format!("`{key}` is required"))
|
||||
}
|
||||
|
||||
fn optional_str<'a>(args: &'a Value, key: &str) -> Option<&'a str> {
|
||||
args.get(key).and_then(Value::as_str)
|
||||
}
|
||||
|
||||
fn into_stdout(output: ExecOutput) -> anyhow::Result<String> {
|
||||
if output.success() {
|
||||
Ok(output.stdout)
|
||||
} else {
|
||||
bail!(
|
||||
"command failed (status {}): {}",
|
||||
output.status,
|
||||
output.stderr.trim()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::gitea::{Comment, PullRequest, Repository, ReviewPayload};
|
||||
|
||||
fn review_webhook() -> WebhookType {
|
||||
WebhookType::Review(ReviewPayload {
|
||||
action: "created".to_string(),
|
||||
pull_request: PullRequest {
|
||||
diff_url: "https://example.com/diff".to_string(),
|
||||
number: 1,
|
||||
title: "My PR".to_string(),
|
||||
},
|
||||
repository: Repository {
|
||||
full_name: "owner/repo".to_string(),
|
||||
},
|
||||
comment: Comment {
|
||||
id: 1,
|
||||
body: "@bot review".to_string(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_webhook_exposes_read_only_tools() {
|
||||
let names: Vec<String> = for_webhook(&review_webhook())
|
||||
.into_iter()
|
||||
.map(|tool| tool.function.name)
|
||||
.collect();
|
||||
|
||||
assert_eq!(names, vec!["ls", "read_file", "grep", "find"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn required_str_reports_missing_key() {
|
||||
let err = required_str(&json!({}), "path").unwrap_err();
|
||||
assert!(err.to_string().contains("path"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user