use super::annotate::annotate_source_line;
use super::ledger::{load_ledger, save_ledger, GithubIssueRef, TodoLedger, TodoLedgerEntry};
#[cfg(feature = "linear")]
use super::ledger::LinearIssueRef;
use super::scan::{scan_todos, TodoHit};
use super::settings::ResolvedTodosSettings;
use super::{print_hits_table, resolve_scan_root};
use crate::commands::text_util::truncate_chars;
use crate::cli::ui::Loader;
use crate::commands::cloudflare_config::is_interactive_terminal;
use crate::commands::github_cmd::{
create_issue as gh_create, ensure_label, resolve_github_token, resolve_repo, CreateIssueInput,
};
#[cfg(feature = "linear")]
use crate::commands::linear_cmd::{
create_issue as linear_create, default_team_hint, ensure_label_ids, ensure_linear_api_key,
resolve_assignee_id, resolve_team_id_auto, CreateIssueInput as LinearCreate,
};
use crate::commands::terminal_table::{render_table, TableStyle};
use chrono::Utc;
use colored::Colorize;
use dialoguer::{theme::ColorfulTheme, Confirm, MultiSelect};
use std::path::PathBuf;
pub use super::settings::SyncTarget;
#[derive(Debug, Clone)]
pub struct SyncOptions {
pub path: Option<PathBuf>,
pub target: Option<SyncTarget>,
pub dry_run: bool,
pub yes: bool,
pub team: Option<String>,
pub owner: Option<String>,
pub repo: Option<String>,
pub annotate: Option<bool>,
}
pub async fn sync_todos(opts: SyncOptions) -> Result<(), String> {
let root = resolve_scan_root(opts.path.as_deref())?;
let settings = ResolvedTodosSettings::load(Some(&root));
let target = opts.target.unwrap_or(settings.default_to);
let auto_yes = opts.yes || settings.auto_yes || !is_interactive_terminal();
let annotate = opts.annotate.unwrap_or(settings.annotate_source);
let hits = scan_todos(&root)?;
let hits: Vec<TodoHit> = hits
.into_iter()
.filter(|h| settings.allows_kind(&h.kind))
.collect();
if hits.is_empty() {
println!("{}", "No TODOs found to sync (after kind filters).".dimmed());
return Ok(());
}
let mut ledger = load_ledger(&root)?;
let mut candidates: Vec<&TodoHit> = hits
.iter()
.filter(|hit| needs_create(hit, &ledger, target))
.collect();
for hit in &hits {
if let Some(entry) = ledger.entries.get_mut(&hit.fingerprint) {
entry.path = hit.path.clone();
entry.line = hit.line;
entry.text = hit.text.clone();
entry.updated_at = Some(Utc::now().to_rfc3339());
}
}
if candidates.is_empty() {
println!(
"{}",
"All scanned TODOs already have linked issues for the selected target(s)."
.bright_green()
);
save_ledger(&root, &ledger)?;
return Ok(());
}
if !auto_yes && is_interactive_terminal() && !opts.dry_run {
let labels: Vec<String> = candidates
.iter()
.map(|h| {
format!(
"[{}] {}:{} — {}",
h.kind,
h.path,
h.line,
h.text
)
})
.collect();
let selected = MultiSelect::with_theme(&ColorfulTheme::default())
.with_prompt("Select TODOs to file as issues (space to toggle, enter to confirm)")
.items(&labels)
.defaults(&vec![true; labels.len()])
.interact()
.map_err(|e| e.to_string())?;
candidates = selected.into_iter().map(|i| candidates[i]).collect();
if candidates.is_empty() {
println!("{}", "Nothing selected.".dimmed());
save_ledger(&root, &ledger)?;
return Ok(());
}
}
println!();
println!(
"{} {} TODO(s) → {:?}{}",
if opts.dry_run {
"Would sync"
} else {
"Syncing"
}
.bright_cyan()
.bold(),
candidates.len(),
target,
if auto_yes && !opts.yes && settings.auto_yes {
" (auto_yes from config)".dimmed().to_string()
} else {
String::new()
}
);
print_hits_table(
&candidates
.iter()
.map(|c| (*c).clone())
.collect::<Vec<_>>(),
);
if opts.dry_run {
let rows: Vec<Vec<String>> = candidates
.iter()
.map(|h| {
let entry = ledger.entries.get(&h.fingerprint);
#[cfg(feature = "linear")]
let lin = entry
.and_then(|e| e.linear.as_ref())
.map(|l| l.identifier.clone())
.unwrap_or_else(|| {
format!(
"create prio={}",
settings
.priority_for_kind(&h.kind)
.map(|p| p.to_string())
.unwrap_or_else(|| "-".into())
)
});
let gh = entry
.and_then(|e| e.github.as_ref())
.map(|g| format!("#{}", g.number))
.unwrap_or_else(|| "create".into());
vec![
format!("{}:{}", h.path, h.line),
{
#[cfg(feature = "linear")]
{
match target {
SyncTarget::Linear => lin,
SyncTarget::Github => gh,
SyncTarget::Both => format!("{lin} / {gh}"),
}
}
#[cfg(not(feature = "linear"))]
{
let _ = target;
gh
}
},
]
})
.collect();
print!(
"{}",
render_table(&["TODO", "Action"], &rows, TableStyle::Pipe, "")
);
return Ok(());
}
let want_linear = target.wants_linear();
let want_github = target.wants_github();
#[cfg(feature = "linear")]
let linear_ctx = if want_linear {
let key = ensure_linear_api_key().await?;
let preferred = opts.team.clone().or_else(default_team_hint);
let team_id = match resolve_team_id_auto(&key, preferred.as_deref()).await {
Ok(id) => id,
Err(err) if crate::commands::cloudflare_config::is_interactive_terminal() => {
eprintln!("{} {err}", "note".bright_black());
crate::commands::linear_cmd::prompt_and_save_default_team(&key).await?;
let preferred = default_team_hint();
resolve_team_id_auto(&key, preferred.as_deref()).await?
}
Err(err) => return Err(err),
};
let label_ids = ensure_label_ids(&key, Some(&team_id), &settings.linear_labels).await?;
let assignee_id = match settings.linear_assignee.as_deref() {
Some(assignee) => resolve_assignee_id(&key, assignee).await?,
None => None,
};
Some(LinearSyncContext {
key,
team_id,
label_ids,
assignee_id,
})
} else {
None
};
#[cfg(not(feature = "linear"))]
let linear_ctx: Option<LinearSyncContext> = {
let _ = want_linear;
None
};
let github_ctx = if want_github {
let token = resolve_github_token()?;
let (owner, repo) = resolve_repo(opts.owner.as_deref(), opts.repo.as_deref())?;
for label in &settings.github_labels {
let _ = ensure_label(&token, &owner, &repo, label).await;
}
Some(GithubSyncContext {
token,
owner,
repo,
})
} else {
None
};
#[cfg(feature = "linear")]
let mut created_linear = 0usize;
#[cfg(not(feature = "linear"))]
let created_linear = 0usize;
let mut created_github = 0usize;
let mut skipped = 0usize;
let mut annotated = 0usize;
for hit in candidates {
let entry = ledger
.entries
.entry(hit.fingerprint.clone())
.or_insert_with(|| TodoLedgerEntry {
fingerprint: hit.fingerprint.clone(),
kind: hit.kind.clone(),
path: hit.path.clone(),
line: hit.line,
text: hit.text.clone(),
linear: None,
github: None,
created_at: Some(Utc::now().to_rfc3339()),
updated_at: None,
});
entry.path = hit.path.clone();
entry.line = hit.line;
entry.text = hit.text.clone();
entry.updated_at = Some(Utc::now().to_rfc3339());
let title = format!("[{}] {}", hit.kind, truncate_chars(&hit.text, 80));
let body = render_issue_body(hit);
let priority = settings.priority_for_kind(&hit.kind);
#[cfg(feature = "linear")]
if let Some(ctx) = linear_ctx.as_ref() {
if entry.linear.is_none() {
let loader = Loader::start(&format!("Linear: {}", hit.path));
match linear_create(
&ctx.key,
LinearCreate {
team_id: ctx.team_id.clone(),
title: title.clone(),
description: Some(body.clone()),
priority,
state_id: None,
assignee_id: ctx.assignee_id.clone(),
label_ids: ctx.label_ids.clone(),
},
)
.await
{
Ok(issue) => {
loader.success_with(&issue.identifier);
entry.linear = Some(LinearIssueRef {
id: issue.id,
identifier: issue.identifier,
url: issue.url,
});
created_linear += 1;
}
Err(e) => {
loader.fail(&e);
eprintln!(
"{} Linear create failed for {}: {e}",
"ERR".red(),
hit.path
);
}
}
} else {
skipped += 1;
}
}
#[cfg(not(feature = "linear"))]
let _ = (&linear_ctx, &priority);
if let Some(ctx) = github_ctx.as_ref() {
if entry.github.is_none() {
let loader = Loader::start(&format!("GitHub: {}", hit.path));
match gh_create(
&ctx.token,
&ctx.owner,
&ctx.repo,
CreateIssueInput {
title: title.clone(),
body: Some(body.clone()),
labels: settings.github_labels.clone(),
assignees: Vec::new(),
},
)
.await
{
Ok(issue) => {
loader.success_with(&format!("#{}", issue.number));
entry.github = Some(GithubIssueRef {
number: issue.number,
url: issue.html_url,
});
created_github += 1;
}
Err(e) => {
loader.fail(&e);
eprintln!(
"{} GitHub create failed for {}: {e}",
"ERR".red(),
hit.path
);
}
}
} else {
skipped += 1;
}
}
if annotate {
let lin = entry.linear.clone();
let gh = entry.github.clone();
if lin.is_some() || gh.is_some() {
match annotate_source_line(&root, hit, lin.as_ref(), gh.as_ref()) {
Ok(true) => {
annotated += 1;
println!(
" {} annotated {}:{}",
"·".bright_green(),
hit.path,
hit.line
);
}
Ok(false) => {}
Err(e) => {
eprintln!(
"{} annotate {}:{}: {e}",
"warn".yellow(),
hit.path,
hit.line
);
}
}
}
}
save_ledger(&root, &ledger)?;
}
println!();
println!(
"{} linear={} github={} annotated={} already-linked-skips≈{}",
"Done.".bright_green().bold(),
created_linear,
created_github,
annotated,
skipped
);
println!(
"Ledger: {}",
super::ledger::ledger_path(&root).display()
);
Ok(())
}
#[cfg(feature = "linear")]
struct LinearSyncContext {
key: String,
team_id: String,
label_ids: Vec<String>,
assignee_id: Option<String>,
}
#[cfg(not(feature = "linear"))]
struct LinearSyncContext;
struct GithubSyncContext {
token: String,
owner: String,
repo: String,
}
fn needs_create(hit: &TodoHit, ledger: &TodoLedger, target: SyncTarget) -> bool {
let entry = ledger.entries.get(&hit.fingerprint);
let missing_github = entry.and_then(|e| e.github.as_ref()).is_none();
#[cfg(feature = "linear")]
{
let missing_linear = entry.and_then(|e| e.linear.as_ref()).is_none();
match target {
SyncTarget::Linear => missing_linear,
SyncTarget::Github => missing_github,
SyncTarget::Both => missing_linear || missing_github,
}
}
#[cfg(not(feature = "linear"))]
{
let _ = target;
missing_github
}
}
fn render_issue_body(hit: &TodoHit) -> String {
let mut body = format!(
"## Source\n\n`{}:{}`\n\n## Marker\n\n**{}**: {}\n\n",
hit.path, hit.line, hit.kind, hit.text
);
if let Some(ctx) = &hit.context {
body.push_str("## Context\n\n```\n");
body.push_str(ctx);
body.push_str("\n```\n\n");
}
body.push_str(&format!(
"---\n_Filed by `xbp todos sync`_\n\n<!-- xbp-todo: {} -->\n",
hit.fingerprint
));
body
}
pub async fn prompt_sync_after_scan(root: &std::path::Path) -> Result<(), String> {
let settings = ResolvedTodosSettings::load(Some(root));
if !settings.prompt_sync_after_scan || !is_interactive_terminal() {
return Ok(());
}
let run = Confirm::with_theme(&ColorfulTheme::default())
.with_prompt(format!(
"Sync unlinked TODOs to {:?} now?",
settings.default_to
))
.default(false)
.interact()
.map_err(|e| e.to_string())?;
if !run {
return Ok(());
}
let dry = Confirm::with_theme(&ColorfulTheme::default())
.with_prompt("Dry-run first (no creates)?")
.default(true)
.interact()
.map_err(|e| e.to_string())?;
sync_todos(SyncOptions {
path: Some(root.to_path_buf()),
target: Some(settings.default_to),
dry_run: dry,
yes: settings.auto_yes,
team: None,
owner: None,
repo: None,
annotate: None,
})
.await
}
pub fn prune_ledger(project_root: &std::path::Path, dry_run: bool) -> Result<usize, String> {
let hits = scan_todos(project_root)?;
let live: std::collections::HashSet<String> =
hits.into_iter().map(|h| h.fingerprint).collect();
let mut ledger = load_ledger(project_root)?;
let before = ledger.entries.len();
let stale: Vec<String> = ledger
.entries
.keys()
.filter(|fp| !live.contains(*fp))
.cloned()
.collect();
if stale.is_empty() {
println!("{}", "No stale ledger entries.".bright_green());
return Ok(0);
}
println!(
"{} stale ledger entr{}:",
stale.len(),
if stale.len() == 1 { "y" } else { "ies" }
);
for fp in &stale {
if let Some(e) = ledger.entries.get(fp) {
println!(
" {} {}:{} — {} [{}]",
e.kind.bright_yellow(),
e.path,
e.line,
truncate_chars(&e.text, 50),
e.linear
.as_ref()
.map(|l| l.identifier.clone())
.or_else(|| e.github.as_ref().map(|g| format!("#{}", g.number)))
.unwrap_or_else(|| "unlinked".into())
);
}
}
if dry_run {
println!("{}", "(dry-run — nothing removed)".dimmed());
return Ok(stale.len());
}
for fp in &stale {
ledger.entries.remove(fp);
}
save_ledger(project_root, &ledger)?;
println!(
"{} pruned {} entr{} ({} → {})",
"OK".bright_green().bold(),
stale.len(),
if stale.len() == 1 { "y" } else { "ies" },
before,
ledger.entries.len()
);
Ok(stale.len())
}