xbp 10.46.0

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
//! Interactive wizard: write issue automation config (+ optional linear team).

use crate::commands::cloudflare_config::is_interactive_terminal;
use crate::config::TodosConfig;
use crate::utils::{find_xbp_config_upwards, PROJECT_CONFIG_HINT};
use colored::Colorize;
#[cfg(feature = "linear")]
use dialoguer::Input;
use dialoguer::{theme::ColorfulTheme, Confirm, Select};
use serde_json::{json, Value};
use std::collections::BTreeMap;
use std::env;
use std::fs;
use std::path::PathBuf;

pub async fn run_setup(namespace: &str) -> Result<(), String> {
    let config_key = if namespace == "issues" {
        "issues"
    } else {
        "todos"
    };
    if !is_interactive_terminal() {
        return Err(format!(
            "Interactive setup requires a TTY. Write an `{config_key}:` block to `.xbp/xbp.yaml` manually."
        ));
    }

    let cwd = env::current_dir().map_err(|e| format!("cwd: {e}"))?;
    let found = find_xbp_config_upwards(&cwd).ok_or_else(|| {
        format!(
            "No XBP project config found. Run `xbp init` or create a project config ({PROJECT_CONFIG_HINT})."
        )
    })?;

    println!();
    println!("{}", "Issue automation setup".bright_cyan().bold());
    println!("Config: {}", found.config_path.display());

    #[cfg(feature = "linear")]
    let targets: &[&str] = &["both", "linear", "github"];
    #[cfg(not(feature = "linear"))]
    let targets: &[&str] = &["github"];
    let to_idx = Select::with_theme(&ColorfulTheme::default())
        .with_prompt("Default sync target")
        .items(targets)
        .default(0)
        .interact()
        .map_err(|e| e.to_string())?;

    let auto_yes = Confirm::with_theme(&ColorfulTheme::default())
        .with_prompt("Auto-file all TODOs without multi-select (`auto_yes`)?")
        .default(false)
        .interact()
        .map_err(|e| e.to_string())?;

    let prompt_sync = Confirm::with_theme(&ColorfulTheme::default())
        .with_prompt("After scan, offer to sync interactively?")
        .default(true)
        .interact()
        .map_err(|e| e.to_string())?;

    let annotate = Confirm::with_theme(&ColorfulTheme::default())
        .with_prompt("Stamp source TODO lines with issue IDs after create (`annotate_source`)?")
        .default(false)
        .interact()
        .map_err(|e| e.to_string())?;

    let openrouter_enrich = Confirm::with_theme(&ColorfulTheme::default())
        .with_prompt(
            "Opt-in: enrich issue titles/bodies with OpenRouter (`openrouter_enrich`)? Default no.",
        )
        .default(false)
        .interact()
        .map_err(|e| e.to_string())?;

    #[cfg(feature = "linear")]
    let team: String = Input::with_theme(&ColorfulTheme::default())
        .with_prompt("Linear default team key (empty to skip)")
        .allow_empty(true)
        .interact_text()
        .map_err(|e| e.to_string())?;
    #[cfg(not(feature = "linear"))]
    let team = String::new();

    #[cfg(feature = "linear")]
    let assignee: String = Input::with_theme(&ColorfulTheme::default())
        .with_prompt("Linear default assignee (`me` / empty to skip)")
        .allow_empty(true)
        .with_initial_text("me")
        .interact_text()
        .map_err(|e| e.to_string())?;
    #[cfg(not(feature = "linear"))]
    let assignee = String::new();

    #[cfg(feature = "linear")]
    let project: String = Input::with_theme(&ColorfulTheme::default())
        .with_prompt("Linear default project name/id (empty to skip)")
        .allow_empty(true)
        .interact_text()
        .map_err(|e| e.to_string())?;
    #[cfg(not(feature = "linear"))]
    let project = String::new();

    let watch_raw: String = Input::with_theme(&ColorfulTheme::default())
        .with_prompt("Watch paths (comma-separated prefixes; empty = whole tree)")
        .allow_empty(true)
        .with_initial_text("crates/cli,crates/core,crates/mcp")
        .interact_text()
        .map_err(|e| e.to_string())?;
    let watch_paths: Vec<String> = watch_raw
        .split(',')
        .map(|s| s.trim().replace('\\', "/").trim_matches('/').to_string())
        .filter(|s| !s.is_empty())
        .collect();

    let mut priority = BTreeMap::new();
    priority.insert("FIXME".into(), 1);
    priority.insert("HACK".into(), 2);
    priority.insert("TODO".into(), 3);
    priority.insert("XXX".into(), 4);

    let todos = TodosConfig {
        default_to: Some(targets[to_idx].to_string()),
        auto_yes: Some(auto_yes),
        prompt_sync_after_scan: Some(prompt_sync),
        linear_labels: Some(vec!["xbp-todo".into()]),
        github_labels: Some(vec!["xbp-todo".into()]),
        linear_assignee: if assignee.trim().is_empty() {
            None
        } else {
            Some(assignee.trim().to_string())
        },
        linear_project: if project.trim().is_empty() {
            None
        } else {
            Some(project.trim().to_string())
        },
        watch_paths: if watch_paths.is_empty() {
            None
        } else {
            Some(watch_paths)
        },
        linear_link_wait_secs: Some(20),
        linear_link_poll_ms: Some(2000),
        purge_duplicate_linear: Some(true),
        kinds: None,
        priority_by_kind: Some(priority),
        annotate_source: Some(annotate),
        openrouter_enrich: Some(openrouter_enrich),
        openrouter_enrich_model: None,
    };

    write_project_todos_config(
        &found.config_path,
        found.kind,
        config_key,
        &todos,
        team.trim(),
    )?;

    println!();
    println!(
        "{} Wrote issue automation settings to {}",
        "OK".bright_green().bold(),
        found.config_path.display()
    );
    println!(
        "{}",
        format!("Try: `xbp {namespace} scan` then `xbp {namespace} sync --dry-run`").dimmed()
    );
    Ok(())
}

fn write_project_todos_config(
    config_path: &PathBuf,
    kind: &str,
    config_key: &str,
    todos: &TodosConfig,
    team_key: &str,
) -> Result<(), String> {
    let content = fs::read_to_string(config_path)
        .map_err(|e| format!("Failed to read {}: {e}", config_path.display()))?;

    let (mut root, _) =
        crate::utils::parse_config_with_auto_heal::<Value>(&content, kind)
            .map_err(|e| format!("Failed to parse project config: {e}"))?;

    let obj = root
        .as_object_mut()
        .ok_or_else(|| "Project config root must be a mapping/object.".to_string())?;

    let todos_value = serde_json::to_value(todos).map_err(|e| e.to_string())?;
    obj.insert(config_key.into(), todos_value);

    if !team_key.is_empty() {
        let linear = obj.entry("linear".to_string()).or_insert_with(|| json!({}));
        if let Some(map) = linear.as_object_mut() {
            map.insert("default_team_key".into(), json!(team_key));
        } else {
            *linear = json!({ "default_team_key": team_key });
        }
    }

    // Keep `todos:` even though XbpConfig does not model it yet — write via Value round-trip
    // in the same format as the source config.
    crate::utils::write_xbp_project_config_at_path(config_path, &root)
}