mod annotate;
pub(crate) mod effort;
mod enrich;
mod ledger;
mod link_discover;
mod reconcile;
mod scan;
mod settings;
mod setup;
mod sync;
use crate::cli::commands::{IssueSearchCmd, TodosCmd, TodosSubCommand, TodosSyncTarget};
use crate::commands::terminal_table::{render_table, TableStyle};
use crate::commands::text_util::truncate_chars;
use crate::utils::find_xbp_config_upwards;
use colored::Colorize;
use settings::ResolvedTodosSettings;
use std::env;
use std::path::{Path, PathBuf};
pub use ledger::{load_ledger, TodoLedger};
pub use scan::{scan_todos, TodoHit};
pub use settings::SyncTarget;
pub use sync::{sync_todos, SyncOptions};
#[derive(Debug, Clone, Copy)]
enum IssueCommandNamespace {
Issues,
Todos,
}
impl IssueCommandNamespace {
fn command(self) -> &'static str {
match self {
Self::Issues => "issues",
Self::Todos => "todos",
}
}
fn sync_command(self) -> &'static str {
match self {
Self::Issues => "xbp issues sync",
Self::Todos => "xbp todos sync",
}
}
fn prune_command(self) -> &'static str {
match self {
Self::Issues => "xbp issues prune",
Self::Todos => "xbp todos prune",
}
}
#[allow(dead_code)]
fn effort_command(self) -> &'static str {
match self {
Self::Issues => "xbp issues effort",
Self::Todos => "xbp todos effort",
}
}
#[allow(dead_code)]
fn reconcile_command(self) -> &'static str {
match self {
Self::Issues => "xbp issues reconcile",
Self::Todos => "xbp todos reconcile",
}
}
}
pub async fn run_todos(cmd: TodosCmd) -> Result<(), String> {
run_issue_marker_command(cmd, IssueCommandNamespace::Todos).await
}
pub async fn run_issues(cmd: TodosCmd) -> Result<(), String> {
run_issue_marker_command(cmd, IssueCommandNamespace::Issues).await
}
async fn run_issue_marker_command(
cmd: TodosCmd,
namespace: IssueCommandNamespace,
) -> Result<(), String> {
match cmd.command {
None | Some(TodosSubCommand::Scan(_)) => {
let path = match &cmd.command {
Some(TodosSubCommand::Scan(args)) => args.path.clone(),
_ => None,
};
let json = matches!(
&cmd.command,
Some(TodosSubCommand::Scan(args)) if args.json
);
let no_prompt = matches!(
&cmd.command,
Some(TodosSubCommand::Scan(args)) if args.no_prompt
);
run_scan(path.as_deref(), json, no_prompt, namespace).await
}
Some(TodosSubCommand::Sync(args)) => {
let target = args.to.map(|t| match t {
#[cfg(feature = "linear")]
TodosSyncTarget::Linear => SyncTarget::Linear,
TodosSyncTarget::Github => SyncTarget::Github,
#[cfg(feature = "linear")]
TodosSyncTarget::Both => SyncTarget::Both,
});
sync_todos(SyncOptions {
path: args.path.clone(),
target,
dry_run: args.dry_run,
yes: args.yes,
#[cfg(feature = "linear")]
team: args.team.clone(),
#[cfg(not(feature = "linear"))]
team: None,
owner: args.owner.clone(),
repo: args.repo.clone(),
annotate: if args.annotate {
Some(true)
} else if args.no_annotate {
Some(false)
} else {
None
},
enrich: if args.enrich {
Some(true)
} else if args.no_enrich {
Some(false)
} else {
None
},
})
.await
}
Some(TodosSubCommand::Status(args)) => run_status(args.path.as_deref(), namespace).await,
Some(TodosSubCommand::Prune(args)) => {
let root = resolve_scan_root(args.path.as_deref())?;
sync::prune_ledger(&root, args.dry_run)?;
Ok(())
}
Some(TodosSubCommand::Effort(args)) => {
effort::run_effort(effort::EffortRecomputeOptions {
path: args.path.clone(),
session_gap_minutes: args.gap_minutes,
json: args.json,
persist: !args.no_persist,
})
.await
}
Some(TodosSubCommand::Reconcile(args)) => {
reconcile::run_reconcile(reconcile::ReconcileOptions {
path: args.path.clone(),
dry_run: args.dry_run,
})
.await
}
Some(TodosSubCommand::Search(args)) => run_issue_search(args).await,
Some(TodosSubCommand::Setup) => setup::run_setup(namespace.command()).await,
}
}
#[derive(Debug)]
struct IssueSearchRow {
provider: &'static str,
id: String,
state: String,
title: String,
url: String,
}
async fn run_issue_search(args: IssueSearchCmd) -> Result<(), String> {
let mut rows = Vec::new();
let search_github = args.github || !any_provider_selected(&args);
#[cfg(feature = "linear")]
let search_linear = args.linear || !any_provider_selected(&args);
if search_github {
let token = crate::commands::github_cmd::resolve_github_token()?;
let (owner, repo) =
crate::commands::github_cmd::resolve_repo(args.owner.as_deref(), args.repo.as_deref())?;
let issues = crate::commands::github_cmd::list_issues(
&token,
&owner,
&repo,
crate::commands::github_cmd::ListIssuesFilter {
state: if args.include_completed {
"all".into()
} else {
args.state.clone().unwrap_or_else(|| "open".into())
},
labels: args.labels.clone(),
assignee: args.assignee.clone(),
query: Some(args.query.clone()),
limit: args.limit,
},
)
.await?;
rows.extend(issues.into_iter().map(|issue| IssueSearchRow {
provider: "github",
id: format!("#{}", issue.number),
state: issue.state,
title: issue.title,
url: issue.html_url.unwrap_or_default(),
}));
}
#[cfg(feature = "linear")]
if search_linear {
let api_key = crate::commands::linear_cmd::ensure_linear_api_key().await?;
let assignee_id = if let Some(assignee) = args.assignee.as_deref() {
crate::commands::linear_cmd::resolve_assignee_id(&api_key, assignee).await?
} else {
None
};
let filter = crate::commands::linear_cmd::ListIssuesFilter {
team_id: None,
state: args.state.clone(),
assignee_id,
query: Some(args.query.clone()),
labels: args.labels.clone(),
include_completed: args.include_completed,
sort: crate::commands::linear_cmd::IssueSortField::parse(&args.sort)?,
sort_asc: args.asc,
limit: args.limit,
};
if args.tui {
return crate::commands::linear_cmd::run_linear_tui_with_filter(&api_key, filter).await;
}
let issues = crate::commands::linear_cmd::list_issues(&api_key, filter).await?;
rows.extend(issues.into_iter().map(|issue| IssueSearchRow {
provider: "linear",
id: issue.identifier,
state: issue.state_name.unwrap_or_else(|| "-".into()),
title: issue.title,
url: issue.url.unwrap_or_default(),
}));
}
print_issue_search_rows(&rows);
Ok(())
}
fn any_provider_selected(args: &IssueSearchCmd) -> bool {
args.github || {
#[cfg(feature = "linear")]
{
args.linear
}
#[cfg(not(feature = "linear"))]
{
false
}
}
}
fn print_issue_search_rows(rows: &[IssueSearchRow]) {
let table_rows = rows
.iter()
.map(|row| {
vec![
row.provider.to_string(),
row.id.clone(),
row.state.clone(),
truncate_chars(&row.title, 72),
row.url.clone(),
]
})
.collect::<Vec<_>>();
println!(
"{}",
render_table(
&["Provider", "ID", "State", "Title", "URL"],
&table_rows,
TableStyle::Pipe,
"",
)
);
}
pub async fn run_todos_scan_default() -> Result<(), String> {
run_scan(None, false, false, IssueCommandNamespace::Todos).await
}
async fn run_scan(
path: Option<&Path>,
json: bool,
no_prompt: bool,
namespace: IssueCommandNamespace,
) -> Result<(), String> {
let root = resolve_scan_root(path)?;
let settings = ResolvedTodosSettings::load(Some(&root));
let hits = scan_todos(&root)?;
let hits: Vec<TodoHit> = hits
.into_iter()
.filter(|h| settings.allows_kind(&h.kind))
.filter(|h| settings.allows_path(&h.path))
.collect();
if json {
println!(
"{}",
serde_json::to_string_pretty(&hits).map_err(|e| e.to_string())?
);
return Ok(());
}
print_hits_table(&hits);
let ledger = load_ledger(&root)?;
let unlinked = hits
.iter()
.filter(|h| {
ledger
.entries
.get(&h.fingerprint)
.map(|e| e.linear.is_none() && e.github.is_none())
.unwrap_or(true)
})
.count();
println!(
"{} scanned under {} ({} unlinked)",
format!("{} TODO(s)", hits.len()).bright_white().bold(),
root.display().to_string().dimmed(),
unlinked
);
if unlinked > 0 {
println!(
"{}",
format!(
"Tip: `{}` --dry-run (default target from config: {:?})",
namespace.sync_command(),
settings.default_to
)
.dimmed()
);
}
if !no_prompt && !json && unlinked > 0 {
sync::prompt_sync_after_scan(&root).await?;
}
Ok(())
}
async fn run_status(path: Option<&Path>, namespace: IssueCommandNamespace) -> Result<(), String> {
let root = resolve_scan_root(path)?;
let settings = ResolvedTodosSettings::load(Some(&root));
let hits = scan_todos(&root)?;
let hits: Vec<TodoHit> = hits
.into_iter()
.filter(|h| settings.allows_kind(&h.kind))
.filter(|h| settings.allows_path(&h.path))
.collect();
let ledger = load_ledger(&root)?;
let live_fps: std::collections::HashSet<_> =
hits.iter().map(|h| h.fingerprint.clone()).collect();
let stale = ledger
.entries
.keys()
.filter(|fp| !live_fps.contains(*fp))
.count();
let mut linked_linear = 0usize;
let mut linked_github = 0usize;
let mut unlinked = 0usize;
let rows: Vec<Vec<String>> = hits
.iter()
.map(|hit| {
let entry = ledger.entries.get(&hit.fingerprint);
let (lin, gh) = match entry {
Some(e) => {
if e.linear.is_some() {
linked_linear += 1;
}
if e.github.is_some() {
linked_github += 1;
}
if e.linear.is_none() && e.github.is_none() {
unlinked += 1;
}
(
e.linear
.as_ref()
.map(|l| l.identifier.clone())
.unwrap_or_else(|| "—".into()),
e.github
.as_ref()
.map(|g| format!("#{}", g.number))
.unwrap_or_else(|| "—".into()),
)
}
None => {
unlinked += 1;
("—".into(), "—".into())
}
};
let coding = entry
.map(|e| {
if e.effort.estimated_coding_seconds == 0 {
"—".into()
} else {
effort::format_duration_public(e.effort.estimated_coding_seconds)
}
})
.unwrap_or_else(|| "—".into());
let status = entry
.map(|e| {
if e.is_open() {
"open".into()
} else {
"done".into()
}
})
.unwrap_or_else(|| "—".into());
vec![
hit.kind.clone().bright_yellow().to_string(),
format!("{}:{}", hit.path, hit.line),
truncate_chars(&hit.text, 36),
if lin == "—" {
lin.dimmed().to_string()
} else {
lin.bright_cyan().to_string()
},
if gh == "—" {
gh.dimmed().to_string()
} else {
gh.bright_green().to_string()
},
status,
coding,
]
})
.collect();
println!();
println!("{}", "Issue sync status".bright_cyan().bold());
println!(" root: {}", root.display());
println!(" scanned: {}", hits.len());
println!(" ledger entries: {}", ledger.entries.len());
println!(" linked Linear: {}", linked_linear);
println!(" linked GitHub: {}", linked_github);
println!(" unlinked: {}", unlinked);
println!(
" stale ledger: {}{}",
stale,
if stale > 0 {
format!(" (run `{}`)", namespace.prune_command())
} else {
String::new()
}
);
println!(" default_to: {:?}", settings.default_to);
println!(
" effort tip: {}",
"`xbp todos effort` · `xbp todos reconcile`".dimmed()
);
println!(" auto_yes: {}", settings.auto_yes);
println!(" annotate_source: {}", settings.annotate_source);
println!(
" openrouter_enrich: {}{}",
settings.openrouter_enrich,
if settings.openrouter_enrich {
" (opt-in ON)"
} else {
" (default off — use --enrich or todos.openrouter_enrich: true)"
}
);
if let Some(model) = &settings.openrouter_enrich_model {
println!(" enrich_model: {model}");
}
println!(" linear labels: {}", settings.linear_labels.join(", "));
println!(" github labels: {}", settings.github_labels.join(", "));
if let Some(a) = &settings.linear_assignee {
println!(" linear assignee: {}", a);
}
if let Some(p) = &settings.linear_project {
println!(" linear project: {}", p);
}
if !settings.watch_paths.is_empty() {
println!(" watch_paths: {}", settings.watch_paths.join(", "));
}
println!(
" link wait: {}s (poll {}ms) purge_dups={}",
settings.linear_link_wait_secs,
settings.linear_link_poll_ms,
settings.purge_duplicate_linear
);
println!();
if !rows.is_empty() {
print!(
"{}",
render_table(
&["Kind", "Location", "Text", "Linear", "GitHub", "State", "Coding"],
&rows,
TableStyle::Pipe,
"",
)
);
}
println!();
Ok(())
}
pub(crate) fn resolve_scan_root(path: Option<&Path>) -> Result<PathBuf, String> {
if let Some(path) = path {
return path
.canonicalize()
.or_else(|_| Ok(path.to_path_buf()))
.map_err(|e: std::io::Error| e.to_string());
}
let cwd = env::current_dir().map_err(|e| format!("Failed to get cwd: {e}"))?;
if let Some(found) = find_xbp_config_upwards(&cwd) {
return Ok(found.project_root);
}
for dir in cwd.ancestors() {
if dir.join(".git").exists() {
return Ok(dir.to_path_buf());
}
}
Ok(cwd)
}
pub(crate) fn print_hits_table(hits: &[TodoHit]) {
if hits.is_empty() {
println!("{}", "No TODO/FIXME markers found.".dimmed());
return;
}
let rows: Vec<Vec<String>> = hits
.iter()
.map(|h| {
vec![
h.kind.clone().bright_yellow().to_string(),
format!("{}:{}", h.path, h.line),
truncate_chars(&h.text, 70),
]
})
.collect();
print!(
"{}",
render_table(&["Kind", "Location", "Text"], &rows, TableStyle::Pipe, "")
);
}