Skip to main content

linear_motion/db/
mapping.rs

1use crate::Result;
2use fjall::{Keyspace, PartitionHandle, PersistMode};
3use serde::{Deserialize, Serialize};
4use std::path::Path;
5use tracing::{debug, info};
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub enum MappingStatus {
9    Pending, // Linear issue fetched, Motion task creation pending
10    Synced,  // Motion task created successfully
11    Failed,  // Motion task creation failed
12    Stale,   // Linear issue may have been updated, needs re-sync
13}
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct TaskMapping {
17    pub linear_issue_id: String,
18    pub motion_task_id: Option<String>, // None if Motion task not yet created
19    pub sync_source: String,
20    pub status: MappingStatus,
21    pub linear_issue_data: serde_json::Value, // Store the full Linear issue data
22    pub created_at: chrono::DateTime<chrono::Utc>,
23    pub updated_at: chrono::DateTime<chrono::Utc>,
24    pub last_sync_attempt: Option<chrono::DateTime<chrono::Utc>>,
25    pub sync_error: Option<String>,
26}
27
28pub struct MappingStore {
29    keyspace: Keyspace,
30    mappings: PartitionHandle,
31}
32
33impl MappingStore {
34    pub async fn new<P: AsRef<Path>>(db_path: P) -> Result<Self> {
35        let keyspace = fjall::Config::new(db_path).open()?;
36        let mappings =
37            keyspace.open_partition("task_mappings", fjall::PartitionCreateOptions::default())?;
38
39        debug!("task mapping store initialized");
40
41        Ok(Self { keyspace, mappings })
42    }
43
44    pub async fn store_mapping(&self, mapping: TaskMapping) -> Result<()> {
45        let key = format!("{}:{}", mapping.sync_source, mapping.linear_issue_id);
46        let value = serde_json::to_vec(&mapping)?;
47
48        self.mappings.insert(&key, &value)?;
49        debug!(
50            "Stored mapping: {} -> {:?} (status: {:?})",
51            mapping.linear_issue_id, mapping.motion_task_id, mapping.status
52        );
53
54        Ok(())
55    }
56
57    pub async fn get_mapping_by_linear_id(
58        &self,
59        sync_source: &str,
60        linear_issue_id: &str,
61    ) -> Result<Option<TaskMapping>> {
62        let key = format!("{}:{}", sync_source, linear_issue_id);
63
64        match self.mappings.get(&key)? {
65            Some(value) => {
66                let mapping: TaskMapping = serde_json::from_slice(&value)?;
67                debug!(
68                    "Found mapping: {} -> {:?}",
69                    linear_issue_id, mapping.motion_task_id
70                );
71                Ok(Some(mapping))
72            }
73            None => {
74                debug!("No mapping found for Linear issue: {}", linear_issue_id);
75                Ok(None)
76            }
77        }
78    }
79
80    pub async fn get_mapping_by_motion_id(
81        &self,
82        motion_task_id: &str,
83    ) -> Result<Option<TaskMapping>> {
84        // Since we need to search by Motion task ID, we'll iterate through all mappings
85        // For better performance in a production system, we might want to maintain a reverse index
86        for item in self.mappings.iter() {
87            let (_, value) = item?;
88            let mapping: TaskMapping = serde_json::from_slice(&value)?;
89
90            if mapping.motion_task_id.as_deref() == Some(motion_task_id) {
91                debug!(
92                    "Found mapping by Motion ID: {} -> {}",
93                    motion_task_id, mapping.linear_issue_id
94                );
95                return Ok(Some(mapping));
96            }
97        }
98
99        debug!("No mapping found for Motion task: {}", motion_task_id);
100        Ok(None)
101    }
102
103    pub async fn remove_mapping(
104        &self,
105        sync_source: &str,
106        linear_issue_id: &str,
107    ) -> Result<Option<TaskMapping>> {
108        let key = format!("{}:{}", sync_source, linear_issue_id);
109
110        let existing = match self.mappings.get(&key)? {
111            Some(value) => Some(serde_json::from_slice(&value)?),
112            None => None,
113        };
114
115        if existing.is_some() {
116            self.mappings.remove(&key)?;
117            debug!("Removed mapping for Linear issue: {}", linear_issue_id);
118        }
119
120        Ok(existing)
121    }
122
123    pub async fn list_mappings_by_source(&self, sync_source: &str) -> Result<Vec<TaskMapping>> {
124        let prefix = format!("{}:", sync_source);
125        let mut mappings = Vec::new();
126
127        for item in self.mappings.iter() {
128            let (key, value) = item?;
129            let key_str = String::from_utf8_lossy(&key);
130
131            if key_str.starts_with(&prefix) {
132                let mapping: TaskMapping = serde_json::from_slice(&value)?;
133                mappings.push(mapping);
134            }
135        }
136
137        debug!(
138            "Found {} mappings for sync source: {}",
139            mappings.len(),
140            sync_source
141        );
142        Ok(mappings)
143    }
144
145    pub async fn list_all_mappings(&self) -> Result<Vec<TaskMapping>> {
146        let mut mappings = Vec::new();
147
148        for item in self.mappings.iter() {
149            let (_, value) = item?;
150            let mapping: TaskMapping = serde_json::from_slice(&value)?;
151            mappings.push(mapping);
152        }
153
154        debug!("Found {} total mappings", mappings.len());
155        Ok(mappings)
156    }
157
158    pub async fn update_mapping(&self, mapping: TaskMapping) -> Result<()> {
159        let updated_mapping = TaskMapping {
160            updated_at: chrono::Utc::now(),
161            ..mapping
162        };
163
164        self.store_mapping(updated_mapping).await
165    }
166
167    /// Create a pending mapping from a Linear issue
168    pub async fn create_pending_mapping(
169        &self,
170        sync_source: &str,
171        issue: &crate::clients::linear::LinearIssue,
172    ) -> Result<TaskMapping> {
173        let mapping = TaskMapping {
174            linear_issue_id: issue.id.clone(),
175            motion_task_id: None,
176            sync_source: sync_source.to_string(),
177            status: MappingStatus::Pending,
178            linear_issue_data: serde_json::to_value(issue)?,
179            created_at: chrono::Utc::now(),
180            updated_at: chrono::Utc::now(),
181            last_sync_attempt: None,
182            sync_error: None,
183        };
184
185        self.store_mapping(mapping.clone()).await?;
186        debug!("Created pending mapping for Linear issue: {}", issue.id);
187        Ok(mapping)
188    }
189
190    /// Update mapping with Motion task ID when sync succeeds
191    pub async fn mark_synced(
192        &self,
193        sync_source: &str,
194        linear_issue_id: &str,
195        motion_task_id: String,
196    ) -> Result<()> {
197        if let Some(mut mapping) = self
198            .get_mapping_by_linear_id(sync_source, linear_issue_id)
199            .await?
200        {
201            mapping.motion_task_id = Some(motion_task_id);
202            mapping.status = MappingStatus::Synced;
203            mapping.updated_at = chrono::Utc::now();
204            mapping.sync_error = None;
205
206            self.store_mapping(mapping).await?;
207            debug!("Marked mapping as synced: {}", linear_issue_id);
208        }
209        Ok(())
210    }
211
212    /// Update mapping when sync fails
213    pub async fn mark_failed(
214        &self,
215        sync_source: &str,
216        linear_issue_id: &str,
217        error: String,
218    ) -> Result<()> {
219        if let Some(mut mapping) = self
220            .get_mapping_by_linear_id(sync_source, linear_issue_id)
221            .await?
222        {
223            mapping.status = MappingStatus::Failed;
224            mapping.updated_at = chrono::Utc::now();
225            mapping.last_sync_attempt = Some(chrono::Utc::now());
226            mapping.sync_error = Some(error);
227
228            self.store_mapping(mapping).await?;
229            debug!("Marked mapping as failed: {}", linear_issue_id);
230        }
231        Ok(())
232    }
233
234    /// Update the stored Linear issue data in a mapping (used for re-sync)
235    pub async fn update_issue_data(
236        &self,
237        sync_source: &str,
238        linear_issue_id: &str,
239        issue: &crate::clients::linear::LinearIssue,
240    ) -> Result<()> {
241        if let Some(mut mapping) = self
242            .get_mapping_by_linear_id(sync_source, linear_issue_id)
243            .await?
244        {
245            mapping.linear_issue_data = serde_json::to_value(issue)?;
246            mapping.updated_at = chrono::Utc::now();
247
248            self.store_mapping(mapping).await?;
249            debug!("Updated issue data for mapping: {}", linear_issue_id);
250        }
251        Ok(())
252    }
253
254    /// Get mappings by status
255    pub async fn list_mappings_by_status(&self, status: MappingStatus) -> Result<Vec<TaskMapping>> {
256        let mut mappings = Vec::new();
257
258        for item in self.mappings.iter() {
259            let (_, value) = item?;
260            let mapping: TaskMapping = serde_json::from_slice(&value)?;
261
262            if std::mem::discriminant(&mapping.status) == std::mem::discriminant(&status) {
263                mappings.push(mapping);
264            }
265        }
266
267        debug!(
268            "Found {} mappings with status: {:?}",
269            mappings.len(),
270            status
271        );
272        Ok(mappings)
273    }
274
275    pub async fn flush(&self) -> Result<()> {
276        self.keyspace.persist(PersistMode::SyncAll)?;
277        Ok(())
278    }
279}
280
281impl TaskMapping {
282    pub fn new(linear_issue_id: String, motion_task_id: String, sync_source: String) -> Self {
283        let now = chrono::Utc::now();
284
285        Self {
286            linear_issue_id,
287            motion_task_id: Some(motion_task_id),
288            sync_source,
289            status: MappingStatus::Synced,
290            linear_issue_data: serde_json::Value::Null, // Legacy constructor doesn't have issue data
291            created_at: now,
292            updated_at: now,
293            last_sync_attempt: Some(now),
294            sync_error: None,
295        }
296    }
297}