Skip to main content

xbp_cli/commands/linear_cmd/
mod.rs

1//! User-facing Linear issue management (`xbp linear`).
2
3mod bulk;
4mod comments;
5mod interactive;
6mod issues;
7mod meta;
8mod tui;
9
10use crate::cli::commands::{LinearCmd, LinearSubCommand};
11use crate::commands::cli_session::fetch_linear_api_key_from_dashboard;
12use crate::commands::cloudflare_config::{is_interactive_terminal, require_interactive_terminal};
13use crate::commands::config_cmd::run_config_secret_set;
14use crate::commands::ssh_helpers::prompt_for_password;
15use crate::config::resolve_linear_api_key;
16use crate::utils::open_with_default_handler;
17use colored::Colorize;
18use dialoguer::{theme::ColorfulTheme, Confirm};
19
20pub use comments::{create_comment, list_comments, LinearComment};
21pub use issues::{
22    archive_issue, attachment_link_url, create_issue, default_team_hint, get_issue,
23    issues_for_attachment_url, list_issues, mark_duplicate_of, prompt_and_save_default_team,
24    sort_issues, update_issue, CreateIssueInput, IssueSortField, LinearIssue, ListIssuesFilter,
25    UpdateIssueInput,
26};
27pub use meta::{
28    ensure_label_ids, list_labels, list_projects, list_teams, list_users, list_workflow_states,
29    resolve_assignee_id, resolve_project_id, resolve_team_id, resolve_team_id_auto, LinearLabel,
30    LinearProject, LinearTeam, LinearUser, LinearWorkflowState,
31};
32
33/// Linear personal API key settings page.
34pub const LINEAR_API_KEYS_URL: &str = "https://linear.app/settings/account/security/api-keys/new";
35
36pub async fn run_linear(cmd: LinearCmd) -> Result<(), String> {
37    let api_key = ensure_linear_api_key().await?;
38    match cmd.command {
39        None => {
40            let filter = issues::build_list_filter_from_hub_cmd(&api_key, &cmd).await?;
41            tui::run_linear_tui(&api_key, filter).await
42        }
43        Some(LinearSubCommand::List(args)) => issues::run_list(&api_key, args).await,
44        Some(LinearSubCommand::Search(args)) => issues::run_search(&api_key, args).await,
45        Some(LinearSubCommand::Show(args)) => issues::run_show(&api_key, args).await,
46        Some(LinearSubCommand::Create(args)) => issues::run_create(&api_key, args).await,
47        Some(LinearSubCommand::Edit(args)) => issues::run_edit(&api_key, args).await,
48        Some(LinearSubCommand::Status(args)) => issues::run_status(&api_key, args).await,
49        Some(LinearSubCommand::Priority(args)) => issues::run_priority(&api_key, args).await,
50        Some(LinearSubCommand::Labels(args)) => issues::run_labels(&api_key, args).await,
51        Some(LinearSubCommand::Assign(args)) => issues::run_assign(&api_key, args).await,
52        Some(LinearSubCommand::Bulk(args)) => bulk::run_bulk(&api_key, args).await,
53        Some(LinearSubCommand::Comment(args)) => comments::run_comment(&api_key, args).await,
54        Some(LinearSubCommand::Comments(args)) => comments::run_comments(&api_key, args).await,
55    }
56}
57
58pub(crate) async fn run_linear_tui_with_filter(
59    api_key: &str,
60    filter: ListIssuesFilter,
61) -> Result<(), String> {
62    tui::run_linear_tui(api_key, filter).await
63}
64
65/// Resolve a Linear API key from local config, dashboard, or interactive setup.
66///
67/// When no key is available and stdin/stdout are a TTY, prompts to create/store one
68/// (opens Linear's API key page, then saves via global config).
69pub async fn ensure_linear_api_key() -> Result<String, String> {
70    if let Some(key) = resolve_linear_api_key() {
71        return Ok(key);
72    }
73    match fetch_linear_api_key_from_dashboard().await {
74        Ok(Some(key)) => return Ok(key),
75        Ok(None) => {}
76        Err(err) => {
77            // Dashboard lookup is best-effort; fall through to interactive setup.
78            eprintln!(
79                "{} dashboard Linear key lookup: {err}",
80                "note".bright_black()
81            );
82        }
83    }
84
85    if !is_interactive_terminal() {
86        return Err(non_interactive_linear_key_message());
87    }
88
89    ensure_linear_api_key_interactive().await
90}
91
92async fn ensure_linear_api_key_interactive() -> Result<String, String> {
93    println!();
94    println!("{}", "Linear API key required".bright_cyan().bold());
95    println!(
96        "{}",
97        "No local or dashboard Linear key found. Set one now to continue.".bright_black()
98    );
99    println!();
100
101    let open = Confirm::with_theme(&ColorfulTheme::default())
102        .with_prompt("Open Linear API key page in your browser?")
103        .default(true)
104        .interact()
105        .map_err(|e| format!("Failed to read choice: {e}"))?;
106    if open {
107        let _ = open_with_default_handler(LINEAR_API_KEYS_URL);
108        println!("{} {}", "Opened:".dimmed(), LINEAR_API_KEYS_URL);
109        println!(
110            "{}",
111            "Create a Personal API key, then paste it below (input is hidden).".bright_black()
112        );
113    } else {
114        println!("{} {}", "Create a key at:".dimmed(), LINEAR_API_KEYS_URL);
115    }
116    println!();
117
118    let key = prompt_for_password("Linear API key: ")?;
119    let key = key.trim().to_string();
120    if key.is_empty() {
121        return Err(
122            "No Linear API key entered. Run `xbp config linear set-key` or retry the command."
123                .to_string(),
124        );
125    }
126
127    run_config_secret_set("linear", Some(key.clone())).await?;
128
129    // Optional: pick default team into project config when missing.
130    if default_team_hint().is_none() {
131        let pick = Confirm::with_theme(&ColorfulTheme::default())
132            .with_prompt("Also pick a default Linear team for this repo now?")
133            .default(true)
134            .interact()
135            .map_err(|e| format!("Failed to read choice: {e}"))?;
136        if pick {
137            if let Err(err) = issues::prompt_and_save_default_team(&key).await {
138                eprintln!(
139                    "{} could not save default team: {err}",
140                    "note".bright_black()
141                );
142                eprintln!(
143                    "{}",
144                    "You can set it later with `linear.default_team_key` in `.xbp/xbp.yaml`."
145                        .bright_black()
146                );
147            }
148        }
149    }
150
151    Ok(key)
152}
153
154fn non_interactive_linear_key_message() -> String {
155    format!(
156        "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}"
157    )
158}
159
160pub(crate) fn require_interactive(context: &str) -> Result<(), String> {
161    require_interactive_terminal(context)
162}
163
164pub(crate) fn priority_label(priority: i32) -> &'static str {
165    match priority {
166        1 => "Urgent",
167        2 => "High",
168        3 => "Medium",
169        4 => "Low",
170        _ => "None",
171    }
172}
173
174pub(crate) fn parse_priority_arg(raw: &str) -> Result<i32, String> {
175    let trimmed = raw.trim();
176    if let Ok(n) = trimmed.parse::<i32>() {
177        if (0..=4).contains(&n) {
178            return Ok(n);
179        }
180        return Err("Priority must be 0–4 (0=None, 1=Urgent, 2=High, 3=Medium, 4=Low).".into());
181    }
182    match trimmed.to_ascii_lowercase().as_str() {
183        "none" | "no" => Ok(0),
184        "urgent" | "u" => Ok(1),
185        "high" | "h" => Ok(2),
186        "medium" | "med" | "m" => Ok(3),
187        "low" | "l" => Ok(4),
188        _ => Err(format!(
189            "Unknown priority `{raw}`. Use 0–4 or none|urgent|high|medium|low."
190        )),
191    }
192}