diff --git a/crates/deployment/src/lib.rs b/crates/deployment/src/lib.rs index 17c3c8a7..bc37f8d2 100644 --- a/crates/deployment/src/lib.rs +++ b/crates/deployment/src/lib.rs @@ -247,9 +247,9 @@ pub trait Deployment: Clone + Send + Sync + 'static { /// Trigger background auto-setup of default projects for new users async fn trigger_auto_project_setup(&self) { // soft timeout to give the filesystem search a chance to complete - let soft_timeout_ms = 800; + let soft_timeout_ms = 2_000; // hard timeout to ensure the background task doesn't run indefinitely - let hard_timeout_ms = 1_000; + let hard_timeout_ms = 2_300; let project_count = Project::count(&self.db().pool).await.unwrap_or(0); // Only proceed if no projects exist @@ -257,7 +257,7 @@ pub trait Deployment: Clone + Send + Sync + 'static { // Discover local git repositories if let Ok(repos) = self .filesystem() - .list_common_git_repos(soft_timeout_ms, hard_timeout_ms, Some(3)) + .list_common_git_repos(soft_timeout_ms, hard_timeout_ms, Some(4)) .await { // Take first 3 repositories and create projects @@ -282,19 +282,34 @@ pub trait Deployment: Clone + Send + Sync + 'static { // Create project (ignore individual failures) let project_id = Uuid::new_v4(); - if let Err(e) = Project::create(&self.db().pool, &create_data, project_id).await - { - tracing::warn!( - "Failed to auto-create project '{}': {}", - create_data.name, - e - ); - } else { - tracing::info!( - "Auto-created project '{}' from {}", - create_data.name, - create_data.git_repo_path - ); + match Project::create(&self.db().pool, &create_data, project_id).await { + Ok(project) => { + tracing::info!( + "Auto-created project '{}' from {}", + create_data.name, + create_data.git_repo_path + ); + + // Track project creation event + self.track_if_analytics_allowed( + "project_created", + serde_json::json!({ + "project_id": project.id.to_string(), + "use_existing_repo": create_data.use_existing_repo, + "has_setup_script": create_data.setup_script.is_some(), + "has_dev_script": create_data.dev_script.is_some(), + "source": "auto_setup", + }), + ) + .await; + } + Err(e) => { + tracing::warn!( + "Failed to auto-create project '{}': {}", + create_data.name, + e + ); + } } } } diff --git a/crates/server/src/routes/config.rs b/crates/server/src/routes/config.rs index c186737b..9a511fba 100644 --- a/crates/server/src/routes/config.rs +++ b/crates/server/src/routes/config.rs @@ -172,7 +172,11 @@ async fn handle_config_events(deployment: &DeploymentImpl, old: &Config, new: &C track_config_events(deployment, old, new).await; if !old.disclaimer_acknowledged && new.disclaimer_acknowledged { - deployment.trigger_auto_project_setup().await; + // Spawn auto project setup as background task to avoid blocking config response + let deployment_clone = deployment.clone(); + tokio::spawn(async move { + deployment_clone.trigger_auto_project_setup().await; + }); } } diff --git a/crates/server/src/routes/github.rs b/crates/server/src/routes/github.rs index 5e8fc26e..2dd03f33 100644 --- a/crates/server/src/routes/github.rs +++ b/crates/server/src/routes/github.rs @@ -1,11 +1,11 @@ #![cfg(feature = "cloud")] use axum::{ + Json, Router, extract::{Query, State}, http::StatusCode, response::Json as ResponseJson, routing::{get, post}, - Json, Router, }; use serde::Deserialize; use ts_rs::TS; @@ -14,13 +14,13 @@ use uuid::Uuid; use crate::{ app_state::AppState, models::{ - project::{CreateProject, Project}, ApiResponse, + project::{CreateProject, Project}, }, services::{ + GitHubServiceError, git_service::GitService, github_service::{GitHubService, RepositoryInfo}, - GitHubServiceError, }, }; @@ -175,13 +175,14 @@ pub async fn create_project_from_github( // Track project creation event app_state .track_analytics_event( - "project_created_from_github", + "project_created", Some(serde_json::json!({ "project_id": project.id.to_string(), "repository_id": payload.repository_id, "clone_url": payload.clone_url, "has_setup_script": has_setup_script, "has_dev_script": has_dev_script, + "source": "github", })), ) .await; diff --git a/crates/server/src/routes/projects.rs b/crates/server/src/routes/projects.rs index 6b30b1ab..8987b49d 100644 --- a/crates/server/src/routes/projects.rs +++ b/crates/server/src/routes/projects.rs @@ -158,6 +158,7 @@ pub async fn create_project( "use_existing_repo": use_existing_repo, "has_setup_script": project.setup_script.is_some(), "has_dev_script": project.dev_script.is_some(), + "source": "manual", }), ) .await; diff --git a/crates/services/tests/filesystem_repo_discovery.rs b/crates/services/tests/filesystem_repo_discovery.rs new file mode 100644 index 00000000..0e67935e --- /dev/null +++ b/crates/services/tests/filesystem_repo_discovery.rs @@ -0,0 +1,182 @@ +#[cfg(test)] +mod filesystem_tests { + use std::{fs, path::Path}; + + use services::services::filesystem::FilesystemService; + use tempfile::TempDir; + + /// Helper function to create a directory structure + fn create_dir_structure(base: &Path, path: &str) { + let full_path = base.join(path); + if let Some(parent) = full_path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::create_dir_all(&full_path).unwrap(); + } + + /// Helper function to create a git repository (just creates .git directory) + fn create_git_repo(base: &Path, path: &str) { + create_dir_structure(base, path); + let git_dir = base.join(path).join(".git"); + fs::create_dir_all(&git_dir).unwrap(); + } + + #[tokio::test] + async fn test_list_git_repos_discovers_repos() { + let temp_dir = TempDir::new().unwrap(); + let base_path = temp_dir.path(); + + // Create test structure: + // temp_dir/ + // ├── project1/ (.git) + // ├── project2/ (.git) + // ├── regular_folder/ + // └── nested/ + // └── deep_repo/ (.git) + create_git_repo(base_path, "project1"); + create_git_repo(base_path, "project2"); + create_dir_structure(base_path, "regular_folder"); + let nested_path = base_path.join("nested"); + fs::create_dir_all(&nested_path).unwrap(); + create_git_repo(&nested_path, "deep_repo"); + + let filesystem_service = FilesystemService::new(); + + // Test discovering repos with reasonable timeouts + let repos = filesystem_service + .list_git_repos( + Some(base_path.to_string_lossy().to_string()), + 5000, // 5 second timeout + 10000, // 10 second hard timeout + Some(3), // max depth 3 + ) + .await + .unwrap(); + + // Verify we found the git repositories + let repo_names: Vec = repos.iter().map(|r| r.name.clone()).collect(); + + assert!(repo_names.contains(&"project1".to_string())); + assert!(repo_names.contains(&"project2".to_string())); + assert!(repo_names.contains(&"deep_repo".to_string())); + assert!(!repo_names.contains(&"regular_folder".to_string())); + + // Verify all discovered entries are marked as git repos + for repo in &repos { + assert!(repo.is_git_repo); + assert!(repo.is_directory); + } + } + + #[tokio::test] + async fn test_list_git_repos_respects_skip_directories() { + let temp_dir = TempDir::new().unwrap(); + let base_path = temp_dir.path(); + + // Create repos in directories that should be skipped + create_git_repo(base_path, "node_modules/some_repo"); + create_git_repo(base_path, "target/debug_repo"); + create_git_repo(base_path, "build/build_repo"); + + // Create repos that should be found + create_git_repo(base_path, "src_repo"); + create_git_repo(base_path, "my_project"); + + let filesystem_service = FilesystemService::new(); + + let repos = filesystem_service + .list_git_repos( + Some(base_path.to_string_lossy().to_string()), + 5000, + 10000, + Some(3), + ) + .await + .unwrap(); + + let repo_names: Vec = repos.iter().map(|r| r.name.clone()).collect(); + + // Should find the valid repos + assert!(repo_names.contains(&"src_repo".to_string())); + assert!(repo_names.contains(&"my_project".to_string())); + + // Should skip repos in ignored directories + assert!(!repo_names.contains(&"some_repo".to_string())); + assert!(!repo_names.contains(&"debug_repo".to_string())); + assert!(!repo_names.contains(&"build_repo".to_string())); + } + + #[tokio::test] + async fn test_list_git_repos_empty_directory() { + let temp_dir = TempDir::new().unwrap(); + let base_path = temp_dir.path(); + + // Create empty directory with no git repos + create_dir_structure(base_path, "empty_folder"); + + let filesystem_service = FilesystemService::new(); + + let repos = filesystem_service + .list_git_repos( + Some(base_path.to_string_lossy().to_string()), + 5000, + 10000, + Some(2), + ) + .await + .unwrap(); + + // Should return empty list + assert!(repos.is_empty()); + } + + #[tokio::test] + async fn test_list_git_repos_nonexistent_path() { + let filesystem_service = FilesystemService::new(); + + let result = filesystem_service + .list_git_repos( + Some("/nonexistent/path/that/does/not/exist".to_string()), + 1000, + 2000, + Some(2), + ) + .await; + + // Should return an error for non-existent path + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_list_git_repos_with_max_depth_limit() { + let temp_dir = TempDir::new().unwrap(); + let base_path = temp_dir.path(); + + // Create nested structure deeper than max depth + let deep_path = base_path.join("level1").join("level2").join("level3"); + fs::create_dir_all(&deep_path).unwrap(); + create_git_repo(&deep_path, "deep_repo"); + create_git_repo(base_path, "shallow_repo"); + + let filesystem_service = FilesystemService::new(); + + // Search with depth limit of 2 + let repos = filesystem_service + .list_git_repos( + Some(base_path.to_string_lossy().to_string()), + 5000, + 10000, + Some(2), // Max depth 2 - should not find deep_repo + ) + .await + .unwrap(); + + let repo_names: Vec = repos.iter().map(|r| r.name.clone()).collect(); + + // Should find shallow repo + assert!(repo_names.contains(&"shallow_repo".to_string())); + + // Should not find deep repo due to depth limit + assert!(!repo_names.contains(&"deep_repo".to_string())); + } +}