securegit 0.8.5

Zero-trust git replacement with 12 built-in security scanners, LLM redteam bridge, universal undo, durable backups, and a 50-tool MCP server
Documentation
//! GraphRAG API Client
//!
//! HTTP client for communicating with the GraphRAG service.
//! Handles repository indexing requests after git operations.

use super::config::GraphRagConfig;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::time::Duration;
use tracing::{debug, info, warn};

/// Request to index a repository
#[derive(Debug, Serialize)]
struct IndexRequest {
    repo_path: String,
    repo_url: Option<String>,
    branch: String,
    incremental: bool,
}

/// Response from index request
#[derive(Debug, Deserialize)]
pub struct IndexResponse {
    pub job_id: String,
    pub status: String,
    pub message: String,
}

/// Response from query request
#[derive(Debug, Deserialize)]
pub struct QueryResponse {
    pub patterns: Vec<CodePattern>,
    pub related_symbols: Vec<RelatedSymbol>,
    pub summary: String,
}

/// A detected code pattern
#[derive(Debug, Deserialize)]
pub struct CodePattern {
    pub pattern_type: String,
    pub name: String,
    pub file_path: String,
    pub line_start: i32,
    pub line_end: i32,
    pub confidence: f32,
    pub description: String,
    pub code_snippet: Option<String>,
}

/// A related code symbol
#[derive(Debug, Deserialize)]
pub struct RelatedSymbol {
    pub name: String,
    pub kind: String,
    pub file_path: String,
    pub line: i32,
    pub relationship: String,
    pub distance: i32,
}

/// GraphRAG API client
pub struct GraphRagClient {
    config: GraphRagConfig,
    client: reqwest::Client,
}

impl GraphRagClient {
    /// Create a new GraphRAG client with default config
    pub fn new() -> Result<Self> {
        Self::with_config(GraphRagConfig::load())
    }

    /// Create a new GraphRAG client with custom config
    pub fn with_config(config: GraphRagConfig) -> Result<Self> {
        let client = reqwest::Client::builder()
            .timeout(Duration::from_secs(config.timeout_secs))
            .build()
            .context("Failed to create HTTP client")?;

        Ok(Self { config, client })
    }

    /// Check if GraphRAG service is available
    pub async fn is_available(&self) -> bool {
        if !self.config.enabled {
            return false;
        }

        let url = format!("{}/health", self.config.api_url);
        match self.client.get(&url).send().await {
            Ok(resp) => resp.status().is_success(),
            Err(_) => false,
        }
    }

    /// Queue a repository for indexing
    ///
    /// Called after clone, fetch, pull, or push operations.
    /// Returns immediately if async_index is true, otherwise waits for completion.
    pub async fn index_repository(
        &self,
        repo_path: &Path,
        repo_url: Option<&str>,
        branch: Option<&str>,
    ) -> Result<IndexResponse> {
        if !self.config.enabled {
            return Ok(IndexResponse {
                job_id: "disabled".to_string(),
                status: "skipped".to_string(),
                message: "GraphRAG indexing is disabled".to_string(),
            });
        }

        let abs_path = repo_path
            .canonicalize()
            .unwrap_or_else(|_| repo_path.to_path_buf());

        // Translate the path if a path mapping is configured (host -> container)
        let translated_path = self.config.translate_path(&abs_path.to_string_lossy());
        debug!(
            "Path translation: {} -> {}",
            abs_path.display(),
            translated_path
        );

        let request = IndexRequest {
            repo_path: translated_path,
            repo_url: repo_url.map(|s| s.to_string()),
            branch: branch.unwrap_or("main").to_string(),
            incremental: self.config.incremental,
        };

        let url = format!("{}/index", self.config.api_url);
        debug!("Sending index request to {}: {:?}", url, request);

        let mut req = self.client.post(&url).json(&request);
        if let Some(ref key) = self.config.api_key {
            req = req.bearer_auth(key);
        }
        let response = req.send().await.context("Failed to send index request")?;

        if !response.status().is_success() {
            let status = response.status();
            let body = response.text().await.unwrap_or_default();
            anyhow::bail!("Index request failed ({}): {}", status, body);
        }

        let result: IndexResponse = response
            .json()
            .await
            .context("Failed to parse index response")?;

        info!(
            "GraphRAG indexing queued: job_id={}, status={}",
            result.job_id, result.status
        );

        // If not async, poll for completion
        if !self.config.async_index {
            self.wait_for_completion(&result.job_id).await?;
        }

        Ok(result)
    }

    /// Wait for an indexing job to complete
    async fn wait_for_completion(&self, job_id: &str) -> Result<IndexResponse> {
        let url = format!("{}/index/{}", self.config.api_url, job_id);
        let max_attempts = 60; // 5 minutes with 5s intervals

        for attempt in 0..max_attempts {
            tokio::time::sleep(Duration::from_secs(5)).await;

            let mut req = self.client.get(&url);
            if let Some(ref key) = self.config.api_key {
                req = req.bearer_auth(key);
            }
            let response = req.send().await?;
            let status: IndexResponse = response.json().await?;

            match status.status.as_str() {
                "completed" => {
                    info!("GraphRAG indexing completed: {}", status.message);
                    return Ok(status);
                }
                "failed" => {
                    anyhow::bail!("Indexing failed: {}", status.message);
                }
                _ => {
                    debug!(
                        "Indexing in progress (attempt {}/{}): {}",
                        attempt + 1,
                        max_attempts,
                        status.status
                    );
                }
            }
        }

        anyhow::bail!("Indexing timed out after {} attempts", max_attempts)
    }

    /// Query the knowledge graph for code understanding
    pub async fn query(
        &self,
        repo_id: &str,
        query: &str,
        max_results: usize,
    ) -> Result<QueryResponse> {
        #[derive(Serialize)]
        struct QueryRequest<'a> {
            repo_id: &'a str,
            query: &'a str,
            max_results: usize,
            include_code: bool,
        }

        let url = format!("{}/query", self.config.api_url);
        let request = QueryRequest {
            repo_id,
            query,
            max_results,
            include_code: true,
        };

        let mut req = self.client.post(&url).json(&request);
        if let Some(ref key) = self.config.api_key {
            req = req.bearer_auth(key);
        }
        let response = req.send().await.context("Failed to send query request")?;

        if !response.status().is_success() {
            let status = response.status();
            let body = response.text().await.unwrap_or_default();
            anyhow::bail!("Query failed ({}): {}", status, body);
        }

        response
            .json()
            .await
            .context("Failed to parse query response")
    }

    /// Get the call graph for a symbol
    pub async fn get_call_graph(
        &self,
        repo_id: &str,
        symbol_name: &str,
        depth: usize,
    ) -> Result<serde_json::Value> {
        #[derive(Serialize)]
        struct CallGraphRequest<'a> {
            repo_id: &'a str,
            symbol_name: &'a str,
            depth: usize,
            direction: &'a str,
        }

        let url = format!("{}/callgraph", self.config.api_url);
        let request = CallGraphRequest {
            repo_id,
            symbol_name,
            depth,
            direction: "both",
        };

        let mut req = self.client.post(&url).json(&request);
        if let Some(ref key) = self.config.api_key {
            req = req.bearer_auth(key);
        }
        let response = req
            .send()
            .await
            .context("Failed to send callgraph request")?;

        if !response.status().is_success() {
            let status = response.status();
            let body = response.text().await.unwrap_or_default();
            anyhow::bail!("Call graph request failed ({}): {}", status, body);
        }

        response
            .json()
            .await
            .context("Failed to parse callgraph response")
    }
}

impl Default for GraphRagClient {
    fn default() -> Self {
        Self::new().expect("Failed to create default GraphRAG client")
    }
}

/// Helper function to trigger indexing after git operations
///
/// This is the main entry point called from git operation handlers.
/// It's non-blocking by default and logs any errors without failing the operation.
pub async fn trigger_indexing(repo_path: &Path, repo_url: Option<&str>, branch: Option<&str>) {
    let config = GraphRagConfig::load();
    if !config.enabled {
        debug!("GraphRAG indexing disabled, skipping");
        return;
    }

    match GraphRagClient::with_config(config) {
        Ok(client) => {
            if !client.is_available().await {
                debug!("GraphRAG service not available, skipping indexing");
                return;
            }

            match client.index_repository(repo_path, repo_url, branch).await {
                Ok(response) => {
                    info!("GraphRAG: {} (job: {})", response.message, response.job_id);
                }
                Err(e) => {
                    warn!("GraphRAG indexing failed: {}", e);
                }
            }
        }
        Err(e) => {
            warn!("Failed to create GraphRAG client: {}", e);
        }
    }
}

/// Synchronous wrapper for trigger_indexing
///
/// Uses tokio runtime if available, otherwise creates a blocking runtime.
pub fn trigger_indexing_sync(repo_path: &Path, repo_url: Option<&str>, branch: Option<&str>) {
    // Convert references to owned types before spawning
    let path = repo_path.to_path_buf();
    let url = repo_url.map(|s| s.to_string());
    let br = branch.map(|s| s.to_string());

    // Try to use existing runtime if we're in an async context
    if let Ok(handle) = tokio::runtime::Handle::try_current() {
        handle.spawn(async move {
            trigger_indexing(&path, url.as_deref(), br.as_deref()).await;
        });
    } else {
        // Create a new runtime for blocking context
        if let Ok(rt) = tokio::runtime::Builder::new_current_thread()
            .enable_all()
            .build()
        {
            rt.block_on(async {
                trigger_indexing(&path, url.as_deref(), br.as_deref()).await;
            });
        }
    }
}