Add setup script

This commit is contained in:
Louis Knight-Webb
2025-06-18 16:43:26 -04:00
parent b9690626a2
commit 376be69b8e
29 changed files with 271 additions and 497 deletions

View File

@@ -6,12 +6,12 @@
{
"name": "id!: Uuid",
"ordinal": 0,
"type_info": "Text"
"type_info": "Blob"
},
{
"name": "project_id!: Uuid",
"ordinal": 1,
"type_info": "Text"
"type_info": "Blob"
},
{
"name": "title",

View File

@@ -6,12 +6,12 @@
{
"name": "id!: Uuid",
"ordinal": 0,
"type_info": "Text"
"type_info": "Blob"
},
{
"name": "task_id!: Uuid",
"ordinal": 1,
"type_info": "Text"
"type_info": "Blob"
},
{
"name": "worktree_path",

View File

@@ -1,12 +1,12 @@
{
"db_name": "SQLite",
"query": "SELECT id as \"id!: Uuid\", name, git_repo_path, created_at as \"created_at!: DateTime<Utc>\", updated_at as \"updated_at!: DateTime<Utc>\" FROM projects WHERE git_repo_path = $1 AND id != $2",
"query": "SELECT id as \"id!: Uuid\", name, git_repo_path, setup_script, created_at as \"created_at!: DateTime<Utc>\", updated_at as \"updated_at!: DateTime<Utc>\" FROM projects WHERE git_repo_path = $1 AND id != $2",
"describe": {
"columns": [
{
"name": "id!: Uuid",
"ordinal": 0,
"type_info": "Text"
"type_info": "Blob"
},
{
"name": "name",
@@ -19,14 +19,19 @@
"type_info": "Text"
},
{
"name": "created_at!: DateTime<Utc>",
"name": "setup_script",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "updated_at!: DateTime<Utc>",
"name": "created_at!: DateTime<Utc>",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "updated_at!: DateTime<Utc>",
"ordinal": 5,
"type_info": "Text"
}
],
"parameters": {
@@ -36,9 +41,10 @@
true,
false,
false,
true,
false,
false
]
},
"hash": "18bfb3eb9408e5268bccf7a17fe02424df8ae3130b70aaad64c5342c198011c9"
"hash": "205da45211b3aa413684ecd76d065fc59f793da42da075246464ac776016f5ff"
}

View File

@@ -6,12 +6,12 @@
{
"name": "id!: Uuid",
"ordinal": 0,
"type_info": "Text"
"type_info": "Blob"
},
{
"name": "task_attempt_id!: Uuid",
"ordinal": 1,
"type_info": "Text"
"type_info": "Blob"
},
{
"name": "status!: TaskAttemptStatus",

View File

@@ -6,12 +6,12 @@
{
"name": "id!: Uuid",
"ordinal": 0,
"type_info": "Text"
"type_info": "Blob"
},
{
"name": "project_id!: Uuid",
"ordinal": 1,
"type_info": "Text"
"type_info": "Blob"
},
{
"name": "title",

View File

@@ -1,12 +1,12 @@
{
"db_name": "SQLite",
"query": "SELECT id as \"id!: Uuid\", name, git_repo_path, created_at as \"created_at!: DateTime<Utc>\", updated_at as \"updated_at!: DateTime<Utc>\" FROM projects WHERE id = $1",
"query": "SELECT id as \"id!: Uuid\", name, git_repo_path, setup_script, created_at as \"created_at!: DateTime<Utc>\", updated_at as \"updated_at!: DateTime<Utc>\" FROM projects WHERE id = $1",
"describe": {
"columns": [
{
"name": "id!: Uuid",
"ordinal": 0,
"type_info": "Text"
"type_info": "Blob"
},
{
"name": "name",
@@ -19,14 +19,19 @@
"type_info": "Text"
},
{
"name": "created_at!: DateTime<Utc>",
"name": "setup_script",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "updated_at!: DateTime<Utc>",
"name": "created_at!: DateTime<Utc>",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "updated_at!: DateTime<Utc>",
"ordinal": 5,
"type_info": "Text"
}
],
"parameters": {
@@ -36,9 +41,10 @@
true,
false,
false,
true,
false,
false
]
},
"hash": "0133ba2ace195776eaa714981cdf492d2eaa3dc97dc3379b549a3c4fb8309975"
"hash": "346d58b8e0628d6a5936675beadc0a43ffa2dca384ed4f4b3a3abfcd09592c07"
}

View File

@@ -6,12 +6,12 @@
{
"name": "id!: Uuid",
"ordinal": 0,
"type_info": "Text"
"type_info": "Blob"
},
{
"name": "project_id!: Uuid",
"ordinal": 1,
"type_info": "Text"
"type_info": "Blob"
},
{
"name": "title",

View File

@@ -6,12 +6,12 @@
{
"name": "id!: Uuid",
"ordinal": 0,
"type_info": "Text"
"type_info": "Blob"
},
{
"name": "task_id!: Uuid",
"ordinal": 1,
"type_info": "Text"
"type_info": "Blob"
},
{
"name": "worktree_path",

View File

@@ -1,12 +1,12 @@
{
"db_name": "SQLite",
"query": "SELECT id as \"id!: Uuid\", name, git_repo_path, created_at as \"created_at!: DateTime<Utc>\", updated_at as \"updated_at!: DateTime<Utc>\" FROM projects ORDER BY created_at DESC",
"query": "SELECT id as \"id!: Uuid\", name, git_repo_path, setup_script, created_at as \"created_at!: DateTime<Utc>\", updated_at as \"updated_at!: DateTime<Utc>\" FROM projects ORDER BY created_at DESC",
"describe": {
"columns": [
{
"name": "id!: Uuid",
"ordinal": 0,
"type_info": "Text"
"type_info": "Blob"
},
{
"name": "name",
@@ -19,14 +19,19 @@
"type_info": "Text"
},
{
"name": "created_at!: DateTime<Utc>",
"name": "setup_script",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "updated_at!: DateTime<Utc>",
"name": "created_at!: DateTime<Utc>",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "updated_at!: DateTime<Utc>",
"ordinal": 5,
"type_info": "Text"
}
],
"parameters": {
@@ -36,9 +41,10 @@
true,
false,
false,
true,
false,
false
]
},
"hash": "022ee13a4082ece0cec1100f5c8c4dc5ecbf84018e1c6b735891ec4057e99f72"
"hash": "420c9eec0dd98062947b090bc695b67c2bcaba9862c06b701a9ba3d8a5b02abf"
}

View File

@@ -6,12 +6,12 @@
{
"name": "id!: Uuid",
"ordinal": 0,
"type_info": "Text"
"type_info": "Blob"
},
{
"name": "task_attempt_id!: Uuid",
"ordinal": 1,
"type_info": "Text"
"type_info": "Blob"
},
{
"name": "status!: TaskAttemptStatus",

View File

@@ -0,0 +1,50 @@
{
"db_name": "SQLite",
"query": "INSERT INTO projects (id, name, git_repo_path, setup_script) VALUES ($1, $2, $3, $4) RETURNING id as \"id!: Uuid\", name, git_repo_path, setup_script, created_at as \"created_at!: DateTime<Utc>\", updated_at as \"updated_at!: DateTime<Utc>\"",
"describe": {
"columns": [
{
"name": "id!: Uuid",
"ordinal": 0,
"type_info": "Blob"
},
{
"name": "name",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "git_repo_path",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "setup_script",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "created_at!: DateTime<Utc>",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "updated_at!: DateTime<Utc>",
"ordinal": 5,
"type_info": "Text"
}
],
"parameters": {
"Right": 4
},
"nullable": [
true,
false,
false,
true,
false,
false
]
},
"hash": "64fd750d2f767096f94b28650018dc657ad41c6a0af908215f694100319b4864"
}

View File

@@ -6,12 +6,12 @@
{
"name": "id!: Uuid",
"ordinal": 0,
"type_info": "Text"
"type_info": "Blob"
},
{
"name": "project_id!: Uuid",
"ordinal": 1,
"type_info": "Text"
"type_info": "Blob"
},
{
"name": "title",

View File

@@ -6,12 +6,12 @@
{
"name": "id!: Uuid",
"ordinal": 0,
"type_info": "Text"
"type_info": "Blob"
},
{
"name": "project_id!: Uuid",
"ordinal": 1,
"type_info": "Text"
"type_info": "Blob"
},
{
"name": "title",

View File

@@ -6,12 +6,12 @@
{
"name": "id!: Uuid",
"ordinal": 0,
"type_info": "Text"
"type_info": "Blob"
},
{
"name": "task_id!: Uuid",
"ordinal": 1,
"type_info": "Text"
"type_info": "Blob"
},
{
"name": "worktree_path",

View File

@@ -1,44 +0,0 @@
{
"db_name": "SQLite",
"query": "INSERT INTO projects (id, name, git_repo_path) VALUES ($1, $2, $3) RETURNING id as \"id!: Uuid\", name, git_repo_path, created_at as \"created_at!: DateTime<Utc>\", updated_at as \"updated_at!: DateTime<Utc>\"",
"describe": {
"columns": [
{
"name": "id!: Uuid",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "name",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "git_repo_path",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "created_at!: DateTime<Utc>",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "updated_at!: DateTime<Utc>",
"ordinal": 4,
"type_info": "Text"
}
],
"parameters": {
"Right": 3
},
"nullable": [
true,
false,
false,
false,
false
]
},
"hash": "ab7c095d6ccb8bb41d159aa7dd4f7feb97505e31427c124b4219a6903e971f5a"
}

View File

@@ -0,0 +1,50 @@
{
"db_name": "SQLite",
"query": "UPDATE projects SET name = $2, git_repo_path = $3, setup_script = $4 WHERE id = $1 RETURNING id as \"id!: Uuid\", name, git_repo_path, setup_script, created_at as \"created_at!: DateTime<Utc>\", updated_at as \"updated_at!: DateTime<Utc>\"",
"describe": {
"columns": [
{
"name": "id!: Uuid",
"ordinal": 0,
"type_info": "Blob"
},
{
"name": "name",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "git_repo_path",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "setup_script",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "created_at!: DateTime<Utc>",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "updated_at!: DateTime<Utc>",
"ordinal": 5,
"type_info": "Text"
}
],
"parameters": {
"Right": 4
},
"nullable": [
true,
false,
false,
true,
false,
false
]
},
"hash": "b3bead952fd42b79bed0908db603726935c0e830ea74ff30064bac71185442fc"
}

View File

@@ -1,12 +1,12 @@
{
"db_name": "SQLite",
"query": "SELECT id as \"id!: Uuid\", name, git_repo_path, created_at as \"created_at!: DateTime<Utc>\", updated_at as \"updated_at!: DateTime<Utc>\" FROM projects WHERE git_repo_path = $1",
"query": "SELECT id as \"id!: Uuid\", name, git_repo_path, setup_script, created_at as \"created_at!: DateTime<Utc>\", updated_at as \"updated_at!: DateTime<Utc>\" FROM projects WHERE git_repo_path = $1",
"describe": {
"columns": [
{
"name": "id!: Uuid",
"ordinal": 0,
"type_info": "Text"
"type_info": "Blob"
},
{
"name": "name",
@@ -19,14 +19,19 @@
"type_info": "Text"
},
{
"name": "created_at!: DateTime<Utc>",
"name": "setup_script",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "updated_at!: DateTime<Utc>",
"name": "created_at!: DateTime<Utc>",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "updated_at!: DateTime<Utc>",
"ordinal": 5,
"type_info": "Text"
}
],
"parameters": {
@@ -36,9 +41,10 @@
true,
false,
false,
true,
false,
false
]
},
"hash": "96aade8205a632c862e4dd11118b7b303cbe521c6a42f5a43eda3cf5f8e6ab2c"
"hash": "b62fa26fe7cdbee672504dbf63d3dbe19fca02a4a4f97d7df7143f340540efa0"
}

View File

@@ -6,7 +6,7 @@
{
"name": "id!: Uuid",
"ordinal": 0,
"type_info": "Text"
"type_info": "Blob"
}
],
"parameters": {

View File

@@ -6,12 +6,12 @@
{
"name": "id!: Uuid",
"ordinal": 0,
"type_info": "Text"
"type_info": "Blob"
},
{
"name": "task_id!: Uuid",
"ordinal": 1,
"type_info": "Text"
"type_info": "Blob"
},
{
"name": "worktree_path",

View File

@@ -6,7 +6,7 @@
{
"name": "id!: Uuid",
"ordinal": 0,
"type_info": "Text"
"type_info": "Blob"
}
],
"parameters": {

View File

@@ -6,7 +6,7 @@
{
"name": "id!: Uuid",
"ordinal": 0,
"type_info": "Text"
"type_info": "Blob"
}
],
"parameters": {

View File

@@ -6,12 +6,12 @@
{
"name": "id!: Uuid",
"ordinal": 0,
"type_info": "Text"
"type_info": "Blob"
},
{
"name": "project_id!: Uuid",
"ordinal": 1,
"type_info": "Text"
"type_info": "Blob"
},
{
"name": "title",

View File

@@ -1,44 +0,0 @@
{
"db_name": "SQLite",
"query": "UPDATE projects SET name = $2, git_repo_path = $3 WHERE id = $1 RETURNING id as \"id!: Uuid\", name, git_repo_path, created_at as \"created_at!: DateTime<Utc>\", updated_at as \"updated_at!: DateTime<Utc>\"",
"describe": {
"columns": [
{
"name": "id!: Uuid",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "name",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "git_repo_path",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "created_at!: DateTime<Utc>",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "updated_at!: DateTime<Utc>",
"ordinal": 4,
"type_info": "Text"
}
],
"parameters": {
"Right": 3
},
"nullable": [
true,
false,
false,
false,
false
]
},
"hash": "eeede8a732db5214bb9054d6c2b7655989102264949864e719610f12cbb4c0c0"
}

View File

@@ -0,0 +1 @@
ALTER TABLE projects ADD COLUMN setup_script TEXT;

View File

@@ -10,6 +10,7 @@ pub struct Project {
pub id: Uuid,
pub name: String,
pub git_repo_path: String,
pub setup_script: Option<String>,
#[ts(type = "Date")]
pub created_at: DateTime<Utc>,
@@ -23,6 +24,7 @@ pub struct CreateProject {
pub name: String,
pub git_repo_path: String,
pub use_existing_repo: bool,
pub setup_script: Option<String>,
}
#[derive(Debug, Deserialize, TS)]
@@ -30,6 +32,7 @@ pub struct CreateProject {
pub struct UpdateProject {
pub name: Option<String>,
pub git_repo_path: Option<String>,
pub setup_script: Option<String>,
}
#[derive(Debug, Serialize, TS)]
@@ -52,7 +55,7 @@ impl Project {
pub async fn find_all(pool: &SqlitePool) -> Result<Vec<Self>, sqlx::Error> {
sqlx::query_as!(
Project,
r#"SELECT id as "id!: Uuid", name, git_repo_path, created_at as "created_at!: DateTime<Utc>", updated_at as "updated_at!: DateTime<Utc>" FROM projects ORDER BY created_at DESC"#
r#"SELECT id as "id!: Uuid", name, git_repo_path, setup_script, created_at as "created_at!: DateTime<Utc>", updated_at as "updated_at!: DateTime<Utc>" FROM projects ORDER BY created_at DESC"#
)
.fetch_all(pool)
.await
@@ -61,7 +64,7 @@ impl Project {
pub async fn find_by_id(pool: &SqlitePool, id: Uuid) -> Result<Option<Self>, sqlx::Error> {
sqlx::query_as!(
Project,
r#"SELECT id as "id!: Uuid", name, git_repo_path, created_at as "created_at!: DateTime<Utc>", updated_at as "updated_at!: DateTime<Utc>" FROM projects WHERE id = $1"#,
r#"SELECT id as "id!: Uuid", name, git_repo_path, setup_script, created_at as "created_at!: DateTime<Utc>", updated_at as "updated_at!: DateTime<Utc>" FROM projects WHERE id = $1"#,
id
)
.fetch_optional(pool)
@@ -74,7 +77,7 @@ impl Project {
) -> Result<Option<Self>, sqlx::Error> {
sqlx::query_as!(
Project,
r#"SELECT id as "id!: Uuid", name, git_repo_path, created_at as "created_at!: DateTime<Utc>", updated_at as "updated_at!: DateTime<Utc>" FROM projects WHERE git_repo_path = $1"#,
r#"SELECT id as "id!: Uuid", name, git_repo_path, setup_script, created_at as "created_at!: DateTime<Utc>", updated_at as "updated_at!: DateTime<Utc>" FROM projects WHERE git_repo_path = $1"#,
git_repo_path
)
.fetch_optional(pool)
@@ -88,7 +91,7 @@ impl Project {
) -> Result<Option<Self>, sqlx::Error> {
sqlx::query_as!(
Project,
r#"SELECT id as "id!: Uuid", name, git_repo_path, created_at as "created_at!: DateTime<Utc>", updated_at as "updated_at!: DateTime<Utc>" FROM projects WHERE git_repo_path = $1 AND id != $2"#,
r#"SELECT id as "id!: Uuid", name, git_repo_path, setup_script, created_at as "created_at!: DateTime<Utc>", updated_at as "updated_at!: DateTime<Utc>" FROM projects WHERE git_repo_path = $1 AND id != $2"#,
git_repo_path,
exclude_id
)
@@ -103,10 +106,11 @@ impl Project {
) -> Result<Self, sqlx::Error> {
sqlx::query_as!(
Project,
r#"INSERT INTO projects (id, name, git_repo_path) VALUES ($1, $2, $3) RETURNING id as "id!: Uuid", name, git_repo_path, created_at as "created_at!: DateTime<Utc>", updated_at as "updated_at!: DateTime<Utc>""#,
r#"INSERT INTO projects (id, name, git_repo_path, setup_script) VALUES ($1, $2, $3, $4) RETURNING id as "id!: Uuid", name, git_repo_path, setup_script, created_at as "created_at!: DateTime<Utc>", updated_at as "updated_at!: DateTime<Utc>""#,
project_id,
data.name,
data.git_repo_path
data.git_repo_path,
data.setup_script
)
.fetch_one(pool)
.await
@@ -117,13 +121,15 @@ impl Project {
id: Uuid,
name: String,
git_repo_path: String,
setup_script: Option<String>,
) -> Result<Self, sqlx::Error> {
sqlx::query_as!(
Project,
r#"UPDATE projects SET name = $2, git_repo_path = $3 WHERE id = $1 RETURNING id as "id!: Uuid", name, git_repo_path, created_at as "created_at!: DateTime<Utc>", updated_at as "updated_at!: DateTime<Utc>""#,
r#"UPDATE projects SET name = $2, git_repo_path = $3, setup_script = $4 WHERE id = $1 RETURNING id as "id!: Uuid", name, git_repo_path, setup_script, created_at as "created_at!: DateTime<Utc>", updated_at as "updated_at!: DateTime<Utc>""#,
id,
name,
git_repo_path
git_repo_path,
setup_script
)
.fetch_one(pool)
.await

View File

@@ -181,6 +181,41 @@ impl TaskAttempt {
let branch_name = format!("attempt-{}", attempt_id);
repo.worktree(&branch_name, worktree_path, None)?;
// Run setup script if it exists
if let Some(setup_script) = &project.setup_script {
if !setup_script.trim().is_empty() {
tracing::info!("Running setup script for task attempt {}", attempt_id);
let output = std::process::Command::new("bash")
.arg("-c")
.arg(setup_script)
.current_dir(worktree_path)
.output()
.map_err(|e| {
TaskAttemptError::Git(git2::Error::from_str(&format!(
"Failed to execute setup script: {}",
e
)))
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
tracing::error!("Setup script failed for attempt {}: {}", attempt_id, stderr);
return Err(TaskAttemptError::Git(git2::Error::from_str(&format!(
"Setup script failed: {}",
stderr
))));
}
let stdout = String::from_utf8_lossy(&output.stdout);
tracing::info!(
"Setup script completed for attempt {}: {}",
attempt_id,
stdout
);
}
}
// Insert the record into the database
Ok(sqlx::query_as!(
TaskAttempt,

View File

@@ -204,8 +204,9 @@ pub async fn update_project(
let git_repo_path = payload
.git_repo_path
.unwrap_or(existing_project.git_repo_path.clone());
let setup_script = payload.setup_script.or(existing_project.setup_script);
match Project::update(&pool, id, name, git_repo_path).await {
match Project::update(&pool, id, name, git_repo_path, setup_script).await {
Ok(project) => Ok(ResponseJson(ApiResponse {
success: true,
data: Some(project),
@@ -387,350 +388,3 @@ pub fn projects_router() -> Router {
)
.route("/projects/:id/search", get(search_project_files))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::{hash_password, AuthUser};
use crate::models::project::{CreateProject, UpdateProject};
use axum::extract::Extension;
use chrono::Utc;
use sqlx::SqlitePool;
use uuid::Uuid;
async fn create_test_user(
pool: &SqlitePool,
email: &str,
password: &str,
is_admin: bool,
) -> User {
let id = Uuid::new_v4();
let now = Utc::now();
let password_hash = hash_password(password).unwrap();
sqlx::query_as!(
User,
"INSERT INTO users (id, email, password_hash, is_admin, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, email, password_hash, is_admin, created_at, updated_at",
id,
email,
password_hash,
is_admin,
now,
now
)
.fetch_one(pool)
.await
.unwrap()
}
async fn create_test_project(
pool: &SqlitePool,
name: &str,
git_repo_path: &str,
owner_id: Uuid,
) -> Project {
let id = Uuid::new_v4();
let now = Utc::now();
sqlx::query_as!(
Project,
"INSERT INTO projects (id, name, git_repo_path, owner_id, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, name, git_repo_path, owner_id, created_at, updated_at",
id,
name,
git_repo_path,
owner_id,
now,
now
)
.fetch_one(pool)
.await
.unwrap()
}
#[sqlx::test]
async fn test_get_projects_success(pool: SqlitePool) {
let user = create_test_user(&pool, "test@example.com", "password123", false).await;
// Create multiple projects
create_test_project(&pool, "Project 1", "/tmp/test1", user.id).await;
create_test_project(&pool, "Project 2", "/tmp/test2", user.id).await;
create_test_project(&pool, "Project 3", "/tmp/test3", user.id).await;
let auth = AuthUser {
user_id: user.id,
email: user.email,
is_admin: false,
};
let result = get_projects(auth, Extension(pool)).await;
assert!(result.is_ok());
let response = result.unwrap().0;
assert!(response.success);
assert!(response.data.is_some());
assert_eq!(response.data.unwrap().len(), 3);
}
#[sqlx::test]
async fn test_get_projects_empty(pool: SqlitePool) {
let user = create_test_user(&pool, "test@example.com", "password123", false).await;
let auth = AuthUser {
user_id: user.id,
email: user.email,
is_admin: false,
};
let result = get_projects(auth, Extension(pool)).await;
assert!(result.is_ok());
let response = result.unwrap().0;
assert!(response.success);
assert!(response.data.is_some());
assert_eq!(response.data.unwrap().len(), 0);
}
#[sqlx::test]
async fn test_get_project_success(pool: SqlitePool) {
let user = create_test_user(&pool, "test@example.com", "password123", false).await;
let project = create_test_project(&pool, "Test Project", "/tmp/test", user.id).await;
let auth = AuthUser {
user_id: user.id,
email: user.email,
is_admin: false,
};
let result = get_project(auth, Path(project.id), Extension(pool)).await;
assert!(result.is_ok());
let response = result.unwrap().0;
assert!(response.success);
assert!(response.data.is_some());
let returned_project = response.data.unwrap();
assert_eq!(returned_project.id, project.id);
assert_eq!(returned_project.name, project.name);
assert_eq!(returned_project.owner_id, project.owner_id);
}
#[sqlx::test]
async fn test_get_project_not_found(pool: SqlitePool) {
let user = create_test_user(&pool, "test@example.com", "password123", false).await;
let nonexistent_project_id = Uuid::new_v4();
let auth = AuthUser {
user_id: user.id,
email: user.email,
is_admin: false,
};
let result = get_project(auth, Path(nonexistent_project_id), Extension(pool)).await;
assert!(result.is_err());
assert_eq!(result.unwrap_err(), StatusCode::NOT_FOUND);
}
#[sqlx::test]
async fn test_create_project_success(pool: SqlitePool) {
let user = create_test_user(&pool, "test@example.com", "password123", false).await;
let auth = AuthUser {
user_id: user.id,
email: user.email.clone(),
is_admin: false,
};
let create_request = CreateProject {
name: "New Project".to_string(),
git_repo_path: "/tmp/new-project".to_string(),
use_existing_repo: false,
};
let result = create_project(auth.clone(), Extension(pool), Json(create_request)).await;
assert!(result.is_ok());
let response = result.unwrap().0;
assert!(response.success);
assert!(response.data.is_some());
let created_project = response.data.unwrap();
assert_eq!(created_project.name, "New Project");
assert_eq!(created_project.owner_id, auth.user_id);
assert_eq!(response.message.unwrap(), "Project created successfully");
}
#[sqlx::test]
async fn test_create_project_as_admin(pool: SqlitePool) {
let admin_user = create_test_user(&pool, "admin@example.com", "password123", true).await;
let auth = AuthUser {
user_id: admin_user.id,
email: admin_user.email.clone(),
is_admin: true,
};
let create_request = CreateProject {
name: "Admin Project".to_string(),
git_repo_path: "/tmp/admin-project".to_string(),
use_existing_repo: false,
};
let result = create_project(auth.clone(), Extension(pool), Json(create_request)).await;
assert!(result.is_ok());
let response = result.unwrap().0;
assert!(response.success);
assert!(response.data.is_some());
let created_project = response.data.unwrap();
assert_eq!(created_project.name, "Admin Project");
assert_eq!(created_project.owner_id, auth.user_id);
}
#[sqlx::test]
async fn test_update_project_success(pool: SqlitePool) {
let user = create_test_user(&pool, "test@example.com", "password123", false).await;
let project = create_test_project(&pool, "Original Name", "/tmp/original", user.id).await;
let update_request = UpdateProject {
name: Some("Updated Name".to_string()),
git_repo_path: None,
};
let result = update_project(Path(project.id), Extension(pool), Json(update_request)).await;
assert!(result.is_ok());
let response = result.unwrap().0;
assert!(response.success);
assert!(response.data.is_some());
let updated_project = response.data.unwrap();
assert_eq!(updated_project.name, "Updated Name");
assert_eq!(updated_project.owner_id, project.owner_id);
assert_eq!(response.message.unwrap(), "Project updated successfully");
}
#[sqlx::test]
async fn test_update_project_partial(pool: SqlitePool) {
let user = create_test_user(&pool, "test@example.com", "password123", false).await;
let project = create_test_project(&pool, "Original Name", "/tmp/original", user.id).await;
// Update with no changes (None for name should keep existing name)
let update_request = UpdateProject {
name: None,
git_repo_path: None,
};
let result = update_project(Path(project.id), Extension(pool), Json(update_request)).await;
assert!(result.is_ok());
let response = result.unwrap().0;
assert!(response.success);
assert!(response.data.is_some());
let updated_project = response.data.unwrap();
assert_eq!(updated_project.name, "Original Name"); // Should remain unchanged
assert_eq!(updated_project.owner_id, project.owner_id);
}
#[sqlx::test]
async fn test_update_project_not_found(pool: SqlitePool) {
let nonexistent_project_id = Uuid::new_v4();
let update_request = UpdateProject {
name: Some("Updated Name".to_string()),
git_repo_path: None,
};
let result = update_project(
Path(nonexistent_project_id),
Extension(pool),
Json(update_request),
)
.await;
assert!(result.is_err());
assert_eq!(result.unwrap_err(), StatusCode::NOT_FOUND);
}
#[sqlx::test]
async fn test_delete_project_success(pool: SqlitePool) {
let user = create_test_user(&pool, "test@example.com", "password123", false).await;
let project =
create_test_project(&pool, "Project to Delete", "/tmp/to-delete", user.id).await;
let result = delete_project(Path(project.id), Extension(pool)).await;
assert!(result.is_ok());
let response = result.unwrap().0;
assert!(response.success);
assert_eq!(response.message.unwrap(), "Project deleted successfully");
}
#[sqlx::test]
async fn test_delete_project_not_found(pool: SqlitePool) {
let nonexistent_project_id = Uuid::new_v4();
let result = delete_project(Path(nonexistent_project_id), Extension(pool)).await;
assert!(result.is_err());
assert_eq!(result.unwrap_err(), StatusCode::NOT_FOUND);
}
#[sqlx::test]
async fn test_delete_project_cascades_to_tasks(pool: SqlitePool) {
use crate::models::task::{Task, TaskStatus};
let user = create_test_user(&pool, "test@example.com", "password123", false).await;
let project =
create_test_project(&pool, "Project with Tasks", "/tmp/with-tasks", user.id).await;
// Create a task in the project
let task_id = Uuid::new_v4();
let now = Utc::now();
sqlx::query!(
"INSERT INTO tasks (id, project_id, title, description, status, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7)",
task_id,
project.id,
"Test Task",
Some("Test Description"),
TaskStatus::Todo as TaskStatus,
now,
now
)
.execute(&pool)
.await
.unwrap();
// Verify task exists
let task_count_before = sqlx::query!(
"SELECT COUNT(*) as count FROM tasks WHERE project_id = $1",
project.id
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(task_count_before.count.unwrap(), 1);
// Delete the project
let result = delete_project(Path(project.id), Extension(pool.clone())).await;
assert!(result.is_ok());
// Verify tasks were cascaded (deleted)
let task_count_after = sqlx::query!(
"SELECT COUNT(*) as count FROM tasks WHERE project_id = $1",
project.id
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(task_count_after.count.unwrap(), 0);
}
#[sqlx::test]
async fn test_projects_belong_to_users(pool: SqlitePool) {
let user1 = create_test_user(&pool, "user1@example.com", "password123", false).await;
let user2 = create_test_user(&pool, "user2@example.com", "password123", false).await;
let project1 = create_test_project(&pool, "User 1 Project", "/tmp/user1", user1.id).await;
let project2 = create_test_project(&pool, "User 2 Project", "/tmp/user2", user2.id).await;
// Verify project ownership
assert_eq!(project1.owner_id, user1.id);
assert_eq!(project2.owner_id, user2.id);
assert_ne!(project1.owner_id, project2.owner_id);
}
}

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -31,6 +31,7 @@ export function ProjectForm({
}: ProjectFormProps) {
const [name, setName] = useState(project?.name || "");
const [gitRepoPath, setGitRepoPath] = useState(project?.git_repo_path || "");
const [setupScript, setSetupScript] = useState(project?.setup_script ?? "");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [showFolderPicker, setShowFolderPicker] = useState(false);
@@ -40,6 +41,19 @@ export function ProjectForm({
const isEditing = !!project;
// Update form fields when project prop changes
useEffect(() => {
if (project) {
setName(project.name || "");
setGitRepoPath(project.git_repo_path || "");
setSetupScript(project.setup_script ?? "");
} else {
setName("");
setGitRepoPath("");
setSetupScript("");
}
}, [project]);
// Auto-populate project name from directory name
const handleGitRepoPathChange = (path: string) => {
setGitRepoPath(path);
@@ -75,6 +89,7 @@ export function ProjectForm({
const updateData: UpdateProject = {
name,
git_repo_path: finalGitRepoPath,
setup_script: setupScript.trim() || null,
};
const response = await makeRequest(
`/api/projects/${project.id}`,
@@ -97,6 +112,7 @@ export function ProjectForm({
name,
git_repo_path: finalGitRepoPath,
use_existing_repo: repoMode === "existing",
setup_script: setupScript.trim() || null,
};
const response = await makeRequest("/api/projects", {
method: "POST",
@@ -116,6 +132,7 @@ export function ProjectForm({
onSuccess();
setName("");
setGitRepoPath("");
setSetupScript("");
setParentPath("");
setFolderName("");
} catch (error) {
@@ -126,8 +143,17 @@ export function ProjectForm({
};
const handleClose = () => {
setName(project?.name || "");
setGitRepoPath(project?.git_repo_path || "");
if (project) {
setName(project.name || "");
setGitRepoPath(project.git_repo_path || "");
setSetupScript(project.setup_script ?? "");
} else {
setName("");
setGitRepoPath("");
setSetupScript("");
}
setParentPath("");
setFolderName("");
setError("");
onClose();
};
@@ -274,6 +300,22 @@ export function ProjectForm({
/>
</div>
<div className="space-y-2">
<Label htmlFor="setup-script">Setup Script (Optional)</Label>
<textarea
id="setup-script"
value={setupScript}
onChange={(e) => setSetupScript(e.target.value)}
placeholder="#!/bin/bash&#10;npm install&#10;# Add any setup commands here..."
rows={4}
className="w-full px-3 py-2 border border-input bg-background text-foreground rounded-md resize-vertical focus:outline-none focus:ring-2 focus:ring-ring"
/>
<p className="text-sm text-muted-foreground">
This script will run after creating the worktree and before the executor starts.
Use it for setup tasks like installing dependencies or preparing the environment.
</p>
</div>
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />

View File

@@ -5,11 +5,11 @@ export type ApiResponse<T> = { success: boolean, data: T | null, message: string
export type ExecutorConfig = { "type": "echo" } | { "type": "claude" } | { "type": "amp" };
export type CreateProject = { name: string, git_repo_path: string, use_existing_repo: boolean, };
export type CreateProject = { name: string, git_repo_path: string, use_existing_repo: boolean, setup_script: string | null, };
export type Project = { id: string, name: string, git_repo_path: string, created_at: Date, updated_at: Date, };
export type Project = { id: string, name: string, git_repo_path: string, setup_script: string | null, created_at: Date, updated_at: Date, };
export type UpdateProject = { name: string | null, git_repo_path: string | null, };
export type UpdateProject = { name: string | null, git_repo_path: string | null, setup_script: string | null, };
export type SearchResult = { path: string, is_file: boolean, match_type: SearchMatchType, };