Files
mission-control/src-tauri/src/data/bridge.rs
teernisse 087b588d71 feat: add Tauri state persistence and BvCli trait
- Add Tauri storage adapter for Zustand (tauri-storage.ts)
- Add read_state, write_state, clear_state Tauri commands
- Wire focus-store and nav-store to use Tauri persistence
- Add BvCli trait for bv CLI mocking with response types
- Add BvError and McError conversion for bv errors
- Add cleanup_tmp_files tests for bridge
- Fix linter-introduced tauri_specta::command issues

Closes bd-2x6, bd-gil, bd-3px

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-26 10:06:57 -05:00

1372 lines
45 KiB
Rust

//! 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<L: LoreCli, B: BeadsCli> {
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<String, MappingEntry>,
}
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<String>,
/// Last successful full reconciliation
pub last_reconciliation: Option<String>,
}
/// 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<String>,
/// 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<String>,
}
/// 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 },
}
impl MappingKey {
/// Serialize to string key format
pub fn to_key_string(&self) -> String {
match self {
MappingKey::MrReview { project, iid } => {
format!("mr_review:{}:{}", project, iid)
}
MappingKey::Issue { project, iid } => {
format!("issue:{}:{}", project, iid)
}
MappingKey::MrAuthored { project, iid } => {
format!("mr_authored:{}:{}", project, iid)
}
}
}
/// Build bead title from this key's event data
pub fn to_bead_title(&self, entity_title: &str) -> String {
match self {
MappingKey::MrReview { iid, .. } => {
format!("Review MR !{}: {}", iid, entity_title)
}
MappingKey::Issue { iid, .. } => {
format!("Issue #{}: {}", iid, entity_title)
}
MappingKey::MrAuthored { iid, .. } => {
format!("Your MR !{}: {}", iid, entity_title)
}
}
}
}
/// Result of a sync operation
#[derive(Debug, Default, Serialize, Type)]
pub struct SyncResult {
/// Number of new beads created
pub created: usize,
/// Number of existing items skipped (dedup)
pub skipped: usize,
/// Number of beads closed (two-strike)
pub closed: usize,
/// Number of suspect_orphan flags cleared (item reappeared)
pub healed: usize,
/// Errors encountered (non-fatal, processing continued)
pub errors: Vec<String>,
}
/// 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<L: LoreCli, B: BeadsCli> Bridge<L, B> {
/// 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(|| PathBuf::from("."))
.join("mc");
Self {
lore,
beads,
data_dir,
}
}
/// Create a bridge with a custom data directory (for testing)
#[cfg(test)]
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<LockGuard, BridgeError> {
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<GitLabBeadMap, BridgeError> {
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 .json.tmp files left behind from
/// crashes during save_map(). Returns the number of files cleaned up.
pub fn cleanup_tmp_files(&self) -> Result<usize, BridgeError> {
if !self.data_dir.exists() {
return Ok(0);
}
let mut cleaned = 0;
for entry in fs::read_dir(&self.data_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().is_some_and(|e| e == "tmp") {
tracing::info!("Cleaning up orphaned tmp file: {:?}", path);
fs::remove_file(&path)?;
cleaned += 1;
}
}
if cleaned > 0 {
tracing::info!("Cleaned up {} orphaned tmp file(s)", cleaned);
}
Ok(cleaned)
}
/// 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<bool, BridgeError> {
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
pub fn recover_pending(&self, map: &mut GitLabBeadMap) -> Result<usize, BridgeError> {
let pending_keys: Vec<String> = map
.mappings
.iter()
.filter(|(_, entry)| entry.pending)
.map(|(key, _)| key.clone())
.collect();
let mut recovered = 0;
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);
}
}
} else {
entry.pending = false;
recovered += 1;
}
}
}
if recovered > 0 {
self.save_map(map)?;
}
Ok(recovered)
}
/// 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<SyncResult, BridgeError> {
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<SyncResult, BridgeError> {
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<String> = map.mappings.keys().cloned().collect();
let mut to_remove: Vec<String> = 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<String, (MappingKey, String)> {
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<MockLoreCli, MockBeadsCli> {
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,
};
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_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"
);
}
// -- 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 = bridge.recover_pending(&mut map).unwrap();
assert_eq!(recovered, 1);
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 = bridge.recover_pending(&mut map).unwrap();
assert_eq!(recovered, 1);
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::<MockLoreCli, MockBeadsCli>::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_handles_missing_dir() {
let dir = TempDir::new().unwrap();
let nonexistent = dir.path().join("nonexistent");
let bridge: Bridge<MockLoreCli, MockBeadsCli> =
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);
}
}