xbp_cli/commands/linear_cmd/
comments.rs1use 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