use super::comments::{list_comments, run_comment};
use super::issues::{
get_issue, list_issues, print_issue_detail, print_issue_table, run_assign, run_create,
run_labels, run_priority, run_status, ListIssuesFilter,
};
use super::require_interactive;
use crate::cli::commands::{
LinearAssignCmd, LinearCommentCmd, LinearCreateCmd, LinearLabelsCmd, LinearPriorityCmd,
LinearStatusCmd,
};
use crate::cli::ui::Loader;
use colored::Colorize;
use dialoguer::{theme::ColorfulTheme, FuzzySelect, Input, Select};
pub async fn run_hub(api_key: &str) -> Result<(), String> {
require_interactive("Linear interactive hub")?;
loop {
println!();
println!("{}", "Linear issues".bright_cyan().bold());
let choices = [
"List issues",
"Create issue",
"Open issue by ID",
"Scan TODOs (xbp todos)",
"Exit",
];
let idx = Select::with_theme(&ColorfulTheme::default())
.with_prompt("What do you want to do?")
.items(&choices)
.default(0)
.interact()
.map_err(|e| e.to_string())?;
match idx {
0 => list_and_act(api_key).await?,
1 => {
run_create(
api_key,
LinearCreateCmd {
title: None,
team: None,
description: None,
priority: None,
state: None,
assignee: None,
labels: Vec::new(),
interactive_body: true,
},
)
.await?;
}
2 => {
let id: String = Input::with_theme(&ColorfulTheme::default())
.with_prompt("Issue ID (e.g. XLX-29)")
.interact_text()
.map_err(|e| e.to_string())?;
if !id.trim().is_empty() {
open_issue_actions(api_key, id.trim()).await?;
}
}
3 => {
println!(
"{}",
"Run `xbp todos scan` then `xbp todos sync --to linear`.".dimmed()
);
let _ = crate::commands::todos::run_todos_scan_default().await;
}
_ => break,
}
}
Ok(())
}
async fn list_and_act(api_key: &str) -> Result<(), String> {
let loader = Loader::start("Fetching Linear issues");
let issues = match list_issues(
api_key,
ListIssuesFilter {
limit: 40,
..Default::default()
},
)
.await
{
Ok(v) => {
loader.success();
v
}
Err(e) => {
loader.fail(&e);
return Err(e);
}
};
if issues.is_empty() {
println!("{}", "No issues found.".dimmed());
return Ok(());
}
print_issue_table(&issues);
let labels: Vec<String> = issues
.iter()
.map(|i| format!("{} — {}", i.identifier, i.title))
.collect();
let mut items = labels;
items.push("(back)".to_string());
let idx = FuzzySelect::with_theme(&ColorfulTheme::default())
.with_prompt("Open issue")
.items(&items)
.default(0)
.interact()
.map_err(|e| e.to_string())?;
if idx >= issues.len() {
return Ok(());
}
open_issue_actions(api_key, &issues[idx].identifier).await
}
async fn open_issue_actions(api_key: &str, id: &str) -> Result<(), String> {
loop {
let issue = get_issue(api_key, id).await?;
print_issue_detail(&issue);
let actions = [
"Change status",
"Change priority",
"Edit labels",
"Assign",
"View comments",
"Add comment",
"Back",
];
let idx = Select::with_theme(&ColorfulTheme::default())
.with_prompt(format!("Actions for {}", issue.identifier))
.items(&actions)
.default(0)
.interact()
.map_err(|e| e.to_string())?;
match idx {
0 => {
run_status(
api_key,
LinearStatusCmd {
id: issue.identifier.clone(),
state: None,
},
)
.await?;
}
1 => {
run_priority(
api_key,
LinearPriorityCmd {
id: issue.identifier.clone(),
priority: None,
},
)
.await?;
}
2 => {
run_labels(
api_key,
LinearLabelsCmd {
id: issue.identifier.clone(),
add: Vec::new(),
remove: Vec::new(),
set: Vec::new(),
clear: false,
},
)
.await?;
}
3 => {
run_assign(
api_key,
LinearAssignCmd {
id: issue.identifier.clone(),
assignee: None,
},
)
.await?;
}
4 => {
let comments = list_comments(api_key, &issue.identifier).await?;
if comments.is_empty() {
println!("{}", "No comments.".dimmed());
} else {
for c in comments {
println!(
"{} {} — {}",
c.user_name.as_deref().unwrap_or("?").bright_cyan(),
c.created_at.as_deref().unwrap_or("").dimmed(),
c.body.replace('\n', " ")
);
}
}
}
5 => {
run_comment(
api_key,
LinearCommentCmd {
id: issue.identifier.clone(),
body: None,
},
)
.await?;
}
_ => break,
}
}
Ok(())
}