Add CLI task commands
Assisted-by: Claude Opus 4.6 via pi
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>(