fix(agent/service): retry jitter, tool executor ordering, curl SSRF, grep/JSON

- agent/client: full jitter backoff (random(0, base_ms)) instead of equal jitter
- agent/tool/executor: fix buffer_unordered ordering mismatch with
  HashMap-by-index approach for concurrent tool execution
- agent/chat: AiChunkType emit fixes, is_retryable_tool_error refinements,
  process_react uses request.max_tool_depth
- agent/chat/context: fix Function message sender_name field
- file_tools/curl: shared reqwest::Client via OnceLock, manual redirect
  following with per-hop SSRF validation, blocked sensitive headers
- file_tools/grep: fix case-insensitive glob matching, segment consumption
- file_tools/json: bracket notation support, remove .vscodeignore from JSONC
- git_tools: git_diff_stats resolve base/head independently,
  DiffFileOut old_file.path for Deleted, reflog offset_minutes
- git/repo: create_commit read parent tree into index, bare repo init
- project_tools/repos: branch/path validation, .git/ prefix check
- service/agent: tokent integration, billing, pr_summary, code_review fixes
This commit is contained in:
ZhenYi 2026-04-25 09:53:31 +08:00
parent 7620f2f281
commit f7e087e066
33 changed files with 1220 additions and 246 deletions

View File

@ -40,7 +40,7 @@ impl AiContextSenderType {
models::rooms::MessageSenderType::Owner => Self::User,
models::rooms::MessageSenderType::Ai => Self::Ai,
models::rooms::MessageSenderType::System => Self::System,
models::rooms::MessageSenderType::Tool => Self::Function,
models::rooms::MessageSenderType::Tool => Self::FunctionResult,
models::rooms::MessageSenderType::Guest => Self::User,
}
}
@ -135,7 +135,7 @@ impl RoomMessageContext {
AiContextSenderType::Function => {
ChatCompletionRequestMessage::Function(ChatCompletionRequestFunctionMessage {
content: Some(self.content.clone()),
name: self.display_content(), // Function name is stored in content
name: self.sender_name.clone().unwrap_or_else(|| "unknown".to_string()),
})
}
AiContextSenderType::FunctionResult => {

View File

@ -13,13 +13,36 @@ use std::collections::HashMap;
use uuid::Uuid;
/// Maximum recursion rounds for tool-call loops (AI → tool → result → AI).
pub const DEFAULT_MAX_TOOL_DEPTH: usize = 3;
/// Previous default of 3 caused frequent silent termination on realistic multi-step queries.
pub const DEFAULT_MAX_TOOL_DEPTH: usize = 99;
/// A single chunk from an AI streaming response.
#[derive(Debug, Clone)]
pub struct AiStreamChunk {
pub content: String,
pub done: bool,
/// What kind of content this chunk contains — helps the frontend render
/// thinking, tool calls, and results with different styles.
pub chunk_type: AiChunkType,
}
/// Type of streaming chunk, used by the frontend for rendering.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AiChunkType {
/// AI reasoning/thinking text before a tool call or answer.
Thinking,
/// Final answer text from the AI.
Answer,
/// A tool call is being executed (content = tool name + args summary).
ToolCall,
/// Tool execution result (content = result or error).
ToolResult,
}
impl Default for AiChunkType {
fn default() -> Self {
Self::Answer
}
}
/// Optional streaming callback: called for each token chunk.

View File

@ -7,7 +7,7 @@ use async_openai::types::chat::{
ChatCompletionRequestAssistantMessageContent, ChatCompletionRequestMessage,
ChatCompletionRequestSystemMessage, ChatCompletionRequestToolMessage,
ChatCompletionRequestToolMessageContent, ChatCompletionRequestUserMessage,
ChatCompletionRequestUserMessageContent, ChatCompletionTool,
ChatCompletionTool,
ChatCompletionTools, CreateChatCompletionRequest, CreateChatCompletionResponse,
CreateChatCompletionStreamResponse, FinishReason, ReasoningEffort, ToolChoiceOptions,
};
@ -18,7 +18,7 @@ use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
use uuid::Uuid;
use super::context::RoomMessageContext;
use super::{AiRequest, AiStreamChunk, Mention, StreamCallback};
use super::{AiChunkType, AiRequest, AiStreamChunk, Mention, StreamCallback};
use crate::client::AiClientConfig;
use crate::compact::{CompactConfig, CompactService};
use crate::embed::EmbedService;
@ -195,29 +195,38 @@ impl ChatService {
.collect();
if !calls.is_empty() {
let calls_for_error = calls.clone();
let tool_messages = match self.execute_tool_calls(calls, &request).await {
Ok(msgs) => msgs,
Err(e) => {
// Surface the error as a tool result so the model can continue
let err_text = format!("[Tool call failed: {}]", e);
messages.push(ChatCompletionRequestMessage::User(
ChatCompletionRequestUserMessage {
content: ChatCompletionRequestUserMessageContent::Text(err_text.clone()),
name: None,
// Surface the error as per-call Tool messages (with matching IDs)
// so the API contract (Tool after Assistant+tool_calls) is preserved.
calls_for_error.iter().map(|c| {
ChatCompletionRequestMessage::Tool(
ChatCompletionRequestToolMessage {
tool_call_id: c.id.clone(),
content: ChatCompletionRequestToolMessageContent::Text(
format!("[Tool call failed: {}]", e),
),
},
));
tool_depth += 1;
if tool_depth >= max_tool_depth {
return Ok(err_text);
}
continue;
)
}).collect()
}
};
messages.extend(tool_messages);
tool_depth += 1;
if tool_depth >= max_tool_depth {
return Ok(String::new());
// Return accumulated content rather than empty string so the user
// sees whatever the AI has produced so far.
let text = choice.message.content.unwrap_or_default();
if text.is_empty() {
return Ok(format!(
"[AI reached maximum tool depth ({}) — no final answer produced]",
max_tool_depth
));
}
return Ok(text);
}
continue;
}
@ -320,6 +329,7 @@ impl ChatService {
on_chunk(AiStreamChunk {
content: text_accumulated.clone(),
done: false,
chunk_type: AiChunkType::Answer,
})
.await;
}
@ -364,12 +374,13 @@ impl ChatService {
.collect();
if !tool_calls.is_empty() {
// Capture thinking text, send it as a completed chunk, then clear for the next turn
// Capture thinking text, send it as a non-final chunk, then clear for the next turn
let thinking_text = text_accumulated.clone();
if !thinking_text.is_empty() {
on_chunk(AiStreamChunk {
content: thinking_text.clone(),
done: true,
done: false,
chunk_type: AiChunkType::Thinking,
})
.await;
}
@ -406,29 +417,88 @@ impl ChatService {
},
));
let calls_for_error = tool_calls.clone();
// Notify frontend which tools are being called
let call_summary: Vec<String> = calls_for_error.iter().map(|c| {
let args_preview: String = {
let args_json: serde_json::Value = serde_json::from_str(&c.arguments)
.unwrap_or(serde_json::Value::Null);
// Show truncated args for readability
let s = serde_json::to_string(&args_json).unwrap_or_default();
if s.len() > 200 { s[..200].to_string() + "..." } else { s }
};
format!("{}({})", c.name, args_preview)
}).collect();
on_chunk(AiStreamChunk {
content: format!("[Calling tools: {}]", call_summary.join(", ")),
done: false,
chunk_type: AiChunkType::ToolCall,
})
.await;
let tool_messages = match self.execute_tool_calls(tool_calls, &request).await {
Ok(msgs) => msgs,
Ok(msgs) => {
// Stream tool results to frontend so user can see what happened
let result_summary: Vec<String> = msgs.iter().map(|m| {
if let ChatCompletionRequestMessage::Tool(tm) = m {
match &tm.content {
ChatCompletionRequestToolMessageContent::Text(t) => {
if t.len() > 300 { t[..300].to_string() + "..." } else { t.clone() }
}
_ => "[binary content]".to_string(),
}
} else { "unknown".to_string() }
}).collect();
on_chunk(AiStreamChunk {
content: format!("[Tool results: {}]", result_summary.join("; ")),
done: false,
chunk_type: AiChunkType::ToolResult,
})
.await;
msgs
}
Err(e) => {
// Stream the FC error as an observation so the user sees it
// Stream the FC error as a non-final observation so the user sees it,
// but do NOT mark done=true — the AI will continue after seeing the error.
let err_text = format!("[Tool call failed: {}]", e);
on_chunk(AiStreamChunk {
content: err_text.clone(),
done: true,
done: false,
chunk_type: AiChunkType::ToolResult,
})
.await;
// Return an empty tool result so the loop can continue
vec![ChatCompletionRequestMessage::Tool(
// Return per-call Tool messages with matching IDs to preserve API contract
calls_for_error.iter().map(|c| {
ChatCompletionRequestMessage::Tool(
ChatCompletionRequestToolMessage {
tool_call_id: String::new(),
content: ChatCompletionRequestToolMessageContent::Text(err_text),
tool_call_id: c.id.clone(),
content: ChatCompletionRequestToolMessageContent::Text(err_text.clone()),
},
)]
)
}).collect()
}
};
messages.extend(tool_messages);
tool_depth += 1;
if tool_depth >= max_tool_depth {
// Emit a final done chunk with whatever content we have so the
// client receives a completion signal instead of hanging forever.
let final_content = if text_accumulated.is_empty() {
format!(
"[AI reached maximum tool depth ({}) — no final answer produced]",
max_tool_depth
)
} else {
text_accumulated.clone()
};
on_chunk(AiStreamChunk {
content: final_content,
done: true,
chunk_type: AiChunkType::Answer,
})
.await;
return Ok(());
}
continue;
@ -438,6 +508,7 @@ impl ChatService {
on_chunk(AiStreamChunk {
content: text_accumulated,
done: true,
chunk_type: AiChunkType::Answer,
})
.await;
return Ok(());
@ -748,7 +819,8 @@ impl ChatService {
/// Returns true if the error message indicates a transient failure that can be retried.
fn is_retryable_tool_error(msg: &str) -> bool {
let msg_lower = msg.to_lowercase();
// Transient errors: network, timeouts, rate limits, permission issues that may be temporary
// Transient errors: network, timeouts, rate limits
// Permission/access errors are NOT retryable — they won't succeed on retry.
msg_lower.contains("connection")
|| msg_lower.contains("timeout")
|| msg_lower.contains("timed out")
@ -762,9 +834,6 @@ impl ChatService {
|| msg_lower.contains("broken pipe")
|| msg_lower.contains("deadline exceeded")
|| msg_lower.contains("try again")
|| msg_lower.contains("not found") // DB/Redis transient not-found
|| msg_lower.contains("permission denied")
|| msg_lower.contains("access denied")
}
/// Process a request using the ReAct (Reasoning + Acting) agent.
@ -885,7 +954,7 @@ impl ChatService {
let tools = self.tools();
let config = ReactConfig {
max_steps: 20,
max_steps: request.max_tool_depth,
stop_sequences: Vec::new(),
tool_executor: Some(executor),
};

View File

@ -113,16 +113,17 @@ impl RetryState {
self.attempt < self.max_retries
}
/// Calculate backoff duration with "full jitter" technique.
/// Calculate backoff duration with full jitter technique.
/// sleep = random(0, min(cap, base * 2^attempt))
fn backoff_duration(&self) -> std::time::Duration {
let exp = self.attempt.min(5);
// base = 500 * 2^exp, capped at max_backoff_ms
let base_ms = 500u64
.saturating_mul(2u64.pow(exp))
.min(self.max_backoff_ms);
// jitter: random [0, base_ms/2]
let jitter = (fastrand_u64(base_ms / 2 + 1)) as u64;
std::time::Duration::from_millis(base_ms / 2 + jitter)
// Full jitter: random value in [0, base_ms]
let jitter = fastrand_u64(base_ms + 1) as u64;
std::time::Duration::from_millis(jitter)
}
fn next(&mut self) {
@ -239,7 +240,11 @@ pub async fn call_with_retry(
}
}
/// Call with custom parameters (temperature, max_tokens, optional tools).
/// Call with custom parameters (temperature, max_tokens, optional tools, optional tool_choice).
///
/// When `tool_choice` is `None` and tools are present, the default is `Auto`.
/// Pass `Some(ChatCompletionToolChoiceOption::None)` to force the model to respond
/// with text only (e.g. when you want JSON-in-text for ReAct parsing).
pub async fn call_with_params(
messages: &[ChatCompletionRequestMessage],
model: &str,
@ -248,6 +253,7 @@ pub async fn call_with_params(
max_tokens: u32,
max_retries: Option<u32>,
tools: Option<&[ChatCompletionTool]>,
tool_choice: Option<ChatCompletionToolChoiceOption>,
) -> Result<AiCallResponse> {
let client = config.build_client();
let mut state = RetryState::new(max_retries.unwrap_or(3));
@ -265,11 +271,7 @@ pub async fn call_with_params(
.map(|t| ChatCompletionTools::Function(t.clone()))
.collect()
}),
tool_choice: tools.filter(|ts| !ts.is_empty()).map(|_| {
ChatCompletionToolChoiceOption::Mode(
async_openai::types::chat::ToolChoiceOptions::Auto,
)
}),
tool_choice: tool_choice.clone(),
..Default::default()
};

View File

@ -137,8 +137,9 @@ impl EmbedClient {
text: &str,
room_id: &str,
user_id: Option<&str>,
model: &str,
) -> crate::Result<()> {
let vector = self.embed_text(text, "").await?;
let vector = self.embed_text(text, model).await?;
let point = EmbedVector {
id: id.to_string(),
vector,
@ -176,9 +177,10 @@ impl EmbedClient {
description: &str,
content: &str,
project_uuid: &str,
model: &str,
) -> crate::Result<()> {
let text = format!("{}: {} {}", name, description, content);
let vector = self.embed_text(&text, "").await?;
let vector = self.embed_text(&text, model).await?;
let point = EmbedVector {
id: id.to_string(),
vector,

View File

@ -188,7 +188,7 @@ impl EmbedService {
let desc = description.unwrap_or_default();
let id = skill_id.to_string();
self.client
.embed_skill(&id, name, desc, content, project_uuid)
.embed_skill(&id, name, desc, content, project_uuid, &self.model_name)
.await
}
@ -214,7 +214,7 @@ impl EmbedService {
) -> crate::Result<()> {
let id = message_id.to_string();
self.client
.embed_memory(&id, text, room_id, user_id)
.embed_memory(&id, text, room_id, user_id, &self.model_name)
.await
}

View File

@ -1,13 +1,14 @@
//! ReAct (Reasoning + Acting) agent core.
use async_openai::types::chat::FunctionCall;
use async_openai::types::chat::{
ChatCompletionMessageToolCall, ChatCompletionMessageToolCalls,
ChatCompletionRequestAssistantMessage, ChatCompletionRequestAssistantMessageContent,
ChatCompletionRequestMessage, ChatCompletionRequestToolMessage,
ChatCompletionRequestToolMessageContent, ChatCompletionRequestUserMessage,
ChatCompletionRequestUserMessageContent,
ChatCompletionRequestUserMessageContent, ToolChoiceOptions,
};
use async_openai::types::chat::ChatCompletionToolChoiceOption;
use async_openai::types::chat::FunctionCall;
use uuid::Uuid;
use std::sync::Arc;
@ -37,9 +38,11 @@ impl ReactAgent {
tools: Vec<async_openai::types::chat::ChatCompletionTool>,
config: ReactConfig,
) -> Self {
let messages = vec![ChatCompletionRequestMessage::User(
ChatCompletionRequestUserMessage {
content: ChatCompletionRequestUserMessageContent::Text(system_prompt.to_string()),
let messages = vec![ChatCompletionRequestMessage::System(
async_openai::types::chat::ChatCompletionRequestSystemMessage {
content: async_openai::types::chat::ChatCompletionRequestSystemMessageContent::Text(
system_prompt.to_string(),
),
..Default::default()
},
)];
@ -109,15 +112,30 @@ impl ReactAgent {
{
loop {
if self.step_count >= self.config.max_steps {
return Err(AgentError::Internal(format!(
"ReAct agent reached max steps ({})",
// Emit a final Answer chunk so the caller receives a completion signal
// rather than a bare Err with no on_chunk notification.
let msg = format!(
"Agent reached maximum reasoning steps ({}) without producing a final answer.",
self.config.max_steps
)));
);
on_chunk(ReactStep::Answer {
step: self.step_count,
answer: msg.clone(),
});
return Ok(msg);
}
self.step_count += 1;
let step = self.step_count;
let tool_choice = if self.tool_definitions.is_empty() {
None
} else {
// Force text-only response so the model follows our JSON-in-text format.
// With tool_choice=Auto the model might return native tool_calls which
// the ReAct parser ignores.
Some(ChatCompletionToolChoiceOption::Mode(ToolChoiceOptions::None))
};
let response = call_with_params(
&self.messages,
model_name,
@ -130,6 +148,7 @@ impl ReactAgent {
} else {
Some(self.tool_definitions.as_slice())
},
tool_choice,
)
.await?;
@ -234,6 +253,10 @@ impl ReactAgent {
_ => {}
}
// Append assistant message with tool_calls so the Tool message has a matching parent.
let assistant_msg = build_tool_call_message(&act);
self.messages.push(assistant_msg);
// Append observation as a tool message so the model sees it in context.
self.messages.push(ChatCompletionRequestMessage::Tool(
ChatCompletionRequestToolMessage {

View File

@ -477,8 +477,9 @@ impl TaskService {
/// Propagate child task status up the tree.
///
/// When a child task reaches a terminal state, checks whether all its
/// siblings are also terminal. If so, marks the parent as failed so that
/// a stuck parent is never left in the `Running` state.
/// siblings are also terminal. If so, marks the parent appropriately:
/// - Done if any child succeeded
/// - Failed if all children failed or were cancelled
pub async fn propagate_to_parent(&self, task_id: i64) -> Result<Option<Model>, DbErr> {
let model = self
.get(task_id)
@ -496,9 +497,15 @@ impl TaskService {
})?;
if parent.is_running() {
let mut active: ActiveModel = parent.into();
let has_success = siblings.iter().any(|s| s.status == TaskStatus::Done);
if has_success {
active.status = sea_orm::Set(TaskStatus::Done);
active.error = sea_orm::Set(None);
} else {
active.status = sea_orm::Set(TaskStatus::Failed);
active.error =
sea_orm::Set(Some("All sub-tasks failed or were cancelled".to_string()));
}
active.done_at = sea_orm::Set(Some(chrono::Utc::now().into()));
active.updated_at = sea_orm::Set(chrono::Utc::now().into());
let updated = active.update(&self.db).await?;

View File

@ -125,6 +125,8 @@ pub fn truncate_to_token_budget(
while low + 100 < high {
let mid = (low + high) / 2;
// Find the nearest valid char boundary to avoid panicking on multi-byte UTF-8
let mid = text.floor_char_boundary(mid);
let candidate = &text[..mid];
let tokens = bpe.encode_ordinary(candidate);

View File

@ -22,11 +22,13 @@ impl ToolCall {
/// The result of executing a tool call.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
#[serde(tag = "status", content = "value")]
pub enum ToolResult {
/// Successful result with a JSON value.
#[serde(rename = "ok")]
Ok(serde_json::Value),
/// Error result with an error message.
#[serde(rename = "error")]
Error(String),
}

View File

@ -70,34 +70,33 @@ impl ToolExecutor {
ctx.increment_tool_calls();
let concurrency = self.max_concurrency;
use tokio::sync::Mutex as AsyncMutex;
let results: AsyncMutex<Vec<ToolCallResult>> =
AsyncMutex::new(Vec::with_capacity(calls.len()));
let calls_clone: Vec<ToolCall> = calls.clone();
stream::iter(calls.into_iter().map(|call| {
// Execute tool calls concurrently but preserve input order for ID matching.
// buffer_unordered returns results in *completion* order, which mispairs IDs
// on concurrent errors. Instead, track each result with its original index.
let indexed_results: Vec<(usize, Result<ToolCallResult, ToolError>)> = stream::iter(
calls.into_iter().enumerate().map(|(i, call)| {
let child_ctx = ctx.child_context();
async move { self.execute_one(call, child_ctx).await }
}))
async move { (i, self.execute_one(call, child_ctx).await) }
})
)
.buffer_unordered(concurrency)
.for_each_concurrent(
concurrency,
|result: Result<ToolCallResult, ToolError>| async {
let r = result.unwrap_or_else(|e| {
ToolCallResult::error(
ToolCall {
id: String::new(),
name: String::new(),
arguments: String::new(),
},
e.to_string(),
)
});
results.lock().await.push(r);
},
)
.collect()
.await;
Ok(results.into_inner())
// Re-sort by original index to restore input order, then pair with original calls.
let mut result_map: std::collections::HashMap<usize, Result<ToolCallResult, ToolError>> =
indexed_results.into_iter().collect();
let results: Vec<ToolCallResult> = calls_clone.into_iter().enumerate().map(|(i, call)| {
let r = result_map.remove(&i).expect("every index must have a result");
r.unwrap_or_else(|e: ToolError| {
ToolCallResult::error(call, e.to_string())
})
}).collect();
Ok(results)
}
async fn execute_one(

View File

@ -412,7 +412,7 @@ async fn call_ai_model(
),
];
agent::call_with_params(&messages, model_name, &client_config, 0.2, 8192, None, None)
agent::call_with_params(&messages, model_name, &client_config, 0.2, 8192, None, None, None)
.await
.map_err(|e| AppError::InternalServerError(format!("AI call failed: {}", e)))
}

View File

@ -0,0 +1,232 @@
//! AI-powered issue triage service.
//!
//! Analyzes newly created issues and suggests labels and priority.
use crate::AppService;
use crate::error::AppError;
use chrono::Utc;
use config::AppConfig;
use models::agents::ModelStatus;
use models::agents::model::{Column as MColumn, Entity as MEntity};
use models::issues::{issue, issue_comment};
use sea_orm::*;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct IssueTriageSuggestion {
pub suggested_labels: Vec<String>,
pub priority: String,
pub reasoning: String,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct IssueTriageResponse {
pub suggestions: Option<IssueTriageSuggestion>,
pub comment_posted: bool,
}
fn build_triage_prompt(title: &str, body: Option<&str>, existing_labels: &[String]) -> String {
let body_text = body.unwrap_or("(no description)");
let labels_text = if existing_labels.is_empty() {
"none".to_string()
} else {
existing_labels.join(", ")
};
format!(
r#"You are an expert software project manager. Analyze the following GitHub issue and suggest how to triage it.
Issue Title: {}
Issue Body:
{}
Existing Labels: {}
Based on the issue, suggest:
1. Additional labels from this standard set: bug, enhancement, documentation, question, help wanted, good first issue, priority:high, priority:medium, priority:low, kind:backend, kind:frontend, kind:dx, kind:security, kind:performance
2. A priority level: high, medium, or low
3. A brief reasoning for your assessment
Respond in JSON format like:
{{
"suggested_labels": ["bug", "priority:high"],
"priority": "high",
"reasoning": "This is a critical security vulnerability in the auth module..."
}}
Only suggest labels not already in the existing list. Be concise."#,
title, body_text, labels_text
)
}
fn parse_triage_response(content: &str) -> Option<IssueTriageSuggestion> {
let content = content.trim();
let json_str = if content.starts_with("```json") {
content
.strip_prefix("```json")?
.strip_prefix('\n')
.unwrap_or(content)
.trim_end_matches("```")
.trim()
} else if content.starts_with("```") {
content
.strip_prefix("```")?
.strip_prefix('\n')
.unwrap_or(content)
.trim_end_matches("```")
.trim()
} else {
content
};
let parsed: serde_json::Value = serde_json::from_str(json_str).ok()?;
Some(IssueTriageSuggestion {
suggested_labels: parsed
.get("suggested_labels")?
.as_array()?
.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect(),
priority: parsed.get("priority")?.as_str()?.to_string(),
reasoning: parsed.get("reasoning")?.as_str()?.to_string(),
})
}
async fn call_ai_for_triage(
model_name: &str,
prompt: &str,
app_config: &AppConfig,
) -> Result<String, AppError> {
let api_key = app_config
.ai_api_key()
.map_err(|e| AppError::InternalServerError(format!("AI API key not configured: {}", e)))?;
let base_url = app_config
.ai_basic_url()
.unwrap_or_else(|_| "https://api.openai.com".into());
let client_config =
::agent::AiClientConfig::new(api_key).with_base_url(base_url);
let messages = vec![async_openai::types::chat::ChatCompletionRequestMessage::User(
async_openai::types::chat::ChatCompletionRequestUserMessage {
content:
async_openai::types::chat::ChatCompletionRequestUserMessageContent::Text(
prompt.to_string(),
),
..Default::default()
},
)];
let response = ::agent::call_with_params(
&messages,
model_name,
&client_config,
0.3,
1024,
None,
None,
None,
)
.await
.map_err(|e| {
AppError::InternalServerError(format!("AI triage call failed: {}", e))
})?;
Ok(response.content)
}
impl AppService {
/// Run AI triage on a newly created issue and post a suggestion comment.
/// Called asynchronously after issue creation.
pub async fn triage_issue(
&self,
project_name: String,
issue_number: i64,
) -> Result<IssueTriageResponse, AppError> {
let project = self.utils_find_project_by_name(project_name.clone()).await?;
let issue_model = issue::Entity::find()
.filter(issue::Column::Project.eq(project.id))
.filter(issue::Column::Number.eq(issue_number))
.one(&self.db)
.await?
.ok_or_else(|| AppError::NotFound("Issue not found".to_string()))?;
let existing_labels: Vec<String> = Vec::new();
let model = match MEntity::find()
.filter(MColumn::Status.eq(ModelStatus::Active.to_string()))
.order_by_asc(MColumn::Name)
.one(&self.db)
.await?
{
Some(m) => m,
None => {
tracing::debug!(
project = %project_name,
issue = issue_number,
"No active AI model for triage — skipping"
);
return Ok(IssueTriageResponse {
suggestions: None,
comment_posted: false,
});
}
};
let prompt =
build_triage_prompt(&issue_model.title, issue_model.body.as_deref(), &existing_labels);
let ai_content = match call_ai_for_triage(&model.name, &prompt, &self.config).await {
Ok(c) => c,
Err(e) => {
tracing::warn!(
project = %project_name,
issue = issue_number,
error = ?e,
"AI triage failed"
);
return Ok(IssueTriageResponse {
suggestions: None,
comment_posted: false,
});
}
};
let suggestions = parse_triage_response(&ai_content);
let mut comment_posted = false;
if let Some(ref s) = suggestions {
let comment_body = format!(
"## AI Triage Suggestions\n\n**Priority:** *{}*\n\n{}\n\n**Suggested Labels:** \
{}\n\n_This analysis was generated automatically by the AI collaborator._",
s.priority.to_uppercase(),
s.reasoning,
if s.suggested_labels.is_empty() {
"none".to_string()
} else {
s.suggested_labels.join(", ")
}
);
let now = Utc::now();
let active = issue_comment::ActiveModel {
issue: Set(issue_model.id),
author: Set(Uuid::nil()),
body: Set(comment_body),
created_at: Set(now),
updated_at: Set(now),
..Default::default()
};
if active.insert(&self.db).await.is_ok() {
comment_posted = true;
}
}
Ok(IssueTriageResponse {
suggestions,
comment_posted,
})
}
}

View File

@ -6,6 +6,7 @@ pub mod provider;
pub mod billing;
pub mod code_review;
pub mod issue_triage;
pub mod model;
pub mod pr_summary;
pub mod sync;

View File

@ -147,7 +147,7 @@ async fn call_ai_model_for_description(
),
];
agent::call_with_params(&messages, model_name, &client_config, 0.3, 4096, None, None)
agent::call_with_params(&messages, model_name, &client_config, 0.3, 4096, None, None, None)
.await
.map_err(|e| AppError::InternalServerError(format!("AI call failed: {}", e)))
}

View File

@ -201,11 +201,10 @@ async fn git_grep_exec(
}
fn glob_match(path: &str, pattern: &str) -> bool {
// Simple glob: support *, ?, **
let parts: Vec<&str> = pattern.split('/').collect();
let path_parts: Vec<&str> = path.split('/').collect();
let _path_lower = path.to_lowercase();
let path_lower = path.to_lowercase();
let pattern_lower = pattern.to_lowercase();
let parts: Vec<&str> = pattern_lower.split('/').collect();
let path_parts: Vec<&str> = path_lower.split('/').collect();
fn matches_part(path_part: &str, pattern_part: &str) -> bool {
if pattern_part.is_empty() || pattern_part == "*" {
@ -231,24 +230,33 @@ fn glob_match(path: &str, pattern: &str) -> bool {
if parts.len() == 1 {
// Simple glob pattern on filename only
let file_name = path_parts.last().unwrap_or(&"");
return matches_part(file_name, &pattern_lower);
return matches_part(file_name, &parts[0]);
}
// Multi-part glob
let mut pi = 0;
for part in &parts {
while pi < path_parts.len() {
if matches_part(path_parts[pi], part) {
pi += 1;
if *part == "**" {
// ** matches zero or more path segments
// If this is the last pattern part, consume all remaining path segments
if part == parts.last().unwrap() {
pi = path_parts.len();
break;
}
if *part != "**" {
// Try skipping segments until the next pattern part matches
let next_part = parts.iter().skip_while(|p| **p == "**").next().unwrap_or(&"*");
while pi < path_parts.len() && !matches_part(path_parts[pi], next_part) {
pi += 1;
}
continue;
}
if pi >= path_parts.len() || !matches_part(path_parts[pi], part) {
return false;
}
pi += 1;
}
}
true
// All pattern parts consumed — check that all path segments were matched too
pi == path_parts.len()
}
pub fn register_grep_tools(registry: &mut ToolRegistry) {

View File

@ -154,7 +154,9 @@ async fn read_json_exec(
}
let text = String::from_utf8_lossy(data);
let is_jsonc = path.ends_with(".jsonc") || path.ends_with(".vscodeignore") || text.contains("//");
// Only treat as JSONC if the extension indicates it, or if we can
// confirm a comment-like pattern outside of a string context.
let is_jsonc = path.ends_with(".jsonc");
let json_text = if is_jsonc {
strip_jsonc_comments(&text)
@ -187,13 +189,15 @@ async fn read_json_exec(
"size_bytes": data.len(),
"schema": schema,
"data": if display.chars().count() > 5000 {
format!("{}... (truncated, {} chars total)", &display[..5000], display.chars().count())
let truncated: String = display.chars().take(5000).collect();
format!("{}... (truncated, {} chars total)", truncated, display.chars().count())
} else { display },
}))
}
/// Simple JSONPath-like query support.
/// Supports: $.key, $[0], $.key.nested, $.arr[0].field
/// Bracket notation ["key.with.dots"] allows accessing keys containing dots.
fn query_json(value: &JsonValue, query: &str) -> Result<JsonValue, String> {
let query = query.trim();
let query = if query.starts_with("$.") {
@ -206,46 +210,77 @@ fn query_json(value: &JsonValue, query: &str) -> Result<JsonValue, String> {
let mut current = value.clone();
for part in query.split('.') {
if part.is_empty() {
continue;
// Parse into access segments: Key("name"), Index(0), BracketKey("key.with.dots")
enum Segment { Key(String), Index(usize), BracketKey(String) }
let mut segments: Vec<Segment> = Vec::new();
let mut i = 0;
let q_chars: Vec<char> = query.chars().collect();
while i < q_chars.len() {
if q_chars[i] == '[' {
// Find matching ]
let mut j = i + 1;
let mut bracket_content = String::new();
while j < q_chars.len() && q_chars[j] != ']' {
bracket_content.push(q_chars[j]);
j += 1;
}
if j < q_chars.len() && q_chars[j] == ']' {
let content = bracket_content.trim();
// Check if it's a quoted string key or a numeric index
if content.starts_with('"') && content.ends_with('"') {
let key = content[1..content.len()-1].to_string();
segments.push(Segment::BracketKey(key));
} else if content.starts_with("'") && content.ends_with("'") {
let key = content[1..content.len()-1].to_string();
segments.push(Segment::BracketKey(key));
} else if let Ok(idx) = content.parse::<usize>() {
segments.push(Segment::Index(idx));
} else {
return Err(format!("Invalid bracket notation: [{}]", content));
}
i = j + 1;
// Skip dot after bracket if present
if i < q_chars.len() && q_chars[i] == '.' {
i += 1;
}
} else {
return Err("Unmatched [ in query".into());
}
} else {
// Read key until . or [
let mut key = String::new();
while i < q_chars.len() && q_chars[i] != '.' && q_chars[i] != '[' {
key.push(q_chars[i]);
i += 1;
}
if !key.is_empty() {
// Check if key contains a numeric-only segment (array index shorthand)
segments.push(Segment::Key(key));
}
if i < q_chars.len() && q_chars[i] == '.' {
i += 1;
}
}
}
// Handle array index like [0]
if let Some(idx_start) = part.find('[') {
let key = &part[..idx_start];
if !key.is_empty() {
for seg in &segments {
match seg {
Segment::Key(key) | Segment::BracketKey(key) => {
if let JsonValue::Object(obj) = &current {
current = obj.get(key).cloned().unwrap_or(JsonValue::Null);
} else {
return Err(format!("cannot access property '{}' on non-object", key));
}
}
let rest = &part[idx_start..];
for bracket in rest.split_inclusive(']') {
if bracket.is_empty() || bracket == "]" {
continue;
}
let inner = bracket.trim_end_matches(']');
if let Some(idx) = inner.strip_prefix('[') {
if let Ok(index) = idx.parse::<usize>() {
Segment::Index(idx) => {
if let JsonValue::Array(arr) = &current {
current = arr.get(index).cloned().unwrap_or(JsonValue::Null);
current = arr.get(*idx).cloned().unwrap_or(JsonValue::Null);
} else {
return Err(format!("index {} on non-array", index));
return Err(format!("index {} on non-array", idx));
}
}
}
}
} else {
if let JsonValue::Object(obj) = &current {
current = obj.get(part).cloned().unwrap_or(JsonValue::Null);
} else {
return Err(format!("property '{}' not found", part));
}
}
}
Ok(current)
}

View File

@ -62,6 +62,7 @@ impl From<git::ConfigSnapshot> for ConfigSnapshotResponse {
#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)]
pub struct GitUpdateRepoRequest {
pub default_branch: Option<String>,
pub ai_code_review_enabled: Option<bool>,
}
#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
pub struct ConfigBoolResponse {
@ -459,6 +460,9 @@ impl AppService {
if let Some(default_branch) = params.default_branch {
active.default_branch = Set(default_branch);
}
if let Some(ai_enabled) = params.ai_code_review_enabled {
active.ai_code_review_enabled = Set(ai_enabled);
}
active.update(&txn).await?;
txn.commit().await?;
Ok(())

View File

@ -55,7 +55,15 @@ async fn git_branches_merged_exec(ctx: GitToolCtx, args: serde_json::Value) -> R
let domain = ctx.open_repo(project_name, repo_name).await?;
let is_merged = domain.branch_is_merged(branch, &into).map_err(|e| e.to_string())?;
let merge_base = domain.merge_base(&git::commit::types::CommitOid::new(branch), &git::commit::types::CommitOid::new(&into))
// Resolve branch names to commit OIDs before calling merge_base
let branch_oid = domain.branch_target(branch)
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("branch '{}' not found or has no target", branch))?;
let into_oid = domain.branch_target(&into)
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("branch '{}' not found or has no target", into))?;
let merge_base = domain.merge_base(&branch_oid, &into_oid)
.map(|oid| oid.to_string()).ok();
Ok(serde_json::json!({ "branch": branch, "into": into, "is_merged": is_merged, "merge_base": merge_base }))

View File

@ -22,7 +22,7 @@ async fn git_log_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde_
// Flatten to simple JSON
let result: Vec<_> = commits.iter().map(|c| {
use chrono::TimeZone;
let ts = c.author.time_secs + (c.author.offset_minutes as i64 * 60);
let ts = c.author.time_secs - (c.author.offset_minutes as i64 * 60);
let time_str = chrono::Utc.timestamp_opt(ts, 0).single()
.map(|dt| dt.to_rfc3339()).unwrap_or_else(|| format!("{}", c.author.time_secs));
@ -63,7 +63,7 @@ async fn git_show_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde
let refs = domain.commit_refs(&meta.oid).map_err(|e| e.to_string())?;
use chrono::TimeZone;
let ts = meta.author.time_secs + (meta.author.offset_minutes as i64 * 60);
let ts = meta.author.time_secs - (meta.author.offset_minutes as i64 * 60);
let author_time = chrono::Utc.timestamp_opt(ts, 0).single()
.map(|dt| dt.to_rfc3339()).unwrap_or_else(|| format!("{}", meta.author.time_secs));
@ -90,7 +90,9 @@ async fn git_search_commits_exec(ctx: GitToolCtx, args: serde_json::Value) -> Re
let limit = p.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize;
let domain = ctx.open_repo(project_name, repo_name).await?;
let commits = domain.commit_log(Some("HEAD"), 0, 100).map_err(|e| e.to_string())?;
// Fetch extra commits to have enough candidates after filtering
let walk_limit = limit.saturating_mul(2).max(100);
let commits = domain.commit_log(Some("HEAD"), 0, walk_limit).map_err(|e| e.to_string())?;
let q = query.to_lowercase();
let result: Vec<_> = commits.iter()
@ -104,7 +106,7 @@ async fn git_search_commits_exec(ctx: GitToolCtx, args: serde_json::Value) -> Re
fn flatten_commit(c: &git::commit::types::CommitMeta) -> serde_json::Value {
use chrono::TimeZone;
let ts = c.author.time_secs + (c.author.offset_minutes as i64 * 60);
let ts = c.author.time_secs - (c.author.offset_minutes as i64 * 60);
let author_time = chrono::Utc.timestamp_opt(ts, 0).single()
.map(|dt| dt.to_rfc3339()).unwrap_or_else(|| format!("{}", c.author.time_secs));
let oid = c.oid.to_string();
@ -160,7 +162,7 @@ async fn git_graph_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serd
for (i, p) in m.parent_ids.iter().enumerate() {
if i == 0 { col_map.insert(p.to_string(), lane_index); } else { col_map.remove(p.as_str()); }
}
let ts = m.author.time_secs + (m.author.offset_minutes as i64 * 60);
let ts = m.author.time_secs - (m.author.offset_minutes as i64 * 60);
let author_time = chrono::Utc.timestamp_opt(ts, 0).single()
.map(|dt| dt.to_rfc3339()).unwrap_or_else(|| format!("{}", m.author.time_secs));

View File

@ -42,11 +42,20 @@ async fn git_diff_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde
let result = match (&base_oid, &head_oid) {
(None, None) => {
let head_meta = domain.commit_get_prefix("HEAD").map_err(|e| e.to_string())?;
// Bare repos have no working tree — use tree-to-tree diff instead
if domain.repo().is_bare() {
domain.diff_tree_to_tree(None, Some(&head_meta.oid), opts).map_err(|e| e.to_string())?
} else {
domain.diff_commit_to_workdir(&head_meta.oid, opts).map_err(|e| e.to_string())?
}
}
(Some(base), None) => {
if domain.repo().is_bare() {
domain.diff_tree_to_tree(Some(base), None, opts).map_err(|e| e.to_string())?
} else {
domain.diff_commit_to_workdir(base, opts).map_err(|e| e.to_string())?
}
}
(Some(base), Some(head_oid_val)) => {
domain.diff_tree_to_tree(Some(base), Some(head_oid_val), opts).map_err(|e| e.to_string())?
}
@ -74,12 +83,20 @@ async fn git_diff_stats_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result
let domain = ctx.open_repo(project_name, repo_name).await?;
let stats = if base.len() >= 40 || head.len() >= 40 {
let stats = if base.len() >= 40 && head.len() >= 40 {
domain.diff_stats(&git::commit::types::CommitOid::new(base), &git::commit::types::CommitOid::new(head))
.map_err(|e| e.to_string())?
} else {
let b = domain.commit_get_prefix(base).map_err(|e| e.to_string())?.oid;
let h = domain.commit_get_prefix(head).map_err(|e| e.to_string())?.oid;
let b = if base.len() >= 40 {
git::commit::types::CommitOid::new(base)
} else {
domain.commit_get_prefix(base).map_err(|e| e.to_string())?.oid
};
let h = if head.len() >= 40 {
git::commit::types::CommitOid::new(head)
} else {
domain.commit_get_prefix(head).map_err(|e| e.to_string())?.oid
};
domain.diff_stats(&b, &h).map_err(|e| e.to_string())?
};

View File

@ -77,10 +77,13 @@ async fn git_file_history_exec(ctx: GitToolCtx, args: serde_json::Value) -> Resu
let project_name = p.get("project_name").and_then(|v| v.as_str()).ok_or("missing project_name")?;
let repo_name = p.get("repo_name").and_then(|v| v.as_str()).ok_or("missing repo_name")?;
let path = p.get("path").and_then(|v| v.as_str()).ok_or("missing path")?;
let rev = p.get("rev").and_then(|v| v.as_str()).map(String::from).unwrap_or_else(|| "HEAD".to_string());
let limit = p.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize;
let domain = ctx.open_repo(project_name, repo_name).await?;
let commits = domain.commit_log(Some("HEAD"), 0, 500).map_err(|e| e.to_string())?;
// Fetch extra commits to have enough candidates after filtering
let walk_limit = limit.saturating_mul(2).max(200);
let commits = domain.commit_log(Some(&rev), 0, walk_limit).map_err(|e| e.to_string())?;
let result: Vec<_> = commits.iter()
.filter(|c| domain.tree_entry_by_path(&c.tree_id, path).is_ok())
@ -125,7 +128,7 @@ async fn git_blob_get_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<s
fn flatten_commit(c: &git::commit::types::CommitMeta) -> serde_json::Value {
use chrono::TimeZone;
let ts = c.author.time_secs + (c.author.offset_minutes as i64 * 60);
let ts = c.author.time_secs - (c.author.offset_minutes as i64 * 60);
let author_time = chrono::Utc.timestamp_opt(ts, 0).single()
.map(|dt| dt.to_rfc3339()).unwrap_or_else(|| format!("{}", c.author.time_secs));
let oid = c.oid.to_string();
@ -182,6 +185,7 @@ pub fn register_git_tools(registry: &mut ToolRegistry) {
("project_name".into(), ToolParam { name: "project_name".into(), param_type: "string".into(), description: Some("Project name (slug)".into()), required: true, properties: None, items: None }),
("repo_name".into(), ToolParam { name: "repo_name".into(), param_type: "string".into(), description: Some("Repository name".into()), required: true, properties: None, items: None }),
("path".into(), ToolParam { name: "path".into(), param_type: "string".into(), description: Some("File path to trace history for".into()), required: true, properties: None, items: None }),
("rev".into(), ToolParam { name: "rev".into(), param_type: "string".into(), description: Some("Revision to start history from (default: HEAD)".into()), required: false, properties: None, items: None }),
("limit".into(), ToolParam { name: "limit".into(), param_type: "integer".into(), description: Some("Maximum number of commits to return (default: 20)".into()), required: false, properties: None, items: None }),
]);
let schema = ToolSchema { schema_type: "object".into(), properties: Some(p), required: Some(vec!["project_name".into(), "repo_name".into(), "path".into()]) };

View File

@ -6,7 +6,7 @@
use base64::Engine;
use chrono::TimeZone;
use git::commit::types::{CommitMeta, CommitReflogEntry};
use git::diff::types::{DiffDelta, DiffStats};
use git::diff::types::{DiffDelta, DiffDeltaStatus, DiffStats};
use git::tree::types::TreeEntry;
use serde::{Deserialize, Serialize};
@ -121,7 +121,8 @@ pub struct ReflogEntryInfo {
impl ReflogEntryInfo {
pub fn from_entry(entry: &CommitReflogEntry) -> Self {
let ts = entry.time_secs;
let time = format_rfc3339(ts, 0);
let offset = entry.offset_minutes;
let time = format_rfc3339(ts, offset);
Self {
oid_new: entry.oid_new.to_string(),
oid_old: entry.oid_old.to_string(),
@ -216,8 +217,13 @@ pub struct DiffFileOut {
impl DiffFileOut {
pub fn from_delta(delta: &DiffDelta) -> Self {
// For deleted files, use old_file.path; for all others, use new_file.path.
let path = match delta.status {
DiffDeltaStatus::Deleted => delta.old_file.path.clone(),
_ => delta.new_file.path.clone(),
};
Self {
path: delta.new_file.path.clone(),
path,
status: format!("{:?}", delta.status),
is_binary: delta.new_file.is_binary,
size: delta.new_file.size,
@ -402,7 +408,8 @@ impl From<&git::commit::graph::CommitGraphLine> for GraphLineOut {
// ---------------------------------------------------------------------------
fn format_rfc3339(time_secs: i64, offset_minutes: i32) -> String {
let secs = time_secs + (offset_minutes as i64 * 60);
// Git stores local time + offset. To convert to UTC, subtract the offset.
let secs = time_secs - (offset_minutes as i64 * 60);
chrono::Utc
.timestamp_opt(secs, 0)
.single()

View File

@ -222,7 +222,7 @@ impl AppService {
ctx: &Session,
) -> Result<IssueResponse, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let project = self.utils_find_project_by_name(project_name).await?;
let project = self.utils_find_project_by_name(project_name.clone()).await?;
// Any project member can create issues
let member = project_members::Entity::find()
@ -280,6 +280,15 @@ impl AppService {
)
.await;
// Run AI triage asynchronously
let project_name_clone = project_name.clone();
let issue_number = number;
let this = self.clone();
drop(project_name); // allow move below
tokio::spawn(async move {
let _ = this.triage_issue(project_name_clone, issue_number).await;
});
Ok(IssueResponse::from(model))
}

View File

@ -16,6 +16,38 @@ pub struct IssueAddLabelRequest {
pub label_id: i64,
}
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct IssueAddLabelsByNamesRequest {
pub names: Vec<String>,
}
fn default_color_for_label(name: &str) -> String {
let lower = name.to_lowercase();
if lower.contains("bug") || lower.contains("critical") || lower.contains("security") {
"ef4444".to_string()
} else if lower.contains("enhancement") || lower.contains("feature") || lower.contains("improvement") {
"22c55e".to_string()
} else if lower.contains("documentation") || lower.contains("docs") {
"3b82f6".to_string()
} else if lower.contains("question") || lower.contains("help wanted") {
"a855f7".to_string()
} else if lower.contains("good first") || lower.contains("beginner") || lower.contains("easy") {
"10b981".to_string()
} else if lower.contains("priority") || lower.contains("high") {
"f97316".to_string()
} else if lower.contains("backend") || lower.contains("server") {
"6366f1".to_string()
} else if lower.contains("frontend") || lower.contains("ui") || lower.contains("ux") {
"ec4899".to_string()
} else if lower.contains("performance") || lower.contains("optimize") {
"eab308".to_string()
} else if lower.contains("dx") || lower.contains("dev") || lower.contains("tool") {
"14b8a6".to_string()
} else {
"6b7280".to_string()
}
}
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct CreateLabelRequest {
pub name: String,
@ -285,6 +317,91 @@ impl AppService {
response
}
/// Add labels to an issue by name, creating missing labels automatically.
pub async fn issue_label_add_by_names(
&self,
project_name: String,
issue_number: i64,
request: IssueAddLabelsByNamesRequest,
ctx: &Session,
) -> Result<Vec<IssueLabelResponse>, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let project = self.utils_find_project_by_name(project_name).await?;
let _member = project_members::Entity::find()
.filter(project_members::Column::Project.eq(project.id))
.filter(project_members::Column::User.eq(user_uid))
.one(&self.db)
.await?
.ok_or(AppError::NoPower)?;
let issue = issue::Entity::find()
.filter(issue::Column::Project.eq(project.id))
.filter(issue::Column::Number.eq(issue_number))
.one(&self.db)
.await?
.ok_or(AppError::NotFound("Issue not found".to_string()))?;
let mut added: Vec<IssueLabelResponse> = Vec::new();
for name in request.names {
let color = default_color_for_label(&name);
// Find or create label
let lbl = label::Entity::find()
.filter(label::Column::Project.eq(project.id))
.filter(label::Column::Name.eq(&name))
.one(&self.db)
.await?;
let lbl = match lbl {
Some(l) => l,
None => {
let active = label::ActiveModel {
id: Set(0),
project: Set(project.id),
name: Set(name.clone()),
color: Set(color),
..Default::default()
};
active.insert(&self.db).await?
}
};
// Check if already linked
let existing = issue_label::Entity::find()
.filter(issue_label::Column::Issue.eq(issue.id))
.filter(issue_label::Column::Label.eq(lbl.id))
.one(&self.db)
.await?;
if existing.is_some() {
continue;
}
let now = Utc::now();
let active = issue_label::ActiveModel {
issue: Set(issue.id),
label: Set(lbl.id),
relation_at: Set(now),
..Default::default()
};
let model = active.insert(&self.db).await?;
added.push(IssueLabelResponse {
issue: model.issue,
label_id: model.label,
label_name: Some(lbl.name.clone()),
label_color: Some(lbl.color.clone()),
relation_at: model.relation_at,
});
}
if !added.is_empty() {
self.invalidate_issue_cache(project.id, issue_number).await;
}
Ok(added)
}
/// Remove a label from an issue.
pub async fn issue_label_remove(
&self,

View File

@ -16,7 +16,7 @@ pub use comment::{
pub use issue::{
IssueCreateRequest, IssueListResponse, IssueResponse, IssueSummaryResponse, IssueUpdateRequest,
};
pub use label::{CreateLabelRequest, IssueAddLabelRequest, IssueLabelResponse, LabelResponse};
pub use label::{CreateLabelRequest, IssueAddLabelRequest, IssueAddLabelsByNamesRequest, IssueLabelResponse, LabelResponse};
pub use pull_request::{IssueLinkPullRequestRequest, IssuePullRequestResponse};
pub use reaction::{ReactionAddRequest, ReactionListResponse, ReactionResponse};
pub use repo::{IssueLinkRepoRequest, IssueRepoResponse};

View File

@ -36,6 +36,7 @@ pub struct ProjectRepositoryItem {
pub last_commit_at: Option<DateTime<Utc>>,
pub ssh_clone_url: String,
pub https_clone_url: String,
pub ai_code_review_enabled: bool,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ToSchema)]
@ -226,6 +227,7 @@ impl AppService {
last_commit_at: last_commit_times.get(&r.id).and_then(|t| *t),
ssh_clone_url: format!("git@{}:{}", ssh_domain, path),
https_clone_url: format!("https://{}/{}", ssh_domain, path),
ai_code_review_enabled: r.ai_code_review_enabled,
}
})
.collect();

View File

@ -9,7 +9,7 @@ const DEFAULT_MAX_RESULTS: usize = 10;
const MAX_MAX_RESULTS: usize = 50;
/// arXiv API base URL (Atom feed).
const ARXIV_API: &str = "http://export.arxiv.org/api/query";
const ARXIV_API: &str = "https://export.arxiv.org/api/query";
/// arXiv Atom feed entry fields we care about.
#[derive(Debug, Deserialize)]

View File

@ -274,6 +274,8 @@ pub async fn create_board_card_exec(
.ok_or_else(|| ToolError::ExecutionError("No sender context".into()))?;
let db = ctx.db();
require_admin(db, project_id, sender_id).await?;
let board_id = args
.get("board_id")
.and_then(|v| Uuid::parse_str(v.as_str()?).ok())

View File

@ -1,11 +1,80 @@
//! Tool: project_curl — perform HTTP requests (GET/POST/PUT/DELETE)
//!
//! Security measures:
//! - SSRF protection: blocks private IPs and blocks redirects to private IPs
//! - Sensitive header injection: blocks Host, Authorization, Cookie, Proxy-*
//! - Connection pooling via a shared reqwest::Client
use agent::{ToolContext, ToolDefinition, ToolError, ToolParam, ToolSchema};
use std::collections::HashMap;
use std::sync::OnceLock;
/// Maximum response body size: 1 MB.
const MAX_BODY_BYTES: usize = 1 << 20;
/// Headers that are blocked from user-supplied values to prevent injection attacks.
const BLOCKED_HEADERS: &[&str] = &[
"host", "authorization", "cookie", "proxy-authorization",
"proxy-connection", "proxy-authenticate",
];
/// Shared reqwest::Client for connection pooling.
static SHARED_CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
fn shared_client() -> &'static reqwest::Client {
SHARED_CLIENT.get_or_init(|| {
reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(10))
.timeout(std::time::Duration::from_secs(120))
// Block automatic redirect following so we can validate each hop
.redirect(reqwest::redirect::Policy::limited(0))
.build()
.expect("reqwest client build should not fail")
})
}
/// Check if a host string resolves to or is a private/internal IP.
fn is_private_host(host: &str) -> bool {
host.eq_ignore_ascii_case("localhost")
|| host.eq_ignore_ascii_case("127.0.0.1")
|| host.eq_ignore_ascii_case("::1")
|| host.eq_ignore_ascii_case("0.0.0.0")
|| host.eq_ignore_ascii_case("metadata.google.internal")
|| host.eq_ignore_ascii_case("169.254.169.254")
|| host.starts_with("10.")
|| host.starts_with("172.16.")
|| host.starts_with("172.17.")
|| host.starts_with("172.18.")
|| host.starts_with("172.19.")
|| host.starts_with("172.20.")
|| host.starts_with("172.21.")
|| host.starts_with("172.22.")
|| host.starts_with("172.23.")
|| host.starts_with("172.24.")
|| host.starts_with("172.25.")
|| host.starts_with("172.26.")
|| host.starts_with("172.27.")
|| host.starts_with("172.28.")
|| host.starts_with("172.29.")
|| host.starts_with("172.30.")
|| host.starts_with("172.31.")
|| host.starts_with("192.168.")
}
/// Validate URL and any redirect hops against SSRF rules.
fn validate_url_against_ssrf(url_str: &str) -> Result<reqwest::Url, ToolError> {
let parsed = reqwest::Url::parse(url_str)
.map_err(|e| ToolError::ExecutionError(format!("Invalid URL: {}", e)))?;
if let Some(host) = parsed.host_str() {
if is_private_host(host) {
return Err(ToolError::ExecutionError(
"Requests to internal/private IPs are not allowed for security reasons".into(),
));
}
}
Ok(parsed)
}
/// Perform an HTTP request and return the response body and metadata.
/// Supports GET, POST, PUT, DELETE methods. Useful for fetching web pages,
/// calling external APIs, or downloading resources.
@ -13,11 +82,14 @@ pub async fn curl_exec(
_ctx: ToolContext,
args: serde_json::Value,
) -> Result<serde_json::Value, ToolError> {
let url = args
let url_str = args
.get("url")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::ExecutionError("url is required".into()))?;
// SSRF protection: validate initial URL
validate_url_against_ssrf(url_str)?;
let method = args
.get("method")
.and_then(|v| v.as_str())
@ -36,24 +108,39 @@ pub async fn curl_exec(
})
.unwrap_or_default();
// Block sensitive headers that could be used for injection attacks
for (key, _) in &headers {
if BLOCKED_HEADERS.contains(&key.to_lowercase().as_str()) {
return Err(ToolError::ExecutionError(
format!("Header '{}' is not allowed for security reasons", key),
));
}
}
let timeout_secs = args
.get("timeout")
.and_then(|v| v.as_u64())
.unwrap_or(30)
.min(120);
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(timeout_secs))
.build()
.map_err(|e| ToolError::ExecutionError(format!("Failed to build HTTP client: {}", e)))?;
let client = shared_client();
// Build a per-request client with the specific timeout by using the shared
// client's connection pool but overriding timeout per request via request builder.
// Since reqwest::Client::builder().redirect(Policy::limited(0)) disables auto-redirects,
// we manually follow up to 5 redirects with SSRF validation on each hop.
let mut current_url = url_str.to_string();
let mut redirect_count = 0u32;
const MAX_REDIRECTS: u32 = 5;
loop {
let mut request = match method.as_str() {
"GET" => client.get(url),
"POST" => client.post(url),
"PUT" => client.put(url),
"DELETE" => client.delete(url),
"PATCH" => client.patch(url),
"HEAD" => client.head(url),
"GET" => client.get(&current_url),
"POST" => client.post(&current_url),
"PUT" => client.put(&current_url),
"DELETE" => client.delete(&current_url),
"PATCH" => client.patch(&current_url),
"HEAD" => client.head(&current_url),
_ => {
return Err(ToolError::ExecutionError(format!(
"Unsupported HTTP method: {}. Use GET, POST, PUT, DELETE, PATCH, or HEAD.",
@ -62,6 +149,8 @@ pub async fn curl_exec(
}
};
request = request.timeout(std::time::Duration::from_secs(timeout_secs));
for (key, value) in &headers {
request = request.header(key, value);
}
@ -81,6 +170,40 @@ pub async fn curl_exec(
.map_err(|e| ToolError::ExecutionError(format!("HTTP request failed: {}", e)))?;
let status = response.status().as_u16();
// Handle redirects manually with SSRF validation
if status >= 300 && status < 400 {
redirect_count += 1;
if redirect_count > MAX_REDIRECTS {
return Err(ToolError::ExecutionError(
format!("Too many redirects (max {})", MAX_REDIRECTS),
));
}
let location = response.headers()
.get("location")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let location = match location {
Some(l) => l,
None => return Err(ToolError::ExecutionError("Redirect with no Location header".into())),
};
// Resolve relative redirect against current URL
let base = reqwest::Url::parse(&current_url)
.map_err(|e| ToolError::ExecutionError(format!("Invalid current URL: {}", e)))?;
let next_url = base.join(&location)
.map_err(|e| ToolError::ExecutionError(format!("Invalid redirect URL: {}", e)))?;
// Validate redirect target against SSRF rules
if let Some(host) = next_url.host_str() {
if is_private_host(host) {
return Err(ToolError::ExecutionError(
"Redirect to internal/private IP is not allowed".into(),
));
}
}
current_url = next_url.to_string();
continue;
}
let status_text = response.status().canonical_reason().unwrap_or("");
let response_headers: std::collections::HashMap<String, String> = response
@ -124,8 +247,8 @@ pub async fn curl_exec(
)
};
Ok(serde_json::json!({
"url": url,
return Ok(serde_json::json!({
"url": current_url,
"method": method,
"status": status,
"status_text": status_text,
@ -133,7 +256,8 @@ pub async fn curl_exec(
"body": body_text,
"truncated": truncated,
"size_bytes": body_len,
}))
}));
}
}
// ─── tool definition ─────────────────────────────────────────────────────────

View File

@ -224,6 +224,17 @@ pub async fn create_issue_exec(
.sender_id()
.ok_or_else(|| ToolError::ExecutionError("No sender context".into()))?;
// Membership check: only project members can create issues
let member = ProjectMember::find()
.filter(project_members::Column::Project.eq(project_id))
.filter(project_members::Column::User.eq(author_id))
.one(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
if member.is_none() {
return Err(ToolError::ExecutionError("You are not a member of this project".into()));
}
let number = next_issue_number(db, project_id).await?;
let now = Utc::now();
@ -248,7 +259,8 @@ pub async fn create_issue_exec(
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
// Add assignees
// Add assignees (collect errors for partial failure reporting)
let mut assignee_errors = Vec::new();
for uid in &assignee_ids {
let a = issue_assignee::ActiveModel {
issue: Set(model.id),
@ -256,10 +268,13 @@ pub async fn create_issue_exec(
assigned_at: Set(now),
..Default::default()
};
let _ = a.insert(db).await;
if let Err(e) = a.insert(db).await {
assignee_errors.push(format!("assignee {}: {}", uid, e));
}
}
// Add labels
let mut label_errors = Vec::new();
for lid in &label_ids {
let l = issue_label::ActiveModel {
issue: Set(model.id),
@ -267,7 +282,9 @@ pub async fn create_issue_exec(
relation_at: Set(now),
..Default::default()
};
let _ = l.insert(db).await;
if let Err(e) = l.insert(db).await {
label_errors.push(format!("label {}: {}", lid, e));
}
}
// Build assignee/label maps for response
@ -330,6 +347,11 @@ pub async fn create_issue_exec(
"updated_at": model.updated_at.to_rfc3339(),
"assignees": assignee_ids.iter().filter_map(|uid| assignee_map.get(uid)).collect::<Vec<_>>(),
"labels": label_ids.iter().filter_map(|lid| label_map.get(lid)).collect::<Vec<_>>(),
"warnings": if assignee_errors.is_empty() && label_errors.is_empty() {
None
} else {
Some([assignee_errors, label_errors].concat())
},
}))
}

View File

@ -4,6 +4,7 @@ use agent::{ToolContext, ToolDefinition, ToolError, ToolParam, ToolSchema};
use chrono::Utc;
use git::commit::types::CommitOid;
use git::commit::types::CommitSignature;
use git2;
use models::projects::{MemberRole, ProjectMember};
use models::projects::project_members;
use models::repos::repo;
@ -85,6 +86,16 @@ pub async fn create_repo_exec(
.ok_or_else(|| ToolError::ExecutionError("name is required".into()))?
.to_string();
// Validate repo name: no path traversal, no special chars
if repo_name.contains("..") || repo_name.contains('/') || repo_name.contains('\\')
|| repo_name.is_empty() || repo_name.len() > 100
|| !repo_name.chars().next().map_or(false, |c| c.is_alphanumeric())
{
return Err(ToolError::ExecutionError(
"Invalid repository name: must start with alphanumeric, contain no path separators or '..', max 100 chars".into(),
));
}
let description = args
.get("description")
.and_then(|v| v.as_str())
@ -145,13 +156,16 @@ pub async fn create_repo_exec(
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
// Initialize the bare git repository on disk
git2::Repository::init_bare(&repo_dir)
.map_err(|e| ToolError::ExecutionError(format!("Failed to init bare repo: {}", e)))?;
Ok(serde_json::json!({
"id": model.id.to_string(),
"name": model.repo_name,
"description": model.description,
"default_branch": model.default_branch,
"is_private": model.is_private,
"storage_path": model.storage_path,
"created_at": model.created_at.to_rfc3339(),
}))
}
@ -294,6 +308,13 @@ pub async fn create_commit_exec(
.unwrap_or("main")
.to_string();
// Validate branch: no path traversal, no slashes
if branch.contains("..") || branch.contains('/') || branch.contains('\\') || branch.is_empty() {
return Err(ToolError::ExecutionError(
"Invalid branch name: must not contain path separators or '..'".into(),
));
}
let files = args
.get("files")
.and_then(|v| v.as_array())
@ -350,32 +371,75 @@ pub async fn create_commit_exec(
let repo = domain.repo();
// Get current head commit (parent)
// If the repo already has commits (has HEAD), the branch must exist.
// Only allow root commits on truly empty repos (no HEAD at all).
let has_head = repo.head().is_ok();
let parent_oid = repo.refname_to_id(&format!("refs/heads/{}", branch)).ok();
if has_head && parent_oid.is_none() {
return Err(ToolError::ExecutionError(
format!("Branch '{}' does not exist in this repository", branch),
));
}
let parent_ids: Vec<CommitOid> = parent_oid
.map(|oid| CommitOid::from_git2(oid))
.into_iter()
.collect();
// Build index with new files
// Build index from existing tree first (preserves all previous files),
// then add/overwrite with the new files.
let mut index = repo
.index()
.map_err(|e| ToolError::ExecutionError(format!("Failed to get index: {}", e)))?;
// If repo has a parent commit, read its tree into the index so we don't
// lose existing files when write_tree() is called.
if let Some(oid) = &parent_oid {
let parent_commit = repo.find_commit(*oid)
.map_err(|e| ToolError::ExecutionError(format!("Failed to find parent commit: {}", e)))?;
let parent_tree = parent_commit.tree()
.map_err(|e| ToolError::ExecutionError(format!("Failed to get parent tree: {}", e)))?;
index.read_tree(&parent_tree)
.map_err(|e| ToolError::ExecutionError(format!("Failed to read parent tree into index: {}", e)))?;
}
for file in files_data {
let path = file
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::ExecutionError("Each file must have a 'path'".into()))?;
// Validate path: no traversal, no absolute paths, no .git/ prefix
if path.contains("..") || path.starts_with('/') || path.starts_with('\\')
|| path.is_empty() || path.starts_with(".git/") || path == ".git"
{
return Err(ToolError::ExecutionError(
format!("Invalid file path '{}': must be relative, no '..' or absolute path components", path)
));
}
let content = file
.get("content")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::ExecutionError("Each file must have 'content'".into()))?;
let _oid = repo.blob(content.as_bytes()).map_err(|e| {
ToolError::ExecutionError(format!("Failed to write blob for '{}': {}", path, e))
})?;
index.add_path(path.as_ref()).map_err(|e| {
// add_frombuffer requires an IndexEntry with at minimum a path field set.
// It works for both bare and non-bare repos (add_path requires a working tree).
let mut entry = git2::IndexEntry {
ctime: git2::IndexTime::new(0, 0),
mtime: git2::IndexTime::new(0, 0),
dev: 0,
ino: 0,
mode: 0o100644,
uid: 0,
gid: 0,
file_size: 0,
id: git2::Oid::zero(),
flags: 0,
flags_extended: 0,
path: path.as_bytes().to_vec(),
};
index.add_frombuffer(&mut entry, content.as_bytes()).map_err(|e| {
ToolError::ExecutionError(format!("Failed to add '{}' to index: {}", path, e))
})?;
}

View File

@ -5,6 +5,7 @@ use db::database::AppDatabase;
use models::issues::issue;
use models::projects::{project, project_members};
use models::repos::repo;
use models::rooms::{room, room_member};
use models::users::user;
use sea_orm::*;
use sea_query::{Expr as SqExpr, extension::postgres::PgExpr};
@ -113,6 +114,39 @@ pub struct UserSearchItem {
pub created_at: DateTime<Utc>,
}
// ─── Global message search ────────────────────────────────────────────────────
#[derive(Debug, Clone, Deserialize, utoipa::IntoParams)]
pub struct GlobalMessageSearchQuery {
#[param(min_length = 1, max_length = 200)]
pub q: String,
pub page: Option<u32>,
pub per_page: Option<u32>,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct GlobalMessageSearchItem {
pub id: Uuid,
pub room_id: Uuid,
pub room_name: String,
pub sender_id: Option<Uuid>,
pub sender_type: String,
pub display_name: Option<String>,
pub content: String,
pub content_type: String,
pub send_at: DateTime<Utc>,
pub highlighted_content: Option<String>,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct GlobalMessageSearchResponse {
pub query: String,
pub messages: Vec<GlobalMessageSearchItem>,
pub total: i64,
pub page: u32,
pub per_page: u32,
}
// ─── Per-type result set ─────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, ToSchema)]
@ -465,4 +499,157 @@ impl AppService {
Ok(SearchResultSet::new(items, total, page, per_page))
}
/// Search messages across all rooms the current user can access.
/// Uses PostgreSQL full-text search with ts_headline for result highlighting.
pub async fn global_message_search(
&self,
ctx: &Session,
params: GlobalMessageSearchQuery,
) -> Result<GlobalMessageSearchResponse, AppError> {
let user_id = ctx.user();
// Anonymous users cannot search messages
let Some(user_id) = user_id else {
return Err(AppError::Unauthorized);
};
if params.q.trim().is_empty() {
return Ok(GlobalMessageSearchResponse {
query: params.q.clone(),
messages: Vec::new(),
total: 0,
page: params.page.unwrap_or(1),
per_page: params.per_page.unwrap_or(20),
});
}
let page = std::cmp::max(1, params.page.unwrap_or(1));
let per_page = std::cmp::min(100, std::cmp::max(1, params.per_page.unwrap_or(20)));
let offset = (page - 1) * per_page;
let q = params.q.trim();
// Build the set of room IDs the user can access:
// 1. Direct room memberships
let direct_rooms: Vec<Uuid> = room_member::Entity::find()
.filter(room_member::Column::User.eq(user_id))
.select_only()
.column(room_member::Column::Room)
.into_tuple::<Uuid>()
.all(&self.db)
.await
.map_err(|_| AppError::InternalError)?;
// 2. Public rooms in projects the user is a member of
let project_ids = accessible_project_ids(&self.db, Some(user_id)).await?;
let public_rooms: Vec<Uuid> = room::Entity::find()
.filter(room::Column::Project.is_in(project_ids.clone()))
.filter(room::Column::Public.eq(true))
.select_only()
.column(room::Column::Id)
.into_tuple::<Uuid>()
.all(&self.db)
.await
.map_err(|_| AppError::InternalError)?;
// Merge and deduplicate accessible room IDs using a HashSet
use std::collections::HashSet;
let mut accessible_set: HashSet<Uuid> = direct_rooms.into_iter().collect();
for rid in public_rooms {
accessible_set.insert(rid);
}
let accessible_rooms: Vec<Uuid> = accessible_set.iter().cloned().collect();
if accessible_rooms.is_empty() {
return Ok(GlobalMessageSearchResponse {
query: q.to_string(),
messages: Vec::new(),
total: 0,
page,
per_page,
});
}
// Fetch room names for the accessible rooms
let room_names_map: std::collections::HashMap<Uuid, String> = room::Entity::find()
.filter(room::Column::Id.is_in(accessible_rooms.clone()))
.all(&self.db)
.await
.map_err(|_| AppError::InternalError)?
.into_iter()
.map(|r| (r.id, r.room_name))
.collect();
let tsquery = format!("plainto_tsquery('simple', $1)");
let sql = format!(
r#"
SELECT m.id, m.room, m.sender_type, m.sender_id,
m.content, m.content_type, m.send_at,
ts_headline('simple', m.content, {}, 'StartSel=<mark>, StopSel=</mark>, MaxWords=50, MinWords=15') AS highlighted_content
FROM room_message m
WHERE m.room = ANY($2)
AND m.content_tsv @@ {}
AND m.revoked IS NULL
ORDER BY m.send_at DESC
LIMIT $3 OFFSET $4"#,
tsquery,
tsquery
);
// Results query
let results_sql = Statement::from_sql_and_values(
DbBackend::Postgres,
&sql,
vec![q.into(), accessible_rooms.clone().into(), per_page.into(), offset.into()],
);
let rows = self.db.query_all_raw(results_sql).await?;
let mut messages: Vec<GlobalMessageSearchItem> = Vec::new();
for row in rows {
let room_id: Uuid = row.try_get::<Uuid>("", "room").unwrap_or_default();
let sender_type_str = row.try_get::<String>("", "sender_type").unwrap_or_default();
let content_type_str = row.try_get::<String>("", "content_type").unwrap_or_default();
let highlighted = row
.try_get::<String>("", "highlighted_content")
.ok();
messages.push(GlobalMessageSearchItem {
id: row.try_get::<Uuid>("", "id").unwrap_or_default(),
room_id,
room_name: room_names_map.get(&room_id).cloned().unwrap_or_default(),
sender_id: row.try_get::<Option<Uuid>>("", "sender_id").ok().flatten(),
sender_type: sender_type_str,
display_name: None,
content: row.try_get::<String>("", "content").unwrap_or_default(),
content_type: content_type_str,
send_at: row.try_get::<DateTime<Utc>>("", "send_at").unwrap_or_default(),
highlighted_content: highlighted,
});
}
// Count total across all accessible rooms
let count_sql = format!(
"SELECT COUNT(*) AS count FROM room_message WHERE room = ANY($1) AND content_tsv @@ {} AND revoked IS NULL",
tsquery
);
let count_stmt = Statement::from_sql_and_values(
DbBackend::Postgres,
&count_sql,
vec![accessible_rooms.into(), q.into()],
);
let count_row = self.db.query_one_raw(count_stmt).await?;
let total: i64 = count_row
.and_then(|r| r.try_get::<i64>("", "count").ok())
.unwrap_or(0);
Ok(GlobalMessageSearchResponse {
query: q.to_string(),
messages,
total,
page,
per_page,
})
}
}