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:
@@ -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"] }
|
||||
|
||||
2
backend/migrations/004_add_git_repo_path.sql
Normal file
2
backend/migrations/004_add_git_repo_path.sql
Normal 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 '';
|
||||
2
backend/migrations/005_unique_git_repo_path.sql
Normal file
2
backend/migrations/005_unique_git_repo_path.sql
Normal 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);
|
||||
33
backend/migrations/006_create_task_attempts.sql
Normal file
33
backend/migrations/006_create_task_attempts.sql
Normal 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();
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
44
backend/src/models/task_attempt.rs
Normal file
44
backend/src/models/task_attempt.rs
Normal 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>,
|
||||
}
|
||||
25
backend/src/models/task_attempt_activity.rs
Normal file
25
backend/src/models/task_attempt_activity.rs
Normal 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>,
|
||||
}
|
||||
207
backend/src/routes/filesystem.rs
Normal file
207
backend/src/routes/filesystem.rs
Normal 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))
|
||||
}
|
||||
@@ -2,3 +2,4 @@ pub mod health;
|
||||
pub mod projects;
|
||||
pub mod tasks;
|
||||
pub mod users;
|
||||
pub mod filesystem;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user