1use super::annotate::annotate_source_line;
4use super::ledger::{
5 load_ledger, save_ledger, GithubIssueRef, TodoIssueStatus, TodoLedger, TodoLedgerEntry,
6};
7#[cfg(feature = "linear")]
8use super::ledger::{LinearIssueRef, LinearLinkSource};
9use super::scan::{scan_todos, TodoHit};
10use super::settings::ResolvedTodosSettings;
11use super::{print_hits_table, resolve_scan_root};
12use crate::cli::ui::Loader;
13use crate::commands::cloudflare_config::is_interactive_terminal;
14use crate::commands::github_cmd::{
15 create_comment as gh_create_comment, create_issue as gh_create, ensure_label,
16 resolve_github_token, resolve_repo, CreateIssueInput,
17};
18#[cfg(feature = "linear")]
19use crate::commands::linear_cmd::{
20 create_issue as linear_create, default_team_hint, ensure_label_ids, ensure_linear_api_key,
21 resolve_assignee_id, resolve_project_id, resolve_team_id_auto,
22 CreateIssueInput as LinearCreate,
23};
24use crate::commands::terminal_table::{render_table, TableStyle};
25use crate::commands::text_util::truncate_chars;
26use crate::utils::{git_remote_url_from_metadata, parse_github_repo_from_remote_url};
27use chrono::Utc;
28use colored::Colorize;
29use dialoguer::{theme::ColorfulTheme, Confirm, MultiSelect};
30use std::path::{Path, PathBuf};
31use std::process::Command;
32
33pub use super::settings::SyncTarget;
34
35#[derive(Debug, Clone)]
36pub struct SyncOptions {
37 pub path: Option<PathBuf>,
38 pub target: Option<SyncTarget>,
39 pub dry_run: bool,
40 pub yes: bool,
41 pub team: Option<String>,
42 pub owner: Option<String>,
43 pub repo: Option<String>,
44 pub annotate: Option<bool>,
46 pub enrich: Option<bool>,
48}
49
50pub async fn sync_todos(opts: SyncOptions) -> Result<(), String> {
51 let root = resolve_scan_root(opts.path.as_deref())?;
52 let settings = ResolvedTodosSettings::load(Some(&root));
53 let target = opts.target.unwrap_or(settings.default_to);
54 let auto_yes = opts.yes || settings.auto_yes || !is_interactive_terminal();
55 let annotate = opts.annotate.unwrap_or(settings.annotate_source);
56 let enrich = opts.enrich.unwrap_or(settings.openrouter_enrich);
57 let source_link = resolve_source_repo_link(&root);
58
59 let hits = scan_todos(&root)?;
60 let hits: Vec<TodoHit> = hits
61 .into_iter()
62 .filter(|h| settings.allows_kind(&h.kind))
63 .filter(|h| settings.allows_path(&h.path))
64 .collect();
65 if hits.is_empty() {
66 println!(
67 "{}",
68 "No TODOs found to sync (after kind / watch_paths filters).".dimmed()
69 );
70 return Ok(());
71 }
72
73 let mut ledger = load_ledger(&root)?;
74 let mut candidates: Vec<&TodoHit> = hits
75 .iter()
76 .filter(|hit| needs_create(hit, &ledger, target))
77 .collect();
78
79 for hit in &hits {
81 if let Some(entry) = ledger.entries.get_mut(&hit.fingerprint) {
82 entry.path = hit.path.clone();
83 entry.line = hit.line;
84 entry.text = hit.text.clone();
85 entry.updated_at = Some(Utc::now().to_rfc3339());
86 }
87 }
88
89 if candidates.is_empty() {
90 println!(
91 "{}",
92 "All scanned TODOs already have linked issues for the selected target(s)."
93 .bright_green()
94 );
95 save_ledger(&root, &ledger)?;
96 return Ok(());
97 }
98
99 if !auto_yes && is_interactive_terminal() && !opts.dry_run {
100 let labels: Vec<String> = candidates
101 .iter()
102 .map(|h| format!("[{}] {}:{} — {}", h.kind, h.path, h.line, h.text))
103 .collect();
104 let selected = MultiSelect::with_theme(&ColorfulTheme::default())
105 .with_prompt("Select TODOs to file as issues (space to toggle, enter to confirm)")
106 .items(&labels)
107 .defaults(&vec![true; labels.len()])
108 .interact()
109 .map_err(|e| e.to_string())?;
110 candidates = selected.into_iter().map(|i| candidates[i]).collect();
111 if candidates.is_empty() {
112 println!("{}", "Nothing selected.".dimmed());
113 save_ledger(&root, &ledger)?;
114 return Ok(());
115 }
116 }
117
118 println!();
119 println!(
120 "{} {} TODO(s) → {:?}{}{}",
121 if opts.dry_run {
122 "Would sync"
123 } else {
124 "Syncing"
125 }
126 .bright_cyan()
127 .bold(),
128 candidates.len(),
129 target,
130 if auto_yes && !opts.yes && settings.auto_yes {
131 " (auto_yes from config)".dimmed().to_string()
132 } else {
133 String::new()
134 },
135 if enrich {
136 " (OpenRouter enrich ON)".bright_yellow().to_string()
137 } else {
138 String::new()
139 }
140 );
141 print_hits_table(&candidates.iter().map(|c| (*c).clone()).collect::<Vec<_>>());
142
143 if opts.dry_run {
144 let rows: Vec<Vec<String>> = candidates
145 .iter()
146 .map(|h| {
147 let entry = ledger.entries.get(&h.fingerprint);
148 #[cfg(feature = "linear")]
149 let lin = entry
150 .and_then(|e| e.linear.as_ref())
151 .map(|l| l.identifier.clone())
152 .unwrap_or_else(|| {
153 format!(
154 "create prio={}",
155 settings
156 .priority_for_kind(&h.kind)
157 .map(|p| p.to_string())
158 .unwrap_or_else(|| "-".into())
159 )
160 });
161 let gh = entry
162 .and_then(|e| e.github.as_ref())
163 .map(|g| format!("#{}", g.number))
164 .unwrap_or_else(|| "create".into());
165 vec![format!("{}:{}", h.path, h.line), {
166 #[cfg(feature = "linear")]
167 {
168 match target {
169 SyncTarget::Linear => lin,
170 SyncTarget::Github => gh,
171 SyncTarget::Both => format!("gh:{gh} → wait → lin:{lin}"),
173 }
174 }
175 #[cfg(not(feature = "linear"))]
176 {
177 let _ = target;
178 gh
179 }
180 }]
181 })
182 .collect();
183 print!(
184 "{}",
185 render_table(&["TODO", "Action"], &rows, TableStyle::Pipe, "")
186 );
187 if target.wants_github() && target.wants_linear() {
188 println!(
189 "{}",
190 format!(
191 "Note: GitHub issues are created first; Linear auto-link wait={}s, purge_dups={}",
192 settings.linear_link_wait_secs, settings.purge_duplicate_linear
193 )
194 .dimmed()
195 );
196 }
197 return Ok(());
198 }
199
200 let want_linear = target.wants_linear();
201 let want_github = target.wants_github();
202
203 #[cfg(feature = "linear")]
204 let linear_ctx = if want_linear {
205 let key = ensure_linear_api_key().await?;
206 let preferred = opts.team.clone().or_else(default_team_hint);
207 let team_id = match resolve_team_id_auto(&key, preferred.as_deref()).await {
208 Ok(id) => id,
209 Err(err) if crate::commands::cloudflare_config::is_interactive_terminal() => {
210 eprintln!("{} {err}", "note".bright_black());
211 crate::commands::linear_cmd::prompt_and_save_default_team(&key).await?;
212 let preferred = default_team_hint();
213 resolve_team_id_auto(&key, preferred.as_deref()).await?
214 }
215 Err(err) => return Err(err),
216 };
217 let label_ids = ensure_label_ids(&key, Some(&team_id), &settings.linear_labels).await?;
218 let assignee_id = match settings.linear_assignee.as_deref() {
219 Some(assignee) => resolve_assignee_id(&key, assignee).await?,
220 None => None,
221 };
222 let project_id = match settings.linear_project.as_deref() {
223 Some(project) => resolve_project_id(&key, project).await?,
224 None => None,
225 };
226 Some(LinearSyncContext {
227 key,
228 team_id,
229 label_ids,
230 assignee_id,
231 project_id,
232 })
233 } else {
234 None
235 };
236 #[cfg(not(feature = "linear"))]
237 let linear_ctx: Option<LinearSyncContext> = {
238 let _ = want_linear;
239 None
240 };
241
242 let github_ctx = if want_github {
243 let token = resolve_github_token()?;
244 let (owner, repo) = resolve_repo(opts.owner.as_deref(), opts.repo.as_deref())?;
245 for label in &settings.github_labels {
246 let _ = ensure_label(&token, &owner, &repo, label).await;
247 }
248 Some(GithubSyncContext { token, owner, repo })
249 } else {
250 None
251 };
252
253 let github_first = want_github && want_linear;
255
256 #[cfg(feature = "linear")]
257 let mut created_linear = 0usize;
258 #[cfg(not(feature = "linear"))]
259 let created_linear = 0usize;
260 let mut created_github = 0usize;
261 let mut auto_linked = 0usize;
262 let mut purged = 0usize;
263 let mut skipped = 0usize;
264 let mut annotated = 0usize;
265 let mut created_titles: Vec<String> = Vec::new();
266
267 struct Prepared<'a> {
269 hit: &'a TodoHit,
270 title: String,
271 enrichment_md: String,
272 body: String,
273 priority: Option<i32>,
274 }
275 let mut prepared: Vec<Prepared<'_>> = Vec::with_capacity(candidates.len());
276 for hit in candidates {
277 ensure_ledger_entry(&mut ledger, hit, &root, source_link.as_ref());
278 let location_label = {
279 let rel = location_repo_relative_path(hit, &root, source_link.as_ref());
280 format!("{rel}:{}", hit.line)
281 };
282 let mut title = format!("[{}] {}", hit.kind, truncate_chars(&hit.text, 80));
283 let mut enrichment_md = String::new();
284 if enrich {
285 match super::enrich::enrich_todo_issue(
286 hit,
287 &location_label,
288 settings.openrouter_enrich_model.as_deref(),
289 )
290 .await
291 {
292 Some(enriched) => {
293 title = format!("[{}] {}", hit.kind, enriched.title);
294 if enriched.title.to_ascii_uppercase().contains(&hit.kind) {
295 title = enriched.title;
296 }
297 enrichment_md = enriched.enrichment_markdown;
298 println!(
299 " {} enriched {}",
300 "·".bright_magenta(),
301 truncate_chars(&title, 70)
302 );
303 }
304 None => {
305 eprintln!(
306 "{} OpenRouter enrich skipped/failed for {}:{} — using raw marker",
307 "note".bright_black(),
308 hit.path,
309 hit.line
310 );
311 }
312 }
313 }
314 let body = render_issue_body(hit, &root, source_link.as_ref(), &enrichment_md);
315 let priority = settings.priority_for_kind(&hit.kind);
316 prepared.push(Prepared {
317 hit,
318 title,
319 enrichment_md,
320 body,
321 priority,
322 });
323 }
324
325 if let Some(ctx) = github_ctx.as_ref() {
327 if github_first {
328 println!(
329 "{}",
330 "Phase 1/3: create GitHub issues (Linear will auto-link when enabled)"
331 .bright_cyan()
332 );
333 }
334 for item in &prepared {
335 let entry = ledger.entries.get_mut(&item.hit.fingerprint).unwrap();
336 if entry.github.is_some() {
337 skipped += 1;
338 continue;
339 }
340 let loader = Loader::start(&format!("GitHub: {}", item.hit.path));
341 match gh_create(
342 &ctx.token,
343 &ctx.owner,
344 &ctx.repo,
345 CreateIssueInput {
346 title: item.title.clone(),
347 body: Some(item.body.clone()),
348 labels: settings.github_labels.clone(),
349 assignees: Vec::new(),
350 },
351 )
352 .await
353 {
354 Ok(issue) => {
355 loader.success_with(&format!("#{}", issue.number));
356 let dashboard = github_issue_dashboard_url(&ctx.owner, &ctx.repo, issue.number);
357 let linked_body = render_issue_body_with_dashboard(
358 item.hit,
359 &root,
360 source_link.as_ref(),
361 &item.enrichment_md,
362 &dashboard,
363 );
364 if let Err(err) = crate::commands::github_cmd::update_issue(
365 &ctx.token,
366 &ctx.owner,
367 &ctx.repo,
368 issue.number,
369 crate::commands::github_cmd::UpdateIssueInput {
370 body: Some(linked_body),
371 ..Default::default()
372 },
373 )
374 .await
375 {
376 eprintln!(
377 "{} GitHub body link update failed for #{}: {err}",
378 "warn".yellow(),
379 issue.number
380 );
381 }
382 let comment = format!(
383 "### XBP dashboard\n\n[Open this issue in XBP]({dashboard})\n\n_Posted by `xbp issues sync` (xbp v{})_",
384 env!("CARGO_PKG_VERSION")
385 );
386 if let Err(err) =
387 gh_create_comment(&ctx.token, &ctx.owner, &ctx.repo, issue.number, &comment)
388 .await
389 {
390 eprintln!(
391 "{} GitHub dashboard comment failed for #{}: {err}",
392 "warn".yellow(),
393 issue.number
394 );
395 }
396 entry.github = Some(GithubIssueRef {
397 number: issue.number,
398 url: issue.html_url,
399 });
400 if entry.repo_owner.is_none() {
401 entry.repo_owner = Some(ctx.owner.clone());
402 }
403 if entry.repo_name.is_none() {
404 entry.repo_name = Some(ctx.repo.clone());
405 }
406 if entry.opened_at.is_none() {
407 entry.opened_at = Some(Utc::now().to_rfc3339());
408 }
409 entry.status = TodoIssueStatus::Open;
410 created_github += 1;
411 created_titles.push(format!("GH: {}", item.title));
412 }
413 Err(e) => {
414 loader.fail(&e);
415 eprintln!(
416 "{} GitHub create failed for {}: {e}",
417 "ERR".red(),
418 item.hit.path
419 );
420 }
421 }
422 save_ledger(&root, &ledger)?;
423 }
424 }
425
426 #[cfg(feature = "linear")]
428 if github_first {
429 if let (Some(lctx), Some(gctx)) = (linear_ctx.as_ref(), github_ctx.as_ref()) {
430 let pending: Vec<String> = prepared
431 .iter()
432 .filter(|p| {
433 ledger
434 .entries
435 .get(&p.hit.fingerprint)
436 .map(|e| e.github.is_some() && e.linear.is_none())
437 .unwrap_or(false)
438 })
439 .map(|p| p.hit.fingerprint.clone())
440 .collect();
441 if !pending.is_empty() {
442 println!(
443 "{}",
444 format!(
445 "Phase 2/3: wait up to {}s for Linear GitHub auto-link ({} pending)",
446 settings.linear_link_wait_secs,
447 pending.len()
448 )
449 .bright_cyan()
450 );
451 auto_linked = super::link_discover::wait_for_linear_links(
452 &lctx.key,
453 &gctx.token,
454 &gctx.owner,
455 &gctx.repo,
456 &mut ledger,
457 &pending,
458 settings.linear_link_wait_secs,
459 settings.linear_link_poll_ms,
460 )
461 .await;
462 save_ledger(&root, &ledger)?;
463
464 if settings.purge_duplicate_linear {
467 for fp in &pending {
468 let entry = match ledger.entries.get(fp) {
469 Some(e) if e.github.is_some() => e.clone(),
470 _ => continue,
471 };
472 let gh = entry.github.as_ref().unwrap();
473 if let Ok(Some(found)) =
474 super::link_discover::discover_linear_for_github_issue(
475 &lctx.key,
476 &gctx.token,
477 &gctx.owner,
478 &gctx.repo,
479 gh.number,
480 gh.url.as_deref(),
481 fp,
482 )
483 .await
484 {
485 if entry.linear.as_ref().map(|l| l.id.as_str())
486 != Some(found.issue.id.as_str())
487 {
488 match super::link_discover::purge_duplicate_linear(
489 &lctx.key,
490 &mut ledger,
491 fp,
492 &found.issue,
493 )
494 .await
495 {
496 Ok(true) => purged += 1,
497 Ok(false) => {
498 if ledger
500 .entries
501 .get(fp)
502 .and_then(|e| e.linear.as_ref())
503 .is_none()
504 {
505 if let Some(e) = ledger.entries.get_mut(fp) {
506 e.linear = Some(found.issue);
507 e.updated_at = Some(Utc::now().to_rfc3339());
508 }
509 }
510 }
511 Err(err) => {
512 eprintln!(
513 "{} purge duplicate Linear for {fp}: {err}",
514 "warn".yellow()
515 );
516 }
517 }
518 }
519 }
520 }
521 save_ledger(&root, &ledger)?;
522 }
523 }
524 }
525 }
526
527 #[cfg(feature = "linear")]
529 if let Some(ctx) = linear_ctx.as_ref() {
530 if github_first {
531 println!(
532 "{}",
533 "Phase 3/3: create Linear issues for TODOs still without a Linear link"
534 .bright_cyan()
535 );
536 }
537 for item in &prepared {
538 let entry = ledger.entries.get_mut(&item.hit.fingerprint).unwrap();
539 if entry.linear.is_some() {
540 if !want_github {
541 skipped += 1;
542 }
543 continue;
544 }
545 let loader = Loader::start(&format!("Linear: {}", item.hit.path));
546 match linear_create(
547 &ctx.key,
548 LinearCreate {
549 team_id: ctx.team_id.clone(),
550 title: item.title.clone(),
551 description: Some(item.body.clone()),
552 priority: item.priority,
553 state_id: None,
554 assignee_id: ctx.assignee_id.clone(),
555 label_ids: ctx.label_ids.clone(),
556 project_id: ctx.project_id.clone(),
557 },
558 )
559 .await
560 {
561 Ok(issue) => {
562 loader.success_with(&issue.identifier);
563 let dashboard = linear_issue_dashboard_url(&issue.identifier);
564 let linked_body = render_issue_body_with_dashboard(
565 item.hit,
566 &root,
567 source_link.as_ref(),
568 &item.enrichment_md,
569 &dashboard,
570 );
571 if let Err(err) = crate::commands::linear_cmd::update_issue(
572 &ctx.key,
573 &issue.id,
574 crate::commands::linear_cmd::UpdateIssueInput {
575 description: Some(linked_body),
576 ..Default::default()
577 },
578 )
579 .await
580 {
581 eprintln!(
582 "{} Linear dashboard link update failed for {}: {err}",
583 "warn".yellow(),
584 issue.identifier
585 );
586 }
587 if let Some(gh) = entry.github.as_ref() {
589 if let Some(url) = gh.url.as_deref() {
590 let _ = crate::commands::linear_cmd::attachment_link_url(
591 &ctx.key,
592 &issue.id,
593 url,
594 Some(&format!("GitHub #{}", gh.number)),
595 )
596 .await;
597 }
598 }
599 entry.linear = Some(LinearIssueRef {
600 id: issue.id,
601 identifier: issue.identifier,
602 url: issue.url,
603 source: Some(LinearLinkSource::XbpCreate),
604 });
605 if entry.opened_at.is_none() {
606 entry.opened_at = Some(Utc::now().to_rfc3339());
607 }
608 entry.status = TodoIssueStatus::Open;
609 created_linear += 1;
610 created_titles.push(format!("Linear: {}", item.title));
611 }
612 Err(e) => {
613 loader.fail(&e);
614 eprintln!(
615 "{} Linear create failed for {}: {e}",
616 "ERR".red(),
617 item.hit.path
618 );
619 }
620 }
621 save_ledger(&root, &ledger)?;
622 }
623 }
624 #[cfg(not(feature = "linear"))]
625 let _ = &linear_ctx;
626
627 if annotate {
629 for item in &prepared {
630 let entry = match ledger.entries.get(&item.hit.fingerprint) {
631 Some(e) => e,
632 None => continue,
633 };
634 let lin = entry.linear.clone();
635 let gh = entry.github.clone();
636 if lin.is_none() && gh.is_none() {
637 continue;
638 }
639 match annotate_source_line(&root, item.hit, lin.as_ref(), gh.as_ref()) {
640 Ok(true) => {
641 annotated += 1;
642 println!(
643 " {} annotated {}:{}",
644 "·".bright_green(),
645 item.hit.path,
646 item.hit.line
647 );
648 }
649 Ok(false) => {}
650 Err(e) => {
651 eprintln!(
652 "{} annotate {}:{}: {e}",
653 "warn".yellow(),
654 item.hit.path,
655 item.hit.line
656 );
657 }
658 }
659 }
660 save_ledger(&root, &ledger)?;
661 }
662
663 println!();
664 println!(
665 "{} linear={} github={} auto_linked={} purged={} annotated={} already-linked-skips≈{}",
666 "Done.".bright_green().bold(),
667 created_linear,
668 created_github,
669 auto_linked,
670 purged,
671 annotated,
672 skipped
673 );
674 println!("Ledger: {}", super::ledger::ledger_path(&root).display());
675
676 if created_github + created_linear > 0 {
677 let xbp = crate::strategies::DeploymentConfig::load_xbp_config(None)
678 .await
679 .ok();
680 crate::commands::discord_notify::notify_discord_for_project(
681 &root,
682 xbp.as_ref(),
683 None,
684 crate::commands::discord_notify::issues_created_notification(
685 created_github,
686 created_linear,
687 &created_titles,
688 false,
689 ),
690 )
691 .await;
692 }
693 Ok(())
694}
695
696fn ensure_ledger_entry(
697 ledger: &mut TodoLedger,
698 hit: &TodoHit,
699 root: &Path,
700 source_link: Option<&SourceRepoLink>,
701) {
702 let entry = ledger
703 .entries
704 .entry(hit.fingerprint.clone())
705 .or_insert_with(|| {
706 let mut entry = TodoLedgerEntry {
707 fingerprint: hit.fingerprint.clone(),
708 kind: hit.kind.clone(),
709 path: hit.path.clone(),
710 line: hit.line,
711 text: hit.text.clone(),
712 paths: Vec::new(),
713 repo_owner: source_link.map(|s| s.owner.clone()),
714 repo_name: source_link.map(|s| s.repo.clone()),
715 linear: None,
716 github: None,
717 opened_at: None,
718 closed_at: None,
719 status: TodoIssueStatus::Open,
720 effort: Default::default(),
721 created_at: Some(Utc::now().to_rfc3339()),
722 updated_at: None,
723 };
724 let loc = location_repo_relative_path(hit, root, source_link);
725 entry.track_path(&loc);
726 entry
727 });
728 entry.path = hit.path.clone();
729 entry.line = hit.line;
730 entry.text = hit.text.clone();
731 entry.updated_at = Some(Utc::now().to_rfc3339());
732 if entry.repo_owner.is_none() {
733 entry.repo_owner = source_link.map(|s| s.owner.clone());
734 }
735 if entry.repo_name.is_none() {
736 entry.repo_name = source_link.map(|s| s.repo.clone());
737 }
738 let loc = location_repo_relative_path(hit, root, source_link);
739 entry.track_path(&loc);
740}
741
742#[cfg(feature = "linear")]
743struct LinearSyncContext {
744 key: String,
745 team_id: String,
746 label_ids: Vec<String>,
747 assignee_id: Option<String>,
748 project_id: Option<String>,
749}
750
751#[cfg(not(feature = "linear"))]
752struct LinearSyncContext;
753
754struct GithubSyncContext {
755 token: String,
756 owner: String,
757 repo: String,
758}
759
760fn needs_create(hit: &TodoHit, ledger: &TodoLedger, target: SyncTarget) -> bool {
761 let entry = ledger.entries.get(&hit.fingerprint);
762 let missing_github = entry.and_then(|e| e.github.as_ref()).is_none();
763 #[cfg(feature = "linear")]
764 {
765 let missing_linear = entry.and_then(|e| e.linear.as_ref()).is_none();
766 match target {
767 SyncTarget::Linear => missing_linear,
768 SyncTarget::Github => missing_github,
769 SyncTarget::Both => missing_linear || missing_github,
770 }
771 }
772 #[cfg(not(feature = "linear"))]
773 {
774 let _ = target;
775 missing_github
776 }
777}
778
779const XBP_APP_BASE_URL: &str = "https://xbp.app";
780
781#[derive(Debug, Clone)]
783struct SourceRepoLink {
784 owner: String,
785 repo: String,
786 rev: String,
788 git_root: PathBuf,
789}
790
791fn linear_issue_dashboard_url(identifier: &str) -> String {
792 format!("{XBP_APP_BASE_URL}/dashboard/issue/{}", identifier.trim())
793}
794
795fn github_issue_dashboard_url(owner: &str, repo: &str, number: u64) -> String {
796 format!("{XBP_APP_BASE_URL}/{owner}/{repo}/issues/{number}")
797}
798
799fn render_issue_body(
800 hit: &TodoHit,
801 scan_root: &Path,
802 source_link: Option<&SourceRepoLink>,
803 enrichment_md: &str,
804) -> String {
805 render_issue_body_with_dashboard(hit, scan_root, source_link, enrichment_md, "")
807}
808
809fn render_issue_body_with_dashboard(
810 hit: &TodoHit,
811 scan_root: &Path,
812 source_link: Option<&SourceRepoLink>,
813 enrichment_md: &str,
814 dashboard_url: &str,
815) -> String {
816 let location = format_location_line(hit, scan_root, source_link);
817 let mut body = format!(
818 "## Source\n\n{location}\n\n## Marker\n\n**{}**: {}\n\n",
819 hit.kind, hit.text
820 );
821 if !enrichment_md.trim().is_empty() {
822 body.push_str(enrichment_md.trim());
823 body.push_str("\n\n");
824 }
825 if let Some(ctx) = &hit.context {
826 body.push_str("## Context\n\n```\n");
827 body.push_str(ctx);
828 body.push_str("\n```\n\n");
829 }
830 body.push_str("---\n");
831 body.push_str(&format!(
832 "_Filed by `xbp issues sync` · **xbp v{}**_\n",
833 env!("CARGO_PKG_VERSION")
834 ));
835 if !enrichment_md.trim().is_empty() {
836 body.push_str("_Title/body enriched via OpenRouter (opt-in)._\n");
837 }
838 if !dashboard_url.trim().is_empty() {
839 body.push_str(&format!("\n[Open in XBP]({})\n", dashboard_url.trim()));
840 }
841 body.push_str(&format!("\n<!-- xbp-todo: {} -->\n", hit.fingerprint));
842 body
843}
844
845fn format_location_line(
847 hit: &TodoHit,
848 scan_root: &Path,
849 source_link: Option<&SourceRepoLink>,
850) -> String {
851 let rel = location_repo_relative_path(hit, scan_root, source_link);
852 let label = format!("{rel}:{}", hit.line);
853 if let Some(link) = source_link {
854 let url = github_blob_url(&link.owner, &link.repo, &link.rev, &rel, hit.line);
855 format!("**Location:** [{label}]({url})")
856 } else {
857 format!("**Location:** `{label}`")
858 }
859}
860
861fn location_repo_relative_path(
863 hit: &TodoHit,
864 scan_root: &Path,
865 source_link: Option<&SourceRepoLink>,
866) -> String {
867 let hit_path = hit.path.replace('\\', "/");
868 let Some(link) = source_link else {
869 return hit_path;
870 };
871 let absolute = scan_root.join(&hit.path);
872 if let Ok(stripped) = absolute.strip_prefix(&link.git_root) {
873 return stripped.to_string_lossy().replace('\\', "/");
874 }
875 if let Ok(package_rel) = scan_root.strip_prefix(&link.git_root) {
877 let package = package_rel.to_string_lossy().replace('\\', "/");
878 if package.is_empty() || package == "." {
879 return hit_path;
880 }
881 return format!("{package}/{hit_path}");
882 }
883 hit_path
884}
885
886fn github_blob_url(owner: &str, repo: &str, rev: &str, path: &str, line: usize) -> String {
887 let encoded_path = path
888 .split('/')
889 .filter(|s| !s.is_empty())
890 .map(percent_encode_path_segment)
891 .collect::<Vec<_>>()
892 .join("/");
893 let encoded_rev = percent_encode_path_segment(rev.trim());
894 format!("https://github.com/{owner}/{repo}/blob/{encoded_rev}/{encoded_path}#L{line}")
895}
896
897fn percent_encode_path_segment(segment: &str) -> String {
898 let mut out = String::with_capacity(segment.len());
899 for b in segment.bytes() {
900 match b {
901 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
902 out.push(b as char);
903 }
904 b'@' => out.push('@'),
906 _ => out.push_str(&format!("%{b:02X}")),
907 }
908 }
909 out
910}
911
912fn resolve_source_repo_link(scan_root: &Path) -> Option<SourceRepoLink> {
913 let git_root = find_git_work_tree(scan_root)?;
914 let remote = git_remote_url_from_metadata(&git_root, "origin")
915 .ok()
916 .flatten()?;
917 let (owner, repo) = parse_github_repo_from_remote_url(&remote)?;
918 let rev = resolve_git_rev_for_blob(&git_root).unwrap_or_else(|| "HEAD".to_string());
919 Some(SourceRepoLink {
920 owner,
921 repo,
922 rev,
923 git_root,
924 })
925}
926
927fn find_git_work_tree(start: &Path) -> Option<PathBuf> {
928 for dir in start.ancestors() {
929 if dir.join(".git").exists() {
930 return Some(dir.to_path_buf());
931 }
932 }
933 None
934}
935
936fn resolve_git_rev_for_blob(git_root: &Path) -> Option<String> {
937 let branch = git_stdout(git_root, &["rev-parse", "--abbrev-ref", "HEAD"])?;
939 let branch = branch.trim();
940 if !branch.is_empty() && branch != "HEAD" {
941 return Some(branch.to_string());
942 }
943 let sha = git_stdout(git_root, &["rev-parse", "HEAD"])?;
944 let sha = sha.trim();
945 if sha.is_empty() {
946 None
947 } else {
948 Some(sha.to_string())
949 }
950}
951
952fn git_stdout(cwd: &Path, args: &[&str]) -> Option<String> {
953 let output = Command::new("git")
954 .current_dir(cwd)
955 .args(args)
956 .output()
957 .ok()?;
958 if !output.status.success() {
959 return None;
960 }
961 String::from_utf8(output.stdout).ok()
962}
963
964#[cfg(test)]
965mod tests {
966 use super::super::scan::fingerprint;
967 use super::*;
968
969 fn sample_hit(path: &str, line: usize) -> TodoHit {
970 TodoHit {
971 fingerprint: fingerprint(path, "TODO", "demo"),
972 kind: "TODO".into(),
973 path: path.into(),
974 line,
975 text: "demo".into(),
976 context: None,
977 }
978 }
979
980 #[test]
981 fn location_line_is_markdown_blob_link() {
982 let hit = sample_hit("crates/athena-backups/src/execution.rs", 42);
983 let git_root = PathBuf::from("repo_root");
984 let link = SourceRepoLink {
985 owner: "xylex-group".into(),
986 repo: "athena".into(),
987 rev: "main".into(),
988 git_root: git_root.clone(),
989 };
990 let line = format_location_line(&hit, &git_root, Some(&link));
991 assert_eq!(
992 line,
993 "**Location:** [crates/athena-backups/src/execution.rs:42](https://github.com/xylex-group/athena/blob/main/crates/athena-backups/src/execution.rs#L42)"
994 );
995 }
996
997 #[test]
998 fn location_prefixes_package_path_under_git_root() {
999 let hit = sample_hit("src/execution.rs", 10);
1000 let git_root = PathBuf::from("repo_root");
1001 let package_root = git_root.join("crates").join("athena-backups");
1002 let link = SourceRepoLink {
1003 owner: "xylex-group".into(),
1004 repo: "athena".into(),
1005 rev: "develop".into(),
1006 git_root: git_root.clone(),
1007 };
1008 let rel = location_repo_relative_path(&hit, &package_root, Some(&link));
1009 assert_eq!(
1010 rel.replace('\\', "/"),
1011 "crates/athena-backups/src/execution.rs"
1012 );
1013 let line = format_location_line(&hit, &package_root, Some(&link));
1014 assert!(line.contains("crates/athena-backups/src/execution.rs:10"));
1015 assert!(line.contains("/blob/develop/crates/athena-backups/src/execution.rs#L10"));
1016 }
1017
1018 #[test]
1019 fn location_falls_back_to_code_when_no_remote() {
1020 let hit = sample_hit("src/a.rs", 3);
1021 let line = format_location_line(&hit, Path::new("proj"), None);
1022 assert_eq!(line, "**Location:** `src/a.rs:3`");
1023 }
1024
1025 #[test]
1026 fn issue_body_includes_xbp_version_and_fingerprint() {
1027 let hit = sample_hit("src/a.rs", 3);
1028 let body = render_issue_body(&hit, Path::new("proj"), None, "");
1029 assert!(
1030 body.contains(&format!("xbp v{}", env!("CARGO_PKG_VERSION"))),
1031 "body should stamp CLI version: {body}"
1032 );
1033 assert!(
1034 body.contains(&format!("<!-- xbp-todo: {} -->", hit.fingerprint)),
1035 "body should include fingerprint marker"
1036 );
1037 }
1038}
1039
1040pub async fn prompt_sync_after_scan(root: &std::path::Path) -> Result<(), String> {
1042 let settings = ResolvedTodosSettings::load(Some(root));
1043 if !settings.prompt_sync_after_scan || !is_interactive_terminal() {
1044 return Ok(());
1045 }
1046 let run = Confirm::with_theme(&ColorfulTheme::default())
1047 .with_prompt(format!(
1048 "Sync unlinked TODOs to {:?} now?",
1049 settings.default_to
1050 ))
1051 .default(false)
1052 .interact()
1053 .map_err(|e| e.to_string())?;
1054 if !run {
1055 return Ok(());
1056 }
1057 let dry = Confirm::with_theme(&ColorfulTheme::default())
1058 .with_prompt("Dry-run first (no creates)?")
1059 .default(true)
1060 .interact()
1061 .map_err(|e| e.to_string())?;
1062 sync_todos(SyncOptions {
1063 path: Some(root.to_path_buf()),
1064 target: Some(settings.default_to),
1065 dry_run: dry,
1066 yes: settings.auto_yes,
1067 team: None,
1068 owner: None,
1069 enrich: None,
1070 repo: None,
1071 annotate: None,
1072 })
1073 .await
1074}
1075
1076pub fn prune_ledger(project_root: &std::path::Path, dry_run: bool) -> Result<usize, String> {
1078 let hits = scan_todos(project_root)?;
1079 let live: std::collections::HashSet<String> = hits.into_iter().map(|h| h.fingerprint).collect();
1080 let mut ledger = load_ledger(project_root)?;
1081 let before = ledger.entries.len();
1082 let stale: Vec<String> = ledger
1083 .entries
1084 .keys()
1085 .filter(|fp| !live.contains(*fp))
1086 .cloned()
1087 .collect();
1088 if stale.is_empty() {
1089 println!("{}", "No stale ledger entries.".bright_green());
1090 return Ok(0);
1091 }
1092 println!(
1093 "{} stale ledger entr{}:",
1094 stale.len(),
1095 if stale.len() == 1 { "y" } else { "ies" }
1096 );
1097 for fp in &stale {
1098 if let Some(e) = ledger.entries.get(fp) {
1099 println!(
1100 " {} {}:{} — {} [{}]",
1101 e.kind.bright_yellow(),
1102 e.path,
1103 e.line,
1104 truncate_chars(&e.text, 50),
1105 e.linear
1106 .as_ref()
1107 .map(|l| l.identifier.clone())
1108 .or_else(|| e.github.as_ref().map(|g| format!("#{}", g.number)))
1109 .unwrap_or_else(|| "unlinked".into())
1110 );
1111 }
1112 }
1113 if dry_run {
1114 println!("{}", "(dry-run — nothing removed)".dimmed());
1115 return Ok(stale.len());
1116 }
1117 for fp in &stale {
1118 ledger.entries.remove(fp);
1119 }
1120 save_ledger(project_root, &ledger)?;
1121 println!(
1122 "{} pruned {} entr{} ({} → {})",
1123 "OK".bright_green().bold(),
1124 stale.len(),
1125 if stale.len() == 1 { "y" } else { "ies" },
1126 before,
1127 ledger.entries.len()
1128 );
1129 Ok(stale.len())
1130}