Skip to main content

xbp_cli/commands/linear_cmd/
comments.rs

1//! Linear issue comments.
2
3use super::require_interactive;
4use crate::cli::commands::{LinearCommentCmd, LinearCommentsCmd};
5use crate::cli::ui::Loader;
6use crate::commands::linear::{linear_graphql_errors, linear_graphql_request};
7use crate::commands::text_util::truncate_chars;
8use crate::commands::linear_cmd::issues::get_issue;
9use crate::commands::terminal_table::{render_table, TableStyle};
10use colored::Colorize;
11use dialoguer::{theme::ColorfulTheme, Input};
12use serde::Deserialize;
13use serde_json::{json, Value as JsonValue};
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct LinearComment {
17    pub id: String,
18    pub body: String,
19    pub created_at: Option<String>,
20    pub user_name: Option<String>,
21}
22
23#[derive(Debug, Deserialize)]
24struct CommentsPage {
25    nodes: Vec<CommentNode>,
26}
27
28#[derive(Debug, Deserialize)]
29struct CommentNode {
30    id: String,
31    body: String,
32    #[serde(default, rename = "createdAt")]
33    created_at: Option<String>,
34    #[serde(default)]
35    user: Option<UserRef>,
36}
37
38#[derive(Debug, Deserialize)]
39struct UserRef {
40    #[serde(default)]
41    name: Option<String>,
42}
43
44pub async fn list_comments(
45    api_key: &str,
46    issue_id_or_identifier: &str,
47) -> Result<Vec<LinearComment>, String> {
48    let issue = get_issue(api_key, issue_id_or_identifier).await?;
49    let body = linear_graphql_request(
50        api_key,
51        &json!({
52            "query": r#"
53                query XbpLinearComments($id: String!) {
54                  issue(id: $id) {
55                    comments(first: 100, orderBy: createdAt) {
56                      nodes {
57                        id
58                        body
59                        createdAt
60                        user { name }
61                      }
62                    }
63                  }
64                }
65            "#,
66            "variables": { "id": issue.id }
67        }),
68    )
69    .await?;
70    linear_graphql_errors(&body)?;
71    let page = body
72        .get("data")
73        .and_then(|d| d.get("issue"))
74        .and_then(|i| i.get("comments"))
75        .cloned()
76        .ok_or_else(|| "Linear comments query returned no data.".to_string())?;
77    let page: CommentsPage = serde_json::from_value(page)
78        .map_err(|e| format!("Failed to decode Linear comments: {e}"))?;
79    Ok(page
80        .nodes
81        .into_iter()
82        .map(|n| LinearComment {
83            id: n.id,
84            body: n.body,
85            created_at: n.created_at,
86            user_name: n.user.and_then(|u| u.name),
87        })
88        .collect())
89}
90
91pub async fn create_comment(
92    api_key: &str,
93    issue_id_or_identifier: &str,
94    body_text: &str,
95) -> Result<LinearComment, String> {
96    let issue = get_issue(api_key, issue_id_or_identifier).await?;
97    let body_text = body_text.trim();
98    if body_text.is_empty() {
99        return Err("Comment body cannot be empty.".into());
100    }
101    let body = linear_graphql_request(
102        api_key,
103        &json!({
104            "query": r#"
105                mutation XbpLinearCommentCreate($input: CommentCreateInput!) {
106                  commentCreate(input: $input) {
107                    success
108                    comment {
109                      id
110                      body
111                      createdAt
112                      user { name }
113                    }
114                  }
115                }
116            "#,
117            "variables": {
118                "input": {
119                    "issueId": issue.id,
120                    "body": body_text
121                }
122            }
123        }),
124    )
125    .await?;
126    linear_graphql_errors(&body)?;
127    let payload = body
128        .get("data")
129        .and_then(|d| d.get("commentCreate"))
130        .cloned()
131        .ok_or_else(|| "Linear commentCreate returned no data.".to_string())?;
132    if payload.get("success").and_then(JsonValue::as_bool) == Some(false) {
133        return Err("Linear commentCreate reported success=false.".into());
134    }
135    let comment = payload
136        .get("comment")
137        .cloned()
138        .ok_or_else(|| "Linear commentCreate returned no comment.".to_string())?;
139    let node: CommentNode = serde_json::from_value(comment)
140        .map_err(|e| format!("Failed to decode created comment: {e}"))?;
141    Ok(LinearComment {
142        id: node.id,
143        body: node.body,
144        created_at: node.created_at,
145        user_name: node.user.and_then(|u| u.name),
146    })
147}
148
149pub async fn run_comments(api_key: &str, args: LinearCommentsCmd) -> Result<(), String> {
150    let loader = Loader::start("Fetching comments");
151    let comments = match list_comments(api_key, &args.id).await {
152        Ok(v) => {
153            loader.success();
154            v
155        }
156        Err(e) => {
157            loader.fail(&e);
158            return Err(e);
159        }
160    };
161    print_comments(&args.id, &comments);
162    Ok(())
163}
164
165pub async fn run_comment(api_key: &str, args: LinearCommentCmd) -> Result<(), String> {
166    let body_text = match args.body.as_deref() {
167        Some(b) if !b.trim().is_empty() => b.to_string(),
168        _ => {
169            require_interactive("Writing a Linear comment")?;
170            Input::<String>::with_theme(&ColorfulTheme::default())
171                .with_prompt(format!("Comment on {}", args.id))
172                .interact_text()
173                .map_err(|e| e.to_string())?
174        }
175    };
176    let loader = Loader::start("Posting comment");
177    let comment = match create_comment(api_key, &args.id, &body_text).await {
178        Ok(v) => {
179            loader.success();
180            v
181        }
182        Err(e) => {
183            loader.fail(&e);
184            return Err(e);
185        }
186    };
187    println!(
188        "{} comment on {} by {}",
189        "OK".bright_green().bold(),
190        args.id.bright_white().bold(),
191        comment.user_name.as_deref().unwrap_or("you").bright_cyan()
192    );
193    println!("{}", comment.body);
194    Ok(())
195}
196
197fn print_comments(issue_id: &str, comments: &[LinearComment]) {
198    if comments.is_empty() {
199        println!(
200            "{} {}",
201            issue_id.bright_white().bold(),
202            "— no comments".dimmed()
203        );
204        return;
205    }
206    println!(
207        "{} ({} comment(s))",
208        issue_id.bright_white().bold(),
209        comments.len()
210    );
211    let rows: Vec<Vec<String>> = comments
212        .iter()
213        .map(|c| {
214            vec![
215                c.user_name
216                    .clone()
217                    .unwrap_or_else(|| "-".into())
218                    .bright_cyan()
219                    .to_string(),
220                c.created_at
221                    .clone()
222                    .unwrap_or_else(|| "-".into())
223                    .dimmed()
224                    .to_string(),
225                truncate_chars(&c.body.replace('\n', " "), 80),
226            ]
227        })
228        .collect();
229    print!(
230        "{}",
231        render_table(
232            &["Author", "When", "Body"],
233            &rows,
234            TableStyle::Pipe,
235            "",
236        )
237    );
238}
239
240