use colored::Colorize;
use crate::db::Database;
use crate::error::Result;
use crate::models::Status;
use super::client::{LinearClient, LinearIssue, LinearLabel};
use super::config::LinearConfig;
use super::mapping;
#[derive(Debug, Clone)]
pub struct SyncStats {
pub pushed_new: usize,
pub pushed_updated: usize,
pub pulled_new: usize,
pub pulled_updated: usize,
pub conflicts: usize,
pub skipped_inactive: usize,
pub errors: Vec<String>,
}
impl SyncStats {
fn new() -> Self {
Self {
pushed_new: 0,
pushed_updated: 0,
pulled_new: 0,
pulled_updated: 0,
conflicts: 0,
skipped_inactive: 0,
errors: Vec::new(),
}
}
}
#[derive(Debug, Clone, Copy)]
pub enum ConflictResolution {
RemoteWins,
LocalWins,
}
fn resolve_filter_label_ids(
client: &LinearClient,
config: &LinearConfig,
filter_labels: &[String],
existing_labels: &[LinearLabel],
) -> Result<Vec<String>> {
let mut ids = Vec::new();
for name in filter_labels {
if let Some(label) = existing_labels
.iter()
.find(|l| l.name.eq_ignore_ascii_case(name))
{
ids.push(label.id.clone());
} else {
let label = client.create_label(&config.team_id, name)?;
ids.push(label.id.clone());
}
}
Ok(ids)
}
pub fn push(
db: &mut Database,
client: &LinearClient,
config: &LinearConfig,
quiet: bool,
) -> Result<SyncStats> {
let mut stats = SyncStats::new();
let filter_labels = config.resolve_filter_labels();
let states = client.fetch_workflow_states(&config.team_id)?;
let members = client.fetch_team_members(&config.team_id)?;
let existing_labels = client.fetch_labels(&config.team_id)?;
let filter_label_ids =
resolve_filter_label_ids(client, config, &filter_labels, &existing_labels)?;
let tasks = db.list_all_tasks()?;
for task in &tasks {
if task.status == Status::Closed {
continue;
}
let sync_entry = db.get_linear_sync_by_task(task.id)?;
match sync_entry {
Some(entry) => {
let current_hash = mapping::hash_task(task);
if current_hash != entry.last_local_hash {
let state_id =
mapping::status_to_linear_state_id(&task.status, &states, config)?;
let priority = mapping::priority_to_linear(&task.priority, config);
let assignee_linear_id =
resolve_assignee(db, task.assignee_id, config, &members)?;
let mut label_ids =
get_label_ids_for_task(db, task, client, config, &existing_labels)?;
for fid in &filter_label_ids {
if !label_ids.contains(fid) {
label_ids.push(fid.clone());
}
}
match client.update_issue(
&entry.linear_issue_id,
Some(&task.title),
task.description.as_deref(),
Some(priority),
Some(&state_id),
assignee_linear_id.as_deref(),
task.due_date.map(|d| d.to_string()).as_deref(),
if label_ids.is_empty() {
None
} else {
Some(&label_ids)
},
) {
Ok(issue) => {
let remote_hash = mapping::hash_issue(&issue);
db.update_linear_sync(task.id, ¤t_hash, &remote_hash)?;
stats.pushed_updated += 1;
if !quiet {
println!(
" {} Updated {} -> {}",
"->".blue(),
task.title,
issue.identifier
);
}
}
Err(e) => {
stats
.errors
.push(format!("Failed to update task {}: {}", task.id, e));
}
}
}
}
None => {
let state_id = mapping::status_to_linear_state_id(&task.status, &states, config)?;
let priority = mapping::priority_to_linear(&task.priority, config);
let assignee_linear_id = resolve_assignee(db, task.assignee_id, config, &members)?;
let mut label_ids =
get_label_ids_for_task(db, task, client, config, &existing_labels)?;
for fid in &filter_label_ids {
if !label_ids.contains(fid) {
label_ids.push(fid.clone());
}
}
match client.create_issue(
&config.team_id,
&task.title,
task.description.as_deref(),
priority,
&state_id,
assignee_linear_id.as_deref(),
task.due_date.map(|d| d.to_string()).as_deref(),
&label_ids,
) {
Ok(issue) => {
let local_hash = mapping::hash_task(task);
let remote_hash = mapping::hash_issue(&issue);
db.create_linear_sync(
task.id,
&issue.id,
Some(&issue.identifier),
&local_hash,
&remote_hash,
)?;
stats.pushed_new += 1;
if !quiet {
println!(
" {} Created {} -> {}",
"++".green(),
task.title,
issue.identifier
);
}
}
Err(e) => {
stats.errors.push(format!(
"Failed to create issue for task {}: {}",
task.id, e
));
}
}
}
}
}
Ok(stats)
}
pub fn pull(
db: &mut Database,
client: &LinearClient,
config: &LinearConfig,
quiet: bool,
) -> Result<SyncStats> {
let mut stats = SyncStats::new();
let filter_labels = config.resolve_filter_labels();
if !quiet {
if filter_labels.is_empty() {
println!(" Fetching active issues from Linear...");
} else {
println!(
" Fetching active issues with labels {:?}...",
filter_labels
);
}
}
let issues = client.fetch_all_issues_filtered(&config.team_id, &filter_labels, true)?;
if !quiet {
println!(" Found {} matching issues", issues.len());
}
for issue in &issues {
let sync_entry = db.get_linear_sync_by_issue(&issue.id)?;
match sync_entry {
Some(entry) => {
let current_hash = mapping::hash_issue(issue);
if current_hash != entry.last_remote_hash {
apply_remote_to_local(db, &entry, issue, ¤t_hash, quiet)?;
stats.pulled_updated += 1;
}
}
None => {
create_local_from_remote(db, issue, quiet)?;
stats.pulled_new += 1;
}
}
}
Ok(stats)
}
pub fn sync(
db: &mut Database,
client: &LinearClient,
config: &LinearConfig,
conflict_resolution: ConflictResolution,
quiet: bool,
) -> Result<SyncStats> {
let mut stats = SyncStats::new();
let filter_labels = config.resolve_filter_labels();
let states = client.fetch_workflow_states(&config.team_id)?;
let members = client.fetch_team_members(&config.team_id)?;
let existing_labels = client.fetch_labels(&config.team_id)?;
let filter_label_ids =
resolve_filter_label_ids(client, config, &filter_labels, &existing_labels)?;
if !quiet {
if filter_labels.is_empty() {
println!(" Fetching active issues from Linear...");
} else {
println!(
" Fetching active issues with labels {:?}...",
filter_labels
);
}
}
let issues = client.fetch_all_issues_filtered(&config.team_id, &filter_labels, true)?;
if !quiet {
println!(" Found {} matching issues", issues.len());
}
for issue in &issues {
let sync_entry = db.get_linear_sync_by_issue(&issue.id)?;
match sync_entry {
Some(entry) => {
let remote_hash = mapping::hash_issue(issue);
let local_task = db.get_task(entry.local_task_id)?;
if let Some(ref task) = local_task {
let local_hash = mapping::hash_task(task);
let remote_changed = remote_hash != entry.last_remote_hash;
let local_changed = local_hash != entry.last_local_hash;
if remote_changed && local_changed {
stats.conflicts += 1;
match conflict_resolution {
ConflictResolution::RemoteWins => {
apply_remote_to_local(db, &entry, issue, &remote_hash, quiet)?;
stats.pulled_updated += 1;
}
ConflictResolution::LocalWins => {
}
}
if !quiet {
let winner = match conflict_resolution {
ConflictResolution::RemoteWins => "remote wins",
ConflictResolution::LocalWins => "local wins",
};
println!(
" {} Conflict on {} ({}) -- {}",
"!!".yellow(),
issue.identifier,
task.title,
winner
);
}
} else if remote_changed {
apply_remote_to_local(db, &entry, issue, &remote_hash, quiet)?;
stats.pulled_updated += 1;
}
}
}
None => {
create_local_from_remote(db, issue, quiet)?;
stats.pulled_new += 1;
}
}
}
let tasks = db.list_all_tasks()?;
for task in &tasks {
if task.status == Status::Closed {
stats.skipped_inactive += 1;
continue;
}
let sync_entry = db.get_linear_sync_by_task(task.id)?;
match sync_entry {
Some(entry) => {
let local_hash = mapping::hash_task(task);
if local_hash != entry.last_local_hash {
let state_id =
mapping::status_to_linear_state_id(&task.status, &states, config)?;
let priority = mapping::priority_to_linear(&task.priority, config);
let assignee_linear_id =
resolve_assignee(db, task.assignee_id, config, &members)?;
let mut label_ids =
get_label_ids_for_task(db, task, client, config, &existing_labels)?;
for fid in &filter_label_ids {
if !label_ids.contains(fid) {
label_ids.push(fid.clone());
}
}
match client.update_issue(
&entry.linear_issue_id,
Some(&task.title),
task.description.as_deref(),
Some(priority),
Some(&state_id),
assignee_linear_id.as_deref(),
task.due_date.map(|d| d.to_string()).as_deref(),
if label_ids.is_empty() {
None
} else {
Some(&label_ids)
},
) {
Ok(issue) => {
let remote_hash = mapping::hash_issue(&issue);
db.update_linear_sync(task.id, &local_hash, &remote_hash)?;
stats.pushed_updated += 1;
if !quiet {
println!(
" {} Updated {} -> {}",
"->".blue(),
task.title,
issue.identifier
);
}
}
Err(e) => stats
.errors
.push(format!("Failed to update {}: {}", task.id, e)),
}
}
}
None => {
let state_id = mapping::status_to_linear_state_id(&task.status, &states, config)?;
let priority = mapping::priority_to_linear(&task.priority, config);
let assignee_linear_id = resolve_assignee(db, task.assignee_id, config, &members)?;
let mut label_ids =
get_label_ids_for_task(db, task, client, config, &existing_labels)?;
for fid in &filter_label_ids {
if !label_ids.contains(fid) {
label_ids.push(fid.clone());
}
}
match client.create_issue(
&config.team_id,
&task.title,
task.description.as_deref(),
priority,
&state_id,
assignee_linear_id.as_deref(),
task.due_date.map(|d| d.to_string()).as_deref(),
&label_ids,
) {
Ok(issue) => {
let local_hash = mapping::hash_task(task);
let remote_hash = mapping::hash_issue(&issue);
db.create_linear_sync(
task.id,
&issue.id,
Some(&issue.identifier),
&local_hash,
&remote_hash,
)?;
stats.pushed_new += 1;
if !quiet {
println!(
" {} Created {} -> {}",
"++".green(),
task.title,
issue.identifier
);
}
}
Err(e) => stats.errors.push(format!(
"Failed to create issue for task {}: {}",
task.id, e
)),
}
}
}
}
Ok(stats)
}
fn apply_remote_to_local(
db: &mut Database,
entry: &crate::db::LinearSyncEntry,
issue: &LinearIssue,
remote_hash: &str,
quiet: bool,
) -> Result<()> {
let status = mapping::status_from_linear(&issue.state);
let priority = mapping::priority_from_linear(issue.priority);
let tags = labels_to_tags(&issue.labels.nodes);
let due_date = issue
.due_date
.as_deref()
.and_then(|d| chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d").ok());
db.update_task(
entry.local_task_id,
Some(&issue.title),
issue.description.as_deref(),
Some(status),
Some(priority),
None,
None,
Some(due_date),
Some(tags.as_deref()),
None,
None,
None,
)?;
let local_task = db.get_task(entry.local_task_id)?;
let local_hash = local_task
.map(|t| mapping::hash_task(&t))
.unwrap_or_default();
db.update_linear_sync(entry.local_task_id, &local_hash, remote_hash)?;
if !quiet {
println!(" {} Updated <- {}", "<-".cyan(), issue.identifier);
}
Ok(())
}
fn create_local_from_remote(db: &mut Database, issue: &LinearIssue, quiet: bool) -> Result<()> {
let status = mapping::status_from_linear(&issue.state);
let priority = mapping::priority_from_linear(issue.priority);
let tags = labels_to_tags(&issue.labels.nodes);
let due_date = issue
.due_date
.as_deref()
.and_then(|d| chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d").ok());
let task = db.create_task(
&issue.title,
issue.description.as_deref(),
None,
priority,
None,
due_date,
tags.as_deref(),
None,
None,
)?;
if status == Status::Closed {
db.update_task(
task.id,
None,
None,
Some(Status::Closed),
None,
None,
None,
None,
None,
None,
None,
None,
)?;
}
let local_hash = mapping::hash_task(&task);
let remote_hash = mapping::hash_issue(issue);
db.create_linear_sync(
task.id,
&issue.id,
Some(&issue.identifier),
&local_hash,
&remote_hash,
)?;
if !quiet {
println!(
" {} Created <- {} ({})",
"++".green(),
issue.identifier,
issue.title
);
}
Ok(())
}
fn labels_to_tags(labels: &[LinearLabel]) -> Option<String> {
if labels.is_empty() {
None
} else {
Some(
labels
.iter()
.map(|l| l.name.as_str())
.collect::<Vec<_>>()
.join(","),
)
}
}
fn resolve_assignee(
db: &Database,
assignee_id: Option<i64>,
config: &LinearConfig,
members: &[super::client::LinearUser],
) -> Result<Option<String>> {
if let Some(id) = assignee_id {
if id > 0 {
if let Some(assignee) = db.get_assignee(id)? {
let found = mapping::find_linear_user_id(
assignee.email.as_deref(),
Some(&assignee.name),
assignee.github_username.as_deref(),
config,
members,
);
if found.is_some() {
return Ok(found);
}
}
}
}
Ok(config.default_assignee_id.clone())
}
fn get_label_ids_for_task(
db: &Database,
task: &crate::models::Task,
client: &LinearClient,
config: &LinearConfig,
existing_labels: &[LinearLabel],
) -> Result<Vec<String>> {
let mut label_ids = Vec::new();
if config.mapping.epic_mode == "label" {
if let Some(epic_id) = task.epic_id {
if let Some(epic) = db.get_epic(epic_id)? {
if let Some(label) = existing_labels.iter().find(|l| l.name == epic.title) {
label_ids.push(label.id.clone());
} else {
match client.create_label(&config.team_id, &epic.title) {
Ok(label) => label_ids.push(label.id.clone()),
Err(e) => {
eprintln!(" Warning: could not create label '{}': {}", epic.title, e);
}
}
}
}
}
}
if let Some(tags) = &task.tags {
for tag in tags.split(',').map(|t| t.trim()).filter(|t| !t.is_empty()) {
if let Some(label) = existing_labels
.iter()
.find(|l| l.name.eq_ignore_ascii_case(tag))
{
if !label_ids.contains(&label.id) {
label_ids.push(label.id.clone());
}
} else {
match client.create_label(&config.team_id, tag) {
Ok(label) => {
if !label_ids.contains(&label.id) {
label_ids.push(label.id.clone());
}
}
Err(_) => {}
}
}
}
}
Ok(label_ids)
}