- 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>
1372 lines
45 KiB
Rust
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);
|
|
}
|
|
}
|