Improve auto project setup (vibe-kanban) (#836)
* Excellent! All checks pass. Let me provide a summary of what I've implemented:
## Summary
I have successfully added analytics for projects created by the auto setup functionality. Here are the changes made:
### 1. **Auto Project Setup Analytics** (`crates/deployment/src/lib.rs:285-312`)
- Added analytics tracking to the `trigger_auto_project_setup` function
- When projects are successfully auto-created, they now track a `project_created` event with `"source": "auto_setup"`
- The event includes standard project creation properties: `project_id`, `use_existing_repo`, `has_setup_script`, `has_dev_script`
### 2. **Manual Project Creation Analytics** (`crates/server/src/routes/projects.rs:152-164`)
- Updated the existing manual project creation analytics to include `"source": "manual"`
- This maintains the same event name (`project_created`) while distinguishing the source
### 3. **GitHub Project Creation Analytics** (`crates/server/src/routes/github.rs:175-188`)
- Added `"source": "github"` to the existing `project_created_from_github` event for consistency
- This maintains the separate event name while adding source context
### Key Benefits:
- **Same Event Name**: All project creations use `project_created` (except GitHub which has its own specialized event)
- **JSON Source Field**: Easy filtering and analysis by source type (`auto_setup`, `manual`, `github`)
- **Backward Compatible**: Existing analytics queries continue to work
- **Consistent Structure**: All events include the same base properties plus source information
The implementation allows for easy analysis of project creation patterns by source, helping understand user behavior and the effectiveness of the auto setup feature.
* Excellent! The fix has been successfully implemented. Here's what I changed:
## Background Task Fix
**Problem**: The `trigger_auto_project_setup()` was being awaited synchronously in the config update endpoint, which means users had to wait for the entire auto-setup process (filesystem scanning + project creation) to complete before getting their config update response.
**Solution**: Wrapped the auto-setup call in `tokio::spawn()` to run it as a background task:
```rust
// Before (blocking):
deployment.trigger_auto_project_setup().await;
// After (non-blocking):
let deployment_clone = deployment.clone();
tokio::spawn(async move {
deployment_clone.trigger_auto_project_setup().await;
});
```
## Benefits:
1. **Faster Response Times**: Config updates now return immediately instead of waiting for filesystem scanning and project creation
2. **Better User Experience**: Users don't experience delays when acknowledging the disclaimer
3. **Non-blocking**: The auto-setup happens in the background without affecting the main request flow
4. **Same Functionality**: The auto-setup still runs as intended, just asynchronously
The change ensures that when users acknowledge the disclaimer (which triggers auto-setup), they get an immediate response while the system quietly discovers and sets up projects in the background. This is much better UX and prevents timeouts on slower systems with many git repositories.
* Add repo discovery tests (vibe-kanban 17e3a502)
We should add a simple test for git repo discovery
* fmt
* Only compile fs tests for test builds, align project creation event names
This commit is contained in:
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
182
crates/services/tests/filesystem_repo_discovery.rs
Normal file
182
crates/services/tests/filesystem_repo_discovery.rs
Normal file
@@ -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<String> = 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<String> = 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<String> = 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()));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user