//! GitLab -> Beads Bridge //! //! Maps GitLab events (from lore) to beads tasks, with: //! - Deduplication via mapping keys //! - Two-strike close rule (suspect_orphan) //! - Crash-safe write-ahead pattern //! - Single-instance locking via flock(2) use serde::{Deserialize, Serialize}; use specta::Type; use std::collections::HashMap; use std::fs::{self, File}; use std::io::{self, Write}; use std::path::PathBuf; use crate::data::beads::BeadsCli; use crate::data::lore::{EventGroup, LoreCli, LoreMeResponse}; /// Bridge configuration and state pub struct Bridge { lore: L, beads: B, data_dir: PathBuf, } /// Mapping file schema (version 1) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GitLabBeadMap { pub schema_version: u32, pub cursor: Cursor, pub mappings: HashMap, } impl Default for GitLabBeadMap { fn default() -> Self { Self { schema_version: 1, cursor: Cursor::default(), mappings: HashMap::new(), } } } /// Cursor tracking for incremental sync #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Cursor { /// Last successful incremental sync pub last_check_timestamp: Option, /// Last successful full reconciliation pub last_reconciliation: Option, } /// A single mapping entry #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MappingEntry { /// Beads task ID, or None if creation was interrupted pub bead_id: Option, /// When this mapping was created pub created_at: String, /// First strike: missing in last reconciliation pub suspect_orphan: bool, /// In-flight: bead creation started but not confirmed pub pending: bool, /// Human-readable title for crash recovery #[serde(default)] pub title: Option, } /// Mapping key types for GitLab events #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum MappingKey { /// MR review requested: mr_review:{project}:{iid} MrReview { project: String, iid: i64 }, /// Issue assigned: issue:{project}:{iid} Issue { project: String, iid: i64 }, /// MR you authored: mr_authored:{project}:{iid} MrAuthored { project: String, iid: i64 }, } /// Maximum length for entity titles in bead titles (to keep beads scannable) const MAX_TITLE_LENGTH: usize = 60; /// Truncate a string to max_len characters, appending "..." if truncated. /// Handles Unicode correctly by counting grapheme clusters. fn truncate_title(s: &str, max_len: usize) -> String { if s.chars().count() <= max_len { s.to_string() } else { let truncated: String = s.chars().take(max_len.saturating_sub(3)).collect(); format!("{}...", truncated.trim_end()) } } impl MappingKey { /// Serialize to string key format. /// /// Keys are designed to be: /// - Stable across project renames (using project path as lore doesn't expose project_id yet) /// - Safe for JSON keys and filesystem paths (no spaces, forward slashes escaped) /// - Unique within an MC instance pub fn to_key_string(&self) -> String { match self { MappingKey::MrReview { project, iid } => { format!("mr_review:{}:{}", Self::escape_project(project), iid) } MappingKey::Issue { project, iid } => { format!("issue:{}:{}", Self::escape_project(project), iid) } MappingKey::MrAuthored { project, iid } => { format!("mr_authored:{}:{}", Self::escape_project(project), iid) } } } /// Build bead title from this key's event data. /// /// Titles are formatted as "{prefix} {entity_title}" with truncation /// to keep them scannable in the UI. pub fn to_bead_title(&self, entity_title: &str) -> String { let truncated = truncate_title(entity_title, MAX_TITLE_LENGTH); match self { MappingKey::MrReview { iid, .. } => { format!("Review MR !{}: {}", iid, truncated) } MappingKey::Issue { iid, .. } => { format!("Issue #{}: {}", iid, truncated) } MappingKey::MrAuthored { iid, .. } => { format!("Your MR !{}: {}", iid, truncated) } } } /// Escape project path for use in mapping keys. /// Replaces / with :: to make keys filesystem-safe. fn escape_project(project: &str) -> String { project.replace('/', "::") } } /// Result of a sync operation #[derive(Debug, Default, Serialize, Type)] pub struct SyncResult { /// Number of new beads created pub created: u32, /// Number of existing items skipped (dedup) pub skipped: u32, /// Number of beads closed (two-strike) pub closed: u32, /// Number of suspect_orphan flags cleared (item reappeared) pub healed: u32, /// Errors encountered (non-fatal, processing continued) pub errors: Vec, } /// Errors that can occur during bridge operations #[derive(Debug, thiserror::Error)] pub enum BridgeError { #[error("Lore error: {0}")] Lore(#[from] crate::data::lore::LoreError), #[error("Beads error: {0}")] Beads(#[from] crate::data::beads::BeadsError), #[error("IO error: {0}")] Io(#[from] io::Error), #[error("JSON error: {0}")] Json(#[from] serde_json::Error), #[error("Another instance is running")] InstanceLocked, } /// RAII lock guard that releases flock on drop pub struct LockGuard { _file: File, } impl Bridge { /// Create a new bridge with the given CLI implementations pub fn new(lore: L, beads: B) -> Self { let data_dir = dirs::data_local_dir() .unwrap_or_else(|| { tracing::warn!( "Could not determine local data directory ($HOME may be unset), falling back to '.'" ); PathBuf::from(".") }) .join("mc"); Self { lore, beads, data_dir, } } /// Create a bridge with a custom data directory pub fn with_data_dir(lore: L, beads: B, data_dir: PathBuf) -> Self { Self { lore, beads, data_dir, } } /// Path to the mapping file fn map_path(&self) -> PathBuf { self.data_dir.join("gitlab_bead_map.json") } /// Path to the lock file fn lock_path(&self) -> PathBuf { self.data_dir.join("mc.lock") } /// Acquire an exclusive flock(2) lock. Returns InstanceLocked if another /// process holds it. The lock is released when the guard is dropped. pub fn acquire_lock(&self) -> Result { fs::create_dir_all(&self.data_dir)?; let file = File::create(self.lock_path())?; #[cfg(unix)] { use std::os::unix::io::AsRawFd; let fd = file.as_raw_fd(); let ret = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) }; if ret != 0 { let err = io::Error::last_os_error(); if err.kind() == io::ErrorKind::WouldBlock { return Err(BridgeError::InstanceLocked); } return Err(BridgeError::Io(err)); } } Ok(LockGuard { _file: file }) } /// Load the mapping file, or create default if missing pub fn load_map(&self) -> Result { let path = self.map_path(); if path.exists() { let content = fs::read_to_string(&path)?; Ok(serde_json::from_str(&content)?) } else { Ok(GitLabBeadMap::default()) } } /// Clean up orphaned .tmp files from interrupted atomic writes. /// /// Called on startup to remove any bridge-owned .tmp files left behind from /// crashes during save_map(). Returns the number of files cleaned up. pub fn cleanup_tmp_files(&self) -> Result { let tmp_path = self.map_path().with_extension("json.tmp"); if tmp_path.exists() { tracing::info!("Cleaning up orphaned tmp file: {:?}", tmp_path); fs::remove_file(&tmp_path)?; return Ok(1); } Ok(0) } /// Save the mapping file atomically (write to .tmp, then rename) pub fn save_map(&self, map: &GitLabBeadMap) -> Result<(), BridgeError> { fs::create_dir_all(&self.data_dir)?; let path = self.map_path(); let tmp_path = path.with_extension("json.tmp"); let content = serde_json::to_string_pretty(map)?; // Use explicit 0600 permissions -- map contains project paths and bead IDs #[cfg(unix)] let mut file = { use std::os::unix::fs::OpenOptionsExt; fs::OpenOptions::new() .write(true) .create(true) .truncate(true) .mode(0o600) .open(&tmp_path)? }; #[cfg(not(unix))] let mut file = File::create(&tmp_path)?; file.write_all(content.as_bytes())?; file.sync_all()?; // Atomic rename fs::rename(&tmp_path, &path)?; Ok(()) } /// Process a single event using the write-ahead pattern. /// /// 1. Check if key exists -> skip (idempotent) /// 2. Write mapping entry with pending=true, bead_id=None /// 3. Save map to disk (write-ahead) /// 4. Create bead via br /// 5. Update entry with bead_id, pending=false /// 6. Save map again pub fn process_event( &self, map: &mut GitLabBeadMap, key: &MappingKey, title: &str, description: &str, now: &str, ) -> Result { let key_str = key.to_key_string(); // Step 1: Dedup check if map.mappings.contains_key(&key_str) { return Ok(false); } // Step 2: Write-ahead entry map.mappings.insert( key_str.clone(), MappingEntry { bead_id: None, created_at: now.to_string(), suspect_orphan: false, pending: true, title: Some(title.to_string()), }, ); // Step 3: Persist write-ahead to disk self.save_map(map)?; // Step 4: Create bead match self.beads.create(title, description) { Ok(bead_id) => { // Step 5: Update entry with real bead_id if let Some(entry) = map.mappings.get_mut(&key_str) { entry.bead_id = Some(bead_id); entry.pending = false; } // Step 6: Persist completed state self.save_map(map)?; Ok(true) } Err(e) => { // Bead creation failed -- remove write-ahead entry so the event // can be retried on the next sync cycle. The PLAN says: // "br create fails -> do NOT add to map (will retry next sync)" map.mappings.remove(&key_str); self.save_map(map)?; Err(BridgeError::Beads(e)) } } } /// Recover from crash: process pending entries. /// /// On startup, scan for entries with pending=true: /// - If bead_id is None -> retry creation /// - If bead_id exists -> clear pending flag /// /// Returns (recovered_count, error_messages) so callers can surface failures. pub fn recover_pending(&self, map: &mut GitLabBeadMap) -> Result<(usize, Vec), BridgeError> { let pending_keys: Vec = map .mappings .iter() .filter(|(_, entry)| entry.pending) .map(|(key, _)| key.clone()) .collect(); let mut recovered = 0; let mut errors = Vec::new(); for key in &pending_keys { if let Some(entry) = map.mappings.get_mut(key) { if entry.bead_id.is_none() { let title = entry .title .clone() .unwrap_or_else(|| format!("Recovered: {}", key)); match self.beads.create(&title, "Recovered from interrupted sync") { Ok(bead_id) => { entry.bead_id = Some(bead_id); entry.pending = false; recovered += 1; } Err(e) => { tracing::error!("Failed to recover bead for {}: {}", key, e); errors.push(format!("Failed to recover pending bead for {}: {}", key, e)); } } } else { entry.pending = false; recovered += 1; } } } if recovered > 0 { self.save_map(map)?; } Ok((recovered, errors)) } /// Incremental sync: process `since_last_check` events from lore. /// /// Fetches `lore --robot me`, iterates events, creates beads for new ones. /// Only advances cursor after ALL events are processed successfully. pub fn incremental_sync(&self, map: &mut GitLabBeadMap) -> Result { let response = self.lore.get_me()?; let now = chrono::Utc::now().to_rfc3339(); let mut result = SyncResult::default(); // Process since_last_check events if let Some(since) = &response.data.since_last_check { for group in &since.groups { let keys_and_titles = Self::keys_from_event_group(group); for (key, title) in keys_and_titles { let description = format!("Auto-created from {} event in {}", group.entity_type, group.project); match self.process_event(map, &key, &title, &description, &now) { Ok(true) => result.created += 1, Ok(false) => result.skipped += 1, Err(e) => { result.errors.push(format!( "Failed to process {}: {}", key.to_key_string(), e )); } } } } // Only advance cursor when all events processed without errors. // If some failed, we need the next sync to re-fetch them. if result.errors.is_empty() { if let Some(cursor_iso) = &since.cursor_iso { map.cursor.last_check_timestamp = Some(cursor_iso.clone()); } } } self.save_map(map)?; Ok(result) } /// Full reconciliation: compare lore's current state against our map. /// /// Algorithm: /// 1. Fetch all open items from lore /// 2. Build expected key set /// 3. For each key in map: /// - If in expected AND suspect_orphan -> clear flag (healed) /// - If NOT in expected AND NOT suspect_orphan -> set suspect_orphan (first strike) /// - If NOT in expected AND suspect_orphan -> close bead, remove from map (second strike) /// 4. For each key in expected but NOT in map -> create bead pub fn full_reconciliation(&self, map: &mut GitLabBeadMap) -> Result { let response = self.lore.get_me()?; let now = chrono::Utc::now().to_rfc3339(); let expected_keys = Self::build_expected_keys(&response); let mut result = SyncResult::default(); // Phase 1: Check existing mappings against expected let existing_keys: Vec = map.mappings.keys().cloned().collect(); let mut to_remove: Vec = Vec::new(); for key_str in &existing_keys { // Skip pending entries -- they're mid-creation if map.mappings.get(key_str).is_some_and(|e| e.pending) { continue; } if expected_keys.contains_key(key_str) { // Item still exists in lore -- clear suspect_orphan if set if let Some(entry) = map.mappings.get_mut(key_str) { if entry.suspect_orphan { entry.suspect_orphan = false; result.healed += 1; } } } else { // Item missing from lore - need to handle suspect_orphan logic // We need to check immutably first, then mutate if needed let is_suspect = map.mappings.get(key_str).is_some_and(|e| e.suspect_orphan); let bead_id = map.mappings.get(key_str).and_then(|e| e.bead_id.clone()); if is_suspect { // Second strike: close the bead if let Some(bead_id) = bead_id { match self.beads.close(&bead_id, "Auto-closed: item no longer in GitLab") { Ok(()) => { to_remove.push(key_str.clone()); result.closed += 1; } Err(e) => { result.errors.push(format!( "Failed to close bead {} for {}: {}", bead_id, key_str, e )); } } } else { // No bead_id and missing twice -- just remove to_remove.push(key_str.clone()); result.closed += 1; } } else if let Some(entry) = map.mappings.get_mut(key_str) { // First strike: mark as suspect entry.suspect_orphan = true; } } } // Remove closed entries for key_str in &to_remove { map.mappings.remove(key_str); } // Phase 2: Create beads for items in lore but not in map for (key_str, (key, title)) in &expected_keys { if !map.mappings.contains_key(key_str) { let description = "Auto-created from GitLab reconciliation"; match self.process_event(map, key, title, description, &now) { Ok(true) => result.created += 1, Ok(false) => result.skipped += 1, Err(e) => { result.errors.push(format!( "Failed to create bead for {}: {}", key_str, e )); } } } } // Update reconciliation cursor map.cursor.last_reconciliation = Some(now); self.save_map(map)?; Ok(result) } /// Extract mapping keys from a since_last_check event group. /// /// Maps entity_type to the appropriate MappingKey variant. fn keys_from_event_group(group: &EventGroup) -> Vec<(MappingKey, String)> { let mut keys = Vec::new(); match group.entity_type.as_str() { "Issue" => { let key = MappingKey::Issue { project: group.project.clone(), iid: group.entity_iid, }; let title = key.to_bead_title(&group.entity_title); keys.push((key, title)); } "MergeRequest" => { // Check events to determine if this is a review request or authored MR let is_review = group.events.iter().any(|e| { e.event_type == "review_requested" || e.event_type == "reviewer_added" || e.event_type == "approval_required" }); if is_review { let key = MappingKey::MrReview { project: group.project.clone(), iid: group.entity_iid, }; let title = key.to_bead_title(&group.entity_title); keys.push((key, title)); } else { let key = MappingKey::MrAuthored { project: group.project.clone(), iid: group.entity_iid, }; let title = key.to_bead_title(&group.entity_title); keys.push((key, title)); } } _ => { tracing::debug!("Unknown entity type: {}", group.entity_type); } } keys } /// Build the expected key set from a full lore response. /// /// Iterates open_issues, open_mrs_authored, and reviewing_mrs to build /// the set of keys we expect to find in our mapping. fn build_expected_keys( response: &LoreMeResponse, ) -> HashMap { let mut keys = HashMap::new(); for issue in &response.data.open_issues { let key = MappingKey::Issue { project: issue.project.clone(), iid: issue.iid, }; let title = key.to_bead_title(&issue.title); keys.insert(key.to_key_string(), (key, title)); } for mr in &response.data.open_mrs_authored { let key = MappingKey::MrAuthored { project: mr.project.clone(), iid: mr.iid, }; let title = key.to_bead_title(&mr.title); keys.insert(key.to_key_string(), (key, title)); } for mr in &response.data.reviewing_mrs { let key = MappingKey::MrReview { project: mr.project.clone(), iid: mr.iid, }; let title = key.to_bead_title(&mr.title); keys.insert(key.to_key_string(), (key, title)); } keys } } #[cfg(test)] mod tests { use super::*; use crate::data::beads::MockBeadsCli; use crate::data::lore::{ EventGroup, LoreEvent, LoreIssue, LoreMeData, LoreMeResponse, LoreMr, MockLoreCli, SinceLastCheck, }; use tempfile::TempDir; // -- Helpers -- fn test_bridge( lore: MockLoreCli, beads: MockBeadsCli, dir: &TempDir, ) -> Bridge { Bridge::with_data_dir(lore, beads, dir.path().to_path_buf()) } fn empty_lore_response() -> LoreMeResponse { LoreMeResponse { ok: true, data: LoreMeData { open_issues: vec![], open_mrs_authored: vec![], reviewing_mrs: vec![], activity: vec![], since_last_check: None, summary: None, username: Some("testuser".to_string()), since_iso: None, }, meta: None, } } fn make_issue(project: &str, iid: i64, title: &str) -> LoreIssue { LoreIssue { iid, title: title.to_string(), project: project.to_string(), state: "opened".to_string(), web_url: format!("https://gitlab.com/{project}/-/issues/{iid}"), labels: vec![], attention_state: None, status_name: None, updated_at_iso: None, } } fn make_mr(project: &str, iid: i64, title: &str) -> LoreMr { LoreMr { iid, title: title.to_string(), project: project.to_string(), state: "opened".to_string(), web_url: format!("https://gitlab.com/{project}/-/merge_requests/{iid}"), labels: vec![], attention_state: None, author_username: None, detailed_merge_status: None, draft: false, updated_at_iso: None, } } // -- MappingKey tests -- #[test] fn test_mapping_key_serialization() { let key = MappingKey::MrReview { project: "group/repo".to_string(), iid: 847, }; // Project path / is escaped to :: for filesystem safety assert_eq!(key.to_key_string(), "mr_review:group::repo:847"); let key = MappingKey::Issue { project: "group/repo".to_string(), iid: 42, }; assert_eq!(key.to_key_string(), "issue:group::repo:42"); let key = MappingKey::MrAuthored { project: "group/repo".to_string(), iid: 100, }; assert_eq!(key.to_key_string(), "mr_authored:group::repo:100"); } #[test] fn test_mapping_key_escapes_nested_groups() { // GitLab supports deeply nested groups like org/team/sub/repo let key = MappingKey::Issue { project: "org/team/sub/repo".to_string(), iid: 42, }; assert_eq!(key.to_key_string(), "issue:org::team::sub::repo:42"); } #[test] fn test_mapping_key_safe_for_filesystem() { let key = MappingKey::MrReview { project: "group/repo".to_string(), iid: 847, }; let key_str = key.to_key_string(); // Keys should not contain characters that are problematic for: // - JSON object keys (no quotes, backslashes) // - Filesystem paths (no forward slashes, colons are acceptable on Unix) assert!(!key_str.contains('/'), "Key should not contain forward slash"); assert!(!key_str.contains(' '), "Key should not contain spaces"); assert!(!key_str.contains('"'), "Key should not contain quotes"); assert!(!key_str.contains('\\'), "Key should not contain backslashes"); } #[test] fn test_bead_title_formatting() { let key = MappingKey::MrReview { project: "g/p".to_string(), iid: 847, }; assert_eq!( key.to_bead_title("Fix auth bug"), "Review MR !847: Fix auth bug" ); let key = MappingKey::Issue { project: "g/p".to_string(), iid: 42, }; assert_eq!( key.to_bead_title("Broken login"), "Issue #42: Broken login" ); let key = MappingKey::MrAuthored { project: "g/p".to_string(), iid: 100, }; assert_eq!( key.to_bead_title("Add feature"), "Your MR !100: Add feature" ); } #[test] fn test_bead_title_truncates_long_titles() { let key = MappingKey::MrReview { project: "g/p".to_string(), iid: 847, }; let long_title = "Fix authentication token refresh logic that was causing intermittent failures in production"; let title = key.to_bead_title(long_title); // Title should be truncated with ellipsis assert!(title.ends_with("..."), "Long title should end with ellipsis"); // The entity_title portion should be max 60 chars // "Review MR !847: " is 16 chars, so total should be under 16 + 60 = 76 assert!(title.len() <= 80, "Title should be reasonably short: {}", title); } #[test] fn test_bead_title_preserves_short_titles() { let key = MappingKey::Issue { project: "g/p".to_string(), iid: 42, }; let short_title = "Quick fix"; let title = key.to_bead_title(short_title); assert!(!title.ends_with("..."), "Short title should not be truncated"); assert_eq!(title, "Issue #42: Quick fix"); } #[test] fn test_truncate_title_exactly_at_limit() { // 60 char title should not be truncated let title_60 = "A".repeat(60); let truncated = truncate_title(&title_60, 60); assert_eq!(truncated.len(), 60); assert!(!truncated.ends_with("...")); } #[test] fn test_truncate_title_just_over_limit() { // 61 char title should be truncated let title_61 = "A".repeat(61); let truncated = truncate_title(&title_61, 60); assert!(truncated.ends_with("...")); assert!(truncated.len() <= 60); } #[test] fn test_truncate_title_handles_unicode() { // Unicode characters should be counted correctly, not by bytes let emoji_title = "Fix 🔥 auth bug with 中文 characters that is very long indeed"; let truncated = truncate_title(emoji_title, 30); // Should truncate by character count, not bytes assert!(truncated.chars().count() <= 30); assert!(truncated.ends_with("...")); } // -- Map persistence tests -- #[test] fn test_default_map() { let map = GitLabBeadMap::default(); assert_eq!(map.schema_version, 1); assert!(map.mappings.is_empty()); assert!(map.cursor.last_check_timestamp.is_none()); } #[test] fn test_mapping_entry_serialization() { let entry = MappingEntry { bead_id: Some("br-x7f".to_string()), created_at: "2026-02-25T10:00:00Z".to_string(), suspect_orphan: false, pending: false, title: Some("Review MR !847: Fix auth bug".to_string()), }; let json = serde_json::to_string(&entry).unwrap(); let parsed: MappingEntry = serde_json::from_str(&json).unwrap(); assert_eq!(parsed.bead_id, Some("br-x7f".to_string())); assert!(!parsed.suspect_orphan); assert!(!parsed.pending); assert_eq!( parsed.title, Some("Review MR !847: Fix auth bug".to_string()) ); } #[test] fn test_save_and_load_map() { let dir = TempDir::new().unwrap(); let bridge = test_bridge(MockLoreCli::new(), MockBeadsCli::new(), &dir); let mut map = GitLabBeadMap::default(); map.mappings.insert( "issue:g::p:42".to_string(), MappingEntry { bead_id: Some("bd-abc".to_string()), created_at: "2026-02-25T10:00:00Z".to_string(), suspect_orphan: false, pending: false, title: Some("Issue #42: Bug".to_string()), }, ); bridge.save_map(&map).unwrap(); let loaded = bridge.load_map().unwrap(); assert_eq!(loaded.mappings.len(), 1); assert!(loaded.mappings.contains_key("issue:g::p:42")); assert_eq!( loaded.mappings["issue:g::p:42"].bead_id, Some("bd-abc".to_string()) ); } #[test] fn test_load_map_returns_default_when_missing() { let dir = TempDir::new().unwrap(); let bridge = test_bridge(MockLoreCli::new(), MockBeadsCli::new(), &dir); let map = bridge.load_map().unwrap(); assert!(map.mappings.is_empty()); assert_eq!(map.schema_version, 1); } // -- process_event tests (write-ahead pattern) -- #[test] fn test_process_event_creates_bead() { let dir = TempDir::new().unwrap(); let mut beads = MockBeadsCli::new(); beads .expect_create() .returning(|_, _| Ok("bd-new".to_string())); let bridge = test_bridge(MockLoreCli::new(), beads, &dir); let mut map = GitLabBeadMap::default(); let key = MappingKey::Issue { project: "g/p".to_string(), iid: 42, }; let created = bridge .process_event(&mut map, &key, "Issue #42: Bug", "desc", "2026-02-25T10:00:00Z"); assert!(created.unwrap()); assert_eq!(map.mappings.len(), 1); let entry = &map.mappings["issue:g::p:42"]; assert_eq!(entry.bead_id, Some("bd-new".to_string())); assert!(!entry.pending); } #[test] fn test_process_event_skips_duplicate() { let dir = TempDir::new().unwrap(); let bridge = test_bridge(MockLoreCli::new(), MockBeadsCli::new(), &dir); let mut map = GitLabBeadMap::default(); // Pre-populate map.mappings.insert( "issue:g::p:42".to_string(), MappingEntry { bead_id: Some("bd-existing".to_string()), created_at: "2026-02-25T10:00:00Z".to_string(), suspect_orphan: false, pending: false, title: None, }, ); let key = MappingKey::Issue { project: "g/p".to_string(), iid: 42, }; // Should skip -- no bead creation expected (MockBeadsCli has no expectations) let created = bridge .process_event(&mut map, &key, "Issue #42: Bug", "desc", "2026-02-25T11:00:00Z") .unwrap(); assert!(!created); assert_eq!(map.mappings.len(), 1); } #[test] fn test_process_event_removes_on_create_failure() { let dir = TempDir::new().unwrap(); let mut beads = MockBeadsCli::new(); beads.expect_create().returning(|_, _| { Err(crate::data::beads::BeadsError::CommandFailed( "br failed".to_string(), )) }); let bridge = test_bridge(MockLoreCli::new(), beads, &dir); let mut map = GitLabBeadMap::default(); let key = MappingKey::Issue { project: "g/p".to_string(), iid: 42, }; let result = bridge.process_event(&mut map, &key, "Issue #42: Bug", "desc", "2026-02-25T10:00:00Z"); assert!(result.is_err()); // Write-ahead entry should be removed on failure assert!(map.mappings.is_empty()); } // -- recover_pending tests -- #[test] fn test_recover_pending_retries_creation() { let dir = TempDir::new().unwrap(); let mut beads = MockBeadsCli::new(); beads .expect_create() .returning(|_, _| Ok("bd-recovered".to_string())); let bridge = test_bridge(MockLoreCli::new(), beads, &dir); let mut map = GitLabBeadMap::default(); // Simulate crashed state: pending=true, bead_id=None map.mappings.insert( "issue:g::p:42".to_string(), MappingEntry { bead_id: None, created_at: "2026-02-25T10:00:00Z".to_string(), suspect_orphan: false, pending: true, title: Some("Issue #42: Bug".to_string()), }, ); bridge.save_map(&map).unwrap(); let (recovered, errors) = bridge.recover_pending(&mut map).unwrap(); assert_eq!(recovered, 1); assert!(errors.is_empty()); let entry = &map.mappings["issue:g::p:42"]; assert_eq!(entry.bead_id, Some("bd-recovered".to_string())); assert!(!entry.pending); } #[test] fn test_recover_pending_clears_completed() { let dir = TempDir::new().unwrap(); let bridge = test_bridge(MockLoreCli::new(), MockBeadsCli::new(), &dir); let mut map = GitLabBeadMap::default(); // Simulate: bead was created but pending flag not cleared map.mappings.insert( "issue:g::p:42".to_string(), MappingEntry { bead_id: Some("bd-exists".to_string()), created_at: "2026-02-25T10:00:00Z".to_string(), suspect_orphan: false, pending: true, title: None, }, ); bridge.save_map(&map).unwrap(); let (recovered, errors) = bridge.recover_pending(&mut map).unwrap(); assert_eq!(recovered, 1); assert!(errors.is_empty()); assert!(!map.mappings["issue:g::p:42"].pending); } // -- incremental_sync tests -- #[test] fn test_incremental_sync_creates_beads_from_events() { let dir = TempDir::new().unwrap(); let mut lore = MockLoreCli::new(); lore.expect_get_me().returning(|| { let mut response = empty_lore_response(); response.data.since_last_check = Some(SinceLastCheck { cursor_iso: Some("2026-02-25T12:00:00Z".to_string()), groups: vec![EventGroup { entity_iid: 42, entity_title: "Fix auth".to_string(), entity_type: "Issue".to_string(), project: "g/p".to_string(), events: vec![LoreEvent { event_type: "assigned".to_string(), actor: Some("bob".to_string()), summary: None, body_preview: None, timestamp_iso: None, }], }], total_event_count: 1, }); Ok(response) }); let mut beads = MockBeadsCli::new(); beads .expect_create() .returning(|_, _| Ok("bd-new".to_string())); let bridge = test_bridge(lore, beads, &dir); let mut map = GitLabBeadMap::default(); let result = bridge.incremental_sync(&mut map).unwrap(); assert_eq!(result.created, 1); assert_eq!(result.skipped, 0); assert!(map.mappings.contains_key("issue:g::p:42")); assert_eq!( map.cursor.last_check_timestamp, Some("2026-02-25T12:00:00Z".to_string()) ); } #[test] fn test_incremental_sync_skips_existing() { let dir = TempDir::new().unwrap(); let mut lore = MockLoreCli::new(); lore.expect_get_me().returning(|| { let mut response = empty_lore_response(); response.data.since_last_check = Some(SinceLastCheck { cursor_iso: Some("2026-02-25T12:00:00Z".to_string()), groups: vec![EventGroup { entity_iid: 42, entity_title: "Fix auth".to_string(), entity_type: "Issue".to_string(), project: "g/p".to_string(), events: vec![], }], total_event_count: 1, }); Ok(response) }); let bridge = test_bridge(lore, MockBeadsCli::new(), &dir); let mut map = GitLabBeadMap::default(); // Pre-populate so it's a duplicate map.mappings.insert( "issue:g::p:42".to_string(), MappingEntry { bead_id: Some("bd-existing".to_string()), created_at: "2026-02-25T09:00:00Z".to_string(), suspect_orphan: false, pending: false, title: None, }, ); let result = bridge.incremental_sync(&mut map).unwrap(); assert_eq!(result.created, 0); assert_eq!(result.skipped, 1); } #[test] fn test_incremental_sync_classifies_mr_review() { let dir = TempDir::new().unwrap(); let mut lore = MockLoreCli::new(); lore.expect_get_me().returning(|| { let mut response = empty_lore_response(); response.data.since_last_check = Some(SinceLastCheck { cursor_iso: None, groups: vec![EventGroup { entity_iid: 100, entity_title: "Add feature".to_string(), entity_type: "MergeRequest".to_string(), project: "g/p".to_string(), events: vec![LoreEvent { event_type: "review_requested".to_string(), actor: Some("alice".to_string()), summary: None, body_preview: None, timestamp_iso: None, }], }], total_event_count: 1, }); Ok(response) }); let mut beads = MockBeadsCli::new(); beads .expect_create() .returning(|_, _| Ok("bd-rev".to_string())); let bridge = test_bridge(lore, beads, &dir); let mut map = GitLabBeadMap::default(); bridge.incremental_sync(&mut map).unwrap(); // Should be classified as mr_review, not mr_authored assert!(map.mappings.contains_key("mr_review:g::p:100")); assert!(!map.mappings.contains_key("mr_authored:g::p:100")); } // -- full_reconciliation tests -- #[test] fn test_reconciliation_heals_suspect_orphan() { let dir = TempDir::new().unwrap(); let mut lore = MockLoreCli::new(); lore.expect_get_me().returning(|| { let mut response = empty_lore_response(); response.data.open_issues = vec![make_issue("g/p", 42, "Bug")]; Ok(response) }); let bridge = test_bridge(lore, MockBeadsCli::new(), &dir); let mut map = GitLabBeadMap::default(); // Simulate first strike from previous reconciliation map.mappings.insert( "issue:g::p:42".to_string(), MappingEntry { bead_id: Some("bd-abc".to_string()), created_at: "2026-02-25T09:00:00Z".to_string(), suspect_orphan: true, pending: false, title: None, }, ); let result = bridge.full_reconciliation(&mut map).unwrap(); assert_eq!(result.healed, 1); assert!(!map.mappings["issue:g::p:42"].suspect_orphan); } #[test] fn test_reconciliation_first_strike_sets_suspect() { let dir = TempDir::new().unwrap(); let mut lore = MockLoreCli::new(); // Return empty -- item is "missing" lore.expect_get_me().returning(|| Ok(empty_lore_response())); let bridge = test_bridge(lore, MockBeadsCli::new(), &dir); let mut map = GitLabBeadMap::default(); map.mappings.insert( "issue:g::p:42".to_string(), MappingEntry { bead_id: Some("bd-abc".to_string()), created_at: "2026-02-25T09:00:00Z".to_string(), suspect_orphan: false, pending: false, title: None, }, ); let result = bridge.full_reconciliation(&mut map).unwrap(); // First strike: should be marked suspect, NOT closed assert_eq!(result.closed, 0); assert!(map.mappings["issue:g::p:42"].suspect_orphan); } #[test] fn test_reconciliation_second_strike_closes_bead() { let dir = TempDir::new().unwrap(); let mut lore = MockLoreCli::new(); lore.expect_get_me().returning(|| Ok(empty_lore_response())); let mut beads = MockBeadsCli::new(); beads.expect_close().returning(|_, _| Ok(())); let bridge = test_bridge(lore, beads, &dir); let mut map = GitLabBeadMap::default(); // Already has first strike map.mappings.insert( "issue:g::p:42".to_string(), MappingEntry { bead_id: Some("bd-abc".to_string()), created_at: "2026-02-25T09:00:00Z".to_string(), suspect_orphan: true, pending: false, title: None, }, ); let result = bridge.full_reconciliation(&mut map).unwrap(); // Second strike: should be closed and removed assert_eq!(result.closed, 1); assert!(!map.mappings.contains_key("issue:g::p:42")); } #[test] fn test_reconciliation_creates_missing_beads() { let dir = TempDir::new().unwrap(); let mut lore = MockLoreCli::new(); lore.expect_get_me().returning(|| { let mut response = empty_lore_response(); response.data.open_issues = vec![make_issue("g/p", 99, "New issue")]; Ok(response) }); let mut beads = MockBeadsCli::new(); beads .expect_create() .returning(|_, _| Ok("bd-new".to_string())); let bridge = test_bridge(lore, beads, &dir); let mut map = GitLabBeadMap::default(); let result = bridge.full_reconciliation(&mut map).unwrap(); assert_eq!(result.created, 1); assert!(map.mappings.contains_key("issue:g::p:99")); } #[test] fn test_reconciliation_advances_cursor() { let dir = TempDir::new().unwrap(); let mut lore = MockLoreCli::new(); lore.expect_get_me().returning(|| Ok(empty_lore_response())); let bridge = test_bridge(lore, MockBeadsCli::new(), &dir); let mut map = GitLabBeadMap::default(); bridge.full_reconciliation(&mut map).unwrap(); assert!(map.cursor.last_reconciliation.is_some()); } #[test] fn test_reconciliation_builds_expected_keys_from_all_sources() { let mut response = empty_lore_response(); response.data.open_issues = vec![make_issue("g/p", 1, "Issue")]; response.data.open_mrs_authored = vec![make_mr("g/p", 10, "My MR")]; response.data.reviewing_mrs = vec![make_mr("g/p", 20, "Review MR")]; let keys = Bridge::::build_expected_keys(&response); assert_eq!(keys.len(), 3); assert!(keys.contains_key("issue:g::p:1")); assert!(keys.contains_key("mr_authored:g::p:10")); assert!(keys.contains_key("mr_review:g::p:20")); } // -- Lock tests -- #[test] fn test_acquire_lock_succeeds() { let dir = TempDir::new().unwrap(); let bridge = test_bridge(MockLoreCli::new(), MockBeadsCli::new(), &dir); let guard = bridge.acquire_lock(); assert!(guard.is_ok()); } #[test] fn test_acquire_lock_fails_when_held() { let dir = TempDir::new().unwrap(); let bridge1 = test_bridge(MockLoreCli::new(), MockBeadsCli::new(), &dir); let bridge2 = test_bridge(MockLoreCli::new(), MockBeadsCli::new(), &dir); let _guard = bridge1.acquire_lock().unwrap(); let result = bridge2.acquire_lock(); assert!(matches!(result, Err(BridgeError::InstanceLocked))); } // -- Integration-style test -- #[test] fn test_full_lifecycle_incremental_then_reconcile() { let dir = TempDir::new().unwrap(); // Phase 1: Incremental sync discovers new issue let mut lore = MockLoreCli::new(); lore.expect_get_me().returning(|| { let mut response = empty_lore_response(); response.data.since_last_check = Some(SinceLastCheck { cursor_iso: Some("2026-02-25T12:00:00Z".to_string()), groups: vec![EventGroup { entity_iid: 42, entity_title: "Fix auth".to_string(), entity_type: "Issue".to_string(), project: "g/p".to_string(), events: vec![], }], total_event_count: 1, }); Ok(response) }); let mut beads = MockBeadsCli::new(); beads .expect_create() .returning(|_, _| Ok("bd-42".to_string())); let bridge = test_bridge(lore, beads, &dir); let mut map = GitLabBeadMap::default(); let r1 = bridge.incremental_sync(&mut map).unwrap(); assert_eq!(r1.created, 1); // Phase 2: Reconciliation -- issue still open (no change) let mut lore2 = MockLoreCli::new(); lore2.expect_get_me().returning(|| { let mut response = empty_lore_response(); response.data.open_issues = vec![make_issue("g/p", 42, "Fix auth")]; Ok(response) }); let bridge2 = Bridge::with_data_dir(lore2, MockBeadsCli::new(), dir.path().to_path_buf()); let r2 = bridge2.full_reconciliation(&mut map).unwrap(); assert_eq!(r2.closed, 0); assert_eq!(r2.created, 0); assert!(!map.mappings["issue:g::p:42"].suspect_orphan); // Phase 3: Issue disappears -- first strike let mut lore3 = MockLoreCli::new(); lore3.expect_get_me().returning(|| Ok(empty_lore_response())); let bridge3 = Bridge::with_data_dir(lore3, MockBeadsCli::new(), dir.path().to_path_buf()); let r3 = bridge3.full_reconciliation(&mut map).unwrap(); assert_eq!(r3.closed, 0); assert!(map.mappings["issue:g::p:42"].suspect_orphan); // Phase 4: Still missing -- second strike, close let mut lore4 = MockLoreCli::new(); lore4.expect_get_me().returning(|| Ok(empty_lore_response())); let mut beads4 = MockBeadsCli::new(); beads4.expect_close().returning(|_, _| Ok(())); let bridge4 = Bridge::with_data_dir(lore4, beads4, dir.path().to_path_buf()); let r4 = bridge4.full_reconciliation(&mut map).unwrap(); assert_eq!(r4.closed, 1); assert!(!map.mappings.contains_key("issue:g::p:42")); } // -- cleanup_tmp_files tests -- #[test] fn test_cleanup_tmp_files_removes_orphaned_tmp() { let dir = TempDir::new().unwrap(); let bridge = test_bridge(MockLoreCli::new(), MockBeadsCli::new(), &dir); // Create an orphaned .tmp file (simulating a crash during save_map) let tmp_file = dir.path().join("gitlab_bead_map.json.tmp"); std::fs::write(&tmp_file, "orphaned data").unwrap(); assert!(tmp_file.exists()); // Cleanup should remove it let cleaned = bridge.cleanup_tmp_files().unwrap(); assert_eq!(cleaned, 1); assert!(!tmp_file.exists()); } #[test] fn test_cleanup_tmp_files_ignores_non_tmp_files() { let dir = TempDir::new().unwrap(); let bridge = test_bridge(MockLoreCli::new(), MockBeadsCli::new(), &dir); // Create a regular file (should not be removed) let json_file = dir.path().join("gitlab_bead_map.json"); std::fs::write(&json_file, "{}").unwrap(); let cleaned = bridge.cleanup_tmp_files().unwrap(); assert_eq!(cleaned, 0); assert!(json_file.exists()); } #[test] fn test_cleanup_tmp_files_ignores_other_modules_tmp_files() { let dir = TempDir::new().unwrap(); let bridge = test_bridge(MockLoreCli::new(), MockBeadsCli::new(), &dir); // Create tmp files belonging to other modules (should not be removed) let state_tmp = dir.path().join("state.json.tmp"); std::fs::write(&state_tmp, "state data").unwrap(); let other_tmp = dir.path().join("other.tmp"); std::fs::write(&other_tmp, "other data").unwrap(); let cleaned = bridge.cleanup_tmp_files().unwrap(); assert_eq!(cleaned, 0); assert!(state_tmp.exists(), "state.json.tmp should not be deleted"); assert!(other_tmp.exists(), "other.tmp should not be deleted"); } #[test] fn test_cleanup_tmp_files_handles_missing_dir() { let dir = TempDir::new().unwrap(); let nonexistent = dir.path().join("nonexistent"); let bridge: Bridge = Bridge::with_data_dir(MockLoreCli::new(), MockBeadsCli::new(), nonexistent); // Should return 0, not error, when dir doesn't exist let cleaned = bridge.cleanup_tmp_files().unwrap(); assert_eq!(cleaned, 0); } }