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