Add comment, tag, and blocker operations
Assisted-by: Claude Opus 4.6 via pi
diff --git a/crates/ranger-lib/src/ops/blocker.rs b/crates/ranger-lib/src/ops/blocker.rs
new file mode 100644
index 0000000..093031f
--- /dev/null
+++ b/crates/ranger-lib/src/ops/blocker.rs
@@ -0,0 +1,92 @@
+use crate::error::RangerError;
+use crate::models::Blocker;
+use sqlx::SqlitePool;
+
+pub async fn add(
+ pool: &SqlitePool,
+ task_id: i64,
+ blocked_by_task_id: i64,
+) -> Result<Blocker, RangerError> {
+ let blocker = sqlx::query_as::<_, Blocker>(
+ "INSERT INTO blockers (task_id, blocked_by_task_id) VALUES (?, ?) \
+ RETURNING id, task_id, blocked_by_task_id",
+ )
+ .bind(task_id)
+ .bind(blocked_by_task_id)
+ .fetch_one(pool)
+ .await?;
+ Ok(blocker)
+}
+
+pub async fn remove(
+ pool: &SqlitePool,
+ task_id: i64,
+ blocked_by_task_id: i64,
+) -> Result<(), RangerError> {
+ sqlx::query("DELETE FROM blockers WHERE task_id = ? AND blocked_by_task_id = ?")
+ .bind(task_id)
+ .bind(blocked_by_task_id)
+ .execute(pool)
+ .await?;
+ Ok(())
+}
+
+pub async fn list_for_task(pool: &SqlitePool, task_id: i64) -> Result<Vec<Blocker>, RangerError> {
+ let blockers = sqlx::query_as::<_, Blocker>(
+ "SELECT id, task_id, blocked_by_task_id FROM blockers WHERE task_id = ?",
+ )
+ .bind(task_id)
+ .fetch_all(pool)
+ .await?;
+ Ok(blockers)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::db;
+ use crate::ops::{backlog, task};
+ use tempfile::tempdir;
+
+ async fn test_pool() -> SqlitePool {
+ let dir = tempdir().unwrap();
+ let dir = Box::leak(Box::new(dir));
+ db::connect(&dir.path().join("test.db")).await.unwrap()
+ }
+
+ #[tokio::test]
+ async fn add_and_list_blockers() {
+ let pool = test_pool().await;
+ let bl = backlog::create(&pool, "Test").await.unwrap();
+ let t1 = task::create(&pool, "Blocked", bl.id, None, None, None)
+ .await
+ .unwrap();
+ let t2 = task::create(&pool, "Blocker", bl.id, None, None, None)
+ .await
+ .unwrap();
+
+ add(&pool, t1.id, t2.id).await.unwrap();
+
+ let blockers = list_for_task(&pool, t1.id).await.unwrap();
+ assert_eq!(blockers.len(), 1);
+ assert_eq!(blockers[0].blocked_by_task_id, t2.id);
+ }
+
+ #[tokio::test]
+ async fn remove_blocker() {
+ let pool = test_pool().await;
+ let bl = backlog::create(&pool, "Test").await.unwrap();
+ let t1 = task::create(&pool, "Blocked", bl.id, None, None, None)
+ .await
+ .unwrap();
+ let t2 = task::create(&pool, "Blocker", bl.id, None, None, None)
+ .await
+ .unwrap();
+
+ add(&pool, t1.id, t2.id).await.unwrap();
+ remove(&pool, t1.id, t2.id).await.unwrap();
+
+ let blockers = list_for_task(&pool, t1.id).await.unwrap();
+ assert_eq!(blockers.len(), 0);
+ }
+}
diff --git a/crates/ranger-lib/src/ops/comment.rs b/crates/ranger-lib/src/ops/comment.rs
new file mode 100644
index 0000000..4463062
--- /dev/null
+++ b/crates/ranger-lib/src/ops/comment.rs
@@ -0,0 +1,56 @@
+use crate::error::RangerError;
+use crate::models::Comment;
+use sqlx::SqlitePool;
+
+pub async fn add(pool: &SqlitePool, task_id: i64, body: &str) -> Result<Comment, RangerError> {
+ let comment = sqlx::query_as::<_, Comment>(
+ "INSERT INTO comments (task_id, body) VALUES (?, ?) \
+ RETURNING id, task_id, body, created_at",
+ )
+ .bind(task_id)
+ .bind(body)
+ .fetch_one(pool)
+ .await?;
+ Ok(comment)
+}
+
+pub async fn list(pool: &SqlitePool, task_id: i64) -> Result<Vec<Comment>, RangerError> {
+ let comments = sqlx::query_as::<_, Comment>(
+ "SELECT id, task_id, body, created_at FROM comments WHERE task_id = ? ORDER BY created_at",
+ )
+ .bind(task_id)
+ .fetch_all(pool)
+ .await?;
+ Ok(comments)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::db;
+ use crate::ops::{backlog, task};
+ use tempfile::tempdir;
+
+ async fn test_pool() -> sqlx::SqlitePool {
+ let dir = tempdir().unwrap();
+ let dir = Box::leak(Box::new(dir));
+ db::connect(&dir.path().join("test.db")).await.unwrap()
+ }
+
+ #[tokio::test]
+ async fn add_and_list_comments() {
+ let pool = test_pool().await;
+ let bl = backlog::create(&pool, "Test").await.unwrap();
+ let t = task::create(&pool, "Task", bl.id, None, None, None)
+ .await
+ .unwrap();
+
+ add(&pool, t.id, "First comment").await.unwrap();
+ add(&pool, t.id, "Second comment").await.unwrap();
+
+ let comments = list(&pool, t.id).await.unwrap();
+ assert_eq!(comments.len(), 2);
+ assert_eq!(comments[0].body, "First comment");
+ assert_eq!(comments[1].body, "Second comment");
+ }
+}
diff --git a/crates/ranger-lib/src/ops/mod.rs b/crates/ranger-lib/src/ops/mod.rs
index 5381e93..3674cef 100644
--- a/crates/ranger-lib/src/ops/mod.rs
+++ b/crates/ranger-lib/src/ops/mod.rs
@@ -1,2 +1,5 @@
pub mod backlog;
+pub mod blocker;
+pub mod comment;
+pub mod tag;
pub mod task;
diff --git a/crates/ranger-lib/src/ops/tag.rs b/crates/ranger-lib/src/ops/tag.rs
new file mode 100644
index 0000000..fc0acd6
--- /dev/null
+++ b/crates/ranger-lib/src/ops/tag.rs
@@ -0,0 +1,112 @@
+use crate::error::RangerError;
+use crate::models::Tag;
+use sqlx::SqlitePool;
+
+/// Get or create a tag by name.
+pub async fn get_or_create(pool: &SqlitePool, name: &str) -> Result<Tag, RangerError> {
+ // Try insert, ignore conflict
+ sqlx::query("INSERT OR IGNORE INTO tags (name) VALUES (?)")
+ .bind(name)
+ .execute(pool)
+ .await?;
+
+ let tag = sqlx::query_as::<_, Tag>("SELECT id, name FROM tags WHERE name = ?")
+ .bind(name)
+ .fetch_one(pool)
+ .await?;
+ Ok(tag)
+}
+
+pub async fn list(pool: &SqlitePool) -> Result<Vec<Tag>, RangerError> {
+ let tags = sqlx::query_as::<_, Tag>("SELECT id, name FROM tags ORDER BY name")
+ .fetch_all(pool)
+ .await?;
+ Ok(tags)
+}
+
+pub async fn add_to_task(pool: &SqlitePool, task_id: i64, tag_id: i64) -> Result<(), RangerError> {
+ sqlx::query("INSERT OR IGNORE INTO task_tags (task_id, tag_id) VALUES (?, ?)")
+ .bind(task_id)
+ .bind(tag_id)
+ .execute(pool)
+ .await?;
+ Ok(())
+}
+
+pub async fn list_for_task(pool: &SqlitePool, task_id: i64) -> Result<Vec<Tag>, RangerError> {
+ let tags = sqlx::query_as::<_, Tag>(
+ "SELECT t.id, t.name FROM tags t \
+ JOIN task_tags tt ON tt.tag_id = t.id \
+ WHERE tt.task_id = ? ORDER BY t.name",
+ )
+ .bind(task_id)
+ .fetch_all(pool)
+ .await?;
+ Ok(tags)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::db;
+ use crate::ops::{backlog, task};
+ use tempfile::tempdir;
+
+ async fn test_pool() -> SqlitePool {
+ let dir = tempdir().unwrap();
+ let dir = Box::leak(Box::new(dir));
+ db::connect(&dir.path().join("test.db")).await.unwrap()
+ }
+
+ #[tokio::test]
+ async fn get_or_create_is_idempotent() {
+ let pool = test_pool().await;
+ let t1 = get_or_create(&pool, "urgent").await.unwrap();
+ let t2 = get_or_create(&pool, "urgent").await.unwrap();
+ assert_eq!(t1.id, t2.id);
+ }
+
+ #[tokio::test]
+ async fn list_tags() {
+ let pool = test_pool().await;
+ get_or_create(&pool, "beta").await.unwrap();
+ get_or_create(&pool, "alpha").await.unwrap();
+
+ let tags = list(&pool).await.unwrap();
+ assert_eq!(tags.len(), 2);
+ assert_eq!(tags[0].name, "alpha");
+ assert_eq!(tags[1].name, "beta");
+ }
+
+ #[tokio::test]
+ async fn add_tag_to_task_and_list() {
+ let pool = test_pool().await;
+ let bl = backlog::create(&pool, "Test").await.unwrap();
+ let t = task::create(&pool, "Task", bl.id, None, None, None)
+ .await
+ .unwrap();
+ let tag = get_or_create(&pool, "important").await.unwrap();
+
+ add_to_task(&pool, t.id, tag.id).await.unwrap();
+
+ let tags = list_for_task(&pool, t.id).await.unwrap();
+ assert_eq!(tags.len(), 1);
+ assert_eq!(tags[0].name, "important");
+ }
+
+ #[tokio::test]
+ async fn add_tag_to_task_is_idempotent() {
+ let pool = test_pool().await;
+ let bl = backlog::create(&pool, "Test").await.unwrap();
+ let t = task::create(&pool, "Task", bl.id, None, None, None)
+ .await
+ .unwrap();
+ let tag = get_or_create(&pool, "dup").await.unwrap();
+
+ add_to_task(&pool, t.id, tag.id).await.unwrap();
+ add_to_task(&pool, t.id, tag.id).await.unwrap();
+
+ let tags = list_for_task(&pool, t.id).await.unwrap();
+ assert_eq!(tags.len(), 1);
+ }
+}