xbp 10.38.0

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
//! User-facing Linear issue management (`xbp linear`).

mod comments;
mod interactive;
mod issues;
mod meta;

use crate::cli::commands::{LinearCmd, LinearSubCommand};
use crate::commands::cli_session::fetch_linear_api_key_from_dashboard;
use crate::commands::cloudflare_config::{
    is_interactive_terminal, require_interactive_terminal,
};
use crate::commands::config_cmd::run_config_secret_set;
use crate::commands::ssh_helpers::prompt_for_password;
use crate::config::resolve_linear_api_key;
use crate::utils::open_with_default_handler;
use colored::Colorize;
use dialoguer::{theme::ColorfulTheme, Confirm};

pub use comments::{create_comment, list_comments, LinearComment};
pub use issues::{
    create_issue, default_team_hint, get_issue, list_issues, prompt_and_save_default_team,
    update_issue, CreateIssueInput, LinearIssue, ListIssuesFilter, UpdateIssueInput,
};
pub use meta::{
    ensure_label_ids, list_labels, list_teams, list_users, list_workflow_states,
    resolve_assignee_id, resolve_team_id, resolve_team_id_auto, LinearLabel, LinearTeam,
    LinearUser, LinearWorkflowState,
};

/// Linear personal API key settings page.
pub const LINEAR_API_KEYS_URL: &str = "https://linear.app/settings/account/security/api-keys/new";

pub async fn run_linear(cmd: LinearCmd) -> Result<(), String> {
    let api_key = ensure_linear_api_key().await?;
    match cmd.command {
        None => interactive::run_hub(&api_key).await,
        Some(LinearSubCommand::List(args)) => issues::run_list(&api_key, args).await,
        Some(LinearSubCommand::Show(args)) => issues::run_show(&api_key, args).await,
        Some(LinearSubCommand::Create(args)) => issues::run_create(&api_key, args).await,
        Some(LinearSubCommand::Edit(args)) => issues::run_edit(&api_key, args).await,
        Some(LinearSubCommand::Status(args)) => issues::run_status(&api_key, args).await,
        Some(LinearSubCommand::Priority(args)) => issues::run_priority(&api_key, args).await,
        Some(LinearSubCommand::Labels(args)) => issues::run_labels(&api_key, args).await,
        Some(LinearSubCommand::Assign(args)) => issues::run_assign(&api_key, args).await,
        Some(LinearSubCommand::Comment(args)) => comments::run_comment(&api_key, args).await,
        Some(LinearSubCommand::Comments(args)) => comments::run_comments(&api_key, args).await,
    }
}

/// Resolve a Linear API key from local config, dashboard, or interactive setup.
///
/// When no key is available and stdin/stdout are a TTY, prompts to create/store one
/// (opens Linear's API key page, then saves via global config).
pub async fn ensure_linear_api_key() -> Result<String, String> {
    if let Some(key) = resolve_linear_api_key() {
        return Ok(key);
    }
    match fetch_linear_api_key_from_dashboard().await {
        Ok(Some(key)) => return Ok(key),
        Ok(None) => {}
        Err(err) => {
            // Dashboard lookup is best-effort; fall through to interactive setup.
            eprintln!(
                "{} dashboard Linear key lookup: {err}",
                "note".bright_black()
            );
        }
    }

    if !is_interactive_terminal() {
        return Err(non_interactive_linear_key_message());
    }

    ensure_linear_api_key_interactive().await
}

async fn ensure_linear_api_key_interactive() -> Result<String, String> {
    println!();
    println!("{}", "Linear API key required".bright_cyan().bold());
    println!(
        "{}",
        "No local or dashboard Linear key found. Set one now to continue."
            .bright_black()
    );
    println!();

    let open = Confirm::with_theme(&ColorfulTheme::default())
        .with_prompt("Open Linear API key page in your browser?")
        .default(true)
        .interact()
        .map_err(|e| format!("Failed to read choice: {e}"))?;
    if open {
        let _ = open_with_default_handler(LINEAR_API_KEYS_URL);
        println!("{} {}", "Opened:".dimmed(), LINEAR_API_KEYS_URL);
        println!(
            "{}",
            "Create a Personal API key, then paste it below (input is hidden)."
                .bright_black()
        );
    } else {
        println!(
            "{} {}",
            "Create a key at:".dimmed(),
            LINEAR_API_KEYS_URL
        );
    }
    println!();

    let key = prompt_for_password("Linear API key: ")?;
    let key = key.trim().to_string();
    if key.is_empty() {
        return Err(
            "No Linear API key entered. Run `xbp config linear set-key` or retry the command."
                .to_string(),
        );
    }

    run_config_secret_set("linear", Some(key.clone())).await?;

    // Optional: pick default team into project config when missing.
    if default_team_hint().is_none() {
        let pick = Confirm::with_theme(&ColorfulTheme::default())
            .with_prompt("Also pick a default Linear team for this repo now?")
            .default(true)
            .interact()
            .map_err(|e| format!("Failed to read choice: {e}"))?;
        if pick {
            if let Err(err) = issues::prompt_and_save_default_team(&key).await {
                eprintln!(
                    "{} could not save default team: {err}",
                    "note".bright_black()
                );
                eprintln!(
                    "{}",
                    "You can set it later with `linear.default_team_key` in `.xbp/xbp.yaml`."
                        .bright_black()
                );
            }
        }
    }

    Ok(key)
}

fn non_interactive_linear_key_message() -> String {
    format!(
        "No Linear API key found. Run `xbp config linear set-key` in an interactive terminal, or save it in the dashboard settings. API keys: {LINEAR_API_KEYS_URL}"
    )
}

pub(crate) fn require_interactive(context: &str) -> Result<(), String> {
    require_interactive_terminal(context)
}

pub(crate) fn priority_label(priority: i32) -> &'static str {
    match priority {
        1 => "Urgent",
        2 => "High",
        3 => "Medium",
        4 => "Low",
        _ => "None",
    }
}

pub(crate) fn parse_priority_arg(raw: &str) -> Result<i32, String> {
    let trimmed = raw.trim();
    if let Ok(n) = trimmed.parse::<i32>() {
        if (0..=4).contains(&n) {
            return Ok(n);
        }
        return Err("Priority must be 0–4 (0=None, 1=Urgent, 2=High, 3=Medium, 4=Low).".into());
    }
    match trimmed.to_ascii_lowercase().as_str() {
        "none" | "no" => Ok(0),
        "urgent" | "u" => Ok(1),
        "high" | "h" => Ok(2),
        "medium" | "med" | "m" => Ok(3),
        "low" | "l" => Ok(4),
        _ => Err(format!(
            "Unknown priority `{raw}`. Use 0–4 or none|urgent|high|medium|low."
        )),
    }
}