Add CLI task commands
Assisted-by: Claude Opus 4.6 via pi
change pyrtxpmxttxwsrqkrwlpzwmukmsuoolq
commit b2c135ba814008bab426cf9a53f7af285023224f
author Alpha Chen <alpha@kejadlen.dev>
date
parent kkwylplr
diff --git a/crates/ranger-cli/src/commands/mod.rs b/crates/ranger-cli/src/commands/mod.rs
index 40d080e..5381e93 100644
--- a/crates/ranger-cli/src/commands/mod.rs
+++ b/crates/ranger-cli/src/commands/mod.rs
@@ -1 +1,2 @@
 pub mod backlog;
+pub mod task;
diff --git a/crates/ranger-cli/src/commands/task.rs b/crates/ranger-cli/src/commands/task.rs
new file mode 100644
index 0000000..492bb26
--- /dev/null
+++ b/crates/ranger-cli/src/commands/task.rs
@@ -0,0 +1,267 @@
+use clap::Subcommand;
+use ranger_lib::db::SqlitePool;
+use ranger_lib::models::Task;
+use ranger_lib::ops;
+
+use crate::output;
+
+#[derive(Subcommand)]
+pub enum TaskCommands {
+    /// Create a new task
+    Create {
+        /// Task title
+        title: String,
+        /// Backlog key or prefix to add the task to
+        #[arg(long)]
+        backlog: String,
+        /// Task description
+        #[arg(long)]
+        description: Option<String>,
+        /// Initial state (icebox, queued, in_progress, done)
+        #[arg(long)]
+        state: Option<String>,
+        /// Parent task key or prefix (makes this a subtask)
+        #[arg(long)]
+        parent: Option<String>,
+        /// Tags to add (comma-separated)
+        #[arg(long)]
+        tag: Option<String>,
+    },
+    /// List tasks
+    List {
+        /// Filter by backlog key or prefix
+        #[arg(long)]
+        backlog: Option<String>,
+        /// Filter by state
+        #[arg(long)]
+        state: Option<String>,
+    },
+    /// Show task details
+    Show {
+        /// Task key or prefix
+        key: String,
+    },
+    /// Edit a task
+    Edit {
+        /// Task key or prefix
+        key: String,
+        /// New title
+        #[arg(long)]
+        title: Option<String>,
+        /// New description
+        #[arg(long)]
+        description: Option<String>,
+        /// New state
+        #[arg(long)]
+        state: Option<String>,
+    },
+    /// Move a task's position within a backlog
+    Move {
+        /// Task key or prefix
+        key: String,
+        /// Place before this task key
+        #[arg(long)]
+        before: Option<String>,
+        /// Place after this task key
+        #[arg(long)]
+        after: Option<String>,
+        /// Backlog to reorder within
+        #[arg(long)]
+        backlog: String,
+    },
+    /// Add a task to a backlog
+    Add {
+        /// Task key or prefix
+        task: String,
+        /// Backlog key or prefix
+        backlog: String,
+    },
+    /// Remove a task from a backlog
+    Remove {
+        /// Task key or prefix
+        task: String,
+        /// Backlog key or prefix
+        backlog: String,
+    },
+    /// Delete a task entirely
+    Delete {
+        /// Task key or prefix
+        key: String,
+    },
+}
+
+pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> anyhow::Result<()> {
+    match command {
+        TaskCommands::Create {
+            title,
+            backlog,
+            description,
+            state,
+            parent,
+            tag,
+        } => {
+            let bl = ops::backlog::get_by_key_prefix(pool, &backlog).await?;
+            let parent_id = if let Some(parent_key) = &parent {
+                Some(
+                    ops::task::get_by_key_prefix(pool, parent_key)
+                        .await?
+                        .id,
+                )
+            } else {
+                None
+            };
+
+            let task = ops::task::create(
+                pool,
+                &title,
+                bl.id,
+                state.as_deref(),
+                parent_id,
+                description.as_deref(),
+            )
+            .await?;
+
+            if let Some(tags) = &tag {
+                for tag_name in tags.split(',').map(str::trim) {
+                    let t = ops::tag::get_or_create(pool, tag_name).await?;
+                    ops::tag::add_to_task(pool, task.id, t.id).await?;
+                }
+            }
+
+            output::print(&task, json, |t| print_task(t));
+        }
+        TaskCommands::List { backlog, state } => {
+            if let Some(backlog_key) = &backlog {
+                let bl = ops::backlog::get_by_key_prefix(pool, backlog_key).await?;
+                let tasks = ops::task::list(pool, bl.id, state.as_deref()).await?;
+                output::print_list(&tasks, json, |t| print_task(t));
+            } else {
+                // List all tasks (no backlog filter)
+                let backlogs = ops::backlog::list(pool).await?;
+                let mut all_tasks = Vec::new();
+                for bl in &backlogs {
+                    let tasks = ops::task::list(pool, bl.id, state.as_deref()).await?;
+                    for t in tasks {
+                        if !all_tasks.iter().any(|at: &Task| at.id == t.id) {
+                            all_tasks.push(t);
+                        }
+                    }
+                }
+                output::print_list(&all_tasks, json, |t| print_task(t));
+            }
+        }
+        TaskCommands::Show { key } => {
+            let task = ops::task::get_by_key_prefix(pool, &key).await?;
+            let comments = ops::comment::list(pool, task.id).await?;
+            let tags = ops::tag::list_for_task(pool, task.id).await?;
+            let blockers = ops::blocker::list_for_task(pool, task.id).await?;
+
+            if json {
+                let detail = serde_json::json!({
+                    "task": task,
+                    "comments": comments,
+                    "tags": tags,
+                    "blockers": blockers,
+                });
+                println!("{}", serde_json::to_string_pretty(&detail).unwrap());
+            } else {
+                print_task_detail(&task);
+                if !tags.is_empty() {
+                    let tag_names: Vec<&str> = tags.iter().map(|t| t.name.as_str()).collect();
+                    println!("Tags:    {}", tag_names.join(", "));
+                }
+                if !blockers.is_empty() {
+                    println!("Blocked by:");
+                    for b in &blockers {
+                        if let Ok(bt) = ops::task::get_by_id(pool, b.blocked_by_task_id).await {
+                            println!("  {} {}", &bt.key[..8], bt.title);
+                        }
+                    }
+                }
+                if !comments.is_empty() {
+                    println!();
+                    for c in &comments {
+                        println!("--- {} ---", c.created_at);
+                        println!("{}", c.body);
+                    }
+                }
+            }
+        }
+        TaskCommands::Edit {
+            key,
+            title,
+            description,
+            state,
+        } => {
+            let task = ops::task::get_by_key_prefix(pool, &key).await?;
+            let updated = ops::task::edit(
+                pool,
+                task.id,
+                title.as_deref(),
+                description.as_deref(),
+                state.as_deref(),
+            )
+            .await?;
+            output::print(&updated, json, |t| print_task(t));
+        }
+        TaskCommands::Move {
+            key,
+            before,
+            after,
+            backlog,
+        } => {
+            let bl = ops::backlog::get_by_key_prefix(pool, &backlog).await?;
+            let task = ops::task::get_by_key_prefix(pool, &key).await?;
+
+            let before_id = if let Some(k) = &before {
+                Some(ops::task::get_by_key_prefix(pool, k).await?.id)
+            } else {
+                None
+            };
+            let after_id = if let Some(k) = &after {
+                Some(ops::task::get_by_key_prefix(pool, k).await?.id)
+            } else {
+                None
+            };
+
+            ops::task::move_task(pool, task.id, bl.id, before_id, after_id).await?;
+            println!("Moved {} {}", &task.key[..8], task.title);
+        }
+        TaskCommands::Add { task, backlog } => {
+            let t = ops::task::get_by_key_prefix(pool, &task).await?;
+            let bl = ops::backlog::get_by_key_prefix(pool, &backlog).await?;
+            ops::task::add_to_backlog(pool, t.id, bl.id).await?;
+            println!("Added {} to {}", &t.key[..8], bl.name);
+        }
+        TaskCommands::Remove { task, backlog } => {
+            let t = ops::task::get_by_key_prefix(pool, &task).await?;
+            let bl = ops::backlog::get_by_key_prefix(pool, &backlog).await?;
+            ops::task::remove_from_backlog(pool, t.id, bl.id).await?;
+            println!("Removed {} from {}", &t.key[..8], bl.name);
+        }
+        TaskCommands::Delete { key } => {
+            let task = ops::task::get_by_key_prefix(pool, &key).await?;
+            ops::task::delete(pool, task.id).await?;
+            println!("Deleted {} {}", &task.key[..8], task.title);
+        }
+    }
+    Ok(())
+}
+
+fn print_task(t: &Task) {
+    println!("{} [{}] {}", &t.key[..8], t.state, t.title);
+}
+
+fn print_task_detail(t: &Task) {
+    println!("Key:     {}", t.key);
+    println!("Title:   {}", t.title);
+    println!("State:   {}", t.state);
+    if let Some(desc) = &t.description {
+        println!("Desc:    {}", desc);
+    }
+    if let Some(pid) = t.parent_id {
+        println!("Parent:  {}", pid);
+    }
+    println!("Created: {}", t.created_at);
+    println!("Updated: {}", t.updated_at);
+}
diff --git a/crates/ranger-cli/src/main.rs b/crates/ranger-cli/src/main.rs
index 6dcae42..53cf556 100644
--- a/crates/ranger-cli/src/main.rs
+++ b/crates/ranger-cli/src/main.rs
@@ -26,6 +26,11 @@ enum Commands {
         #[command(subcommand)]
         command: commands::backlog::BacklogCommands,
     },
+    /// Manage tasks
+    Task {
+        #[command(subcommand)]
+        command: commands::task::TaskCommands,
+    },
 }
 
 fn resolve_db_path(cli_path: Option<PathBuf>) -> PathBuf {
@@ -47,6 +52,9 @@ async fn main() -> anyhow::Result<()> {
         Commands::Backlog { command } => {
             commands::backlog::run(&pool, command, cli.json).await?;
         }
+        Commands::Task { command } => {
+            commands::task::run(&pool, command, cli.json).await?;
+        }
     }
 
     Ok(())
diff --git a/crates/ranger-lib/src/ops/task.rs b/crates/ranger-lib/src/ops/task.rs
index a745c23..3dbfdf3 100644
--- a/crates/ranger-lib/src/ops/task.rs
+++ b/crates/ranger-lib/src/ops/task.rs
@@ -86,6 +86,17 @@ pub async fn list(
     Ok(tasks)
 }
 
+pub async fn get_by_id(pool: &SqlitePool, id: i64) -> Result<Task, RangerError> {
+    let task = sqlx::query_as::<_, Task>(
+        "SELECT id, key, parent_id, title, description, state, created_at, updated_at \
+         FROM tasks WHERE id = ?",
+    )
+    .bind(id)
+    .fetch_one(pool)
+    .await?;
+    Ok(task)
+}
+
 pub async fn get_by_key_prefix(pool: &SqlitePool, prefix: &str) -> Result<Task, RangerError> {
     let pattern = format!("{prefix}%");
     let matches = sqlx::query_as::<_, Task>(