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, LinearSearchCmd, LinearShowCmd, LinearStatusCmd,
10};
11use crate::cli::ui::Loader;
12use crate::commands::linear::{linear_graphql_errors, linear_graphql_request};
13use crate::commands::terminal_table::{render_table, TableStyle};
14use crate::commands::text_util::truncate_chars;
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    pub updated_at: Option<String>,
43    pub created_at: Option<String>,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
47pub enum IssueSortField {
48    #[default]
49    Updated,
50    Created,
51    Priority,
52    Identifier,
53    Title,
54    State,
55}
56
57impl IssueSortField {
58    pub fn parse(raw: &str) -> Result<Self, String> {
59        match raw.trim().to_ascii_lowercase().as_str() {
60            "updated" | "updatedat" | "updated_at" => Ok(Self::Updated),
61            "created" | "createdat" | "created_at" => Ok(Self::Created),
62            "priority" | "pri" => Ok(Self::Priority),
63            "identifier" | "id" | "key" => Ok(Self::Identifier),
64            "title" | "name" => Ok(Self::Title),
65            "state" | "status" => Ok(Self::State),
66            other => Err(format!(
67                "Unknown sort field `{other}`. Use updated|created|priority|identifier|title|state."
68            )),
69        }
70    }
71
72    pub fn as_str(self) -> &'static str {
73        match self {
74            Self::Updated => "updated",
75            Self::Created => "created",
76            Self::Priority => "priority",
77            Self::Identifier => "identifier",
78            Self::Title => "title",
79            Self::State => "state",
80        }
81    }
82
83    pub fn cycle(self) -> Self {
84        match self {
85            Self::Updated => Self::Created,
86            Self::Created => Self::Priority,
87            Self::Priority => Self::Identifier,
88            Self::Identifier => Self::Title,
89            Self::Title => Self::State,
90            Self::State => Self::Updated,
91        }
92    }
93}
94
95#[derive(Debug, Clone, Default)]
96pub struct ListIssuesFilter {
97    pub team_id: Option<String>,
98    pub state: Option<String>,
99    pub assignee_id: Option<String>,
100    pub query: Option<String>,
101    pub labels: Vec<String>,
102    /// When false, exclude completed/canceled state types (unless `state` is set).
103    pub include_completed: bool,
104    pub sort: IssueSortField,
105    pub sort_asc: bool,
106    pub limit: usize,
107}
108
109#[derive(Debug, Clone, Default)]
110pub struct CreateIssueInput {
111    pub team_id: String,
112    pub title: String,
113    pub description: Option<String>,
114    pub priority: Option<i32>,
115    pub state_id: Option<String>,
116    pub assignee_id: Option<String>,
117    pub label_ids: Vec<String>,
118    /// Optional Linear project UUID.
119    pub project_id: Option<String>,
120}
121
122#[derive(Debug, Clone, Default)]
123pub struct UpdateIssueInput {
124    pub title: Option<String>,
125    pub description: Option<String>,
126    pub priority: Option<i32>,
127    pub state_id: Option<String>,
128    pub assignee_id: Option<Option<String>>,
129    pub label_ids: Option<Vec<String>>,
130    /// `Some(None)` clears the project; `Some(Some(id))` sets it.
131    pub project_id: Option<Option<String>>,
132    /// `Some(None)` clears the cycle; `Some(Some(id))` sets it.
133    pub cycle_id: Option<Option<String>>,
134}
135
136#[derive(Debug, Deserialize)]
137struct IssueNode {
138    id: String,
139    identifier: String,
140    title: String,
141    #[serde(default)]
142    description: Option<String>,
143    #[serde(default)]
144    priority: i32,
145    #[serde(default)]
146    url: Option<String>,
147    #[serde(default)]
148    state: Option<NamedRef>,
149    #[serde(default)]
150    assignee: Option<NamedRef>,
151    #[serde(default)]
152    team: Option<TeamRef>,
153    #[serde(default)]
154    labels: Option<LabelsNodes>,
155    #[serde(default, rename = "updatedAt")]
156    updated_at: Option<String>,
157    #[serde(default, rename = "createdAt")]
158    created_at: Option<String>,
159}
160
161#[derive(Debug, Deserialize)]
162struct NamedRef {
163    id: String,
164    name: String,
165    #[serde(default, rename = "type")]
166    type_name: Option<String>,
167}
168
169#[derive(Debug, Deserialize)]
170struct TeamRef {
171    id: String,
172    key: String,
173    name: String,
174}
175
176#[derive(Debug, Deserialize)]
177struct LabelsNodes {
178    nodes: Vec<LabelRef>,
179}
180
181#[derive(Debug, Deserialize)]
182struct LabelRef {
183    id: String,
184    name: String,
185}
186
187impl From<IssueNode> for LinearIssue {
188    fn from(n: IssueNode) -> Self {
189        let labels = n
190            .labels
191            .map(|l| l.nodes.into_iter().map(|x| (x.id, x.name)).collect())
192            .unwrap_or_default();
193        Self {
194            id: n.id,
195            identifier: n.identifier,
196            title: n.title,
197            description: n.description,
198            priority: n.priority,
199            url: n.url,
200            state_id: n.state.as_ref().map(|s| s.id.clone()),
201            state_name: n.state.as_ref().map(|s| s.name.clone()),
202            state_type: n.state.and_then(|s| s.type_name),
203            assignee_id: n.assignee.as_ref().map(|a| a.id.clone()),
204            assignee_name: n.assignee.map(|a| a.name),
205            team_id: n.team.as_ref().map(|t| t.id.clone()),
206            team_key: n.team.as_ref().map(|t| t.key.clone()),
207            team_name: n.team.map(|t| t.name),
208            labels,
209            updated_at: n.updated_at,
210            created_at: n.created_at,
211        }
212    }
213}
214
215const ISSUE_FIELDS: &str = r#"
216  id
217  identifier
218  title
219  description
220  priority
221  url
222  updatedAt
223  createdAt
224  state { id name type }
225  assignee { id name }
226  team { id key name }
227  labels { nodes { id name } }
228"#;
229
230pub async fn list_issues(
231    api_key: &str,
232    filter: ListIssuesFilter,
233) -> Result<Vec<LinearIssue>, String> {
234    let limit = filter.limit.clamp(1, 250);
235    let mut filter_obj = JsonMap::new();
236    if let Some(team_id) = filter.team_id {
237        filter_obj.insert("team".into(), json!({ "id": { "eq": team_id } }));
238    }
239    if let Some(ref state) = filter.state {
240        let state = state.trim();
241        if !state.is_empty() {
242            // Accept type (started/completed) or name via OR-ish: try name first via contains
243            if matches!(
244                state.to_ascii_lowercase().as_str(),
245                "backlog" | "unstarted" | "started" | "completed" | "canceled" | "cancelled"
246            ) {
247                let st = if state.eq_ignore_ascii_case("cancelled") {
248                    "canceled"
249                } else {
250                    state
251                };
252                filter_obj.insert("state".into(), json!({ "type": { "eq": st } }));
253            } else {
254                filter_obj.insert(
255                    "state".into(),
256                    json!({ "name": { "eq": canonical_state_name(state) } }),
257                );
258            }
259        }
260    }
261    if let Some(assignee_id) = filter.assignee_id {
262        filter_obj.insert("assignee".into(), json!({ "id": { "eq": assignee_id } }));
263    }
264    // Default: open-ish only unless a concrete state filter is set or include_completed.
265    if !filter.include_completed
266        && filter
267            .state
268            .as_deref()
269            .map(str::trim)
270            .filter(|s| !s.is_empty())
271            .is_none()
272    {
273        filter_obj.insert(
274            "state".into(),
275            json!({ "type": { "nin": ["completed", "canceled"] } }),
276        );
277    }
278
279    let order_by = match filter.sort {
280        IssueSortField::Created => "createdAt",
281        // Priority/identifier/title/state are sorted client-side after fetch.
282        _ => "updatedAt",
283    };
284
285    let query = if filter_obj.is_empty() {
286        format!(
287            r#"
288            query XbpLinearIssues($first: Int!) {{
289              issues(first: $first, orderBy: {order_by}) {{
290                nodes {{ {fields} }}
291              }}
292            }}
293            "#,
294            order_by = order_by,
295            fields = ISSUE_FIELDS
296        )
297    } else {
298        format!(
299            r#"
300            query XbpLinearIssues($first: Int!, $filter: IssueFilter) {{
301              issues(first: $first, filter: $filter, orderBy: {order_by}) {{
302                nodes {{ {fields} }}
303              }}
304            }}
305            "#,
306            order_by = order_by,
307            fields = ISSUE_FIELDS
308        )
309    };
310
311    let mut variables = json!({ "first": limit as i64 });
312    if !filter_obj.is_empty() {
313        variables["filter"] = JsonValue::Object(filter_obj);
314    }
315
316    let body =
317        linear_graphql_request(api_key, &json!({ "query": query, "variables": variables })).await?;
318    linear_graphql_errors(&body)?;
319
320    let mut issues: Vec<LinearIssue> = body
321        .get("data")
322        .and_then(|d| d.get("issues"))
323        .and_then(|i| i.get("nodes"))
324        .and_then(JsonValue::as_array)
325        .ok_or_else(|| "Linear issues query returned no data.".to_string())?
326        .iter()
327        .cloned()
328        .map(|v| {
329            serde_json::from_value::<IssueNode>(v)
330                .map(LinearIssue::from)
331                .map_err(|e| format!("Failed to decode Linear issue: {e}"))
332        })
333        .collect::<Result<Vec<_>, _>>()?;
334
335    if let Some(q) = filter.query {
336        let q = q.trim().to_ascii_lowercase();
337        if !q.is_empty() {
338            issues.retain(|issue| issue_matches_query(issue, &q));
339        }
340    }
341
342    if !filter.labels.is_empty() {
343        let wanted: Vec<String> = filter
344            .labels
345            .iter()
346            .map(|l| l.trim().to_ascii_lowercase())
347            .filter(|l| !l.is_empty())
348            .collect();
349        if !wanted.is_empty() {
350            issues.retain(|issue| {
351                wanted.iter().all(|label| {
352                    issue
353                        .labels
354                        .iter()
355                        .any(|(_, name)| name.to_ascii_lowercase() == *label)
356                })
357            });
358        }
359    }
360
361    sort_issues(&mut issues, filter.sort, filter.sort_asc);
362    Ok(issues)
363}
364
365fn canonical_state_name(state: &str) -> String {
366    match state.trim().to_ascii_lowercase().as_str() {
367        "done" => "Done".into(),
368        "completed" | "complete" => "Completed".into(),
369        "canceled" | "cancelled" => "Canceled".into(),
370        "duplicate" => "Duplicate".into(),
371        "started" => "Started".into(),
372        "backlog" => "Backlog".into(),
373        "triage" => "Triage".into(),
374        "unstarted" => "Unstarted".into(),
375        _ => state.to_string(),
376    }
377}
378
379pub fn issue_matches_query(issue: &LinearIssue, query_lower: &str) -> bool {
380    if query_lower.is_empty() {
381        return true;
382    }
383    issue.title.to_ascii_lowercase().contains(query_lower)
384        || issue.identifier.to_ascii_lowercase().contains(query_lower)
385        || issue
386            .assignee_name
387            .as_deref()
388            .is_some_and(|a| a.to_ascii_lowercase().contains(query_lower))
389        || issue
390            .state_name
391            .as_deref()
392            .is_some_and(|s| s.to_ascii_lowercase().contains(query_lower))
393        || issue
394            .description
395            .as_deref()
396            .is_some_and(|d| d.to_ascii_lowercase().contains(query_lower))
397        || issue
398            .labels
399            .iter()
400            .any(|(_, name)| name.to_ascii_lowercase().contains(query_lower))
401}
402
403pub fn sort_issues(issues: &mut [LinearIssue], field: IssueSortField, ascending: bool) {
404    issues.sort_by(|a, b| {
405        let ord = match field {
406            IssueSortField::Updated => a
407                .updated_at
408                .as_deref()
409                .unwrap_or("")
410                .cmp(b.updated_at.as_deref().unwrap_or("")),
411            IssueSortField::Created => a
412                .created_at
413                .as_deref()
414                .unwrap_or("")
415                .cmp(b.created_at.as_deref().unwrap_or("")),
416            IssueSortField::Priority => {
417                // Linear: 1 urgent .. 4 low; 0 none — treat 0 as last when descending urgency.
418                let ap = if a.priority == 0 { 99 } else { a.priority };
419                let bp = if b.priority == 0 { 99 } else { b.priority };
420                ap.cmp(&bp)
421            }
422            IssueSortField::Identifier => a.identifier.cmp(&b.identifier),
423            IssueSortField::Title => a
424                .title
425                .to_ascii_lowercase()
426                .cmp(&b.title.to_ascii_lowercase()),
427            IssueSortField::State => a
428                .state_name
429                .as_deref()
430                .unwrap_or("")
431                .cmp(b.state_name.as_deref().unwrap_or("")),
432        };
433        if ascending {
434            ord
435        } else {
436            ord.reverse()
437        }
438    });
439}
440
441pub async fn get_issue(api_key: &str, id_or_identifier: &str) -> Result<LinearIssue, String> {
442    let id = id_or_identifier.trim();
443    if id.is_empty() {
444        return Err("Issue id or identifier is required.".into());
445    }
446    let query = format!(
447        r#"
448        query XbpLinearIssue($id: String!) {{
449          issue(id: $id) {{ {fields} }}
450        }}
451        "#,
452        fields = ISSUE_FIELDS
453    );
454    let body = linear_graphql_request(
455        api_key,
456        &json!({ "query": query, "variables": { "id": id } }),
457    )
458    .await?;
459    linear_graphql_errors(&body)?;
460    let value = body
461        .get("data")
462        .and_then(|d| d.get("issue"))
463        .cloned()
464        .filter(|v| !v.is_null())
465        .ok_or_else(|| format!("Linear issue `{id}` not found."))?;
466    let node: IssueNode =
467        serde_json::from_value(value).map_err(|e| format!("Failed to decode Linear issue: {e}"))?;
468    Ok(node.into())
469}
470
471pub async fn create_issue(api_key: &str, input: CreateIssueInput) -> Result<LinearIssue, String> {
472    let mut create_input = JsonMap::new();
473    create_input.insert("teamId".into(), json!(input.team_id));
474    create_input.insert("title".into(), json!(input.title));
475    if let Some(description) = input.description {
476        create_input.insert("description".into(), json!(description));
477    }
478    if let Some(priority) = input.priority {
479        create_input.insert("priority".into(), json!(priority));
480    }
481    if let Some(state_id) = input.state_id {
482        create_input.insert("stateId".into(), json!(state_id));
483    }
484    if let Some(assignee_id) = input.assignee_id {
485        create_input.insert("assigneeId".into(), json!(assignee_id));
486    }
487    if !input.label_ids.is_empty() {
488        create_input.insert("labelIds".into(), json!(input.label_ids));
489    }
490    if let Some(project_id) = input.project_id {
491        create_input.insert("projectId".into(), json!(project_id));
492    }
493
494    let query = format!(
495        r#"
496        mutation XbpLinearIssueCreate($input: IssueCreateInput!) {{
497          issueCreate(input: $input) {{
498            success
499            issue {{ {fields} }}
500          }}
501        }}
502        "#,
503        fields = ISSUE_FIELDS
504    );
505    let body = linear_graphql_request(
506        api_key,
507        &json!({
508            "query": query,
509            "variables": { "input": JsonValue::Object(create_input) }
510        }),
511    )
512    .await?;
513    linear_graphql_errors(&body)?;
514    let payload = body
515        .get("data")
516        .and_then(|d| d.get("issueCreate"))
517        .cloned()
518        .ok_or_else(|| "Linear issueCreate returned no data.".to_string())?;
519    if payload.get("success").and_then(JsonValue::as_bool) == Some(false) {
520        return Err("Linear issueCreate reported success=false.".into());
521    }
522    let issue = payload
523        .get("issue")
524        .cloned()
525        .ok_or_else(|| "Linear issueCreate returned no issue.".to_string())?;
526    let node: IssueNode = serde_json::from_value(issue)
527        .map_err(|e| format!("Failed to decode created issue: {e}"))?;
528    Ok(node.into())
529}
530
531/// Archive a Linear issue by id or identifier.
532pub async fn archive_issue(api_key: &str, id_or_identifier: &str) -> Result<(), String> {
533    let existing = get_issue(api_key, id_or_identifier).await?;
534    let body = linear_graphql_request(
535        api_key,
536        &json!({
537            "query": r#"
538                mutation XbpLinearIssueArchive($id: String!) {
539                  issueArchive(id: $id) {
540                    success
541                  }
542                }
543            "#,
544            "variables": { "id": existing.id }
545        }),
546    )
547    .await?;
548    linear_graphql_errors(&body)?;
549    let success = body
550        .get("data")
551        .and_then(|d| d.get("issueArchive"))
552        .and_then(|p| p.get("success"))
553        .and_then(JsonValue::as_bool)
554        .unwrap_or(false);
555    if !success {
556        return Err(format!(
557            "Linear issueArchive reported success=false for `{}`.",
558            existing.identifier
559        ));
560    }
561    Ok(())
562}
563
564/// Mark `duplicate` as a duplicate of `canonical` (both id or identifier).
565pub async fn mark_duplicate_of(
566    api_key: &str,
567    duplicate: &str,
568    canonical: &str,
569) -> Result<LinearIssue, String> {
570    let dup = get_issue(api_key, duplicate).await?;
571    let canon = get_issue(api_key, canonical).await?;
572    let mut update = JsonMap::new();
573    update.insert("duplicateOfId".into(), json!(canon.id));
574    let query = format!(
575        r#"
576        mutation XbpLinearIssueDuplicate($id: String!, $input: IssueUpdateInput!) {{
577          issueUpdate(id: $id, input: $input) {{
578            success
579            issue {{ {fields} }}
580          }}
581        }}
582        "#,
583        fields = ISSUE_FIELDS
584    );
585    let body = linear_graphql_request(
586        api_key,
587        &json!({
588            "query": query,
589            "variables": {
590                "id": dup.id,
591                "input": JsonValue::Object(update)
592            }
593        }),
594    )
595    .await?;
596    linear_graphql_errors(&body)?;
597    let payload = body
598        .get("data")
599        .and_then(|d| d.get("issueUpdate"))
600        .cloned()
601        .ok_or_else(|| "Linear issueUpdate (duplicateOf) returned no data.".to_string())?;
602    if payload.get("success").and_then(JsonValue::as_bool) == Some(false) {
603        return Err("Linear issueUpdate (duplicateOf) reported success=false.".into());
604    }
605    let issue = payload
606        .get("issue")
607        .cloned()
608        .ok_or_else(|| "Linear issueUpdate returned no issue.".to_string())?;
609    let node: IssueNode = serde_json::from_value(issue)
610        .map_err(|e| format!("Failed to decode updated issue: {e}"))?;
611    Ok(node.into())
612}
613
614/// Look up Linear issues that have an attachment pointing at `url` (e.g. a GitHub issue URL).
615pub async fn issues_for_attachment_url(
616    api_key: &str,
617    url: &str,
618) -> Result<Vec<LinearIssue>, String> {
619    let url = url.trim();
620    if url.is_empty() {
621        return Ok(Vec::new());
622    }
623    let body = linear_graphql_request(
624        api_key,
625        &json!({
626            "query": r#"
627                query XbpAttachmentsForURL($url: String!) {
628                  attachmentsForURL(url: $url) {
629                    nodes {
630                      id
631                      url
632                      issue {
633                        id
634                        identifier
635                        title
636                        description
637                        priority
638                        url
639                        updatedAt
640                        createdAt
641                        state { id name type }
642                        assignee { id name }
643                        team { id key name }
644                        labels { nodes { id name } }
645                      }
646                    }
647                  }
648                }
649            "#,
650            "variables": { "url": url }
651        }),
652    )
653    .await?;
654    linear_graphql_errors(&body)?;
655    let nodes = body
656        .get("data")
657        .and_then(|d| d.get("attachmentsForURL"))
658        .and_then(|a| a.get("nodes"))
659        .and_then(|n| n.as_array())
660        .cloned()
661        .unwrap_or_default();
662    let mut issues = Vec::new();
663    for node in nodes {
664        if let Some(issue_val) = node.get("issue").cloned().filter(|v| !v.is_null()) {
665            if let Ok(issue_node) = serde_json::from_value::<IssueNode>(issue_val) {
666                issues.push(issue_node.into());
667            }
668        }
669    }
670    Ok(issues)
671}
672
673/// Attach an external URL to a Linear issue (generic link attachment).
674pub async fn attachment_link_url(
675    api_key: &str,
676    issue_id: &str,
677    url: &str,
678    title: Option<&str>,
679) -> Result<(), String> {
680    let url = url.trim();
681    if url.is_empty() {
682        return Err("Attachment URL cannot be empty.".into());
683    }
684    let mut input = JsonMap::new();
685    input.insert("issueId".into(), json!(issue_id));
686    input.insert("url".into(), json!(url));
687    if let Some(t) = title.map(str::trim).filter(|s| !s.is_empty()) {
688        input.insert("title".into(), json!(t));
689    }
690    let body = linear_graphql_request(
691        api_key,
692        &json!({
693            "query": r#"
694                mutation XbpAttachmentLinkURL($input: AttachmentCreateInput!) {
695                  attachmentCreate(input: $input) {
696                    success
697                  }
698                }
699            "#,
700            "variables": { "input": JsonValue::Object(input) }
701        }),
702    )
703    .await?;
704    // attachmentCreate may not exist on all workspaces; soft-fail.
705    if let Err(err) = linear_graphql_errors(&body) {
706        // Try older attachmentLinkURL mutation name.
707        let body2 = linear_graphql_request(
708            api_key,
709            &json!({
710                "query": r#"
711                    mutation XbpAttachmentLinkURL2($issueId: String!, $url: String!, $title: String) {
712                      attachmentLinkURL(issueId: $issueId, url: $url, title: $title) {
713                        success
714                      }
715                    }
716                "#,
717                "variables": {
718                    "issueId": issue_id,
719                    "url": url,
720                    "title": title
721                }
722            }),
723        )
724        .await?;
725        if let Err(err2) = linear_graphql_errors(&body2) {
726            return Err(format!("Failed to attach URL: {err}; fallback: {err2}"));
727        }
728        return Ok(());
729    }
730    Ok(())
731}
732
733pub async fn update_issue(
734    api_key: &str,
735    id_or_identifier: &str,
736    input: UpdateIssueInput,
737) -> Result<LinearIssue, String> {
738    let existing = get_issue(api_key, id_or_identifier).await?;
739    let mut update = JsonMap::new();
740    if let Some(title) = input.title {
741        update.insert("title".into(), json!(title));
742    }
743    if let Some(description) = input.description {
744        update.insert("description".into(), json!(description));
745    }
746    if let Some(priority) = input.priority {
747        update.insert("priority".into(), json!(priority));
748    }
749    if let Some(state_id) = input.state_id {
750        update.insert("stateId".into(), json!(state_id));
751    }
752    if let Some(assignee) = input.assignee_id {
753        match assignee {
754            Some(id) => {
755                update.insert("assigneeId".into(), json!(id));
756            }
757            None => {
758                update.insert("assigneeId".into(), JsonValue::Null);
759            }
760        }
761    }
762    if let Some(label_ids) = input.label_ids {
763        update.insert("labelIds".into(), json!(label_ids));
764    }
765    if let Some(project) = input.project_id {
766        match project {
767            Some(id) => {
768                update.insert("projectId".into(), json!(id));
769            }
770            None => {
771                update.insert("projectId".into(), JsonValue::Null);
772            }
773        }
774    }
775    if let Some(cycle) = input.cycle_id {
776        match cycle {
777            Some(id) => {
778                update.insert("cycleId".into(), json!(id));
779            }
780            None => {
781                update.insert("cycleId".into(), JsonValue::Null);
782            }
783        }
784    }
785    if update.is_empty() {
786        return Ok(existing);
787    }
788
789    let query = format!(
790        r#"
791        mutation XbpLinearIssueUpdate($id: String!, $input: IssueUpdateInput!) {{
792          issueUpdate(id: $id, input: $input) {{
793            success
794            issue {{ {fields} }}
795          }}
796        }}
797        "#,
798        fields = ISSUE_FIELDS
799    );
800    let body = linear_graphql_request(
801        api_key,
802        &json!({
803            "query": query,
804            "variables": {
805                "id": existing.id,
806                "input": JsonValue::Object(update)
807            }
808        }),
809    )
810    .await?;
811    linear_graphql_errors(&body)?;
812    let payload = body
813        .get("data")
814        .and_then(|d| d.get("issueUpdate"))
815        .cloned()
816        .ok_or_else(|| "Linear issueUpdate returned no data.".to_string())?;
817    if payload.get("success").and_then(JsonValue::as_bool) == Some(false) {
818        return Err("Linear issueUpdate reported success=false.".into());
819    }
820    let issue = payload
821        .get("issue")
822        .cloned()
823        .ok_or_else(|| "Linear issueUpdate returned no issue.".to_string())?;
824    let node: IssueNode = serde_json::from_value(issue)
825        .map_err(|e| format!("Failed to decode updated issue: {e}"))?;
826    Ok(node.into())
827}
828
829pub async fn run_list(api_key: &str, args: LinearListCmd) -> Result<(), String> {
830    if args.tui {
831        let filter = build_list_filter_from_list_cmd(api_key, &args).await?;
832        return super::tui::run_linear_tui(api_key, filter).await;
833    }
834    let loader = Loader::start("Fetching Linear issues");
835    let filter = build_list_filter_from_list_cmd(api_key, &args).await;
836    let filter = match filter {
837        Ok(f) => f,
838        Err(e) => {
839            loader.fail(&e);
840            return Err(e);
841        }
842    };
843    let issues = match list_issues(api_key, filter).await {
844        Ok(v) => {
845            loader.success();
846            v
847        }
848        Err(e) => {
849            loader.fail(&e);
850            return Err(e);
851        }
852    };
853    print_issue_table(&issues);
854    Ok(())
855}
856
857pub async fn run_search(api_key: &str, args: LinearSearchCmd) -> Result<(), String> {
858    let filter = build_list_filter_from_search_cmd(api_key, &args).await?;
859    if args.tui {
860        return super::tui::run_linear_tui(api_key, filter).await;
861    }
862    let loader = Loader::start("Searching Linear issues");
863    let issues = match list_issues(api_key, filter).await {
864        Ok(v) => {
865            loader.success();
866            v
867        }
868        Err(e) => {
869            loader.fail(&e);
870            return Err(e);
871        }
872    };
873    print_issue_table(&issues);
874    Ok(())
875}
876
877pub async fn build_list_filter_from_list_cmd(
878    api_key: &str,
879    args: &LinearListCmd,
880) -> Result<ListIssuesFilter, String> {
881    let sort = IssueSortField::parse(&args.sort)?;
882    let team_id = if let Some(team) = args.team.as_deref() {
883        Some(resolve_team_id(api_key, team).await?)
884    } else {
885        None
886    };
887    let assignee_spec = if args.me {
888        Some("me")
889    } else {
890        args.assignee.as_deref()
891    };
892    let assignee_id = if let Some(assignee) = assignee_spec {
893        resolve_assignee_id(api_key, assignee).await?
894    } else {
895        None
896    };
897    Ok(ListIssuesFilter {
898        team_id,
899        state: args.state.clone(),
900        assignee_id,
901        query: args.query.clone(),
902        labels: args.labels.clone(),
903        include_completed: args.include_completed,
904        sort,
905        sort_asc: args.asc,
906        limit: args.limit,
907    })
908}
909
910pub async fn build_list_filter_from_search_cmd(
911    api_key: &str,
912    args: &LinearSearchCmd,
913) -> Result<ListIssuesFilter, String> {
914    let sort = IssueSortField::parse(&args.sort)?;
915    let team_id = if let Some(team) = args.team.as_deref() {
916        Some(resolve_team_id(api_key, team).await?)
917    } else {
918        None
919    };
920    let assignee_spec = if args.me {
921        Some("me")
922    } else {
923        args.assignee.as_deref()
924    };
925    let assignee_id = if let Some(assignee) = assignee_spec {
926        resolve_assignee_id(api_key, assignee).await?
927    } else {
928        None
929    };
930    Ok(ListIssuesFilter {
931        team_id,
932        state: args.state.clone(),
933        assignee_id,
934        query: Some(args.query.clone()),
935        labels: args.labels.clone(),
936        include_completed: args.include_completed,
937        sort,
938        sort_asc: args.asc,
939        limit: args.limit,
940    })
941}
942
943pub async fn build_list_filter_from_hub_cmd(
944    api_key: &str,
945    args: &crate::cli::commands::LinearIssuesCmd,
946) -> Result<ListIssuesFilter, String> {
947    let sort = IssueSortField::parse(&args.sort)?;
948    let team_id = if let Some(team) = args.team.as_deref() {
949        Some(resolve_team_id(api_key, team).await?)
950    } else {
951        None
952    };
953    let assignee_spec = if args.me {
954        Some("me")
955    } else {
956        args.assignee.as_deref()
957    };
958    let assignee_id = if let Some(assignee) = assignee_spec {
959        resolve_assignee_id(api_key, assignee).await?
960    } else {
961        None
962    };
963    Ok(ListIssuesFilter {
964        team_id,
965        state: args.state.clone(),
966        assignee_id,
967        query: args.query.clone(),
968        labels: args.labels.clone(),
969        include_completed: args.include_completed,
970        sort,
971        sort_asc: args.asc,
972        limit: args.limit,
973    })
974}
975
976pub async fn run_show(api_key: &str, args: LinearShowCmd) -> Result<(), String> {
977    let loader = Loader::start("Fetching Linear issue");
978    let issue = match get_issue(api_key, &args.id).await {
979        Ok(v) => {
980            loader.success();
981            v
982        }
983        Err(e) => {
984            loader.fail(&e);
985            return Err(e);
986        }
987    };
988    print_issue_detail(&issue);
989    Ok(())
990}
991
992pub async fn run_create(api_key: &str, args: LinearCreateCmd) -> Result<(), String> {
993    let team_id = match args.team.as_deref() {
994        Some(t) if !t.trim().is_empty() => resolve_team_id(api_key, t).await?,
995        _ => match default_team_hint() {
996            Some(t) => resolve_team_id(api_key, &t).await?,
997            None => match super::meta::resolve_team_id_auto(api_key, None).await {
998                Ok(id) => id,
999                Err(_) if is_tty() => pick_team_interactive(api_key).await?,
1000                Err(e) => return Err(e),
1001            },
1002        },
1003    };
1004
1005    let title = match args.title.as_deref() {
1006        Some(t) if !t.trim().is_empty() => t.trim().to_string(),
1007        _ if is_tty() => {
1008            require_interactive("Creating a Linear issue")?;
1009            Input::<String>::with_theme(&ColorfulTheme::default())
1010                .with_prompt("Title")
1011                .interact_text()
1012                .map_err(|e| e.to_string())?
1013        }
1014        _ => return Err("Title is required (`--title`).".into()),
1015    };
1016
1017    let description = match args.description.clone() {
1018        Some(d) => Some(d),
1019        None if args.interactive_body && is_tty() => {
1020            let body: String = Input::with_theme(&ColorfulTheme::default())
1021                .with_prompt("Description (optional)")
1022                .allow_empty(true)
1023                .interact_text()
1024                .map_err(|e| e.to_string())?;
1025            if body.trim().is_empty() {
1026                None
1027            } else {
1028                Some(body)
1029            }
1030        }
1031        None => None,
1032    };
1033
1034    let priority = match args.priority.as_deref() {
1035        Some(p) => Some(parse_priority_arg(p)?),
1036        None => None,
1037    };
1038
1039    let state_id = match args.state.as_deref() {
1040        Some(s) => Some(resolve_state_id(api_key, &team_id, s).await?),
1041        None => None,
1042    };
1043
1044    let assignee_id = match args.assignee.as_deref() {
1045        Some(a) => resolve_assignee_id(api_key, a).await?,
1046        None => None,
1047    };
1048
1049    let label_ids = resolve_label_ids(api_key, Some(&team_id), &args.labels).await?;
1050
1051    let loader = Loader::start("Creating Linear issue");
1052    let issue = match create_issue(
1053        api_key,
1054        CreateIssueInput {
1055            team_id,
1056            title,
1057            description,
1058            priority,
1059            state_id,
1060            assignee_id,
1061            label_ids,
1062            project_id: None,
1063        },
1064    )
1065    .await
1066    {
1067        Ok(v) => {
1068            loader.success_with(&format!("created {}", v.identifier));
1069            v
1070        }
1071        Err(e) => {
1072            loader.fail(&e);
1073            return Err(e);
1074        }
1075    };
1076    print_issue_detail(&issue);
1077    Ok(())
1078}
1079
1080pub async fn run_edit(api_key: &str, args: LinearEditCmd) -> Result<(), String> {
1081    let mut input = UpdateIssueInput {
1082        title: args.title.clone(),
1083        description: args.description.clone(),
1084        priority: args
1085            .priority
1086            .as_deref()
1087            .map(parse_priority_arg)
1088            .transpose()?,
1089        state_id: None,
1090        assignee_id: None,
1091        label_ids: None,
1092        project_id: None,
1093        cycle_id: None,
1094    };
1095
1096    if input.title.is_none()
1097        && input.description.is_none()
1098        && input.priority.is_none()
1099        && args.state.is_none()
1100        && args.assignee.is_none()
1101        && args.labels.is_empty()
1102    {
1103        require_interactive("Editing a Linear issue")?;
1104        let issue = get_issue(api_key, &args.id).await?;
1105        let title: String = Input::with_theme(&ColorfulTheme::default())
1106            .with_prompt("Title")
1107            .with_initial_text(&issue.title)
1108            .interact_text()
1109            .map_err(|e| e.to_string())?;
1110        input.title = Some(title);
1111        let description: String = Input::with_theme(&ColorfulTheme::default())
1112            .with_prompt("Description")
1113            .with_initial_text(issue.description.as_deref().unwrap_or(""))
1114            .allow_empty(true)
1115            .interact_text()
1116            .map_err(|e| e.to_string())?;
1117        input.description = Some(description);
1118    }
1119
1120    if let Some(state) = args.state.as_deref() {
1121        let issue = get_issue(api_key, &args.id).await?;
1122        let team_id = issue
1123            .team_id
1124            .ok_or_else(|| "Issue has no team; cannot resolve state.".to_string())?;
1125        input.state_id = Some(resolve_state_id(api_key, &team_id, state).await?);
1126    }
1127    if let Some(assignee) = args.assignee.as_deref() {
1128        input.assignee_id = Some(resolve_assignee_id(api_key, assignee).await?);
1129    }
1130    if !args.labels.is_empty() {
1131        let issue = get_issue(api_key, &args.id).await?;
1132        input.label_ids =
1133            Some(resolve_label_ids(api_key, issue.team_id.as_deref(), &args.labels).await?);
1134    }
1135
1136    let loader = Loader::start("Updating Linear issue");
1137    let issue = match update_issue(api_key, &args.id, input).await {
1138        Ok(v) => {
1139            loader.success_with(&format!("updated {}", v.identifier));
1140            v
1141        }
1142        Err(e) => {
1143            loader.fail(&e);
1144            return Err(e);
1145        }
1146    };
1147    print_issue_detail(&issue);
1148    Ok(())
1149}
1150
1151pub async fn run_status(api_key: &str, args: LinearStatusCmd) -> Result<(), String> {
1152    let issue = get_issue(api_key, &args.id).await?;
1153    let team_id = issue
1154        .team_id
1155        .clone()
1156        .ok_or_else(|| "Issue has no team; cannot change status.".to_string())?;
1157    let state = match args.state.as_deref() {
1158        Some(s) if !s.trim().is_empty() => s.to_string(),
1159        _ => {
1160            require_interactive("Changing Linear issue status")?;
1161            let states = list_workflow_states(api_key, &team_id).await?;
1162            if states.is_empty() {
1163                return Err("No workflow states found for this team.".into());
1164            }
1165            let labels: Vec<String> = states
1166                .iter()
1167                .map(|s| format!("{} ({})", s.name, s.state_type))
1168                .collect();
1169            let idx = FuzzySelect::with_theme(&ColorfulTheme::default())
1170                .with_prompt(format!("Status for {}", issue.identifier))
1171                .items(&labels)
1172                .default(0)
1173                .interact()
1174                .map_err(|e| e.to_string())?;
1175            states[idx].name.clone()
1176        }
1177    };
1178    let state_id = resolve_state_id(api_key, &team_id, &state).await?;
1179    let loader = Loader::start("Updating status");
1180    let updated = match update_issue(
1181        api_key,
1182        &issue.id,
1183        UpdateIssueInput {
1184            state_id: Some(state_id),
1185            ..Default::default()
1186        },
1187    )
1188    .await
1189    {
1190        Ok(v) => {
1191            loader.success();
1192            v
1193        }
1194        Err(e) => {
1195            loader.fail(&e);
1196            return Err(e);
1197        }
1198    };
1199    println!(
1200        "{} {} → {}",
1201        "OK".bright_green().bold(),
1202        updated.identifier.bright_white().bold(),
1203        updated.state_name.as_deref().unwrap_or("?").bright_cyan()
1204    );
1205    Ok(())
1206}
1207
1208pub async fn run_priority(api_key: &str, args: LinearPriorityCmd) -> Result<(), String> {
1209    let priority = match args.priority.as_deref() {
1210        Some(p) => parse_priority_arg(p)?,
1211        None => {
1212            require_interactive("Changing Linear issue priority")?;
1213            let options = ["None", "Urgent", "High", "Medium", "Low"];
1214            let idx = FuzzySelect::with_theme(&ColorfulTheme::default())
1215                .with_prompt(format!("Priority for {}", args.id))
1216                .items(&options)
1217                .default(0)
1218                .interact()
1219                .map_err(|e| e.to_string())?;
1220            idx as i32
1221        }
1222    };
1223    let loader = Loader::start("Updating priority");
1224    let updated = match update_issue(
1225        api_key,
1226        &args.id,
1227        UpdateIssueInput {
1228            priority: Some(priority),
1229            ..Default::default()
1230        },
1231    )
1232    .await
1233    {
1234        Ok(v) => {
1235            loader.success();
1236            v
1237        }
1238        Err(e) => {
1239            loader.fail(&e);
1240            return Err(e);
1241        }
1242    };
1243    println!(
1244        "{} {} priority → {}",
1245        "OK".bright_green().bold(),
1246        updated.identifier.bright_white().bold(),
1247        priority_label(updated.priority).bright_yellow()
1248    );
1249    Ok(())
1250}
1251
1252pub async fn run_labels(api_key: &str, args: LinearLabelsCmd) -> Result<(), String> {
1253    let issue = get_issue(api_key, &args.id).await?;
1254    let team_id = issue.team_id.clone();
1255    let label_ids = if !args.add.is_empty() || !args.set.is_empty() || args.clear {
1256        if args.clear {
1257            Vec::new()
1258        } else if !args.set.is_empty() {
1259            resolve_label_ids(api_key, team_id.as_deref(), &args.set).await?
1260        } else {
1261            let mut current: Vec<String> = issue.labels.iter().map(|(id, _)| id.clone()).collect();
1262            let add_ids = resolve_label_ids(api_key, team_id.as_deref(), &args.add).await?;
1263            for id in add_ids {
1264                if !current.contains(&id) {
1265                    current.push(id);
1266                }
1267            }
1268            if !args.remove.is_empty() {
1269                let remove_ids =
1270                    resolve_label_ids(api_key, team_id.as_deref(), &args.remove).await?;
1271                current.retain(|id| !remove_ids.contains(id));
1272            }
1273            current
1274        }
1275    } else {
1276        require_interactive("Assigning Linear labels")?;
1277        let known = super::meta::list_labels(api_key, team_id.as_deref()).await?;
1278        if known.is_empty() {
1279            return Err("No labels found for this workspace/team.".into());
1280        }
1281        let names: Vec<String> = known.iter().map(|l| l.name.clone()).collect();
1282        let defaults: Vec<bool> = known
1283            .iter()
1284            .map(|l| issue.labels.iter().any(|(id, _)| id == &l.id))
1285            .collect();
1286        let selected = MultiSelect::with_theme(&ColorfulTheme::default())
1287            .with_prompt(format!("Labels for {}", issue.identifier))
1288            .items(&names)
1289            .defaults(&defaults)
1290            .interact()
1291            .map_err(|e| e.to_string())?;
1292        selected.into_iter().map(|i| known[i].id.clone()).collect()
1293    };
1294
1295    let loader = Loader::start("Updating labels");
1296    let updated = match update_issue(
1297        api_key,
1298        &issue.id,
1299        UpdateIssueInput {
1300            label_ids: Some(label_ids),
1301            ..Default::default()
1302        },
1303    )
1304    .await
1305    {
1306        Ok(v) => {
1307            loader.success();
1308            v
1309        }
1310        Err(e) => {
1311            loader.fail(&e);
1312            return Err(e);
1313        }
1314    };
1315    let label_str = if updated.labels.is_empty() {
1316        "(none)".dimmed().to_string()
1317    } else {
1318        updated
1319            .labels
1320            .iter()
1321            .map(|(_, n)| n.as_str())
1322            .collect::<Vec<_>>()
1323            .join(", ")
1324    };
1325    println!(
1326        "{} {} labels → {}",
1327        "OK".bright_green().bold(),
1328        updated.identifier.bright_white().bold(),
1329        label_str
1330    );
1331    Ok(())
1332}
1333
1334pub async fn run_assign(api_key: &str, args: LinearAssignCmd) -> Result<(), String> {
1335    let assignee = match args.assignee.as_deref() {
1336        Some(a) => a.to_string(),
1337        None => {
1338            require_interactive("Assigning a Linear issue")?;
1339            let users = super::meta::list_users(api_key).await?;
1340            let mut labels = vec!["(unassign)".to_string(), "me".to_string()];
1341            labels.extend(users.iter().map(|u| {
1342                format!(
1343                    "{}{}",
1344                    u.display_name.as_deref().unwrap_or(&u.name),
1345                    u.email
1346                        .as_ref()
1347                        .map(|e| format!(" <{e}>"))
1348                        .unwrap_or_default()
1349                )
1350            }));
1351            let idx = FuzzySelect::with_theme(&ColorfulTheme::default())
1352                .with_prompt(format!("Assignee for {}", args.id))
1353                .items(&labels)
1354                .default(1)
1355                .interact()
1356                .map_err(|e| e.to_string())?;
1357            if idx == 0 {
1358                "none".to_string()
1359            } else if idx == 1 {
1360                "me".to_string()
1361            } else {
1362                users[idx - 2].id.clone()
1363            }
1364        }
1365    };
1366    let assignee_id = resolve_assignee_id(api_key, &assignee).await?;
1367    let loader = Loader::start("Updating assignee");
1368    let updated = match update_issue(
1369        api_key,
1370        &args.id,
1371        UpdateIssueInput {
1372            assignee_id: Some(assignee_id),
1373            ..Default::default()
1374        },
1375    )
1376    .await
1377    {
1378        Ok(v) => {
1379            loader.success();
1380            v
1381        }
1382        Err(e) => {
1383            loader.fail(&e);
1384            return Err(e);
1385        }
1386    };
1387    println!(
1388        "{} {} assignee → {}",
1389        "OK".bright_green().bold(),
1390        updated.identifier.bright_white().bold(),
1391        updated
1392            .assignee_name
1393            .as_deref()
1394            .unwrap_or("(none)")
1395            .bright_cyan()
1396    );
1397    Ok(())
1398}
1399
1400pub fn print_issue_table(issues: &[LinearIssue]) {
1401    if issues.is_empty() {
1402        println!("{}", "No issues found.".dimmed());
1403        return;
1404    }
1405    let rows: Vec<Vec<String>> = issues
1406        .iter()
1407        .map(|issue| {
1408            vec![
1409                issue.identifier.clone().bright_white().bold().to_string(),
1410                issue
1411                    .state_name
1412                    .clone()
1413                    .unwrap_or_else(|| "-".into())
1414                    .bright_cyan()
1415                    .to_string(),
1416                priority_label(issue.priority).bright_yellow().to_string(),
1417                issue.assignee_name.clone().unwrap_or_else(|| "-".into()),
1418                truncate_chars(&issue.title, 60),
1419            ]
1420        })
1421        .collect();
1422    print!(
1423        "{}",
1424        render_table(
1425            &["ID", "State", "Pri", "Assignee", "Title"],
1426            &rows,
1427            TableStyle::Pipe,
1428            "",
1429        )
1430    );
1431    println!("Total: {} issue(s)", issues.len());
1432}
1433
1434pub fn print_issue_detail(issue: &LinearIssue) {
1435    println!();
1436    println!(
1437        "{} {}",
1438        issue.identifier.bright_white().bold(),
1439        issue.title.bright_cyan()
1440    );
1441    if let Some(url) = &issue.url {
1442        println!("{} {}", "url".dimmed(), url.underline());
1443    }
1444    println!(
1445        "{} {}",
1446        "state".dimmed(),
1447        issue.state_name.as_deref().unwrap_or("-")
1448    );
1449    println!("{} {}", "priority".dimmed(), priority_label(issue.priority));
1450    println!(
1451        "{} {}",
1452        "assignee".dimmed(),
1453        issue.assignee_name.as_deref().unwrap_or("-")
1454    );
1455    println!(
1456        "{} {}",
1457        "team".dimmed(),
1458        issue
1459            .team_key
1460            .as_deref()
1461            .or(issue.team_name.as_deref())
1462            .unwrap_or("-")
1463    );
1464    let labels = if issue.labels.is_empty() {
1465        "-".to_string()
1466    } else {
1467        issue
1468            .labels
1469            .iter()
1470            .map(|(_, n)| n.as_str())
1471            .collect::<Vec<_>>()
1472            .join(", ")
1473    };
1474    println!("{} {}", "labels".dimmed(), labels);
1475    if let Some(desc) = &issue.description {
1476        if !desc.trim().is_empty() {
1477            println!();
1478            println!("{}", desc.trim());
1479        }
1480    }
1481    println!();
1482}
1483
1484async fn pick_team_interactive(api_key: &str) -> Result<String, String> {
1485    require_interactive("Selecting a Linear team")?;
1486    let teams = super::meta::list_teams(api_key).await?;
1487    if teams.is_empty() {
1488        return Err("No Linear teams available for this API key.".into());
1489    }
1490    let labels: Vec<String> = teams
1491        .iter()
1492        .map(|t| format!("{} — {}", t.key, t.name))
1493        .collect();
1494    let idx = FuzzySelect::with_theme(&ColorfulTheme::default())
1495        .with_prompt("Select Linear team")
1496        .items(&labels)
1497        .default(0)
1498        .interact()
1499        .map_err(|e| e.to_string())?;
1500    Ok(teams[idx].id.clone())
1501}
1502
1503/// Interactive team picker that persists `linear.default_team_key` (and id) on the project.
1504pub async fn prompt_and_save_default_team(api_key: &str) -> Result<(), String> {
1505    require_interactive("Saving default Linear team")?;
1506    let teams = super::meta::list_teams(api_key).await?;
1507    if teams.is_empty() {
1508        return Err("No Linear teams available for this API key.".into());
1509    }
1510    let labels: Vec<String> = teams
1511        .iter()
1512        .map(|t| format!("{} — {}", t.key, t.name))
1513        .collect();
1514    let idx = FuzzySelect::with_theme(&ColorfulTheme::default())
1515        .with_prompt("Default Linear team for this repo")
1516        .items(&labels)
1517        .default(0)
1518        .interact()
1519        .map_err(|e| e.to_string())?;
1520    let selected = &teams[idx];
1521    save_default_team_to_project(&selected.id, &selected.key)?;
1522    println!(
1523        "{} default team `{}` ({}) → .xbp/xbp.yaml",
1524        "OK".bright_green().bold(),
1525        selected.key.bright_white().bold(),
1526        selected.name
1527    );
1528    Ok(())
1529}
1530
1531fn save_default_team_to_project(team_id: &str, team_key: &str) -> Result<(), String> {
1532    let cwd = env::current_dir().map_err(|e| format!("cwd: {e}"))?;
1533    let found = find_xbp_config_upwards(&cwd).ok_or_else(|| {
1534        format!(
1535            "No XBP project config found. Run this inside a repo with {}.",
1536            crate::utils::PROJECT_CONFIG_HINT
1537        )
1538    })?;
1539    let content = fs::read_to_string(&found.config_path)
1540        .map_err(|e| format!("Failed to read {}: {e}", found.config_path.display()))?;
1541
1542    let (mut root, _) =
1543        crate::utils::parse_config_with_auto_heal::<serde_json::Value>(&content, found.kind)
1544            .map_err(|e| format!("Failed to parse project config: {e}"))?;
1545
1546    let obj = root
1547        .as_object_mut()
1548        .ok_or_else(|| "Project config root must be a mapping.".to_string())?;
1549    let linear = obj
1550        .entry("linear".to_string())
1551        .or_insert_with(|| serde_json::json!({}));
1552    if linear.is_null() {
1553        *linear = serde_json::json!({});
1554    }
1555    let linear_obj = linear
1556        .as_object_mut()
1557        .ok_or_else(|| "Project `linear` must be an object.".to_string())?;
1558    linear_obj.insert(
1559        "default_team_id".into(),
1560        serde_json::Value::String(team_id.to_string()),
1561    );
1562    linear_obj.insert(
1563        "default_team_key".into(),
1564        serde_json::Value::String(team_key.to_string()),
1565    );
1566
1567    crate::utils::write_xbp_project_config_at_path(&found.config_path, &root)
1568}
1569
1570pub fn default_team_hint() -> Option<String> {
1571    if let Ok(cwd) = env::current_dir() {
1572        if let Some(found) = find_xbp_config_upwards(&cwd) {
1573            if let Ok(content) = fs::read_to_string(&found.config_path) {
1574                let linear = crate::utils::parse_config_with_auto_heal::<XbpConfig>(
1575                    &content,
1576                    found.kind,
1577                )
1578                .ok()
1579                .and_then(|(cfg, _)| cfg.linear);
1580                if let Some(linear) = linear {
1581                    if let Some(id) = linear.default_team_id.filter(|s| !s.trim().is_empty()) {
1582                        return Some(id);
1583                    }
1584                    if let Some(key) = linear.default_team_key.filter(|s| !s.trim().is_empty()) {
1585                        return Some(key);
1586                    }
1587                }
1588            }
1589        }
1590    }
1591    if let Ok(cfg) = SshConfig::load() {
1592        if let Some(LinearConfig {
1593            default_team_id,
1594            default_team_key,
1595            ..
1596        }) = cfg.linear
1597        {
1598            if let Some(id) = default_team_id.filter(|s| !s.trim().is_empty()) {
1599                return Some(id);
1600            }
1601            if let Some(key) = default_team_key.filter(|s| !s.trim().is_empty()) {
1602                return Some(key);
1603            }
1604        }
1605    }
1606    None
1607}
1608
1609fn is_tty() -> bool {
1610    crate::commands::cloudflare_config::is_interactive_terminal()
1611}