Add comment, tag, and blocker operations
Assisted-by: Claude Opus 4.6 via pi
change olzmxwonypkutznpkukrlztqmqsunzqn
commit 0e1a0277017cf7309c276f5aab9f71715a877820
author Alpha Chen <alpha@kejadlen.dev>
date
parent ysyusvkl
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);
+    }
+}