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 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 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 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 self.database
80 .status
81 .update_source_stats(&source_name, false, Some(e.to_string()))
82 .await?;
83 }
84 }
85 }
86
87 self.database.flush().await?;
89
90 if let Err(e) = self.cleanup_unassigned_tasks(config).await {
92 error!("Failed to cleanup unassigned tasks: {}", e);
93 }
95
96 if let Err(e) = self.sync_completed_tasks(config).await {
98 error!("Failed to sync completed tasks: {}", e);
99 }
101
102 self.database.flush().await?;
104
105 info!("Full sync completed");
106 Ok(())
107 }
108
109 #[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 let sync_rules = source.effective_sync_rules(global_rules);
122
123 let linear_client = LinearClient::new(source.linear_api_key.clone())?;
125
126 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 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 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 for issue in &issues {
170 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 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 matches!(mapping.status, crate::db::MappingStatus::Synced) {
196 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 } else {
211 debug!("issue {} unchanged, skipping", issue.identifier);
212 continue;
213 }
214 }
215 mapping
217 }
218 None => {
219 debug!(
220 "Processing new issue: {} - {}",
221 issue.identifier, issue.title
222 );
223
224 database
226 .mappings
227 .create_pending_mapping(&source.name, issue)
228 .await?
229 }
230 };
231
232 let status_entry = database
234 .status
235 .create_status_entry(source.name.clone(), issue.id.clone())
236 .await?;
237
238 let is_update = _mapping.motion_task_id.is_some();
240
241 if is_update {
242 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 database
256 .mappings
257 .update_issue_data(&source.name, &issue.id, issue)
258 .await?;
259
260 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 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 database
299 .mappings
300 .mark_synced(&source.name, &issue.id, motion_task_id.clone())
301 .await?;
302
303 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 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 database
335 .status
336 .update_source_stats(&source.name, true, None)
337 .await?;
338
339 Ok(synced_count)
340 }
341
342 #[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 let workspaces = self.motion_client.list_workspaces().await?;
349
350 for workspace in &workspaces {
351 info!("Checking workspace: {}", workspace.name);
352
353 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 let task_id = match &task.id {
368 Some(id) => id,
369 None => continue,
370 };
371
372 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 if let Some(mapping) = self
385 .database
386 .mappings
387 .get_mapping_by_motion_id(task_id)
388 .await?
389 {
390 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 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 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 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 #[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 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 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; }
484 };
485
486 let current_issue_ids: std::collections::HashSet<String> =
488 current_issues.iter().map(|issue| issue.id.clone()).collect();
489
490 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 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 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 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 }
544 }
545 } else {
546 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 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 fn issue_needs_update(
591 mapping: &crate::db::mapping::TaskMapping,
592 current_issue: &crate::clients::linear::LinearIssue,
593 ) -> Result<bool> {
594 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 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 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 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 let due_date = issue.due_date.as_ref().and_then(|date_str| {
651 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 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 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 let workspaces = motion_client.list_workspaces().await?;
693
694 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 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 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 let due_date = issue
729 .due_date
730 .as_ref()
731 .and_then(|date_str| {
732 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 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 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}