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:
Alex Netsch
2025-09-25 17:19:38 +01:00
committed by GitHub
parent 5b0be87d3f
commit 6513793b77
5 changed files with 224 additions and 21 deletions

View File

@@ -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
);
}
}
}
}

View File

@@ -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;
});
}
}

View File

@@ -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;

View File

@@ -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;

View 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()));
}
}