1use 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)>, 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 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 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 pub project_id: Option<Option<String>>,
132 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 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 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 _ => "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 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
531pub 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
564pub 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
614pub 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
673pub 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 if let Err(err) = linear_graphql_errors(&body) {
706 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
1503pub 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}