use super::annotate::annotate_source_line;
use super::ledger::{
load_ledger, save_ledger, GithubIssueRef, TodoIssueStatus, TodoLedger, TodoLedgerEntry,
};
#[cfg(feature = "linear")]
use super::ledger::{LinearIssueRef, LinearLinkSource};
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_comment as gh_create_comment, 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_project_id, resolve_team_id_auto, CreateIssueInput as LinearCreate,
};
use crate::commands::terminal_table::{render_table, TableStyle};
use crate::utils::{git_remote_url_from_metadata, parse_github_repo_from_remote_url};
use chrono::Utc;
use colored::Colorize;
use dialoguer::{theme::ColorfulTheme, Confirm, MultiSelect};
use std::path::{Path, PathBuf};
use std::process::Command;
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 enrich: 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 enrich = opts.enrich.unwrap_or(settings.openrouter_enrich);
let source_link = resolve_source_repo_link(&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 hits.is_empty() {
println!(
"{}",
"No TODOs found to sync (after kind / watch_paths 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()
},
if enrich {
" (OpenRouter enrich ON)".bright_yellow().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!("gh:{gh} → wait → lin:{lin}"),
}
}
#[cfg(not(feature = "linear"))]
{
let _ = target;
gh
}
},
]
})
.collect();
print!(
"{}",
render_table(&["TODO", "Action"], &rows, TableStyle::Pipe, "")
);
if target.wants_github() && target.wants_linear() {
println!(
"{}",
format!(
"Note: GitHub issues are created first; Linear auto-link wait={}s, purge_dups={}",
settings.linear_link_wait_secs, settings.purge_duplicate_linear
)
.dimmed()
);
}
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,
};
let project_id = match settings.linear_project.as_deref() {
Some(project) => resolve_project_id(&key, project).await?,
None => None,
};
Some(LinearSyncContext {
key,
team_id,
label_ids,
assignee_id,
project_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
};
let github_first = want_github && want_linear;
#[cfg(feature = "linear")]
let mut created_linear = 0usize;
#[cfg(not(feature = "linear"))]
let created_linear = 0usize;
let mut created_github = 0usize;
let mut auto_linked = 0usize;
let mut purged = 0usize;
let mut skipped = 0usize;
let mut annotated = 0usize;
struct Prepared<'a> {
hit: &'a TodoHit,
title: String,
enrichment_md: String,
body: String,
priority: Option<i32>,
}
let mut prepared: Vec<Prepared<'_>> = Vec::with_capacity(candidates.len());
for hit in candidates {
ensure_ledger_entry(&mut ledger, hit, &root, source_link.as_ref());
let location_label = {
let rel = location_repo_relative_path(hit, &root, source_link.as_ref());
format!("{rel}:{}", hit.line)
};
let mut title = format!("[{}] {}", hit.kind, truncate_chars(&hit.text, 80));
let mut enrichment_md = String::new();
if enrich {
match super::enrich::enrich_todo_issue(
hit,
&location_label,
settings.openrouter_enrich_model.as_deref(),
)
.await
{
Some(enriched) => {
title = format!("[{}] {}", hit.kind, enriched.title);
if enriched.title.to_ascii_uppercase().contains(&hit.kind) {
title = enriched.title;
}
enrichment_md = enriched.enrichment_markdown;
println!(
" {} enriched {}",
"·".bright_magenta(),
truncate_chars(&title, 70)
);
}
None => {
eprintln!(
"{} OpenRouter enrich skipped/failed for {}:{} — using raw marker",
"note".bright_black(),
hit.path,
hit.line
);
}
}
}
let body = render_issue_body(hit, &root, source_link.as_ref(), &enrichment_md);
let priority = settings.priority_for_kind(&hit.kind);
prepared.push(Prepared {
hit,
title,
enrichment_md,
body,
priority,
});
}
if let Some(ctx) = github_ctx.as_ref() {
if github_first {
println!(
"{}",
"Phase 1/3: create GitHub issues (Linear will auto-link when enabled)"
.bright_cyan()
);
}
for item in &prepared {
let entry = ledger.entries.get_mut(&item.hit.fingerprint).unwrap();
if entry.github.is_some() {
skipped += 1;
continue;
}
let loader = Loader::start(&format!("GitHub: {}", item.hit.path));
match gh_create(
&ctx.token,
&ctx.owner,
&ctx.repo,
CreateIssueInput {
title: item.title.clone(),
body: Some(item.body.clone()),
labels: settings.github_labels.clone(),
assignees: Vec::new(),
},
)
.await
{
Ok(issue) => {
loader.success_with(&format!("#{}", issue.number));
let dashboard =
github_issue_dashboard_url(&ctx.owner, &ctx.repo, issue.number);
let linked_body = render_issue_body_with_dashboard(
item.hit,
&root,
source_link.as_ref(),
&item.enrichment_md,
&dashboard,
);
if let Err(err) = crate::commands::github_cmd::update_issue(
&ctx.token,
&ctx.owner,
&ctx.repo,
issue.number,
crate::commands::github_cmd::UpdateIssueInput {
body: Some(linked_body),
..Default::default()
},
)
.await
{
eprintln!(
"{} GitHub body link update failed for #{}: {err}",
"warn".yellow(),
issue.number
);
}
let comment = format!(
"### XBP dashboard\n\n[Open this issue in XBP]({dashboard})\n\n_Posted by `xbp todos sync` (xbp v{})_",
env!("CARGO_PKG_VERSION")
);
if let Err(err) = gh_create_comment(
&ctx.token,
&ctx.owner,
&ctx.repo,
issue.number,
&comment,
)
.await
{
eprintln!(
"{} GitHub dashboard comment failed for #{}: {err}",
"warn".yellow(),
issue.number
);
}
entry.github = Some(GithubIssueRef {
number: issue.number,
url: issue.html_url,
});
if entry.repo_owner.is_none() {
entry.repo_owner = Some(ctx.owner.clone());
}
if entry.repo_name.is_none() {
entry.repo_name = Some(ctx.repo.clone());
}
if entry.opened_at.is_none() {
entry.opened_at = Some(Utc::now().to_rfc3339());
}
entry.status = TodoIssueStatus::Open;
created_github += 1;
}
Err(e) => {
loader.fail(&e);
eprintln!(
"{} GitHub create failed for {}: {e}",
"ERR".red(),
item.hit.path
);
}
}
save_ledger(&root, &ledger)?;
}
}
#[cfg(feature = "linear")]
if github_first {
if let (Some(lctx), Some(gctx)) = (linear_ctx.as_ref(), github_ctx.as_ref()) {
let pending: Vec<String> = prepared
.iter()
.filter(|p| {
ledger
.entries
.get(&p.hit.fingerprint)
.map(|e| e.github.is_some() && e.linear.is_none())
.unwrap_or(false)
})
.map(|p| p.hit.fingerprint.clone())
.collect();
if !pending.is_empty() {
println!(
"{}",
format!(
"Phase 2/3: wait up to {}s for Linear GitHub auto-link ({} pending)",
settings.linear_link_wait_secs,
pending.len()
)
.bright_cyan()
);
auto_linked = super::link_discover::wait_for_linear_links(
&lctx.key,
&gctx.token,
&gctx.owner,
&gctx.repo,
&mut ledger,
&pending,
settings.linear_link_wait_secs,
settings.linear_link_poll_ms,
)
.await;
save_ledger(&root, &ledger)?;
if settings.purge_duplicate_linear {
for fp in &pending {
let entry = match ledger.entries.get(fp) {
Some(e) if e.github.is_some() => e.clone(),
_ => continue,
};
let gh = entry.github.as_ref().unwrap();
if let Ok(Some(found)) = super::link_discover::discover_linear_for_github_issue(
&lctx.key,
&gctx.token,
&gctx.owner,
&gctx.repo,
gh.number,
gh.url.as_deref(),
fp,
)
.await
{
if entry.linear.as_ref().map(|l| l.id.as_str())
!= Some(found.issue.id.as_str())
{
match super::link_discover::purge_duplicate_linear(
&lctx.key,
&mut ledger,
fp,
&found.issue,
)
.await
{
Ok(true) => purged += 1,
Ok(false) => {
if ledger
.entries
.get(fp)
.and_then(|e| e.linear.as_ref())
.is_none()
{
if let Some(e) = ledger.entries.get_mut(fp) {
e.linear = Some(found.issue);
e.updated_at = Some(Utc::now().to_rfc3339());
}
}
}
Err(err) => {
eprintln!(
"{} purge duplicate Linear for {fp}: {err}",
"warn".yellow()
);
}
}
}
}
}
save_ledger(&root, &ledger)?;
}
}
}
}
#[cfg(feature = "linear")]
if let Some(ctx) = linear_ctx.as_ref() {
if github_first {
println!(
"{}",
"Phase 3/3: create Linear issues for TODOs still without a Linear link"
.bright_cyan()
);
}
for item in &prepared {
let entry = ledger.entries.get_mut(&item.hit.fingerprint).unwrap();
if entry.linear.is_some() {
if !want_github {
skipped += 1;
}
continue;
}
let loader = Loader::start(&format!("Linear: {}", item.hit.path));
match linear_create(
&ctx.key,
LinearCreate {
team_id: ctx.team_id.clone(),
title: item.title.clone(),
description: Some(item.body.clone()),
priority: item.priority,
state_id: None,
assignee_id: ctx.assignee_id.clone(),
label_ids: ctx.label_ids.clone(),
project_id: ctx.project_id.clone(),
},
)
.await
{
Ok(issue) => {
loader.success_with(&issue.identifier);
let dashboard = linear_issue_dashboard_url(&issue.identifier);
let linked_body = render_issue_body_with_dashboard(
item.hit,
&root,
source_link.as_ref(),
&item.enrichment_md,
&dashboard,
);
if let Err(err) = crate::commands::linear_cmd::update_issue(
&ctx.key,
&issue.id,
crate::commands::linear_cmd::UpdateIssueInput {
description: Some(linked_body),
..Default::default()
},
)
.await
{
eprintln!(
"{} Linear dashboard link update failed for {}: {err}",
"warn".yellow(),
issue.identifier
);
}
if let Some(gh) = entry.github.as_ref() {
if let Some(url) = gh.url.as_deref() {
let _ = crate::commands::linear_cmd::attachment_link_url(
&ctx.key,
&issue.id,
url,
Some(&format!("GitHub #{}", gh.number)),
)
.await;
}
}
entry.linear = Some(LinearIssueRef {
id: issue.id,
identifier: issue.identifier,
url: issue.url,
source: Some(LinearLinkSource::XbpCreate),
});
if entry.opened_at.is_none() {
entry.opened_at = Some(Utc::now().to_rfc3339());
}
entry.status = TodoIssueStatus::Open;
created_linear += 1;
}
Err(e) => {
loader.fail(&e);
eprintln!(
"{} Linear create failed for {}: {e}",
"ERR".red(),
item.hit.path
);
}
}
save_ledger(&root, &ledger)?;
}
}
#[cfg(not(feature = "linear"))]
let _ = &linear_ctx;
if annotate {
for item in &prepared {
let entry = match ledger.entries.get(&item.hit.fingerprint) {
Some(e) => e,
None => continue,
};
let lin = entry.linear.clone();
let gh = entry.github.clone();
if lin.is_none() && gh.is_none() {
continue;
}
match annotate_source_line(&root, item.hit, lin.as_ref(), gh.as_ref()) {
Ok(true) => {
annotated += 1;
println!(
" {} annotated {}:{}",
"·".bright_green(),
item.hit.path,
item.hit.line
);
}
Ok(false) => {}
Err(e) => {
eprintln!(
"{} annotate {}:{}: {e}",
"warn".yellow(),
item.hit.path,
item.hit.line
);
}
}
}
save_ledger(&root, &ledger)?;
}
println!();
println!(
"{} linear={} github={} auto_linked={} purged={} annotated={} already-linked-skips≈{}",
"Done.".bright_green().bold(),
created_linear,
created_github,
auto_linked,
purged,
annotated,
skipped
);
println!(
"Ledger: {}",
super::ledger::ledger_path(&root).display()
);
Ok(())
}
fn ensure_ledger_entry(
ledger: &mut TodoLedger,
hit: &TodoHit,
root: &Path,
source_link: Option<&SourceRepoLink>,
) {
let entry = ledger
.entries
.entry(hit.fingerprint.clone())
.or_insert_with(|| {
let mut entry = TodoLedgerEntry {
fingerprint: hit.fingerprint.clone(),
kind: hit.kind.clone(),
path: hit.path.clone(),
line: hit.line,
text: hit.text.clone(),
paths: Vec::new(),
repo_owner: source_link.map(|s| s.owner.clone()),
repo_name: source_link.map(|s| s.repo.clone()),
linear: None,
github: None,
opened_at: None,
closed_at: None,
status: TodoIssueStatus::Open,
effort: Default::default(),
created_at: Some(Utc::now().to_rfc3339()),
updated_at: None,
};
let loc = location_repo_relative_path(hit, root, source_link);
entry.track_path(&loc);
entry
});
entry.path = hit.path.clone();
entry.line = hit.line;
entry.text = hit.text.clone();
entry.updated_at = Some(Utc::now().to_rfc3339());
if entry.repo_owner.is_none() {
entry.repo_owner = source_link.map(|s| s.owner.clone());
}
if entry.repo_name.is_none() {
entry.repo_name = source_link.map(|s| s.repo.clone());
}
let loc = location_repo_relative_path(hit, root, source_link);
entry.track_path(&loc);
}
#[cfg(feature = "linear")]
struct LinearSyncContext {
key: String,
team_id: String,
label_ids: Vec<String>,
assignee_id: Option<String>,
project_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
}
}
const XBP_APP_BASE_URL: &str = "https://xbp.app";
#[derive(Debug, Clone)]
struct SourceRepoLink {
owner: String,
repo: String,
rev: String,
git_root: PathBuf,
}
fn linear_issue_dashboard_url(identifier: &str) -> String {
format!(
"{XBP_APP_BASE_URL}/dashboard/issue/{}",
identifier.trim()
)
}
fn github_issue_dashboard_url(owner: &str, repo: &str, number: u64) -> String {
format!("{XBP_APP_BASE_URL}/{owner}/{repo}/issues/{number}")
}
fn render_issue_body(
hit: &TodoHit,
scan_root: &Path,
source_link: Option<&SourceRepoLink>,
enrichment_md: &str,
) -> String {
render_issue_body_with_dashboard(hit, scan_root, source_link, enrichment_md, "")
}
fn render_issue_body_with_dashboard(
hit: &TodoHit,
scan_root: &Path,
source_link: Option<&SourceRepoLink>,
enrichment_md: &str,
dashboard_url: &str,
) -> String {
let location = format_location_line(hit, scan_root, source_link);
let mut body = format!(
"## Source\n\n{location}\n\n## Marker\n\n**{}**: {}\n\n",
hit.kind, hit.text
);
if !enrichment_md.trim().is_empty() {
body.push_str(enrichment_md.trim());
body.push_str("\n\n");
}
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("---\n");
body.push_str(&format!(
"_Filed by `xbp todos sync` · **xbp v{}**_\n",
env!("CARGO_PKG_VERSION")
));
if !enrichment_md.trim().is_empty() {
body.push_str("_Title/body enriched via OpenRouter (opt-in)._\n");
}
if !dashboard_url.trim().is_empty() {
body.push_str(&format!("\n[Open in XBP]({})\n", dashboard_url.trim()));
}
body.push_str(&format!("\n<!-- xbp-todo: {} -->\n", hit.fingerprint));
body
}
fn format_location_line(
hit: &TodoHit,
scan_root: &Path,
source_link: Option<&SourceRepoLink>,
) -> String {
let rel = location_repo_relative_path(hit, scan_root, source_link);
let label = format!("{rel}:{}", hit.line);
if let Some(link) = source_link {
let url = github_blob_url(&link.owner, &link.repo, &link.rev, &rel, hit.line);
format!("**Location:** [{label}]({url})")
} else {
format!("**Location:** `{label}`")
}
}
fn location_repo_relative_path(
hit: &TodoHit,
scan_root: &Path,
source_link: Option<&SourceRepoLink>,
) -> String {
let hit_path = hit.path.replace('\\', "/");
let Some(link) = source_link else {
return hit_path;
};
let absolute = scan_root.join(&hit.path);
if let Ok(stripped) = absolute.strip_prefix(&link.git_root) {
return stripped.to_string_lossy().replace('\\', "/");
}
if let Ok(package_rel) = scan_root.strip_prefix(&link.git_root) {
let package = package_rel.to_string_lossy().replace('\\', "/");
if package.is_empty() || package == "." {
return hit_path;
}
return format!("{package}/{hit_path}");
}
hit_path
}
fn github_blob_url(owner: &str, repo: &str, rev: &str, path: &str, line: usize) -> String {
let encoded_path = path
.split('/')
.filter(|s| !s.is_empty())
.map(percent_encode_path_segment)
.collect::<Vec<_>>()
.join("/");
let encoded_rev = percent_encode_path_segment(rev.trim());
format!(
"https://github.com/{owner}/{repo}/blob/{encoded_rev}/{encoded_path}#L{line}"
)
}
fn percent_encode_path_segment(segment: &str) -> String {
let mut out = String::with_capacity(segment.len());
for b in segment.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char);
}
b'@' => out.push('@'),
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
fn resolve_source_repo_link(scan_root: &Path) -> Option<SourceRepoLink> {
let git_root = find_git_work_tree(scan_root)?;
let remote = git_remote_url_from_metadata(&git_root, "origin")
.ok()
.flatten()?;
let (owner, repo) = parse_github_repo_from_remote_url(&remote)?;
let rev = resolve_git_rev_for_blob(&git_root).unwrap_or_else(|| "HEAD".to_string());
Some(SourceRepoLink {
owner,
repo,
rev,
git_root,
})
}
fn find_git_work_tree(start: &Path) -> Option<PathBuf> {
for dir in start.ancestors() {
if dir.join(".git").exists() {
return Some(dir.to_path_buf());
}
}
None
}
fn resolve_git_rev_for_blob(git_root: &Path) -> Option<String> {
let branch = git_stdout(git_root, &["rev-parse", "--abbrev-ref", "HEAD"])?;
let branch = branch.trim();
if !branch.is_empty() && branch != "HEAD" {
return Some(branch.to_string());
}
let sha = git_stdout(git_root, &["rev-parse", "HEAD"])?;
let sha = sha.trim();
if sha.is_empty() {
None
} else {
Some(sha.to_string())
}
}
fn git_stdout(cwd: &Path, args: &[&str]) -> Option<String> {
let output = Command::new("git")
.current_dir(cwd)
.args(args)
.output()
.ok()?;
if !output.status.success() {
return None;
}
String::from_utf8(output.stdout).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use super::super::scan::fingerprint;
fn sample_hit(path: &str, line: usize) -> TodoHit {
TodoHit {
fingerprint: fingerprint(path, "TODO", "demo"),
kind: "TODO".into(),
path: path.into(),
line,
text: "demo".into(),
context: None,
}
}
#[test]
fn location_line_is_markdown_blob_link() {
let hit = sample_hit("crates/athena-backups/src/execution.rs", 42);
let git_root = PathBuf::from("repo_root");
let link = SourceRepoLink {
owner: "xylex-group".into(),
repo: "athena".into(),
rev: "main".into(),
git_root: git_root.clone(),
};
let line = format_location_line(&hit, &git_root, Some(&link));
assert_eq!(
line,
"**Location:** [crates/athena-backups/src/execution.rs:42](https://github.com/xylex-group/athena/blob/main/crates/athena-backups/src/execution.rs#L42)"
);
}
#[test]
fn location_prefixes_package_path_under_git_root() {
let hit = sample_hit("src/execution.rs", 10);
let git_root = PathBuf::from("repo_root");
let package_root = git_root.join("crates").join("athena-backups");
let link = SourceRepoLink {
owner: "xylex-group".into(),
repo: "athena".into(),
rev: "develop".into(),
git_root: git_root.clone(),
};
let rel = location_repo_relative_path(&hit, &package_root, Some(&link));
assert_eq!(
rel.replace('\\', "/"),
"crates/athena-backups/src/execution.rs"
);
let line = format_location_line(&hit, &package_root, Some(&link));
assert!(line.contains("crates/athena-backups/src/execution.rs:10"));
assert!(line.contains("/blob/develop/crates/athena-backups/src/execution.rs#L10"));
}
#[test]
fn location_falls_back_to_code_when_no_remote() {
let hit = sample_hit("src/a.rs", 3);
let line = format_location_line(&hit, Path::new("proj"), None);
assert_eq!(line, "**Location:** `src/a.rs:3`");
}
#[test]
fn issue_body_includes_xbp_version_and_fingerprint() {
let hit = sample_hit("src/a.rs", 3);
let body = render_issue_body(&hit, Path::new("proj"), None, "");
assert!(
body.contains(&format!("xbp v{}", env!("CARGO_PKG_VERSION"))),
"body should stamp CLI version: {body}"
);
assert!(
body.contains(&format!("<!-- xbp-todo: {} -->", hit.fingerprint)),
"body should include fingerprint marker"
);
}
}
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,
enrich: 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())
}