gitstack 5.3.0

Git history viewer with insights - Author stats, file heatmap, code ownership
Documentation
//! Review feedback loop queue
//!
//! Manages a queue of review requests (from MCP or other sources) that
//! humans can approve/reject via the TUI.

use chrono::Local;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;

/// Review status
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum ReviewStatus {
    #[default]
    Pending,
    Approved,
    Rejected,
}

/// A single review item
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReviewItem {
    /// Unique identifier
    pub id: String,
    /// Related commit hash
    pub commit_hash: String,
    /// Who requested the review
    pub requested_by: String,
    /// Review points to address
    pub review_points: Vec<String>,
    /// Current status
    pub status: ReviewStatus,
    /// When the review was requested (ISO 8601 string)
    pub created_at: String,
    /// When the review was resolved (ISO 8601 string)
    pub resolved_at: Option<String>,
    /// Human response/comment
    pub response: Option<String>,
}

/// Review queue manager
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ReviewQueue {
    pub items: Vec<ReviewItem>,
}

impl ReviewQueue {
    /// Get the config file path for the review queue
    fn queue_path() -> PathBuf {
        let config_dir = dirs::config_dir()
            .unwrap_or_else(|| PathBuf::from("."))
            .join("gitstack");
        config_dir.join("review_queue.json")
    }

    /// Load the review queue from disk
    pub fn load() -> Self {
        let path = Self::queue_path();
        if !path.exists() {
            return Self::default();
        }
        fs::read_to_string(&path)
            .ok()
            .and_then(|s| serde_json::from_str(&s).ok())
            .unwrap_or_default()
    }

    /// Save the review queue to disk
    pub fn save(&self) -> anyhow::Result<()> {
        let path = Self::queue_path();
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent)?;
        }
        let json = serde_json::to_string_pretty(self)?;
        fs::write(&path, json)?;
        Ok(())
    }

    /// Add a new review item
    pub fn add_review(&mut self, item: ReviewItem) -> anyhow::Result<()> {
        self.items.push(item);
        self.save()
    }

    /// Resolve a review item
    pub fn resolve_review(
        &mut self,
        id: &str,
        status: ReviewStatus,
        response: &str,
    ) -> anyhow::Result<()> {
        if let Some(item) = self.items.iter_mut().find(|i| i.id == id) {
            item.status = status;
            item.response = Some(response.to_string());
            item.resolved_at = Some(Local::now().to_rfc3339());
        }
        self.save()
    }

    /// List pending reviews
    pub fn list_pending(&self) -> Vec<&ReviewItem> {
        self.items
            .iter()
            .filter(|i| i.status == ReviewStatus::Pending)
            .collect()
    }

    /// List resolved reviews (most recent N)
    pub fn list_resolved(&self, limit: usize) -> Vec<&ReviewItem> {
        self.items
            .iter()
            .filter(|i| i.status != ReviewStatus::Pending)
            .rev()
            .take(limit)
            .collect()
    }

    /// Count pending reviews
    pub fn pending_count(&self) -> usize {
        self.items
            .iter()
            .filter(|i| i.status == ReviewStatus::Pending)
            .count()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn make_pending_item(id: &str, hash: &str) -> ReviewItem {
        ReviewItem {
            id: id.to_string(),
            commit_hash: hash.to_string(),
            requested_by: "claude".to_string(),
            review_points: vec!["Check error handling".to_string()],
            status: ReviewStatus::Pending,
            created_at: Local::now().to_rfc3339(),
            resolved_at: None,
            response: None,
        }
    }

    fn make_resolved_item(id: &str, status: ReviewStatus) -> ReviewItem {
        ReviewItem {
            id: id.to_string(),
            commit_hash: "abc1234".to_string(),
            requested_by: "claude".to_string(),
            review_points: vec![],
            status,
            created_at: Local::now().to_rfc3339(),
            resolved_at: Some(Local::now().to_rfc3339()),
            response: Some("Done".to_string()),
        }
    }

    #[test]
    fn test_review_status_default() {
        assert_eq!(ReviewStatus::default(), ReviewStatus::Pending);
    }

    #[test]
    fn test_review_status_eq() {
        assert_eq!(ReviewStatus::Pending, ReviewStatus::Pending);
        assert_ne!(ReviewStatus::Pending, ReviewStatus::Approved);
        assert_ne!(ReviewStatus::Approved, ReviewStatus::Rejected);
    }

    #[test]
    fn test_review_queue_default_empty() {
        let queue = ReviewQueue::default();
        assert!(queue.items.is_empty());
        assert_eq!(queue.pending_count(), 0);
    }

    #[test]
    fn test_review_queue_list_pending() {
        let queue = ReviewQueue {
            items: vec![
                make_pending_item("1", "abc1234"),
                make_resolved_item("2", ReviewStatus::Approved),
            ],
        };
        assert_eq!(queue.list_pending().len(), 1);
        assert_eq!(queue.list_pending()[0].id, "1");
        assert_eq!(queue.pending_count(), 1);
    }

    #[test]
    fn test_review_queue_list_pending_multiple() {
        let queue = ReviewQueue {
            items: vec![
                make_pending_item("1", "abc"),
                make_pending_item("2", "def"),
                make_pending_item("3", "ghi"),
            ],
        };
        assert_eq!(queue.list_pending().len(), 3);
        assert_eq!(queue.pending_count(), 3);
    }

    #[test]
    fn test_review_queue_list_resolved() {
        let queue = ReviewQueue {
            items: vec![make_resolved_item("1", ReviewStatus::Approved)],
        };
        assert_eq!(queue.list_resolved(20).len(), 1);
    }

    #[test]
    fn test_review_queue_list_resolved_limit() {
        let queue = ReviewQueue {
            items: vec![
                make_resolved_item("1", ReviewStatus::Approved),
                make_resolved_item("2", ReviewStatus::Rejected),
                make_resolved_item("3", ReviewStatus::Approved),
            ],
        };
        assert_eq!(queue.list_resolved(2).len(), 2);
        assert_eq!(queue.list_resolved(20).len(), 3);
    }

    #[test]
    fn test_review_queue_list_resolved_excludes_pending() {
        let queue = ReviewQueue {
            items: vec![
                make_pending_item("1", "abc"),
                make_resolved_item("2", ReviewStatus::Approved),
            ],
        };
        let resolved = queue.list_resolved(20);
        assert_eq!(resolved.len(), 1);
        assert_eq!(resolved[0].id, "2");
    }

    #[test]
    fn test_review_queue_serialize_deserialize() {
        let queue = ReviewQueue {
            items: vec![
                make_pending_item("1", "abc1234"),
                make_resolved_item("2", ReviewStatus::Approved),
            ],
        };
        let json = serde_json::to_string(&queue).unwrap();
        let deserialized: ReviewQueue = serde_json::from_str(&json).unwrap();
        assert_eq!(deserialized.items.len(), 2);
        assert_eq!(deserialized.items[0].id, "1");
        assert_eq!(deserialized.items[0].status, ReviewStatus::Pending);
        assert_eq!(deserialized.items[1].status, ReviewStatus::Approved);
    }

    #[test]
    fn test_review_item_serialize_roundtrip() {
        let item = make_pending_item("test-id", "commit-hash");
        let json = serde_json::to_string(&item).unwrap();
        let deserialized: ReviewItem = serde_json::from_str(&json).unwrap();
        assert_eq!(deserialized.id, "test-id");
        assert_eq!(deserialized.commit_hash, "commit-hash");
        assert_eq!(deserialized.requested_by, "claude");
        assert_eq!(deserialized.review_points.len(), 1);
        assert_eq!(deserialized.status, ReviewStatus::Pending);
        assert!(deserialized.resolved_at.is_none());
        assert!(deserialized.response.is_none());
    }

    #[test]
    fn test_review_queue_pending_count_all_resolved() {
        let queue = ReviewQueue {
            items: vec![
                make_resolved_item("1", ReviewStatus::Approved),
                make_resolved_item("2", ReviewStatus::Rejected),
            ],
        };
        assert_eq!(queue.pending_count(), 0);
    }

    #[test]
    fn test_review_status_clone_copy() {
        let status = ReviewStatus::Approved;
        let copied = status;
        assert_eq!(status, copied);
    }

    #[test]
    fn test_review_queue_clone() {
        let queue = ReviewQueue {
            items: vec![make_pending_item("1", "abc")],
        };
        let cloned = queue.clone();
        assert_eq!(cloned.items.len(), 1);
        assert_eq!(cloned.items[0].id, "1");
    }
}