Skip to main content

linear_motion/sync/
orchestrator.rs

1use crate::clients::{
2    linear::LinearClient,
3    motion::{AutoScheduled, Label, MotionClient, MotionTask, MotionWorkspace, Status},
4};
5use crate::config::{AppConfig, SyncRules, SyncSource};
6use crate::db::SyncDatabase;
7use crate::{Error, Result};
8use chrono::Days;
9use std::sync::Arc;
10use tracing::{debug, error, info, warn};
11
12pub struct SyncOrchestrator {
13    pub database: Arc<SyncDatabase>,
14    pub motion_client: Arc<MotionClient>,
15}
16
17impl SyncOrchestrator {
18    pub async fn new(config: &AppConfig) -> Result<Self> {
19        let database = Arc::new(SyncDatabase::new(config.database_path()).await?);
20
21        // Initialize Motion client
22        let motion_client = Arc::new(MotionClient::new(config.motion_api_key.clone())?);
23
24        Ok(Self {
25            database,
26            motion_client,
27        })
28    }
29
30    pub async fn run_full_sync(&self, config: &AppConfig, force_update: bool) -> Result<()> {
31        debug!(
32            "starting full sync for {} sources",
33            config.sync_sources.len()
34        );
35
36        // Test Motion connection first
37        match self.motion_client.get_current_user().await {
38            Ok(_) => {}
39            Err(e) => {
40                error!("❌ Failed to connect to Motion: {}", e);
41                return Err(e);
42            }
43        }
44
45        // Process each sync source in parallel
46        use futures::future::join_all;
47
48        let sync_futures: Vec<_> = config
49            .sync_sources
50            .iter()
51            .map(|source| {
52                let database = Arc::clone(&self.database);
53                let motion_client = Arc::clone(&self.motion_client);
54                let source = source.clone();
55                let global_rules = config.global_sync_rules.clone();
56
57                async move {
58                    let result = Self::sync_source(
59                        database.clone(),
60                        motion_client,
61                        &source,
62                        &global_rules,
63                        force_update,
64                    )
65                    .await;
66                    (source.name.clone(), result)
67                }
68            })
69            .collect();
70
71        let results = join_all(sync_futures).await;
72
73        for (source_name, result) in results {
74            match result {
75                Ok(count) => debug!("synced {} issues from '{}'", count, source_name),
76                Err(e) => {
77                    error!("failed to sync from '{}': {}", source_name, e);
78                    // Continue with other sources even if one fails
79                    self.database
80                        .status
81                        .update_source_stats(&source_name, false, Some(e.to_string()))
82                        .await?;
83                }
84            }
85        }
86
87        // Flush database changes
88        self.database.flush().await?;
89
90        // Clean up Motion tasks for Linear issues that are no longer assigned
91        if let Err(e) = self.cleanup_unassigned_tasks(config).await {
92            error!("Failed to cleanup unassigned tasks: {}", e);
93            // Don't fail the whole sync if cleanup fails
94        }
95
96        // Check for completed Motion tasks and tag corresponding Linear issues
97        if let Err(e) = self.sync_completed_tasks(config).await {
98            error!("Failed to sync completed tasks: {}", e);
99            // Don't fail the whole sync if completion sync fails
100        }
101
102        // Flush database changes again after completion sync
103        self.database.flush().await?;
104
105        info!("Full sync completed");
106        Ok(())
107    }
108
109    // write source name to span
110    #[tracing::instrument(skip(database, motion_client, source, global_rules), fields(source = source.name.as_str()))]
111    async fn sync_source(
112        database: Arc<SyncDatabase>,
113        motion_client: Arc<MotionClient>,
114        source: &SyncSource,
115        global_rules: &SyncRules,
116        force_update: bool,
117    ) -> Result<usize> {
118        debug!("syncing source: {}", source.name);
119
120        // Get effective sync rules for this source
121        let sync_rules = source.effective_sync_rules(global_rules);
122
123        // Initialize Linear client for this source
124        let linear_client = LinearClient::new(source.linear_api_key.clone())?;
125
126        // Test Linear connection
127        match linear_client.get_viewer().await {
128            Ok(_) => {}
129            Err(e) => {
130                error!("❌ Failed to connect to Linear: {}", e);
131                return Err(e);
132            }
133        }
134
135        // Get projects to sync from (None means fetch all assigned issues)
136        let projects = source.projects.clone();
137
138        match &projects {
139            Some(project_ids) => {
140                if project_ids.is_empty() {
141                    info!(
142                        "Empty project list for source '{}' - fetching all assigned issues",
143                        source.name
144                    );
145                } else {
146                    info!(
147                        "Fetching issues from {} specific projects: {:?}",
148                        project_ids.len(),
149                        project_ids
150                    );
151                }
152            }
153            None => {
154                info!(
155                    "no projects specified for source '{}' - fetching all assigned issues",
156                    source.name
157                );
158            }
159        }
160
161        // Fetch assigned issues from Linear
162        let issues = linear_client.get_assigned_issues(projects).await?;
163        info!("Found {} assigned issues in Linear", issues.len());
164        debug!("found issues: {:?}", issues);
165
166        let mut synced_count = 0;
167
168        // Process each issue
169        for issue in &issues {
170            // Check if issue already has the completion tag
171            if linear_client
172                .check_issue_has_label(&issue.id, &sync_rules.completed_linear_tag)
173                .await?
174            {
175                debug!(
176                    "Issue {} already has completion tag, skipping",
177                    issue.identifier
178                );
179                continue;
180            }
181
182            // Check if we already have a mapping for this issue
183            let existing_mapping = database
184                .mappings
185                .get_mapping_by_linear_id(&source.name, &issue.id)
186                .await?;
187
188            let _mapping = match existing_mapping {
189                Some(mapping) => {
190                    debug!(
191                        "Issue {} already tracked with status: {:?}",
192                        issue.identifier, mapping.status
193                    );
194                    // If it's already synced successfully, check if it needs updating
195                    if matches!(mapping.status, crate::db::MappingStatus::Synced) {
196                        // Check if the issue has been updated since last sync or if force update is enabled
197                        if force_update || Self::issue_needs_update(&mapping, issue)? {
198                            if force_update {
199                                debug!(
200                                    "force update enabled, updating Motion task for {}",
201                                    issue.identifier
202                                );
203                            } else {
204                                debug!(
205                                    "issue {} has changes, updating Motion task",
206                                    issue.identifier
207                                );
208                            }
209                            // Continue with update process
210                        } else {
211                            debug!("issue {} unchanged, skipping", issue.identifier);
212                            continue;
213                        }
214                    }
215                    // If it failed or is pending, we'll retry
216                    mapping
217                }
218                None => {
219                    debug!(
220                        "Processing new issue: {} - {}",
221                        issue.identifier, issue.title
222                    );
223
224                    // Create pending mapping immediately when we fetch the issue
225                    database
226                        .mappings
227                        .create_pending_mapping(&source.name, issue)
228                        .await?
229                }
230            };
231
232            // Create status entry for tracking
233            let status_entry = database
234                .status
235                .create_status_entry(source.name.clone(), issue.id.clone())
236                .await?;
237
238            // Determine if this is an update or create operation
239            let is_update = _mapping.motion_task_id.is_some();
240
241            if is_update {
242                // Update existing Motion task
243                let motion_task_id = _mapping.motion_task_id.as_ref().unwrap();
244                match Self::update_motion_task_from_issue(
245                    &motion_client,
246                    motion_task_id,
247                    issue,
248                    &sync_rules,
249                    &source.name,
250                )
251                .await
252                {
253                    Ok(_) => {
254                        // Update the stored Linear issue data in the mapping
255                        database
256                            .mappings
257                            .update_issue_data(&source.name, &issue.id, issue)
258                            .await?;
259
260                        // Update status as completed
261                        database
262                            .status
263                            .mark_completed(&status_entry.id, motion_task_id.clone())
264                            .await?;
265
266                        synced_count += 1;
267                        info!(
268                            "✅ Updated: {} → Motion task {}",
269                            issue.identifier, motion_task_id
270                        );
271                    }
272                    Err(e) => {
273                        error!(
274                            "❌ Failed to update Motion task for {}: {}",
275                            issue.identifier, e
276                        );
277
278                        database
279                            .status
280                            .mark_failed(&status_entry.id, e.to_string())
281                            .await?;
282                    }
283                }
284            } else {
285                // Create new Motion task
286                match Self::create_motion_task_from_issue(
287                    &motion_client,
288                    issue,
289                    &sync_rules,
290                    &source.name,
291                )
292                .await
293                {
294                    Ok(motion_task) => {
295                        let motion_task_id = motion_task.id.clone().unwrap_or_default();
296
297                        // Mark mapping as synced with Motion task ID
298                        database
299                            .mappings
300                            .mark_synced(&source.name, &issue.id, motion_task_id.clone())
301                            .await?;
302
303                        // Update status as completed
304                        database
305                            .status
306                            .mark_completed(&status_entry.id, motion_task_id)
307                            .await?;
308
309                        synced_count += 1;
310                        info!("✅ Created: {} → Motion task", issue.identifier);
311                    }
312                    Err(e) => {
313                        error!(
314                            "❌ Failed to create Motion task for {}: {}",
315                            issue.identifier, e
316                        );
317
318                        // Mark mapping as failed
319                        database
320                            .mappings
321                            .mark_failed(&source.name, &issue.id, e.to_string())
322                            .await?;
323
324                        database
325                            .status
326                            .mark_failed(&status_entry.id, e.to_string())
327                            .await?;
328                    }
329                }
330            }
331        }
332
333        // Update source statistics
334        database
335            .status
336            .update_source_stats(&source.name, true, None)
337            .await?;
338
339        Ok(synced_count)
340    }
341
342    /// Check for completed tasks in Motion and tag corresponding Linear issues
343    #[tracing::instrument(skip(self, config))]
344    pub async fn sync_completed_tasks(&self, config: &AppConfig) -> Result<()> {
345        info!("Checking for completed Motion tasks to tag in Linear");
346
347        // Get Motion workspaces
348        let workspaces = self.motion_client.list_workspaces().await?;
349
350        for workspace in &workspaces {
351            info!("Checking workspace: {}", workspace.name);
352
353            // Get completed tasks from this workspace
354            let completed_tasks = self
355                .motion_client
356                .list_completed_tasks(&workspace.id)
357                .await?;
358
359            debug!(
360                "Found {} completed tasks in workspace {}",
361                completed_tasks.len(),
362                workspace.name
363            );
364
365            for task in &completed_tasks {
366                // Skip tasks without IDs or without the linear-sync label
367                let task_id = match &task.id {
368                    Some(id) => id,
369                    None => continue,
370                };
371
372                // Check if this task has the linear-sync label
373                let has_linear_label = task
374                    .labels
375                    .as_ref()
376                    .map(|labels| labels.iter().any(|l| l.name == "linear-sync"))
377                    .unwrap_or(false);
378
379                if !has_linear_label {
380                    continue;
381                }
382
383                // Find the corresponding Linear issue in our database
384                if let Some(mapping) = self
385                    .database
386                    .mappings
387                    .get_mapping_by_motion_id(task_id)
388                    .await?
389                {
390                    // Check if this issue is already tagged to avoid re-tagging
391                    if let Some(sync_source_config) = config
392                        .sync_sources
393                        .iter()
394                        .find(|s| s.name == mapping.sync_source)
395                    {
396                        let linear_client = crate::clients::linear::LinearClient::new(
397                            sync_source_config.linear_api_key.clone(),
398                        )?;
399
400                        let completion_tag = &config.global_sync_rules.completed_linear_tag;
401
402                        // Check if issue already has the completion tag
403                        if linear_client
404                            .check_issue_has_label(&mapping.linear_issue_id, completion_tag)
405                            .await?
406                        {
407                            debug!(
408                                "Linear issue {} already has completion tag, skipping",
409                                mapping.linear_issue_id
410                            );
411                            continue;
412                        }
413
414                        info!(
415                            "Motion task {} completed, tagging Linear issue {}",
416                            task_id, mapping.linear_issue_id
417                        );
418
419                        // Add the completion tag to the Linear issue
420                        match linear_client
421                            .add_label_to_issue(&mapping.linear_issue_id, completion_tag)
422                            .await
423                        {
424                            Ok(()) => {
425                                info!(
426                                    "✅ Successfully tagged Linear issue {} with '{}'",
427                                    mapping.linear_issue_id, completion_tag
428                                );
429
430                                // Remove the mapping from the database as per PRD
431                                self.database
432                                    .mappings
433                                    .remove_mapping(&mapping.sync_source, &mapping.linear_issue_id)
434                                    .await?;
435
436                                info!(
437                                    "🗑️  Removed mapping for completed task: {} -> {}",
438                                    mapping.linear_issue_id, task_id
439                                );
440                            }
441                            Err(e) => {
442                                error!(
443                                    "❌ Failed to tag Linear issue {}: {}",
444                                    mapping.linear_issue_id, e
445                                );
446                            }
447                        }
448                    }
449                }
450            }
451        }
452
453        Ok(())
454    }
455
456    /// Clean up Motion tasks for Linear issues that are no longer assigned to the user
457    #[tracing::instrument(skip(self, config))]
458    pub async fn cleanup_unassigned_tasks(&self, config: &AppConfig) -> Result<()> {
459        info!("Cleaning up Motion tasks for unassigned Linear issues");
460
461        for source in &config.sync_sources {
462            debug!("Checking for orphaned tasks from source: {}", source.name);
463
464            // Get all mappings for this source
465            let existing_mappings = self
466                .database
467                .mappings
468                .list_mappings_by_source(&source.name)
469                .await?;
470
471            if existing_mappings.is_empty() {
472                debug!("No existing mappings found for source: {}", source.name);
473                continue;
474            }
475
476            // Get currently assigned issues from Linear
477            let linear_client = LinearClient::new(source.linear_api_key.clone())?;
478            let current_issues = match linear_client.get_assigned_issues(source.projects.clone()).await {
479                Ok(issues) => issues,
480                Err(e) => {
481                    error!("Failed to fetch current issues for source '{}': {}", source.name, e);
482                    continue; // Skip this source if we can't fetch current issues
483                }
484            };
485
486            // Create a set of currently assigned issue IDs for quick lookup
487            let current_issue_ids: std::collections::HashSet<String> = 
488                current_issues.iter().map(|issue| issue.id.clone()).collect();
489
490            // Find orphaned mappings (exist in DB but issue is no longer assigned)
491            let orphaned_mappings: Vec<&crate::db::mapping::TaskMapping> = existing_mappings
492                .iter()
493                .filter(|mapping| !current_issue_ids.contains(&mapping.linear_issue_id))
494                .collect();
495
496            info!(
497                "Found {} orphaned mappings for source '{}' (out of {} total mappings)",
498                orphaned_mappings.len(),
499                source.name,
500                existing_mappings.len()
501            );
502
503            // Delete Motion tasks and remove mappings for orphaned items
504            for mapping in orphaned_mappings {
505                if let Some(motion_task_id) = &mapping.motion_task_id {
506                    debug!(
507                        "Deleting Motion task {} for unassigned Linear issue {}",
508                        motion_task_id, mapping.linear_issue_id
509                    );
510
511                    // Delete the Motion task
512                    match self.motion_client.delete_task(motion_task_id).await {
513                        Ok(()) => {
514                            info!(
515                                "✅ Deleted Motion task {} for unassigned issue {}",
516                                motion_task_id, mapping.linear_issue_id
517                            );
518
519                            // Remove the mapping from the database
520                            if let Err(e) = self
521                                .database
522                                .mappings
523                                .remove_mapping(&source.name, &mapping.linear_issue_id)
524                                .await
525                            {
526                                error!(
527                                    "Failed to remove mapping for {}: {}",
528                                    mapping.linear_issue_id, e
529                                );
530                            } else {
531                                debug!(
532                                    "Removed mapping for unassigned issue: {}",
533                                    mapping.linear_issue_id
534                                );
535                            }
536                        }
537                        Err(e) => {
538                            error!(
539                                "❌ Failed to delete Motion task {} for unassigned issue {}: {}",
540                                motion_task_id, mapping.linear_issue_id, e
541                            );
542                            // Don't remove the mapping if we couldn't delete the Motion task
543                        }
544                    }
545                } else {
546                    // Mapping exists but no Motion task was created yet - just remove the mapping
547                    debug!(
548                        "Removing orphaned mapping without Motion task for issue: {}",
549                        mapping.linear_issue_id
550                    );
551
552                    if let Err(e) = self
553                        .database
554                        .mappings
555                        .remove_mapping(&source.name, &mapping.linear_issue_id)
556                        .await
557                    {
558                        error!(
559                            "Failed to remove orphaned mapping for {}: {}",
560                            mapping.linear_issue_id, e
561                        );
562                    }
563                }
564            }
565        }
566
567        info!("Cleanup of unassigned tasks completed");
568        Ok(())
569    }
570
571    /// Format the description with a link back to the Linear issue
572    fn format_description_with_link(
573        issue: &crate::clients::linear::LinearIssue,
574        sync_source_name: &str,
575    ) -> Option<String> {
576        let base_description = issue.description.as_ref().cloned().unwrap_or_default();
577        let linear_link = format!(
578            "https://linear.app/{}/issue/{}",
579            sync_source_name, issue.identifier
580        );
581
582        if base_description.is_empty() {
583            Some(format!("Linear: {}", linear_link))
584        } else {
585            Some(format!("{}\n\nLinear: {}", base_description, linear_link))
586        }
587    }
588
589    /// Check if a Linear issue has changes that require updating the Motion task
590    fn issue_needs_update(
591        mapping: &crate::db::mapping::TaskMapping,
592        current_issue: &crate::clients::linear::LinearIssue,
593    ) -> Result<bool> {
594        // Parse the stored issue data from the mapping
595        let stored_issue: crate::clients::linear::LinearIssue =
596            serde_json::from_value(mapping.linear_issue_data.clone())
597                .map_err(|e| Error::Json(e))?;
598
599        // Compare key fields that affect Motion tasks
600        let needs_update = stored_issue.title != current_issue.title
601            || stored_issue.description != current_issue.description
602            || stored_issue.estimate != current_issue.estimate
603            || stored_issue.priority != current_issue.priority
604            || stored_issue.due_date != current_issue.due_date
605            || stored_issue.updated_at != current_issue.updated_at;
606
607        if needs_update {
608            debug!(
609                "Issue {} changes detected: title={}, description={}, estimate={}, priority={}, due_date={}, updated_at={}",
610                current_issue.identifier,
611                stored_issue.title != current_issue.title,
612                stored_issue.description != current_issue.description,
613                stored_issue.estimate != current_issue.estimate,
614                stored_issue.priority != current_issue.priority,
615                stored_issue.due_date != current_issue.due_date,
616                stored_issue.updated_at != current_issue.updated_at,
617            );
618        }
619
620        Ok(needs_update)
621    }
622
623    async fn update_motion_task_from_issue(
624        motion_client: &MotionClient,
625        motion_task_id: &str,
626        issue: &crate::clients::linear::LinearIssue,
627        sync_rules: &SyncRules,
628        sync_source_name: &str,
629    ) -> Result<MotionTask> {
630        // Calculate duration from Linear estimate
631        let duration_mins = if let Some(estimate) = issue.estimate {
632            sync_rules
633                .time_estimate_strategy
634                .convert_estimate_by_value(estimate)
635                .unwrap_or(sync_rules.default_task_duration_mins)
636        } else {
637            sync_rules.default_task_duration_mins
638        };
639
640        // Convert priority (Linear uses 0-4, Motion uses ASAP/HIGH/MEDIUM/LOW)
641        let priority = match issue.priority {
642            Some(1) => Some("ASAP".to_string()),
643            Some(2) => Some("HIGH".to_string()),
644            Some(3) => Some("MEDIUM".to_string()),
645            Some(4) | Some(_) => Some("LOW".to_string()),
646            None => Some("MEDIUM".to_string()),
647        };
648
649        // Convert due_date string to DateTime if present
650        let due_date = issue.due_date.as_ref().and_then(|date_str| {
651            // Linear sends dates as YYYY-MM-DD, convert to DateTime
652            chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
653                .ok()
654                .map(|date| date.and_hms_opt(23, 59, 59).unwrap().and_utc())
655        });
656
657        // Create Motion task object with the updates
658        let motion_task = MotionTask {
659            id: Some(motion_task_id.to_string()),
660            name: format!("[{}] {}", issue.identifier, issue.title),
661            description: Self::format_description_with_link(issue, sync_source_name),
662            duration: Some(crate::clients::motion::TaskDuration::from_minutes(
663                duration_mins,
664            )),
665            priority,
666            due_date,
667            labels: Some(vec![Label {
668                name: "linear-sync".to_string(),
669            }]),
670            ..Default::default()
671        };
672
673        // Update the task in Motion
674        let updated_task = motion_client
675            .update_task(motion_task_id, &motion_task)
676            .await?;
677        debug!(
678            "Updated Motion task: {} ({})",
679            updated_task.name, motion_task_id
680        );
681
682        Ok(updated_task)
683    }
684
685    async fn create_motion_task_from_issue(
686        motion_client: &MotionClient,
687        issue: &crate::clients::linear::LinearIssue,
688        sync_rules: &SyncRules,
689        sync_source_name: &str,
690    ) -> Result<MotionTask> {
691        // Get Motion workspaces to determine where to create the task
692        let workspaces = motion_client.list_workspaces().await?;
693
694        // Find "My Private Workspace" or use the first available workspace
695        let workspace = workspaces
696            .iter()
697            .find(|w| w.name == "My Private Workspace")
698            .or_else(|| workspaces.first())
699            .ok_or_else(|| Error::MotionApi {
700                message: "No Motion workspaces found".to_string(),
701            })?;
702
703        // Ensure the "linear-sync" label exists in the workspace
704        // self.motion_client
705        //     .ensure_label_exists(&workspace.id, "linear-sync")
706        //     .await?;
707
708        // Calculate duration from Linear estimate
709        let duration_mins = if let Some(estimate) = issue.estimate {
710            sync_rules
711                .time_estimate_strategy
712                .convert_estimate_by_value(estimate)
713                .unwrap_or(sync_rules.default_task_duration_mins)
714        } else {
715            sync_rules.default_task_duration_mins
716        };
717
718        // Convert priority (Linear uses 0-4, Motion uses ASAP/HIGH/MEDIUM/LOW)
719        let priority = match issue.priority {
720            Some(1) => Some("ASAP".to_string()),
721            Some(2) => Some("HIGH".to_string()),
722            Some(3) => Some("MEDIUM".to_string()),
723            Some(4) | Some(_) => Some("LOW".to_string()),
724            None => Some("MEDIUM".to_string()),
725        };
726
727        // Convert due_date string to DateTime if present
728        let due_date = issue
729            .due_date
730            .as_ref()
731            .and_then(|date_str| {
732                // Linear sends dates as YYYY-MM-DD, convert to DateTime
733                chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
734                    .ok()
735                    .map(|date| date.and_hms_opt(23, 59, 59).unwrap().and_utc())
736            })
737            .unwrap_or(
738                chrono::Utc::now()
739                    .checked_add_days(Days::new(1))
740                    .expect("valid"),
741            );
742
743        // Create Motion task
744        let motion_task = MotionTask {
745            id: None,
746            name: format!("[{}] {}", issue.identifier, issue.title),
747            description: Self::format_description_with_link(issue, sync_source_name),
748            duration: Some(crate::clients::motion::TaskDuration::from_minutes(
749                duration_mins,
750            )),
751            workspace: Some(MotionWorkspace {
752                id: workspace.id.clone(),
753                name: workspace.name.clone(),
754                team_id: None,
755                workspace_type: "INDIVIDUAL".to_string(),
756            }),
757            auto_scheduled: Some(AutoScheduled {
758                deadline_type: "SOFT".to_string(),
759                schedule: "Work hours".to_string(),
760                start_date: Some(chrono::Utc::now()),
761            }),
762            status: Some(Status {
763                name: "Todo".to_string(),
764                ..Default::default()
765            }),
766            priority,
767            due_date: Some(due_date),
768
769            completed: Some(false),
770            labels: Some(vec![Label {
771                name: "linear-sync".to_string(),
772            }]),
773            ..Default::default()
774        };
775
776        // Create the task in Motion
777        let created_task = motion_client.create_task(&motion_task).await?;
778        info!(
779            "Created Motion task: {} ({})",
780            created_task.name,
781            created_task.id.as_ref().unwrap_or(&"unknown".to_string())
782        );
783
784        Ok(created_task)
785    }
786}