use colored::Colorize;
use std::io::Write;
use crate::cli::OutputFormat;
use crate::commands::{
ensure_initialized, ERROR_PREFIX, INFO_PREFIX, SUCCESS_PREFIX, WARNING_PREFIX,
};
use crate::error::Result;
use crate::linear::client::LinearClient;
use crate::linear::config::LinearConfig;
use crate::linear::sync::{self, ConflictResolution};
pub fn setup(quiet: bool) -> Result<()> {
if LinearConfig::exists() {
if !quiet {
println!("{} Linear is already configured.", WARNING_PREFIX);
print!(" Reconfigure? [y/N] ");
std::io::stdout().flush().ok();
let mut input = String::new();
std::io::stdin().read_line(&mut input).ok();
if !input.trim().eq_ignore_ascii_case("y") {
return Ok(());
}
}
}
if !quiet {
println!("\n{} Linear Integration Setup", INFO_PREFIX);
println!(" Get your API key from: https://linear.app/settings/api\n");
}
print!(" Linear API key: ");
std::io::stdout().flush().ok();
let mut api_key = String::new();
std::io::stdin().read_line(&mut api_key).ok();
let api_key = api_key.trim().to_string();
if api_key.is_empty() {
return Err(crate::error::MyceliumError::LinearConfig(
"API key cannot be empty".to_string(),
));
}
if !quiet {
println!("\n Verifying API key...");
}
let client = LinearClient::new(&api_key);
let teams = client.fetch_teams()?;
if teams.is_empty() {
return Err(crate::error::MyceliumError::LinearConfig(
"No teams found for this API key".to_string(),
));
}
let (team_id, team_name) = if teams.len() == 1 {
if !quiet {
println!(" Found team: {} ({})", teams[0].name.green(), teams[0].key);
}
(teams[0].id.clone(), teams[0].name.clone())
} else {
if !quiet {
println!("\n Available teams:");
for (i, team) in teams.iter().enumerate() {
println!(" {}. {} ({})", i + 1, team.name, team.key);
}
}
print!("\n Select team [1-{}]: ", teams.len());
std::io::stdout().flush().ok();
let mut choice = String::new();
std::io::stdin().read_line(&mut choice).ok();
let idx: usize = choice.trim().parse().unwrap_or(1);
let idx = idx.saturating_sub(1).min(teams.len() - 1);
(teams[idx].id.clone(), teams[idx].name.clone())
};
if !quiet {
println!("\n How should epics be mapped to Linear?");
println!(" 1. Labels (default)");
println!(" 2. Projects");
}
print!(" Choice [1]: ");
std::io::stdout().flush().ok();
let mut epic_choice = String::new();
std::io::stdin().read_line(&mut epic_choice).ok();
let epic_mode = if epic_choice.trim() == "2" {
"project".to_string()
} else {
"label".to_string()
};
let detected_repo = crate::linear::config::LinearConfig::resolve_filter_labels(
&crate::linear::config::LinearConfig {
api_key: String::new(),
team_id: String::new(),
team_name: String::new(),
sync_enabled: true,
repo_label: "auto".to_string(),
branch_label: "auto".to_string(),
extra_labels: vec![],
default_assignee_id: None,
mapping: Default::default(),
},
);
if !quiet {
println!("\n Sync filtering: issues are filtered by labels derived from git context.");
println!(" This ensures each repo+branch only syncs its own issues.\n");
if !detected_repo.is_empty() {
println!(" Detected labels: {:?}", detected_repo);
}
println!("\n Repo label source:");
println!(" 'auto' = derived from git remote name (default)");
println!(" 'none' = don't filter by repo");
println!(" anything else = use that as a fixed label");
}
print!(" Repo label [auto]: ");
std::io::stdout().flush().ok();
let mut repo_input = String::new();
std::io::stdin().read_line(&mut repo_input).ok();
let repo_label = repo_input.trim().to_string();
let repo_label = if repo_label.is_empty() {
"auto".to_string()
} else {
repo_label
};
if !quiet {
println!("\n Branch label source:");
println!(" 'auto' = derived from current git branch (default)");
println!(" 'none' = don't filter by branch");
println!(" anything else = use that as a fixed label");
}
print!(" Branch label [auto]: ");
std::io::stdout().flush().ok();
let mut branch_input = String::new();
std::io::stdin().read_line(&mut branch_input).ok();
let branch_label = branch_input.trim().to_string();
let branch_label = if branch_label.is_empty() {
"auto".to_string()
} else {
branch_label
};
let mut extra_labels: Vec<String> = Vec::new();
loop {
print!(" Additional static label [leave empty to finish]: ");
std::io::stdout().flush().ok();
let mut extra = String::new();
std::io::stdin().read_line(&mut extra).ok();
let extra = extra.trim().to_string();
if extra.is_empty() {
break;
}
extra_labels.push(extra);
}
if !quiet {
println!("\n Fetching team members for assignee mapping...");
}
let members = client.fetch_team_members(&team_id)?;
let mut assignee_map = std::collections::HashMap::new();
let mut default_assignee_id: Option<String> = None;
let git_email = std::process::Command::new("git")
.args(["config", "user.email"])
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let git_name = std::process::Command::new("git")
.args(["config", "user.name"])
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let git_username = std::process::Command::new("git")
.args(["remote", "get-url", "origin"])
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.and_then(|url| {
let url = url.trim();
if let Some(rest) = url.strip_prefix("git@github.com:") {
rest.split('/').next().map(|s| s.to_string())
} else if url.contains("github.com/") {
url.split("github.com/")
.nth(1)
.and_then(|s| s.split('/').next())
.map(|s| s.to_string())
} else {
None
}
})
.filter(|s| !s.is_empty());
if !members.is_empty() {
if !quiet {
println!("\n Linear team members:");
for (i, member) in members.iter().enumerate() {
println!(" {}. {} ({})", i + 1, member.name, member.email);
}
}
let matched_member = None
.or_else(|| {
git_email
.as_ref()
.and_then(|email| members.iter().find(|m| m.email.eq_ignore_ascii_case(email)))
})
.or_else(|| {
git_name.as_ref().and_then(|name| {
members.iter().find(|m| {
m.name.eq_ignore_ascii_case(name)
|| m.display_name.eq_ignore_ascii_case(name)
})
})
})
.or_else(|| {
git_username.as_ref().and_then(|username| {
members.iter().find(|m| {
m.name.eq_ignore_ascii_case(username)
|| m.display_name.eq_ignore_ascii_case(username)
|| m.email
.split('@')
.next()
.map_or(false, |local| local.eq_ignore_ascii_case(username))
})
})
})
.or_else(|| {
git_name.as_ref().and_then(|name| {
let lower = name.to_lowercase();
members.iter().find(|m| {
m.name.to_lowercase().contains(&lower)
|| lower.contains(&m.name.to_lowercase())
|| m.display_name.to_lowercase().contains(&lower)
|| lower.contains(&m.display_name.to_lowercase())
})
})
});
if let Some(member) = matched_member {
default_assignee_id = Some(member.id.clone());
if let Some(ref email) = git_email {
assignee_map.insert(email.clone(), member.id.clone());
}
if let Some(ref name) = git_name {
assignee_map.insert(name.clone(), member.id.clone());
}
if let Some(ref username) = git_username {
assignee_map.insert(username.clone(), member.id.clone());
}
if !quiet {
println!(
"\n {} Matched you to: {} ({})",
SUCCESS_PREFIX,
member.name.green(),
member.email
);
}
} else {
if !quiet {
println!("\n Could not auto-detect your Linear user.");
print!(" Select your user [1-{}, or 0 to skip]: ", members.len());
std::io::stdout().flush().ok();
let mut choice = String::new();
std::io::stdin().read_line(&mut choice).ok();
let idx: usize = choice.trim().parse().unwrap_or(0);
if idx > 0 && idx <= members.len() {
let member = &members[idx - 1];
default_assignee_id = Some(member.id.clone());
if let Some(ref email) = git_email {
assignee_map.insert(email.clone(), member.id.clone());
}
if let Some(ref name) = git_name {
assignee_map.insert(name.clone(), member.id.clone());
}
println!(
" {} Set default assignee to: {}",
SUCCESS_PREFIX,
member.name.green()
);
}
}
}
if let Ok(db) = ensure_initialized() {
if let Ok(assignees) = db.list_assignees() {
for assignee in &assignees {
if let Some(ref email) = assignee.email {
if let Some(member) =
members.iter().find(|m| m.email.eq_ignore_ascii_case(email))
{
if !assignee_map.values().any(|v| v == &member.id) {
assignee_map.insert(email.clone(), member.id.clone());
}
}
}
if let Some(ref github) = assignee.github_username {
if let Some(member) = members.iter().find(|m| {
m.display_name.eq_ignore_ascii_case(github)
|| m.name.eq_ignore_ascii_case(github)
}) {
if !assignee_map.values().any(|v| v == &member.id) {
assignee_map.insert(github.clone(), member.id.clone());
}
}
}
}
}
}
}
let config = LinearConfig {
api_key,
team_id,
team_name: team_name.clone(),
sync_enabled: true,
repo_label,
branch_label,
extra_labels,
default_assignee_id,
mapping: crate::linear::config::MappingConfig {
epic_mode,
assignees: assignee_map,
..Default::default()
},
};
config.save()?;
if !quiet {
println!(
"\n{} Linear integration configured for team '{}'",
SUCCESS_PREFIX,
team_name.green()
);
println!(
" Config saved to: {}",
LinearConfig::config_path().display()
);
println!(" Run `myc linear sync` to start syncing.");
}
Ok(())
}
pub fn sync_cmd(force_local: bool, force_remote: bool, quiet: bool) -> Result<()> {
let config = LinearConfig::load()?;
let mut db = ensure_initialized()?;
let client = LinearClient::new(&config.api_key);
let resolution = if force_local && force_remote {
if !quiet {
println!(
"{} Cannot use both --force-local and --force-remote. Defaulting to remote wins.",
WARNING_PREFIX
);
}
ConflictResolution::RemoteWins
} else if force_local {
ConflictResolution::LocalWins
} else if force_remote {
ConflictResolution::RemoteWins
} else {
ConflictResolution::RemoteWins
};
if !quiet {
println!(
"{} Syncing with Linear (team: {})...",
INFO_PREFIX, config.team_name
);
}
let stats = sync::sync(&mut db, &client, &config, resolution, quiet)?;
print_stats(&stats, quiet);
Ok(())
}
pub fn push_cmd(quiet: bool) -> Result<()> {
let config = LinearConfig::load()?;
let mut db = ensure_initialized()?;
let client = LinearClient::new(&config.api_key);
if !quiet {
println!(
"{} Pushing to Linear (team: {})...",
INFO_PREFIX, config.team_name
);
}
let stats = sync::push(&mut db, &client, &config, quiet)?;
print_stats(&stats, quiet);
Ok(())
}
pub fn pull_cmd(quiet: bool) -> Result<()> {
let config = LinearConfig::load()?;
let mut db = ensure_initialized()?;
let client = LinearClient::new(&config.api_key);
if !quiet {
println!(
"{} Pulling from Linear (team: {})...",
INFO_PREFIX, config.team_name
);
}
let stats = sync::pull(&mut db, &client, &config, quiet)?;
print_stats(&stats, quiet);
Ok(())
}
pub fn status_cmd(format: &OutputFormat, quiet: bool) -> Result<()> {
if !LinearConfig::exists() {
if !quiet {
println!(
"{} Linear is not configured. Run `myc linear setup`.",
INFO_PREFIX
);
}
return Ok(());
}
let config = LinearConfig::load()?;
let db = ensure_initialized()?;
let synced_count = db.count_linear_synced()?;
let last_sync = db.get_last_sync_time()?;
let resolved_labels = config.resolve_filter_labels();
match format {
OutputFormat::Json => {
let status = serde_json::json!({
"configured": true,
"team_name": config.team_name,
"team_id": config.team_id,
"sync_enabled": config.sync_enabled,
"epic_mode": config.mapping.epic_mode,
"repo_label": config.repo_label,
"branch_label": config.branch_label,
"resolved_labels": resolved_labels,
"synced_tasks": synced_count,
"last_sync": last_sync,
"assignee_mappings": config.mapping.assignees.len(),
});
if !quiet {
println!("{}", serde_json::to_string_pretty(&status).unwrap());
}
}
OutputFormat::Table => {
if !quiet {
println!("{} Linear Integration Status", INFO_PREFIX);
println!(
" Team: {} ({})",
config.team_name.green(),
config.team_id
);
println!(
" Sync enabled: {}",
if config.sync_enabled {
"yes".green()
} else {
"no".red()
}
);
println!(" Epic mapping: {}", config.mapping.epic_mode);
println!(
" Repo label: {} ({})",
config.repo_label,
resolved_labels.first().unwrap_or(&"none".to_string())
);
println!(
" Branch label: {} ({})",
config.branch_label,
resolved_labels.get(1).unwrap_or(&"none".to_string())
);
if !resolved_labels.is_empty() {
println!(" Active filter: {:?}", resolved_labels);
}
println!(" Synced tasks: {}", synced_count);
println!(" Assignee mappings: {}", config.mapping.assignees.len());
match last_sync {
Some(t) => println!(" Last sync: {}", t),
None => println!(" Last sync: never"),
}
}
}
}
Ok(())
}
pub fn unlink_cmd(quiet: bool) -> Result<()> {
if !LinearConfig::exists() {
if !quiet {
println!("{} Linear is not configured.", INFO_PREFIX);
}
return Ok(());
}
if !quiet {
print!(
"{} Remove Linear configuration and sync data? [y/N] ",
WARNING_PREFIX
);
std::io::stdout().flush().ok();
let mut input = String::new();
std::io::stdin().read_line(&mut input).ok();
if !input.trim().eq_ignore_ascii_case("y") {
return Ok(());
}
}
if let Ok(mut db) = ensure_initialized() {
db.delete_all_linear_sync()?;
}
LinearConfig::remove()?;
if !quiet {
println!("{} Linear integration removed.", SUCCESS_PREFIX);
}
Ok(())
}
fn print_stats(stats: &sync::SyncStats, quiet: bool) {
if quiet {
return;
}
println!("\n Sync complete:");
if stats.pushed_new > 0 {
println!(
" Pushed new: {}",
stats.pushed_new.to_string().green()
);
}
if stats.pushed_updated > 0 {
println!(
" Pushed updated: {}",
stats.pushed_updated.to_string().blue()
);
}
if stats.pulled_new > 0 {
println!(
" Pulled new: {}",
stats.pulled_new.to_string().green()
);
}
if stats.pulled_updated > 0 {
println!(
" Pulled updated: {}",
stats.pulled_updated.to_string().cyan()
);
}
if stats.conflicts > 0 {
println!(
" Conflicts: {}",
stats.conflicts.to_string().yellow()
);
}
if !stats.errors.is_empty() {
println!(" Errors:");
for err in &stats.errors {
println!(" {} {}", ERROR_PREFIX, err);
}
}
if stats.pushed_new + stats.pushed_updated + stats.pulled_new + stats.pulled_updated == 0 {
println!(" Everything is up to date.");
}
}