Skip to main content

xbp_cli/commands/linear_cmd/
issues.rs

1//! Linear issue list/create/update operations and CLI handlers.
2
3use super::meta::{
4    list_workflow_states, resolve_assignee_id, resolve_label_ids, resolve_state_id, resolve_team_id,
5};
6use super::{parse_priority_arg, priority_label, require_interactive};
7use crate::cli::commands::{
8    LinearAssignCmd, LinearCreateCmd, LinearEditCmd, LinearLabelsCmd, LinearListCmd,
9    LinearPriorityCmd, LinearShowCmd, LinearStatusCmd,
10};
11use crate::cli::ui::Loader;
12use crate::commands::linear::{linear_graphql_errors, linear_graphql_request};
13use crate::commands::text_util::truncate_chars;
14use crate::commands::terminal_table::{render_table, TableStyle};
15use crate::config::{LinearConfig, SshConfig};
16use crate::strategies::deployment_config::XbpConfig;
17use crate::utils::find_xbp_config_upwards;
18use colored::Colorize;
19use dialoguer::{theme::ColorfulTheme, FuzzySelect, Input, MultiSelect};
20use serde::Deserialize;
21use serde_json::{json, Map as JsonMap, Value as JsonValue};
22use std::env;
23use std::fs;
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct LinearIssue {
27    pub id: String,
28    pub identifier: String,
29    pub title: String,
30    pub description: Option<String>,
31    pub priority: i32,
32    pub url: Option<String>,
33    pub state_id: Option<String>,
34    pub state_name: Option<String>,
35    pub state_type: Option<String>,
36    pub assignee_id: Option<String>,
37    pub assignee_name: Option<String>,
38    pub team_id: Option<String>,
39    pub team_key: Option<String>,
40    pub team_name: Option<String>,
41    pub labels: Vec<(String, String)>, // id, name
42}
43
44#[derive(Debug, Clone, Default)]
45pub struct ListIssuesFilter {
46    pub team_id: Option<String>,
47    pub state: Option<String>,
48    pub assignee_id: Option<String>,
49    pub query: Option<String>,
50    pub limit: usize,
51}
52
53#[derive(Debug, Clone, Default)]
54pub struct CreateIssueInput {
55    pub team_id: String,
56    pub title: String,
57    pub description: Option<String>,
58    pub priority: Option<i32>,
59    pub state_id: Option<String>,
60    pub assignee_id: Option<String>,
61    pub label_ids: Vec<String>,
62}
63
64#[derive(Debug, Clone, Default)]
65pub struct UpdateIssueInput {
66    pub title: Option<String>,
67    pub description: Option<String>,
68    pub priority: Option<i32>,
69    pub state_id: Option<String>,
70    pub assignee_id: Option<Option<String>>,
71    pub label_ids: Option<Vec<String>>,
72}
73
74#[derive(Debug, Deserialize)]
75struct IssueNode {
76    id: String,
77    identifier: String,
78    title: String,
79    #[serde(default)]
80    description: Option<String>,
81    #[serde(default)]
82    priority: i32,
83    #[serde(default)]
84    url: Option<String>,
85    #[serde(default)]
86    state: Option<NamedRef>,
87    #[serde(default)]
88    assignee: Option<NamedRef>,
89    #[serde(default)]
90    team: Option<TeamRef>,
91    #[serde(default)]
92    labels: Option<LabelsNodes>,
93}
94
95#[derive(Debug, Deserialize)]
96struct NamedRef {
97    id: String,
98    name: String,
99    #[serde(default, rename = "type")]
100    type_name: Option<String>,
101}
102
103#[derive(Debug, Deserialize)]
104struct TeamRef {
105    id: String,
106    key: String,
107    name: String,
108}
109
110#[derive(Debug, Deserialize)]
111struct LabelsNodes {
112    nodes: Vec<LabelRef>,
113}
114
115#[derive(Debug, Deserialize)]
116struct LabelRef {
117    id: String,
118    name: String,
119}
120
121impl From<IssueNode> for LinearIssue {
122    fn from(n: IssueNode) -> Self {
123        let labels = n
124            .labels
125            .map(|l| l.nodes.into_iter().map(|x| (x.id, x.name)).collect())
126            .unwrap_or_default();
127        Self {
128            id: n.id,
129            identifier: n.identifier,
130            title: n.title,
131            description: n.description,
132            priority: n.priority,
133            url: n.url,
134            state_id: n.state.as_ref().map(|s| s.id.clone()),
135            state_name: n.state.as_ref().map(|s| s.name.clone()),
136            state_type: n.state.and_then(|s| s.type_name),
137            assignee_id: n.assignee.as_ref().map(|a| a.id.clone()),
138            assignee_name: n.assignee.map(|a| a.name),
139            team_id: n.team.as_ref().map(|t| t.id.clone()),
140            team_key: n.team.as_ref().map(|t| t.key.clone()),
141            team_name: n.team.map(|t| t.name),
142            labels,
143        }
144    }
145}
146
147const ISSUE_FIELDS: &str = r#"
148  id
149  identifier
150  title
151  description
152  priority
153  url
154  state { id name type }
155  assignee { id name }
156  team { id key name }
157  labels { nodes { id name } }
158"#;
159
160pub async fn list_issues(
161    api_key: &str,
162    filter: ListIssuesFilter,
163) -> Result<Vec<LinearIssue>, String> {
164    let limit = filter.limit.clamp(1, 250);
165    let mut filter_obj = JsonMap::new();
166    if let Some(team_id) = filter.team_id {
167        filter_obj.insert("team".into(), json!({ "id": { "eq": team_id } }));
168    }
169    if let Some(state) = filter.state {
170        let state = state.trim();
171        if !state.is_empty() {
172            // Accept type (started/completed) or name via OR-ish: try name first via contains
173            if matches!(
174                state.to_ascii_lowercase().as_str(),
175                "backlog" | "unstarted" | "started" | "completed" | "canceled" | "cancelled"
176            ) {
177                let st = if state.eq_ignore_ascii_case("cancelled") {
178                    "canceled"
179                } else {
180                    state
181                };
182                filter_obj.insert("state".into(), json!({ "type": { "eq": st } }));
183            } else {
184                filter_obj.insert("state".into(), json!({ "name": { "eq": state } }));
185            }
186        }
187    }
188    if let Some(assignee_id) = filter.assignee_id {
189        filter_obj.insert(
190            "assignee".into(),
191            json!({ "id": { "eq": assignee_id } }),
192        );
193    }
194
195    let query = if filter_obj.is_empty() {
196        format!(
197            r#"
198            query XbpLinearIssues($first: Int!) {{
199              issues(first: $first, orderBy: updatedAt) {{
200                nodes {{ {fields} }}
201              }}
202            }}
203            "#,
204            fields = ISSUE_FIELDS
205        )
206    } else {
207        format!(
208            r#"
209            query XbpLinearIssues($first: Int!, $filter: IssueFilter) {{
210              issues(first: $first, filter: $filter, orderBy: updatedAt) {{
211                nodes {{ {fields} }}
212              }}
213            }}
214            "#,
215            fields = ISSUE_FIELDS
216        )
217    };
218
219    let mut variables = json!({ "first": limit as i64 });
220    if !filter_obj.is_empty() {
221        variables["filter"] = JsonValue::Object(filter_obj);
222    }
223
224    let body = linear_graphql_request(api_key, &json!({ "query": query, "variables": variables }))
225        .await?;
226    linear_graphql_errors(&body)?;
227
228    let mut issues: Vec<LinearIssue> = body
229        .get("data")
230        .and_then(|d| d.get("issues"))
231        .and_then(|i| i.get("nodes"))
232        .and_then(JsonValue::as_array)
233        .ok_or_else(|| "Linear issues query returned no data.".to_string())?
234        .iter()
235        .cloned()
236        .map(|v| {
237            serde_json::from_value::<IssueNode>(v)
238                .map(LinearIssue::from)
239                .map_err(|e| format!("Failed to decode Linear issue: {e}"))
240        })
241        .collect::<Result<Vec<_>, _>>()?;
242
243    if let Some(q) = filter.query {
244        let q = q.trim().to_ascii_lowercase();
245        if !q.is_empty() {
246            issues.retain(|issue| {
247                issue.title.to_ascii_lowercase().contains(&q)
248                    || issue.identifier.to_ascii_lowercase().contains(&q)
249                    || issue
250                        .description
251                        .as_deref()
252                        .is_some_and(|d| d.to_ascii_lowercase().contains(&q))
253            });
254        }
255    }
256
257    Ok(issues)
258}
259
260pub async fn get_issue(api_key: &str, id_or_identifier: &str) -> Result<LinearIssue, String> {
261    let id = id_or_identifier.trim();
262    if id.is_empty() {
263        return Err("Issue id or identifier is required.".into());
264    }
265    let query = format!(
266        r#"
267        query XbpLinearIssue($id: String!) {{
268          issue(id: $id) {{ {fields} }}
269        }}
270        "#,
271        fields = ISSUE_FIELDS
272    );
273    let body = linear_graphql_request(
274        api_key,
275        &json!({ "query": query, "variables": { "id": id } }),
276    )
277    .await?;
278    linear_graphql_errors(&body)?;
279    let value = body
280        .get("data")
281        .and_then(|d| d.get("issue"))
282        .cloned()
283        .filter(|v| !v.is_null())
284        .ok_or_else(|| format!("Linear issue `{id}` not found."))?;
285    let node: IssueNode =
286        serde_json::from_value(value).map_err(|e| format!("Failed to decode Linear issue: {e}"))?;
287    Ok(node.into())
288}
289
290pub async fn create_issue(api_key: &str, input: CreateIssueInput) -> Result<LinearIssue, String> {
291    let mut create_input = JsonMap::new();
292    create_input.insert("teamId".into(), json!(input.team_id));
293    create_input.insert("title".into(), json!(input.title));
294    if let Some(description) = input.description {
295        create_input.insert("description".into(), json!(description));
296    }
297    if let Some(priority) = input.priority {
298        create_input.insert("priority".into(), json!(priority));
299    }
300    if let Some(state_id) = input.state_id {
301        create_input.insert("stateId".into(), json!(state_id));
302    }
303    if let Some(assignee_id) = input.assignee_id {
304        create_input.insert("assigneeId".into(), json!(assignee_id));
305    }
306    if !input.label_ids.is_empty() {
307        create_input.insert("labelIds".into(), json!(input.label_ids));
308    }
309
310    let query = format!(
311        r#"
312        mutation XbpLinearIssueCreate($input: IssueCreateInput!) {{
313          issueCreate(input: $input) {{
314            success
315            issue {{ {fields} }}
316          }}
317        }}
318        "#,
319        fields = ISSUE_FIELDS
320    );
321    let body = linear_graphql_request(
322        api_key,
323        &json!({
324            "query": query,
325            "variables": { "input": JsonValue::Object(create_input) }
326        }),
327    )
328    .await?;
329    linear_graphql_errors(&body)?;
330    let payload = body
331        .get("data")
332        .and_then(|d| d.get("issueCreate"))
333        .cloned()
334        .ok_or_else(|| "Linear issueCreate returned no data.".to_string())?;
335    if payload.get("success").and_then(JsonValue::as_bool) == Some(false) {
336        return Err("Linear issueCreate reported success=false.".into());
337    }
338    let issue = payload
339        .get("issue")
340        .cloned()
341        .ok_or_else(|| "Linear issueCreate returned no issue.".to_string())?;
342    let node: IssueNode =
343        serde_json::from_value(issue).map_err(|e| format!("Failed to decode created issue: {e}"))?;
344    Ok(node.into())
345}
346
347pub async fn update_issue(
348    api_key: &str,
349    id_or_identifier: &str,
350    input: UpdateIssueInput,
351) -> Result<LinearIssue, String> {
352    let existing = get_issue(api_key, id_or_identifier).await?;
353    let mut update = JsonMap::new();
354    if let Some(title) = input.title {
355        update.insert("title".into(), json!(title));
356    }
357    if let Some(description) = input.description {
358        update.insert("description".into(), json!(description));
359    }
360    if let Some(priority) = input.priority {
361        update.insert("priority".into(), json!(priority));
362    }
363    if let Some(state_id) = input.state_id {
364        update.insert("stateId".into(), json!(state_id));
365    }
366    if let Some(assignee) = input.assignee_id {
367        match assignee {
368            Some(id) => {
369                update.insert("assigneeId".into(), json!(id));
370            }
371            None => {
372                update.insert("assigneeId".into(), JsonValue::Null);
373            }
374        }
375    }
376    if let Some(label_ids) = input.label_ids {
377        update.insert("labelIds".into(), json!(label_ids));
378    }
379    if update.is_empty() {
380        return Ok(existing);
381    }
382
383    let query = format!(
384        r#"
385        mutation XbpLinearIssueUpdate($id: String!, $input: IssueUpdateInput!) {{
386          issueUpdate(id: $id, input: $input) {{
387            success
388            issue {{ {fields} }}
389          }}
390        }}
391        "#,
392        fields = ISSUE_FIELDS
393    );
394    let body = linear_graphql_request(
395        api_key,
396        &json!({
397            "query": query,
398            "variables": {
399                "id": existing.id,
400                "input": JsonValue::Object(update)
401            }
402        }),
403    )
404    .await?;
405    linear_graphql_errors(&body)?;
406    let payload = body
407        .get("data")
408        .and_then(|d| d.get("issueUpdate"))
409        .cloned()
410        .ok_or_else(|| "Linear issueUpdate returned no data.".to_string())?;
411    if payload.get("success").and_then(JsonValue::as_bool) == Some(false) {
412        return Err("Linear issueUpdate reported success=false.".into());
413    }
414    let issue = payload
415        .get("issue")
416        .cloned()
417        .ok_or_else(|| "Linear issueUpdate returned no issue.".to_string())?;
418    let node: IssueNode =
419        serde_json::from_value(issue).map_err(|e| format!("Failed to decode updated issue: {e}"))?;
420    Ok(node.into())
421}
422
423pub async fn run_list(api_key: &str, args: LinearListCmd) -> Result<(), String> {
424    let loader = Loader::start("Fetching Linear issues");
425    let filter = build_list_filter(api_key, &args).await;
426    let filter = match filter {
427        Ok(f) => f,
428        Err(e) => {
429            loader.fail(&e);
430            return Err(e);
431        }
432    };
433    let issues = match list_issues(api_key, filter).await {
434        Ok(v) => {
435            loader.success();
436            v
437        }
438        Err(e) => {
439            loader.fail(&e);
440            return Err(e);
441        }
442    };
443    print_issue_table(&issues);
444    Ok(())
445}
446
447async fn build_list_filter(
448    api_key: &str,
449    args: &LinearListCmd,
450) -> Result<ListIssuesFilter, String> {
451    let team_id = if let Some(team) = args.team.as_deref() {
452        Some(resolve_team_id(api_key, team).await?)
453    } else {
454        None
455    };
456    let assignee_id = if let Some(assignee) = args.assignee.as_deref() {
457        resolve_assignee_id(api_key, assignee).await?
458    } else {
459        None
460    };
461    Ok(ListIssuesFilter {
462        team_id,
463        state: args.state.clone(),
464        assignee_id,
465        query: args.query.clone(),
466        limit: args.limit,
467    })
468}
469
470pub async fn run_show(api_key: &str, args: LinearShowCmd) -> Result<(), String> {
471    let loader = Loader::start("Fetching Linear issue");
472    let issue = match get_issue(api_key, &args.id).await {
473        Ok(v) => {
474            loader.success();
475            v
476        }
477        Err(e) => {
478            loader.fail(&e);
479            return Err(e);
480        }
481    };
482    print_issue_detail(&issue);
483    Ok(())
484}
485
486pub async fn run_create(api_key: &str, args: LinearCreateCmd) -> Result<(), String> {
487    let team_id = match args.team.as_deref() {
488        Some(t) if !t.trim().is_empty() => resolve_team_id(api_key, t).await?,
489        _ => match default_team_hint() {
490            Some(t) => resolve_team_id(api_key, &t).await?,
491            None => match super::meta::resolve_team_id_auto(api_key, None).await {
492                Ok(id) => id,
493                Err(_) if is_tty() => pick_team_interactive(api_key).await?,
494                Err(e) => return Err(e),
495            },
496        },
497    };
498
499    let title = match args.title.as_deref() {
500        Some(t) if !t.trim().is_empty() => t.trim().to_string(),
501        _ if is_tty() => {
502            require_interactive("Creating a Linear issue")?;
503            Input::<String>::with_theme(&ColorfulTheme::default())
504                .with_prompt("Title")
505                .interact_text()
506                .map_err(|e| e.to_string())?
507        }
508        _ => return Err("Title is required (`--title`).".into()),
509    };
510
511    let description = match args.description.clone() {
512        Some(d) => Some(d),
513        None if args.interactive_body && is_tty() => {
514            let body: String = Input::with_theme(&ColorfulTheme::default())
515                .with_prompt("Description (optional)")
516                .allow_empty(true)
517                .interact_text()
518                .map_err(|e| e.to_string())?;
519            if body.trim().is_empty() {
520                None
521            } else {
522                Some(body)
523            }
524        }
525        None => None,
526    };
527
528    let priority = match args.priority.as_deref() {
529        Some(p) => Some(parse_priority_arg(p)?),
530        None => None,
531    };
532
533    let state_id = match args.state.as_deref() {
534        Some(s) => Some(resolve_state_id(api_key, &team_id, s).await?),
535        None => None,
536    };
537
538    let assignee_id = match args.assignee.as_deref() {
539        Some(a) => resolve_assignee_id(api_key, a).await?,
540        None => None,
541    };
542
543    let label_ids = resolve_label_ids(api_key, Some(&team_id), &args.labels).await?;
544
545    let loader = Loader::start("Creating Linear issue");
546    let issue = match create_issue(
547        api_key,
548        CreateIssueInput {
549            team_id,
550            title,
551            description,
552            priority,
553            state_id,
554            assignee_id,
555            label_ids,
556        },
557    )
558    .await
559    {
560        Ok(v) => {
561            loader.success_with(&format!("created {}", v.identifier));
562            v
563        }
564        Err(e) => {
565            loader.fail(&e);
566            return Err(e);
567        }
568    };
569    print_issue_detail(&issue);
570    Ok(())
571}
572
573pub async fn run_edit(api_key: &str, args: LinearEditCmd) -> Result<(), String> {
574    let mut input = UpdateIssueInput {
575        title: args.title.clone(),
576        description: args.description.clone(),
577        priority: args
578            .priority
579            .as_deref()
580            .map(parse_priority_arg)
581            .transpose()?,
582        state_id: None,
583        assignee_id: None,
584        label_ids: None,
585    };
586
587    if input.title.is_none()
588        && input.description.is_none()
589        && input.priority.is_none()
590        && args.state.is_none()
591        && args.assignee.is_none()
592        && args.labels.is_empty()
593    {
594        require_interactive("Editing a Linear issue")?;
595        let issue = get_issue(api_key, &args.id).await?;
596        let title: String = Input::with_theme(&ColorfulTheme::default())
597            .with_prompt("Title")
598            .with_initial_text(&issue.title)
599            .interact_text()
600            .map_err(|e| e.to_string())?;
601        input.title = Some(title);
602        let description: String = Input::with_theme(&ColorfulTheme::default())
603            .with_prompt("Description")
604            .with_initial_text(issue.description.as_deref().unwrap_or(""))
605            .allow_empty(true)
606            .interact_text()
607            .map_err(|e| e.to_string())?;
608        input.description = Some(description);
609    }
610
611    if let Some(state) = args.state.as_deref() {
612        let issue = get_issue(api_key, &args.id).await?;
613        let team_id = issue
614            .team_id
615            .ok_or_else(|| "Issue has no team; cannot resolve state.".to_string())?;
616        input.state_id = Some(resolve_state_id(api_key, &team_id, state).await?);
617    }
618    if let Some(assignee) = args.assignee.as_deref() {
619        input.assignee_id = Some(resolve_assignee_id(api_key, assignee).await?);
620    }
621    if !args.labels.is_empty() {
622        let issue = get_issue(api_key, &args.id).await?;
623        input.label_ids = Some(
624            resolve_label_ids(api_key, issue.team_id.as_deref(), &args.labels).await?,
625        );
626    }
627
628    let loader = Loader::start("Updating Linear issue");
629    let issue = match update_issue(api_key, &args.id, input).await {
630        Ok(v) => {
631            loader.success_with(&format!("updated {}", v.identifier));
632            v
633        }
634        Err(e) => {
635            loader.fail(&e);
636            return Err(e);
637        }
638    };
639    print_issue_detail(&issue);
640    Ok(())
641}
642
643pub async fn run_status(api_key: &str, args: LinearStatusCmd) -> Result<(), String> {
644    let issue = get_issue(api_key, &args.id).await?;
645    let team_id = issue
646        .team_id
647        .clone()
648        .ok_or_else(|| "Issue has no team; cannot change status.".to_string())?;
649    let state = match args.state.as_deref() {
650        Some(s) if !s.trim().is_empty() => s.to_string(),
651        _ => {
652            require_interactive("Changing Linear issue status")?;
653            let states = list_workflow_states(api_key, &team_id).await?;
654            if states.is_empty() {
655                return Err("No workflow states found for this team.".into());
656            }
657            let labels: Vec<String> = states
658                .iter()
659                .map(|s| format!("{} ({})", s.name, s.state_type))
660                .collect();
661            let idx = FuzzySelect::with_theme(&ColorfulTheme::default())
662                .with_prompt(format!("Status for {}", issue.identifier))
663                .items(&labels)
664                .default(0)
665                .interact()
666                .map_err(|e| e.to_string())?;
667            states[idx].name.clone()
668        }
669    };
670    let state_id = resolve_state_id(api_key, &team_id, &state).await?;
671    let loader = Loader::start("Updating status");
672    let updated = match update_issue(
673        api_key,
674        &issue.id,
675        UpdateIssueInput {
676            state_id: Some(state_id),
677            ..Default::default()
678        },
679    )
680    .await
681    {
682        Ok(v) => {
683            loader.success();
684            v
685        }
686        Err(e) => {
687            loader.fail(&e);
688            return Err(e);
689        }
690    };
691    println!(
692        "{} {} → {}",
693        "OK".bright_green().bold(),
694        updated.identifier.bright_white().bold(),
695        updated
696            .state_name
697            .as_deref()
698            .unwrap_or("?")
699            .bright_cyan()
700    );
701    Ok(())
702}
703
704pub async fn run_priority(api_key: &str, args: LinearPriorityCmd) -> Result<(), String> {
705    let priority = match args.priority.as_deref() {
706        Some(p) => parse_priority_arg(p)?,
707        None => {
708            require_interactive("Changing Linear issue priority")?;
709            let options = ["None", "Urgent", "High", "Medium", "Low"];
710            let idx = FuzzySelect::with_theme(&ColorfulTheme::default())
711                .with_prompt(format!("Priority for {}", args.id))
712                .items(&options)
713                .default(0)
714                .interact()
715                .map_err(|e| e.to_string())?;
716            idx as i32
717        }
718    };
719    let loader = Loader::start("Updating priority");
720    let updated = match update_issue(
721        api_key,
722        &args.id,
723        UpdateIssueInput {
724            priority: Some(priority),
725            ..Default::default()
726        },
727    )
728    .await
729    {
730        Ok(v) => {
731            loader.success();
732            v
733        }
734        Err(e) => {
735            loader.fail(&e);
736            return Err(e);
737        }
738    };
739    println!(
740        "{} {} priority → {}",
741        "OK".bright_green().bold(),
742        updated.identifier.bright_white().bold(),
743        priority_label(updated.priority).bright_yellow()
744    );
745    Ok(())
746}
747
748pub async fn run_labels(api_key: &str, args: LinearLabelsCmd) -> Result<(), String> {
749    let issue = get_issue(api_key, &args.id).await?;
750    let team_id = issue.team_id.clone();
751    let label_ids = if !args.add.is_empty() || !args.set.is_empty() || args.clear {
752        if args.clear {
753            Vec::new()
754        } else if !args.set.is_empty() {
755            resolve_label_ids(api_key, team_id.as_deref(), &args.set).await?
756        } else {
757            let mut current: Vec<String> = issue.labels.iter().map(|(id, _)| id.clone()).collect();
758            let add_ids = resolve_label_ids(api_key, team_id.as_deref(), &args.add).await?;
759            for id in add_ids {
760                if !current.contains(&id) {
761                    current.push(id);
762                }
763            }
764            if !args.remove.is_empty() {
765                let remove_ids =
766                    resolve_label_ids(api_key, team_id.as_deref(), &args.remove).await?;
767                current.retain(|id| !remove_ids.contains(id));
768            }
769            current
770        }
771    } else {
772        require_interactive("Assigning Linear labels")?;
773        let known = super::meta::list_labels(api_key, team_id.as_deref()).await?;
774        if known.is_empty() {
775            return Err("No labels found for this workspace/team.".into());
776        }
777        let names: Vec<String> = known.iter().map(|l| l.name.clone()).collect();
778        let defaults: Vec<bool> = known
779            .iter()
780            .map(|l| issue.labels.iter().any(|(id, _)| id == &l.id))
781            .collect();
782        let selected = MultiSelect::with_theme(&ColorfulTheme::default())
783            .with_prompt(format!("Labels for {}", issue.identifier))
784            .items(&names)
785            .defaults(&defaults)
786            .interact()
787            .map_err(|e| e.to_string())?;
788        selected.into_iter().map(|i| known[i].id.clone()).collect()
789    };
790
791    let loader = Loader::start("Updating labels");
792    let updated = match update_issue(
793        api_key,
794        &issue.id,
795        UpdateIssueInput {
796            label_ids: Some(label_ids),
797            ..Default::default()
798        },
799    )
800    .await
801    {
802        Ok(v) => {
803            loader.success();
804            v
805        }
806        Err(e) => {
807            loader.fail(&e);
808            return Err(e);
809        }
810    };
811    let label_str = if updated.labels.is_empty() {
812        "(none)".dimmed().to_string()
813    } else {
814        updated
815            .labels
816            .iter()
817            .map(|(_, n)| n.as_str())
818            .collect::<Vec<_>>()
819            .join(", ")
820    };
821    println!(
822        "{} {} labels → {}",
823        "OK".bright_green().bold(),
824        updated.identifier.bright_white().bold(),
825        label_str
826    );
827    Ok(())
828}
829
830pub async fn run_assign(api_key: &str, args: LinearAssignCmd) -> Result<(), String> {
831    let assignee = match args.assignee.as_deref() {
832        Some(a) => a.to_string(),
833        None => {
834            require_interactive("Assigning a Linear issue")?;
835            let users = super::meta::list_users(api_key).await?;
836            let mut labels = vec!["(unassign)".to_string(), "me".to_string()];
837            labels.extend(users.iter().map(|u| {
838                format!(
839                    "{}{}",
840                    u.display_name.as_deref().unwrap_or(&u.name),
841                    u.email
842                        .as_ref()
843                        .map(|e| format!(" <{e}>"))
844                        .unwrap_or_default()
845                )
846            }));
847            let idx = FuzzySelect::with_theme(&ColorfulTheme::default())
848                .with_prompt(format!("Assignee for {}", args.id))
849                .items(&labels)
850                .default(1)
851                .interact()
852                .map_err(|e| e.to_string())?;
853            if idx == 0 {
854                "none".to_string()
855            } else if idx == 1 {
856                "me".to_string()
857            } else {
858                users[idx - 2].id.clone()
859            }
860        }
861    };
862    let assignee_id = resolve_assignee_id(api_key, &assignee).await?;
863    let loader = Loader::start("Updating assignee");
864    let updated = match update_issue(
865        api_key,
866        &args.id,
867        UpdateIssueInput {
868            assignee_id: Some(assignee_id),
869            ..Default::default()
870        },
871    )
872    .await
873    {
874        Ok(v) => {
875            loader.success();
876            v
877        }
878        Err(e) => {
879            loader.fail(&e);
880            return Err(e);
881        }
882    };
883    println!(
884        "{} {} assignee → {}",
885        "OK".bright_green().bold(),
886        updated.identifier.bright_white().bold(),
887        updated
888            .assignee_name
889            .as_deref()
890            .unwrap_or("(none)")
891            .bright_cyan()
892    );
893    Ok(())
894}
895
896pub fn print_issue_table(issues: &[LinearIssue]) {
897    if issues.is_empty() {
898        println!("{}", "No issues found.".dimmed());
899        return;
900    }
901    let rows: Vec<Vec<String>> = issues
902        .iter()
903        .map(|issue| {
904            vec![
905                issue.identifier.clone().bright_white().bold().to_string(),
906                issue
907                    .state_name
908                    .clone()
909                    .unwrap_or_else(|| "-".into())
910                    .bright_cyan()
911                    .to_string(),
912                priority_label(issue.priority).bright_yellow().to_string(),
913                issue
914                    .assignee_name
915                    .clone()
916                    .unwrap_or_else(|| "-".into()),
917                truncate_chars(&issue.title, 60),
918            ]
919        })
920        .collect();
921    print!(
922        "{}",
923        render_table(
924            &["ID", "State", "Pri", "Assignee", "Title"],
925            &rows,
926            TableStyle::Pipe,
927            "",
928        )
929    );
930    println!("Total: {} issue(s)", issues.len());
931}
932
933pub fn print_issue_detail(issue: &LinearIssue) {
934    println!();
935    println!(
936        "{} {}",
937        issue.identifier.bright_white().bold(),
938        issue.title.bright_cyan()
939    );
940    if let Some(url) = &issue.url {
941        println!("{} {}", "url".dimmed(), url.underline());
942    }
943    println!(
944        "{} {}",
945        "state".dimmed(),
946        issue.state_name.as_deref().unwrap_or("-")
947    );
948    println!(
949        "{} {}",
950        "priority".dimmed(),
951        priority_label(issue.priority)
952    );
953    println!(
954        "{} {}",
955        "assignee".dimmed(),
956        issue.assignee_name.as_deref().unwrap_or("-")
957    );
958    println!(
959        "{} {}",
960        "team".dimmed(),
961        issue
962            .team_key
963            .as_deref()
964            .or(issue.team_name.as_deref())
965            .unwrap_or("-")
966    );
967    let labels = if issue.labels.is_empty() {
968        "-".to_string()
969    } else {
970        issue
971            .labels
972            .iter()
973            .map(|(_, n)| n.as_str())
974            .collect::<Vec<_>>()
975            .join(", ")
976    };
977    println!("{} {}", "labels".dimmed(), labels);
978    if let Some(desc) = &issue.description {
979        if !desc.trim().is_empty() {
980            println!();
981            println!("{}", desc.trim());
982        }
983    }
984    println!();
985}
986
987async fn pick_team_interactive(api_key: &str) -> Result<String, String> {
988    require_interactive("Selecting a Linear team")?;
989    let teams = super::meta::list_teams(api_key).await?;
990    if teams.is_empty() {
991        return Err("No Linear teams available for this API key.".into());
992    }
993    let labels: Vec<String> = teams
994        .iter()
995        .map(|t| format!("{} — {}", t.key, t.name))
996        .collect();
997    let idx = FuzzySelect::with_theme(&ColorfulTheme::default())
998        .with_prompt("Select Linear team")
999        .items(&labels)
1000        .default(0)
1001        .interact()
1002        .map_err(|e| e.to_string())?;
1003    Ok(teams[idx].id.clone())
1004}
1005
1006/// Interactive team picker that persists `linear.default_team_key` (and id) on the project.
1007pub async fn prompt_and_save_default_team(api_key: &str) -> Result<(), String> {
1008    require_interactive("Saving default Linear team")?;
1009    let teams = super::meta::list_teams(api_key).await?;
1010    if teams.is_empty() {
1011        return Err("No Linear teams available for this API key.".into());
1012    }
1013    let labels: Vec<String> = teams
1014        .iter()
1015        .map(|t| format!("{} — {}", t.key, t.name))
1016        .collect();
1017    let idx = FuzzySelect::with_theme(&ColorfulTheme::default())
1018        .with_prompt("Default Linear team for this repo")
1019        .items(&labels)
1020        .default(0)
1021        .interact()
1022        .map_err(|e| e.to_string())?;
1023    let selected = &teams[idx];
1024    save_default_team_to_project(&selected.id, &selected.key)?;
1025    println!(
1026        "{} default team `{}` ({}) → .xbp/xbp.yaml",
1027        "OK".bright_green().bold(),
1028        selected.key.bright_white().bold(),
1029        selected.name
1030    );
1031    Ok(())
1032}
1033
1034fn save_default_team_to_project(team_id: &str, team_key: &str) -> Result<(), String> {
1035    let cwd = env::current_dir().map_err(|e| format!("cwd: {e}"))?;
1036    let found = find_xbp_config_upwards(&cwd).ok_or_else(|| {
1037        "No XBP project config found. Run this inside a repo with `.xbp/xbp.yaml`.".to_string()
1038    })?;
1039    let content = fs::read_to_string(&found.config_path)
1040        .map_err(|e| format!("Failed to read {}: {e}", found.config_path.display()))?;
1041
1042    let mut root: serde_json::Value = if found.kind == "yaml" {
1043        let yaml: serde_yaml::Value =
1044            serde_yaml::from_str(&content).map_err(|e| format!("parse yaml: {e}"))?;
1045        serde_json::to_value(yaml).map_err(|e| e.to_string())?
1046    } else {
1047        serde_json::from_str(&content).map_err(|e| format!("parse json: {e}"))?
1048    };
1049
1050    let obj = root
1051        .as_object_mut()
1052        .ok_or_else(|| "Project config root must be a mapping.".to_string())?;
1053    let linear = obj
1054        .entry("linear".to_string())
1055        .or_insert_with(|| serde_json::json!({}));
1056    if linear.is_null() {
1057        *linear = serde_json::json!({});
1058    }
1059    let linear_obj = linear
1060        .as_object_mut()
1061        .ok_or_else(|| "Project `linear` must be an object.".to_string())?;
1062    linear_obj.insert(
1063        "default_team_id".into(),
1064        serde_json::Value::String(team_id.to_string()),
1065    );
1066    linear_obj.insert(
1067        "default_team_key".into(),
1068        serde_json::Value::String(team_key.to_string()),
1069    );
1070
1071    if found.kind == "yaml" {
1072        let yaml_val: serde_yaml::Value =
1073            serde_json::from_value(root).map_err(|e| e.to_string())?;
1074        let yaml = serde_yaml::to_string(&yaml_val).map_err(|e| e.to_string())?;
1075        fs::write(&found.config_path, yaml)
1076            .map_err(|e| format!("Failed to write {}: {e}", found.config_path.display()))?;
1077    } else {
1078        let json = serde_json::to_string_pretty(&root).map_err(|e| e.to_string())?;
1079        fs::write(&found.config_path, json + "\n")
1080            .map_err(|e| format!("Failed to write {}: {e}", found.config_path.display()))?;
1081    }
1082    Ok(())
1083}
1084
1085pub fn default_team_hint() -> Option<String> {
1086    if let Ok(cwd) = env::current_dir() {
1087        if let Some(found) = find_xbp_config_upwards(&cwd) {
1088            if let Ok(content) = fs::read_to_string(&found.config_path) {
1089                let linear = if found.kind == "yaml" {
1090                    serde_yaml::from_str::<XbpConfig>(&content)
1091                        .ok()
1092                        .and_then(|cfg| cfg.linear)
1093                } else {
1094                    serde_json::from_str::<XbpConfig>(&content)
1095                        .ok()
1096                        .and_then(|cfg| cfg.linear)
1097                };
1098                if let Some(linear) = linear {
1099                    if let Some(id) = linear.default_team_id.filter(|s| !s.trim().is_empty()) {
1100                        return Some(id);
1101                    }
1102                    if let Some(key) = linear.default_team_key.filter(|s| !s.trim().is_empty()) {
1103                        return Some(key);
1104                    }
1105                }
1106            }
1107        }
1108    }
1109    if let Ok(cfg) = SshConfig::load() {
1110        if let Some(LinearConfig {
1111            default_team_id,
1112            default_team_key,
1113            ..
1114        }) = cfg.linear
1115        {
1116            if let Some(id) = default_team_id.filter(|s| !s.trim().is_empty()) {
1117                return Some(id);
1118            }
1119            if let Some(key) = default_team_key.filter(|s| !s.trim().is_empty()) {
1120                return Some(key);
1121            }
1122        }
1123    }
1124    None
1125}
1126
1127fn is_tty() -> bool {
1128    crate::commands::cloudflare_config::is_interactive_terminal()
1129}
1130