Squashed commit of the following:

commit 38f68d5ed489f416ea91630aea3496ab15365e66
Author: Louis Knight-Webb <louis@bloop.ai>
Date:   Mon Jun 16 16:16:28 2025 -0400

    Fix click and drag

commit eb5c41cf31fd8032fe88fd47fe5f3e7f517f6d30
Author: Louis Knight-Webb <louis@bloop.ai>
Date:   Mon Jun 16 15:57:13 2025 -0400

    Update tasks

commit 979d4b15373df3193eb1bd41c18ece1dbe044eba
Author: Louis Knight-Webb <louis@bloop.ai>
Date:   Mon Jun 16 15:19:20 2025 -0400

    Status

commit fa26f1fa8fefe1d84b5b2153327c7e8c0132952a
Author: Louis Knight-Webb <louis@bloop.ai>
Date:   Mon Jun 16 14:54:48 2025 -0400

    Cleanup project card

commit 14d7a1d7d7574dd8745167b280c04603ba22b189
Author: Louis Knight-Webb <louis@bloop.ai>
Date:   Mon Jun 16 14:49:19 2025 -0400

    Improve existing vs new repo

commit 277e1f05ef68e5c67d73b246557a6df2ab23d32c
Author: Louis Knight-Webb <louis@bloop.ai>
Date:   Mon Jun 16 13:01:21 2025 -0400

    Make repo path unique

commit f80ef55f2ba16836276a81844fc33639872bcc53
Author: Louis Knight-Webb <louis@bloop.ai>
Date:   Mon Jun 16 12:52:20 2025 -0400

    Fix styles

commit 077869458fcab199a10ef0fe2fe39f9f4216ce5b
Author: Louis Knight-Webb <louis@bloop.ai>
Date:   Mon Jun 16 12:41:48 2025 -0400

    First select repo

commit 1b0d9c0280e4cb75294348bb53b2a534458a2e37
Author: Louis Knight-Webb <louis@bloop.ai>
Date:   Mon Jun 16 11:45:19 2025 -0400

    Init
This commit is contained in:
Louis Knight-Webb
2025-06-16 16:16:42 -04:00
parent eca26240fe
commit 22edb7a1db
26 changed files with 2041 additions and 409 deletions

View File

@@ -25,6 +25,7 @@ dotenvy = "0.15"
bcrypt = "0.15"
jsonwebtoken = "9.2"
ts-rs = { version = "9.0", features = ["uuid-impl", "chrono-impl"] }
dirs = "5.0"
[build-dependencies]
ts-rs = { version = "9.0", features = ["uuid-impl", "chrono-impl"] }

View File

@@ -0,0 +1,2 @@
-- Add git_repo_path field to projects table
ALTER TABLE projects ADD COLUMN git_repo_path VARCHAR(500) NOT NULL DEFAULT '';

View File

@@ -0,0 +1,2 @@
-- Add unique constraint to git_repo_path to prevent duplicate repository paths
ALTER TABLE projects ADD CONSTRAINT unique_git_repo_path UNIQUE (git_repo_path);

View File

@@ -0,0 +1,33 @@
-- Create task_attempt_status enum
CREATE TYPE task_attempt_status AS ENUM ('init', 'inprogress', 'paused');
-- Create task_attempts table
CREATE TABLE task_attempts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
worktree_path VARCHAR(255) NOT NULL,
base_commit VARCHAR(255),
merge_commit VARCHAR(255),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Create task_attempt_activities table
CREATE TABLE task_attempt_activities (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
task_attempt_id UUID NOT NULL REFERENCES task_attempts(id) ON DELETE CASCADE,
status task_attempt_status NOT NULL DEFAULT 'init',
note TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Create indexes for better performance
CREATE INDEX idx_task_attempts_task_id ON task_attempts(task_id);
CREATE INDEX idx_task_attempt_activities_task_attempt_id ON task_attempt_activities(task_attempt_id);
CREATE INDEX idx_task_attempt_activities_status ON task_attempt_activities(status);
CREATE INDEX idx_task_attempt_activities_created_at ON task_attempt_activities(created_at);
-- Create triggers to auto-update updated_at
CREATE TRIGGER update_task_attempts_updated_at
BEFORE UPDATE ON task_attempts
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();

View File

@@ -56,7 +56,20 @@ export {}
export {}
export {}
"#,
export {}
export {}
export {}
export {}
export {}
export {}
export {}"#,
bloop_backend::models::ApiResponse::<()>::decl(),
bloop_backend::models::project::CreateProject::decl(),
bloop_backend::models::project::Project::decl(),
@@ -65,11 +78,18 @@ export {}
bloop_backend::models::task::TaskStatus::decl(),
bloop_backend::models::task::Task::decl(),
bloop_backend::models::task::UpdateTask::decl(),
bloop_backend::models::task_attempt::TaskAttemptStatus::decl(),
bloop_backend::models::task_attempt::TaskAttempt::decl(),
bloop_backend::models::task_attempt::CreateTaskAttempt::decl(),
bloop_backend::models::task_attempt::UpdateTaskAttempt::decl(),
bloop_backend::models::task_attempt_activity::TaskAttemptActivity::decl(),
bloop_backend::models::task_attempt_activity::CreateTaskAttemptActivity::decl(),
bloop_backend::models::user::CreateUser::decl(),
bloop_backend::models::user::LoginRequest::decl(),
bloop_backend::models::user::LoginResponse::decl(),
bloop_backend::models::user::UpdateUser::decl(),
bloop_backend::models::user::UserResponse::decl(),
bloop_backend::routes::filesystem::DirectoryEntry::decl(),
);
std::fs::write(shared_path.join("types.ts"), consolidated_content).unwrap();

View File

@@ -15,7 +15,7 @@ mod routes;
use auth::{auth_middleware, hash_password};
use models::ApiResponse;
use routes::{health, projects, tasks, users};
use routes::{health, projects, tasks, users, filesystem};
async fn echo_handler(
Json(payload): Json<serde_json::Value>,
@@ -67,6 +67,7 @@ async fn main() -> anyhow::Result<()> {
.merge(projects::projects_router())
.merge(tasks::tasks_router())
.merge(users::protected_users_router())
.merge(filesystem::filesystem_router())
.layer(Extension(pool.clone()))
.layer(middleware::from_fn(auth_middleware));

View File

@@ -1,6 +1,8 @@
pub mod api_response;
pub mod project;
pub mod task;
pub mod task_attempt;
pub mod task_attempt_activity;
pub mod user;
pub use api_response::ApiResponse;

View File

@@ -9,6 +9,7 @@ use uuid::Uuid;
pub struct Project {
pub id: Uuid,
pub name: String,
pub git_repo_path: String,
pub owner_id: Uuid, // Foreign key to User
#[ts(type = "Date")]
pub created_at: DateTime<Utc>,
@@ -20,10 +21,13 @@ pub struct Project {
#[ts(export)]
pub struct CreateProject {
pub name: String,
pub git_repo_path: String,
pub use_existing_repo: bool,
}
#[derive(Debug, Deserialize, TS)]
#[ts(export)]
pub struct UpdateProject {
pub name: Option<String>,
pub git_repo_path: Option<String>,
}

View File

@@ -0,0 +1,44 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::{FromRow, Type};
use ts_rs::TS;
use uuid::Uuid;
#[derive(Debug, Clone, Type, Serialize, Deserialize, PartialEq, TS)]
#[sqlx(type_name = "task_attempt_status", rename_all = "lowercase")]
#[serde(rename_all = "lowercase")]
#[ts(export)]
pub enum TaskAttemptStatus {
Init,
InProgress,
Paused,
}
#[derive(Debug, Clone, FromRow, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct TaskAttempt {
pub id: Uuid,
pub task_id: Uuid, // Foreign key to Task
pub worktree_path: String,
pub base_commit: Option<String>,
pub merge_commit: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize, TS)]
#[ts(export)]
pub struct CreateTaskAttempt {
pub task_id: Uuid,
pub worktree_path: String,
pub base_commit: Option<String>,
pub merge_commit: Option<String>,
}
#[derive(Debug, Deserialize, TS)]
#[ts(export)]
pub struct UpdateTaskAttempt {
pub worktree_path: Option<String>,
pub base_commit: Option<String>,
pub merge_commit: Option<String>,
}

View File

@@ -0,0 +1,25 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use ts_rs::TS;
use uuid::Uuid;
use super::task_attempt::TaskAttemptStatus;
#[derive(Debug, Clone, FromRow, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct TaskAttemptActivity {
pub id: Uuid,
pub task_attempt_id: Uuid, // Foreign key to TaskAttempt
pub status: TaskAttemptStatus,
pub note: Option<String>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize, TS)]
#[ts(export)]
pub struct CreateTaskAttemptActivity {
pub task_attempt_id: Uuid,
pub status: Option<TaskAttemptStatus>, // Default to Init if not provided
pub note: Option<String>,
}

View File

@@ -0,0 +1,207 @@
use axum::{
routing::get,
Router,
Json,
response::Json as ResponseJson,
extract::{Query, Extension},
http::StatusCode,
};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::fs;
use ts_rs::TS;
use crate::models::ApiResponse;
use crate::auth::AuthUser;
#[derive(Debug, Serialize, TS)]
#[ts(export)]
pub struct DirectoryEntry {
pub name: String,
pub path: String,
pub is_directory: bool,
pub is_git_repo: bool,
}
#[derive(Debug, Deserialize)]
pub struct ListDirectoryQuery {
path: Option<String>,
}
pub async fn list_directory(
_auth: AuthUser,
Query(query): Query<ListDirectoryQuery>,
) -> Result<ResponseJson<ApiResponse<Vec<DirectoryEntry>>>, StatusCode> {
let path_str = query.path.unwrap_or_else(|| {
// Default to user's home directory
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("/"))
.to_string_lossy()
.to_string()
});
let path = Path::new(&path_str);
if !path.exists() {
return Ok(ResponseJson(ApiResponse {
success: false,
data: None,
message: Some("Directory does not exist".to_string()),
}));
}
if !path.is_dir() {
return Ok(ResponseJson(ApiResponse {
success: false,
data: None,
message: Some("Path is not a directory".to_string()),
}));
}
match fs::read_dir(path) {
Ok(entries) => {
let mut directory_entries = Vec::new();
for entry in entries {
if let Ok(entry) = entry {
let path = entry.path();
let metadata = entry.metadata().ok();
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
// Skip hidden files/directories
if name.starts_with('.') && name != ".." {
continue;
}
let is_directory = metadata.map_or(false, |m| m.is_dir());
let is_git_repo = if is_directory {
path.join(".git").exists()
} else {
false
};
directory_entries.push(DirectoryEntry {
name: name.to_string(),
path: path.to_string_lossy().to_string(),
is_directory,
is_git_repo,
});
}
}
}
// Sort: directories first, then files, both alphabetically
directory_entries.sort_by(|a, b| {
match (a.is_directory, b.is_directory) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
}
});
Ok(ResponseJson(ApiResponse {
success: true,
data: Some(directory_entries),
message: None,
}))
}
Err(e) => {
tracing::error!("Failed to read directory: {}", e);
Ok(ResponseJson(ApiResponse {
success: false,
data: None,
message: Some(format!("Failed to read directory: {}", e)),
}))
}
}
}
pub async fn validate_git_path(
_auth: AuthUser,
Query(query): Query<ListDirectoryQuery>,
) -> Result<ResponseJson<ApiResponse<bool>>, StatusCode> {
let path_str = query.path.ok_or(StatusCode::BAD_REQUEST)?;
let path = Path::new(&path_str);
// Check if path exists and is a git repo
let is_valid_git_repo = path.exists() && path.is_dir() && path.join(".git").exists();
Ok(ResponseJson(ApiResponse {
success: true,
data: Some(is_valid_git_repo),
message: if is_valid_git_repo {
Some("Valid git repository".to_string())
} else {
Some("Not a valid git repository".to_string())
},
}))
}
pub async fn create_git_repo(
_auth: AuthUser,
Query(query): Query<ListDirectoryQuery>,
) -> Result<ResponseJson<ApiResponse<()>>, StatusCode> {
let path_str = query.path.ok_or(StatusCode::BAD_REQUEST)?;
let path = Path::new(&path_str);
// Create directory if it doesn't exist
if !path.exists() {
if let Err(e) = fs::create_dir_all(path) {
tracing::error!("Failed to create directory: {}", e);
return Ok(ResponseJson(ApiResponse {
success: false,
data: None,
message: Some(format!("Failed to create directory: {}", e)),
}));
}
}
// Check if it's already a git repo
if path.join(".git").exists() {
return Ok(ResponseJson(ApiResponse {
success: true,
data: Some(()),
message: Some("Directory is already a git repository".to_string()),
}));
}
// Initialize git repository
match std::process::Command::new("git")
.arg("init")
.current_dir(path)
.output()
{
Ok(output) => {
if output.status.success() {
Ok(ResponseJson(ApiResponse {
success: true,
data: Some(()),
message: Some("Git repository initialized successfully".to_string()),
}))
} else {
let error_msg = String::from_utf8_lossy(&output.stderr);
tracing::error!("Git init failed: {}", error_msg);
Ok(ResponseJson(ApiResponse {
success: false,
data: None,
message: Some(format!("Git init failed: {}", error_msg)),
}))
}
}
Err(e) => {
tracing::error!("Failed to run git init: {}", e);
Ok(ResponseJson(ApiResponse {
success: false,
data: None,
message: Some(format!("Failed to run git init: {}", e)),
}))
}
}
}
pub fn filesystem_router() -> Router {
Router::new()
.route("/filesystem/list", get(list_directory))
.route("/filesystem/validate-git", get(validate_git_path))
.route("/filesystem/create-git", get(create_git_repo))
}

View File

@@ -2,3 +2,4 @@ pub mod health;
pub mod projects;
pub mod tasks;
pub mod users;
pub mod filesystem;

View File

@@ -19,7 +19,7 @@ pub async fn get_projects(
) -> Result<ResponseJson<ApiResponse<Vec<Project>>>, StatusCode> {
match sqlx::query_as!(
Project,
"SELECT id, name, owner_id, created_at, updated_at FROM projects ORDER BY created_at DESC"
"SELECT id, name, git_repo_path, owner_id, created_at, updated_at FROM projects ORDER BY created_at DESC"
)
.fetch_all(&pool)
.await
@@ -43,7 +43,7 @@ pub async fn get_project(
) -> Result<ResponseJson<ApiResponse<Project>>, StatusCode> {
match sqlx::query_as!(
Project,
"SELECT id, name, owner_id, created_at, updated_at FROM projects WHERE id = $1",
"SELECT id, name, git_repo_path, owner_id, created_at, updated_at FROM projects WHERE id = $1",
id
)
.fetch_optional(&pool)
@@ -72,11 +72,110 @@ pub async fn create_project(
tracing::debug!("Creating project '{}' for user {}", payload.name, auth.user_id);
// Check if git repo path is already used by another project
let existing_project = sqlx::query!(
"SELECT id FROM projects WHERE git_repo_path = $1",
payload.git_repo_path
)
.fetch_optional(&pool)
.await;
match existing_project {
Ok(Some(_)) => {
return Ok(ResponseJson(ApiResponse {
success: false,
data: None,
message: Some("A project with this git repository path already exists".to_string()),
}));
}
Ok(None) => {
// Path is available, continue
}
Err(e) => {
tracing::error!("Failed to check for existing git repo path: {}", e);
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
}
// Validate and setup git repository
let path = std::path::Path::new(&payload.git_repo_path);
if payload.use_existing_repo {
// For existing repos, validate that the path exists and is a git repository
if !path.exists() {
return Ok(ResponseJson(ApiResponse {
success: false,
data: None,
message: Some("The specified path does not exist".to_string()),
}));
}
if !path.is_dir() {
return Ok(ResponseJson(ApiResponse {
success: false,
data: None,
message: Some("The specified path is not a directory".to_string()),
}));
}
if !path.join(".git").exists() {
return Ok(ResponseJson(ApiResponse {
success: false,
data: None,
message: Some("The specified directory is not a git repository".to_string()),
}));
}
} else {
// For new repos, create directory and initialize git
// Create directory if it doesn't exist
if !path.exists() {
if let Err(e) = std::fs::create_dir_all(path) {
tracing::error!("Failed to create directory: {}", e);
return Ok(ResponseJson(ApiResponse {
success: false,
data: None,
message: Some(format!("Failed to create directory: {}", e)),
}));
}
}
// Check if it's already a git repo, if not initialize it
if !path.join(".git").exists() {
match std::process::Command::new("git")
.arg("init")
.current_dir(path)
.output()
{
Ok(output) => {
if !output.status.success() {
let error_msg = String::from_utf8_lossy(&output.stderr);
tracing::error!("Git init failed: {}", error_msg);
return Ok(ResponseJson(ApiResponse {
success: false,
data: None,
message: Some(format!("Git init failed: {}", error_msg)),
}));
}
}
Err(e) => {
tracing::error!("Failed to run git init: {}", e);
return Ok(ResponseJson(ApiResponse {
success: false,
data: None,
message: Some(format!("Failed to run git init: {}", e)),
}));
}
}
}
}
match sqlx::query_as!(
Project,
"INSERT INTO projects (id, name, owner_id, created_at, updated_at) VALUES ($1, $2, $3, $4, $5) RETURNING id, name, owner_id, created_at, updated_at",
"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,
payload.name,
payload.git_repo_path,
auth.user_id,
now,
now
@@ -106,7 +205,7 @@ pub async fn update_project(
// Check if project exists first
let existing_project = sqlx::query_as!(
Project,
"SELECT id, name, owner_id, created_at, updated_at FROM projects WHERE id = $1",
"SELECT id, name, git_repo_path, owner_id, created_at, updated_at FROM projects WHERE id = $1",
id
)
.fetch_optional(&pool)
@@ -121,14 +220,46 @@ pub async fn update_project(
}
};
// Use existing name if not provided in update
// If git_repo_path is being changed, check if the new path is already used by another project
if let Some(new_git_repo_path) = &payload.git_repo_path {
if new_git_repo_path != &existing_project.git_repo_path {
let duplicate_project = sqlx::query!(
"SELECT id FROM projects WHERE git_repo_path = $1 AND id != $2",
new_git_repo_path,
id
)
.fetch_optional(&pool)
.await;
match duplicate_project {
Ok(Some(_)) => {
return Ok(ResponseJson(ApiResponse {
success: false,
data: None,
message: Some("A project with this git repository path already exists".to_string()),
}));
}
Ok(None) => {
// Path is available, continue
}
Err(e) => {
tracing::error!("Failed to check for existing git repo path: {}", e);
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
}
}
}
// Use existing values if not provided in update
let name = payload.name.unwrap_or(existing_project.name);
let git_repo_path = payload.git_repo_path.unwrap_or(existing_project.git_repo_path.clone());
match sqlx::query_as!(
Project,
"UPDATE projects SET name = $2, updated_at = $3 WHERE id = $1 RETURNING id, name, owner_id, created_at, updated_at",
"UPDATE projects SET name = $2, git_repo_path = $3, updated_at = $4 WHERE id = $1 RETURNING id, name, git_repo_path, owner_id, created_at, updated_at",
id,
name,
git_repo_path,
now
)
.fetch_one(&pool)
@@ -208,15 +339,16 @@ mod tests {
.unwrap()
}
async fn create_test_project(pool: &PgPool, name: &str, owner_id: Uuid) -> Project {
async fn create_test_project(pool: &PgPool, 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, owner_id, created_at, updated_at) VALUES ($1, $2, $3, $4, $5) RETURNING id, name, owner_id, created_at, updated_at",
"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
@@ -231,9 +363,9 @@ mod tests {
let user = create_test_user(&pool, "test@example.com", "password123", false).await;
// Create multiple projects
create_test_project(&pool, "Project 1", user.id).await;
create_test_project(&pool, "Project 2", user.id).await;
create_test_project(&pool, "Project 3", user.id).await;
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,
@@ -272,7 +404,7 @@ mod tests {
#[sqlx::test]
async fn test_get_project_success(pool: PgPool) {
let user = create_test_user(&pool, "test@example.com", "password123", false).await;
let project = create_test_project(&pool, "Test Project", user.id).await;
let project = create_test_project(&pool, "Test Project", "/tmp/test", user.id).await;
let auth = AuthUser {
user_id: user.id,
@@ -320,6 +452,7 @@ mod tests {
let create_request = CreateProject {
name: "New Project".to_string(),
git_repo_path: "/tmp/new-project".to_string(),
};
let result = create_project(auth.clone(), Extension(pool), Json(create_request)).await;
@@ -346,6 +479,7 @@ mod tests {
let create_request = CreateProject {
name: "Admin Project".to_string(),
git_repo_path: "/tmp/admin-project".to_string(),
};
let result = create_project(auth.clone(), Extension(pool), Json(create_request)).await;
@@ -362,10 +496,11 @@ mod tests {
#[sqlx::test]
async fn test_update_project_success(pool: PgPool) {
let user = create_test_user(&pool, "test@example.com", "password123", false).await;
let project = create_test_project(&pool, "Original Name", user.id).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;
@@ -383,11 +518,12 @@ mod tests {
#[sqlx::test]
async fn test_update_project_partial(pool: PgPool) {
let user = create_test_user(&pool, "test@example.com", "password123", false).await;
let project = create_test_project(&pool, "Original Name", user.id).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;
@@ -407,6 +543,7 @@ mod tests {
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;
@@ -417,7 +554,7 @@ mod tests {
#[sqlx::test]
async fn test_delete_project_success(pool: PgPool) {
let user = create_test_user(&pool, "test@example.com", "password123", false).await;
let project = create_test_project(&pool, "Project to Delete", user.id).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());
@@ -441,7 +578,7 @@ mod tests {
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", user.id).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();
@@ -490,8 +627,8 @@ mod tests {
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", user1.id).await;
let project2 = create_test_project(&pool, "User 2 Project", user2.id).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);

View File

@@ -10,7 +10,12 @@ use sqlx::PgPool;
use uuid::Uuid;
use chrono::Utc;
use crate::models::{ApiResponse, task::{Task, CreateTask, UpdateTask, TaskStatus}};
use crate::models::{
ApiResponse,
task::{Task, CreateTask, UpdateTask, TaskStatus},
task_attempt::{TaskAttempt, CreateTaskAttempt, UpdateTaskAttempt, TaskAttemptStatus},
task_attempt_activity::{TaskAttemptActivity, CreateTaskAttemptActivity}
};
use crate::auth::AuthUser;
pub async fn get_project_tasks(
@@ -217,12 +222,246 @@ pub async fn delete_task(
}
}
// Task Attempts endpoints
pub async fn get_task_attempts(
_auth: AuthUser,
Path((project_id, task_id)): Path<(Uuid, Uuid)>,
Extension(pool): Extension<PgPool>
) -> Result<ResponseJson<ApiResponse<Vec<TaskAttempt>>>, StatusCode> {
// Verify task exists in project first
let task_exists = sqlx::query!(
"SELECT id FROM tasks WHERE id = $1 AND project_id = $2",
task_id,
project_id
)
.fetch_optional(&pool)
.await;
match task_exists {
Ok(None) => return Err(StatusCode::NOT_FOUND),
Err(e) => {
tracing::error!("Failed to check task existence: {}", e);
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
Ok(Some(_)) => {}
}
match sqlx::query_as!(
TaskAttempt,
r#"SELECT id, task_id, worktree_path, base_commit, merge_commit, created_at, updated_at
FROM task_attempts
WHERE task_id = $1
ORDER BY created_at DESC"#,
task_id
)
.fetch_all(&pool)
.await
{
Ok(attempts) => Ok(ResponseJson(ApiResponse {
success: true,
data: Some(attempts),
message: None,
})),
Err(e) => {
tracing::error!("Failed to fetch task attempts for task {}: {}", task_id, e);
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
pub async fn get_task_attempt_activities(
_auth: AuthUser,
Path((project_id, task_id, attempt_id)): Path<(Uuid, Uuid, Uuid)>,
Extension(pool): Extension<PgPool>
) -> Result<ResponseJson<ApiResponse<Vec<TaskAttemptActivity>>>, StatusCode> {
// Verify task attempt exists and belongs to the correct task
let attempt_exists = sqlx::query!(
"SELECT ta.id FROM task_attempts ta
JOIN tasks t ON ta.task_id = t.id
WHERE ta.id = $1 AND t.id = $2 AND t.project_id = $3",
attempt_id,
task_id,
project_id
)
.fetch_optional(&pool)
.await;
match attempt_exists {
Ok(None) => return Err(StatusCode::NOT_FOUND),
Err(e) => {
tracing::error!("Failed to check task attempt existence: {}", e);
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
Ok(Some(_)) => {}
}
match sqlx::query_as!(
TaskAttemptActivity,
r#"SELECT id, task_attempt_id, status as "status!: TaskAttemptStatus", note, created_at
FROM task_attempt_activities
WHERE task_attempt_id = $1
ORDER BY created_at DESC"#,
attempt_id
)
.fetch_all(&pool)
.await
{
Ok(activities) => Ok(ResponseJson(ApiResponse {
success: true,
data: Some(activities),
message: None,
})),
Err(e) => {
tracing::error!("Failed to fetch task attempt activities for attempt {}: {}", attempt_id, e);
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
pub async fn create_task_attempt(
_auth: AuthUser,
Path((project_id, task_id)): Path<(Uuid, Uuid)>,
Extension(pool): Extension<PgPool>,
Json(mut payload): Json<CreateTaskAttempt>
) -> Result<ResponseJson<ApiResponse<TaskAttempt>>, StatusCode> {
// Verify task exists in project first
let task_exists = sqlx::query!(
"SELECT id FROM tasks WHERE id = $1 AND project_id = $2",
task_id,
project_id
)
.fetch_optional(&pool)
.await;
match task_exists {
Ok(None) => return Err(StatusCode::NOT_FOUND),
Err(e) => {
tracing::error!("Failed to check task existence: {}", e);
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
Ok(Some(_)) => {}
}
let id = Uuid::new_v4();
let now = Utc::now();
// Ensure the task_id in the payload matches the path parameter
payload.task_id = task_id;
match sqlx::query_as!(
TaskAttempt,
r#"INSERT INTO task_attempts (id, task_id, worktree_path, base_commit, merge_commit, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, task_id, worktree_path, base_commit, merge_commit, created_at, updated_at"#,
id,
payload.task_id,
payload.worktree_path,
payload.base_commit,
payload.merge_commit,
now,
now
)
.fetch_one(&pool)
.await
{
Ok(attempt) => {
// Create initial activity record
let activity_id = Uuid::new_v4();
let _ = sqlx::query!(
r#"INSERT INTO task_attempt_activities (id, task_attempt_id, status, note, created_at)
VALUES ($1, $2, $3, $4, $5)"#,
activity_id,
attempt.id,
TaskAttemptStatus::Init as TaskAttemptStatus,
Option::<String>::None,
now
)
.execute(&pool)
.await;
Ok(ResponseJson(ApiResponse {
success: true,
data: Some(attempt),
message: Some("Task attempt created successfully".to_string()),
}))
}
Err(e) => {
tracing::error!("Failed to create task attempt: {}", e);
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
pub async fn create_task_attempt_activity(
_auth: AuthUser,
Path((project_id, task_id, attempt_id)): Path<(Uuid, Uuid, Uuid)>,
Extension(pool): Extension<PgPool>,
Json(mut payload): Json<CreateTaskAttemptActivity>
) -> Result<ResponseJson<ApiResponse<TaskAttemptActivity>>, StatusCode> {
// Verify task attempt exists and belongs to the correct task
let attempt_exists = sqlx::query!(
"SELECT ta.id FROM task_attempts ta
JOIN tasks t ON ta.task_id = t.id
WHERE ta.id = $1 AND t.id = $2 AND t.project_id = $3",
attempt_id,
task_id,
project_id
)
.fetch_optional(&pool)
.await;
match attempt_exists {
Ok(None) => return Err(StatusCode::NOT_FOUND),
Err(e) => {
tracing::error!("Failed to check task attempt existence: {}", e);
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
Ok(Some(_)) => {}
}
let id = Uuid::new_v4();
let now = Utc::now();
// Ensure the task_attempt_id in the payload matches the path parameter
payload.task_attempt_id = attempt_id;
// Default to Init status if not provided
let status = payload.status.unwrap_or(TaskAttemptStatus::Init);
match sqlx::query_as!(
TaskAttemptActivity,
r#"INSERT INTO task_attempt_activities (id, task_attempt_id, status, note, created_at)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, task_attempt_id, status as "status!: TaskAttemptStatus", note, created_at"#,
id,
payload.task_attempt_id,
status as TaskAttemptStatus,
payload.note,
now
)
.fetch_one(&pool)
.await
{
Ok(activity) => Ok(ResponseJson(ApiResponse {
success: true,
data: Some(activity),
message: Some("Task attempt activity created successfully".to_string()),
})),
Err(e) => {
tracing::error!("Failed to create task attempt activity: {}", e);
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
pub fn tasks_router() -> Router {
use axum::routing::{post, put, delete};
Router::new()
.route("/projects/:project_id/tasks", get(get_project_tasks).post(create_task))
.route("/projects/:project_id/tasks/:task_id", get(get_task).put(update_task).delete(delete_task))
.route("/projects/:project_id/tasks/:task_id/attempts", get(get_task_attempts).post(create_task_attempt))
.route("/projects/:project_id/tasks/:task_id/attempts/:attempt_id/activities", get(get_task_attempt_activities).post(create_task_attempt_activity))
}
#[cfg(test)]