Skip to main content

xbp_cli/commands/todos/
sync.rs

1//! Idempotent TODO → Linear/GitHub issue creation with config-driven automation.
2
3use 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    /// Stamp source TODO lines with issue IDs after create (overrides config when Some).
45    pub annotate: Option<bool>,
46    /// Opt-in OpenRouter enrichment (overrides config when Some). Default config is off.
47    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    // Refresh locations for already-linked hits.
80    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                            // GitHub first, then wait/reconcile, then Linear create if needed.
172                            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    // When both targets: create GitHub first so Linear's GitHub integration can auto-link.
254    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    // Prepare ledger rows + title/body (enrich) for all candidates up front.
268    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    // ── Phase 1: GitHub batch (or sole target) ─────────────────────────────
326    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    // ── Phase 2: wait / discover Linear auto-links ─────────────────────────
427    #[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                // Purge: if we later create Linear and find auto-link, handled below;
465                // also re-check any entry that already had xbp linear + now has auto-link candidate.
466                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 no prior linear, just adopt discovered.
499                                        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    // ── Phase 3: Linear create for still-unlinked ──────────────────────────
528    #[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                    // Link GitHub issue URL onto Linear when we have one.
588                    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    // Annotate source lines once links are final.
628    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/// GitHub identity used to turn file paths into blob permalinks.
782#[derive(Debug, Clone)]
783struct SourceRepoLink {
784    owner: String,
785    repo: String,
786    /// Branch name or commit SHA suitable for `/blob/{rev}/…`.
787    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    // Initial create body (no dashboard id yet). Deep-link is patched in after create.
806    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
845/// `Location: [path:line](https://github.com/owner/repo/blob/rev/path#Lline)` when remote is known.
846fn 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
861/// Prefer path relative to the git work tree (monorepo-friendly).
862fn 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    // Fallback: if scan root is under git root, prefix the package-relative path.
876    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            // Keep common path-friendly chars unescaped for readable URLs.
905            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    // Prefer branch name when not detached; otherwise use full HEAD sha.
938    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
1040/// Interactive helper used after scan: offer to sync immediately.
1041pub 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
1076/// Remove ledger entries whose fingerprints are no longer present in the codebase.
1077pub 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}