//! lore CLI integration //! //! Provides trait-based abstraction over the lore CLI for testability. use serde::{Deserialize, Serialize}; use std::process::Command; #[cfg(test)] use mockall::automock; /// Trait for interacting with lore CLI /// /// This abstraction allows us to mock lore in tests. /// Note: We don't use `lore health` because it's too strict (checks schema /// migrations, index freshness, etc). MC only cares if we can get data. #[cfg_attr(test, automock)] pub trait LoreCli: Send + Sync { /// Execute `lore --robot me` and return the parsed result fn get_me(&self) -> Result; } /// Real implementation that shells out to lore CLI #[derive(Debug, Default)] pub struct RealLoreCli; impl LoreCli for RealLoreCli { fn get_me(&self) -> Result { let output = Command::new("lore") .args(["--robot", "me"]) .output() .map_err(|e| LoreError::ExecutionFailed(e.to_string()))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(LoreError::CommandFailed(stderr.to_string())); } let stdout = String::from_utf8_lossy(&output.stdout); serde_json::from_str(&stdout).map_err(|e| LoreError::ParseFailed(e.to_string())) } } /// Errors that can occur when interacting with lore #[derive(Debug, Clone, thiserror::Error)] pub enum LoreError { #[error("Failed to execute lore command: {0}")] ExecutionFailed(String), #[error("lore command failed: {0}")] CommandFailed(String), #[error("Failed to parse lore output: {0}")] ParseFailed(String), } /// Response from `lore --robot me` #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LoreMeResponse { pub ok: bool, pub data: LoreMeData, #[serde(default)] pub meta: Option, } /// Metadata from lore response #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LoreMeta { pub elapsed_ms: Option, } /// Data section of `lore --robot me` /// /// Note: Field names match actual lore CLI output format #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LoreMeData { /// Issues assigned to you #[serde(default)] pub open_issues: Vec, /// MRs you authored that are open #[serde(default)] pub open_mrs_authored: Vec, /// MRs where you're a reviewer #[serde(default)] pub reviewing_mrs: Vec, /// Recent activity across GitLab #[serde(default)] pub activity: Vec, /// Events since last cursor check #[serde(default)] pub since_last_check: Option, /// Summary counts #[serde(default)] pub summary: Option, /// Your username #[serde(default)] pub username: Option, /// ISO timestamp since when activity is shown #[serde(default)] pub since_iso: Option, } /// Summary statistics from lore #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LoreSummary { pub authored_mr_count: i64, pub needs_attention_count: i64, pub open_issue_count: i64, pub project_count: i64, pub reviewing_mr_count: i64, } /// A GitLab issue from lore #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LoreIssue { pub iid: i64, pub title: String, #[serde(default)] pub project: String, pub state: String, pub web_url: String, #[serde(default)] pub labels: Vec, #[serde(default)] pub attention_state: Option, #[serde(default)] pub status_name: Option, #[serde(default)] pub updated_at_iso: Option, } /// A GitLab merge request from lore #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LoreMr { pub iid: i64, pub title: String, #[serde(default)] pub project: String, pub state: String, pub web_url: String, #[serde(default)] pub labels: Vec, #[serde(default)] pub attention_state: Option, #[serde(default)] pub author_username: Option, #[serde(default)] pub detailed_merge_status: Option, #[serde(default)] pub draft: bool, #[serde(default)] pub updated_at_iso: Option, } /// Recent activity item from lore #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LoreActivity { pub actor: String, pub event_type: String, pub entity_iid: i64, pub entity_type: String, pub project: String, #[serde(default)] pub summary: Option, #[serde(default)] pub body_preview: Option, #[serde(default)] pub is_own: bool, #[serde(default)] pub timestamp_iso: Option, } /// Events since last lore cursor check #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SinceLastCheck { #[serde(default)] pub cursor_iso: Option, #[serde(default)] pub groups: Vec, #[serde(default)] pub total_event_count: i64, } /// A group of related events (e.g., all events on one MR) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct EventGroup { pub entity_iid: i64, pub entity_title: String, pub entity_type: String, pub project: String, #[serde(default)] pub events: Vec, } /// A GitLab event from the since_last_check section #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LoreEvent { pub event_type: String, #[serde(default)] pub actor: Option, #[serde(default)] pub summary: Option, #[serde(default)] pub body_preview: Option, #[serde(default)] pub timestamp_iso: Option, } #[cfg(test)] mod tests { use super::*; fn sample_lore_me_response() -> LoreMeResponse { LoreMeResponse { ok: true, data: LoreMeData { open_issues: vec![LoreIssue { iid: 42, title: "Fix authentication bug".to_string(), project: "mygroup/myproject".to_string(), state: "opened".to_string(), web_url: "https://gitlab.com/mygroup/myproject/-/issues/42".to_string(), labels: vec![], attention_state: None, status_name: None, updated_at_iso: None, }], open_mrs_authored: vec![], reviewing_mrs: vec![LoreMr { iid: 100, title: "Add new feature".to_string(), project: "mygroup/myproject".to_string(), state: "opened".to_string(), web_url: "https://gitlab.com/mygroup/myproject/-/merge_requests/100" .to_string(), labels: vec![], attention_state: Some("needs_attention".to_string()), author_username: Some("johndoe".to_string()), detailed_merge_status: None, draft: false, updated_at_iso: None, }], activity: vec![], since_last_check: Some(SinceLastCheck { cursor_iso: None, groups: vec![], total_event_count: 0, }), summary: None, username: Some("testuser".to_string()), since_iso: None, }, meta: None, } } #[test] fn test_mock_lore_cli_returns_expected_data() { let mut mock = MockLoreCli::new(); let expected = sample_lore_me_response(); let expected_clone = expected.clone(); mock.expect_get_me() .times(1) .returning(move || Ok(expected_clone.clone())); let result = mock.get_me().unwrap(); assert!(result.ok); assert_eq!(result.data.open_issues.len(), 1); assert_eq!(result.data.reviewing_mrs.len(), 1); assert_eq!(result.data.open_issues[0].iid, 42); } #[test] fn test_mock_lore_cli_can_return_error() { let mut mock = MockLoreCli::new(); mock.expect_get_me() .times(1) .returning(|| Err(LoreError::ExecutionFailed("lore not found".to_string()))); let result = mock.get_me(); assert!(result.is_err()); } #[test] fn test_lore_response_deserialize_empty() { let json = r#"{ "ok": true, "data": { "open_issues": [], "open_mrs_authored": [], "reviewing_mrs": [], "activity": [], "since_last_check": null } }"#; let response: LoreMeResponse = serde_json::from_str(json).unwrap(); assert!(response.ok); assert!(response.data.open_issues.is_empty()); } }