1use crate::api::models::{Comment, PullRequest};
2use crate::commands::pr::Ctx;
3use crate::error::{BbError, Result};
4use crate::output::{self, Format};
5use serde::Serialize;
6
7#[derive(Debug, Serialize)]
8pub struct CommentView {
9 pub id: u64,
10 pub author: String,
11 pub timestamp: String,
12 pub body: String,
13 pub file: Option<String>,
14 pub line: Option<u64>,
15 pub resolved: bool,
16}
17
18fn to_view(comment: &Comment) -> CommentView {
19 let inline = comment.inline.as_ref();
20 CommentView {
21 id: comment.id,
22 author: comment.author().to_string(),
23 timestamp: comment
24 .created_on
25 .as_deref()
26 .map(output::relative_time)
27 .unwrap_or_else(|| "-".into()),
28 body: comment.body(),
29 file: inline.and_then(|i| i.path.clone()),
30 line: inline.and_then(|i| i.to.or(i.from)),
31 resolved: comment.is_resolved(),
32 }
33}
34
35pub fn partition(
39 mut comments: Vec<Comment>,
40 unresolved: bool,
41) -> (Vec<CommentView>, Vec<CommentView>) {
42 comments.sort_by(|a, b| a.created_on.cmp(&b.created_on));
43
44 let mut general = Vec::new();
45 let mut inline = Vec::new();
46 for comment in &comments {
47 if comment.is_inline() {
48 if unresolved && comment.is_resolved() {
49 continue;
50 }
51 inline.push(to_view(comment));
52 } else {
53 general.push(to_view(comment));
54 }
55 }
56 (general, inline)
57}
58
59pub async fn view(ctx: &Ctx, id: u64, unresolved: bool, comments_only: bool) -> Result<()> {
60 let pr: Option<PullRequest> = if comments_only {
61 None
62 } else {
63 Some(
64 ctx.client
65 .get_json(&ctx.path(&format!("/pullrequests/{id}")))
66 .await?,
67 )
68 };
69
70 let spinner = output::spinner("fetching comments");
71 let comments: Vec<Comment> = ctx
72 .client
73 .paginate(&ctx.path(&format!("/pullrequests/{id}/comments?pagelen=100")))
74 .await?;
75 spinner.finish_and_clear();
76
77 let (general, inline) = partition(comments, unresolved);
78
79 match ctx.format {
80 Format::Json => output::print_json(&serde_json::json!({
81 "pull_request": pr.as_ref().map(|pr| serde_json::json!({
82 "id": pr.id,
83 "title": pr.title,
84 "state": pr.state,
85 "author": pr.author_name(),
86 "source": pr.source_branch(),
87 "destination": pr.destination_branch(),
88 "url": pr.html_url(),
89 })),
90 "general": general,
91 "inline": inline,
92 }))?,
93 Format::Human => {
94 if let Some(pr) = &pr {
95 output::heading(&format!(
96 "#{} {}",
97 pr.id,
98 pr.title.clone().unwrap_or_default()
99 ));
100 output::info(&format!(
101 "{} → {} · {} · by {}",
102 pr.source_branch(),
103 pr.destination_branch(),
104 pr.state.clone().unwrap_or_else(|| "-".into()),
105 pr.author_name()
106 ));
107 output::info(pr.html_url());
108 println!();
109 }
110
111 output::heading("general comments");
112 if general.is_empty() {
113 output::info("none");
114 }
115 for c in &general {
116 println!(" {} ({}):", c.author, c.timestamp);
117 for line in c.body.lines() {
118 println!(" {line}");
119 }
120 println!();
121 }
122
123 output::heading(if unresolved {
124 "inline comments (unresolved)"
125 } else {
126 "inline comments"
127 });
128 if inline.is_empty() {
129 output::info("none");
130 }
131 for c in &inline {
132 let location = match (c.file.as_deref(), c.line) {
133 (Some(file), Some(line)) => format!("{file}:{line}"),
134 (Some(file), None) => file.to_string(),
135 _ => "-".to_string(),
136 };
137 let marker = if c.resolved { " [resolved]" } else { "" };
138 println!(" {location}{marker} (comment {})", c.id);
139 println!(" {} ({}):", c.author, c.timestamp);
140 for line in c.body.lines() {
141 println!(" {line}");
142 }
143 println!();
144 }
145 }
146 }
147
148 Ok(())
149}
150
151#[derive(Debug, Default)]
152pub struct CommentArgs {
153 pub id: u64,
154 pub body: Option<String>,
155 pub body_stdin: bool,
156 pub file: Option<String>,
157 pub line: Option<u64>,
158 pub reply_to: Option<u64>,
159 pub web: bool,
160}
161
162pub fn build_payload(args: &CommentArgs, body: &str) -> Result<serde_json::Value> {
164 if body.trim().is_empty() {
165 return Err(BbError::Config("comment body is empty".into()));
166 }
167 if args.line.is_some() && args.file.is_none() {
168 return Err(BbError::Config("--line requires --file".into()));
169 }
170 if args.reply_to.is_some() && (args.file.is_some() || args.line.is_some()) {
171 return Err(BbError::Config(
172 "--reply-to cannot be combined with --file or --line — a reply inherits its parent's location".into(),
173 ));
174 }
175
176 let mut payload = serde_json::json!({ "content": { "raw": body } });
177
178 if let Some(file) = &args.file {
179 let mut inline = serde_json::json!({ "path": file });
180 if let Some(line) = args.line {
181 inline["to"] = serde_json::Value::from(line);
182 }
183 payload["inline"] = inline;
184 }
185
186 if let Some(parent) = args.reply_to {
187 payload["parent"] = serde_json::json!({ "id": parent });
188 }
189
190 Ok(payload)
191}
192
193fn read_body(args: &CommentArgs) -> Result<String> {
194 if args.body_stdin {
195 let mut buf = String::new();
196 std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf)?;
197 return Ok(buf.trim_end_matches('\n').to_string());
198 }
199 if let Some(body) = &args.body {
200 return Ok(body.clone());
201 }
202 if !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
203 return Err(BbError::Config(
204 "no comment body — pass --body or --body-stdin".into(),
205 ));
206 }
207 inquire::Editor::new("comment:")
208 .prompt()
209 .map_err(|e| BbError::Config(format!("cancelled: {e}")))
210}
211
212pub async fn comment(ctx: &Ctx, args: CommentArgs) -> Result<()> {
213 let body = read_body(&args)?;
214 let payload = build_payload(&args, &body)?;
215
216 let spinner = if ctx.format.is_json() {
217 None
218 } else {
219 Some(output::spinner("posting comment"))
220 };
221 let created: Comment = ctx
222 .client
223 .post_json(
224 &ctx.path(&format!("/pullrequests/{}/comments", args.id)),
225 &payload,
226 )
227 .await?;
228 if let Some(spinner) = spinner {
229 spinner.finish_and_clear();
230 }
231
232 let url = format!(
233 "{}/pull-requests/{}#comment-{}",
234 ctx.slug.browse_url(),
235 args.id,
236 created.id
237 );
238
239 if args.web {
240 let _ = open::that_detached(&url);
241 }
242
243 match ctx.format {
244 Format::Json => output::print_json(&serde_json::json!({
245 "id": created.id,
246 "pull_request": args.id,
247 "url": url,
248 }))?,
249 Format::Human => {
250 output::success(&format!("comment {} added to #{}", created.id, args.id));
251 output::info(&url);
252 }
253 }
254
255 Ok(())
256}
257
258#[cfg(test)]
259#[allow(clippy::unwrap_used)]
260mod build_payload_tests {
261 use super::*;
262
263 fn args() -> CommentArgs {
264 CommentArgs {
265 id: 7,
266 ..Default::default()
267 }
268 }
269
270 #[test]
271 fn general_comment_payload_has_only_content() {
272 let payload = build_payload(&args(), "hi").unwrap();
273 assert_eq!(payload["content"]["raw"], "hi");
274 assert!(payload.get("inline").is_none());
275 assert!(payload.get("parent").is_none());
276 }
277
278 #[test]
279 fn inline_payload_carries_path_and_line() {
280 let a = CommentArgs {
281 file: Some("src/main.rs".into()),
282 line: Some(9),
283 ..args()
284 };
285 let payload = build_payload(&a, "hi").unwrap();
286 assert_eq!(payload["inline"]["path"], "src/main.rs");
287 assert_eq!(payload["inline"]["to"], 9);
288 }
289
290 #[test]
291 fn file_without_line_comments_on_the_file() {
292 let a = CommentArgs {
293 file: Some("src/main.rs".into()),
294 ..args()
295 };
296 let payload = build_payload(&a, "hi").unwrap();
297 assert_eq!(payload["inline"]["path"], "src/main.rs");
298 assert!(payload["inline"].get("to").is_none());
299 }
300
301 #[test]
302 fn empty_body_rejected() {
303 assert!(build_payload(&args(), " ").is_err());
304 }
305
306 #[test]
307 fn line_without_file_rejected() {
308 let a = CommentArgs {
309 line: Some(9),
310 ..args()
311 };
312 assert!(build_payload(&a, "hi").is_err());
313 }
314
315 #[test]
316 fn reply_with_inline_location_rejected() {
317 let a = CommentArgs {
318 reply_to: Some(1),
319 file: Some("x".into()),
320 ..args()
321 };
322 assert!(build_payload(&a, "hi").is_err());
323 }
324}
325
326#[cfg(test)]
327#[allow(clippy::unwrap_used)]
328mod partition_tests {
329 use super::*;
330
331 fn comment(json: serde_json::Value) -> Comment {
332 serde_json::from_value(json).unwrap()
333 }
334
335 #[test]
336 fn general_comment_survives_unresolved_filter() {
337 let c = comment(serde_json::json!({
338 "id": 1,
339 "content": { "raw": "hi" },
340 "user": { "display_name": "Me" },
341 "created_on": "2026-08-04T10:00:00+00:00",
342 }));
343 let (general, inline) = partition(vec![c], true);
344 assert_eq!(general.len(), 1);
345 assert!(inline.is_empty());
346 }
347
348 fn resolved_inline() -> serde_json::Value {
349 serde_json::json!({
350 "id": 2,
351 "content": { "raw": "fix this" },
352 "user": { "display_name": "Me" },
353 "created_on": "2026-08-04T10:00:00+00:00",
354 "inline": { "path": "src/main.rs", "to": 1 },
355 "resolution": { "user": { "display_name": "Me" } },
356 })
357 }
358
359 #[test]
360 fn resolved_inline_comment_dropped_when_unresolved_true() {
361 let (_, inline) = partition(vec![comment(resolved_inline())], true);
362 assert!(inline.is_empty());
363 }
364
365 #[test]
366 fn resolved_inline_comment_kept_when_unresolved_false() {
367 let (_, inline) = partition(vec![comment(resolved_inline())], false);
368 assert_eq!(inline.len(), 1);
369 }
370
371 #[test]
372 fn buckets_are_oldest_first_by_created_on() {
373 let newer = comment(serde_json::json!({
374 "id": 1,
375 "content": { "raw": "newer" },
376 "user": { "display_name": "Me" },
377 "created_on": "2026-08-04T12:00:00+00:00",
378 }));
379 let older = comment(serde_json::json!({
380 "id": 2,
381 "content": { "raw": "older" },
382 "user": { "display_name": "Me" },
383 "created_on": "2026-08-04T09:00:00+00:00",
384 }));
385 let (general, _) = partition(vec![newer, older], false);
386 assert_eq!(general[0].body, "older");
387 assert_eq!(general[1].body, "newer");
388 }
389
390 #[test]
391 fn empty_resolution_object_counts_as_resolved() {
392 let c = comment(serde_json::json!({
393 "id": 3,
394 "content": { "raw": "done" },
395 "user": { "display_name": "Me" },
396 "created_on": "2026-08-04T10:00:00+00:00",
397 "inline": { "path": "src/main.rs", "to": 1 },
398 "resolution": {},
399 }));
400 let (_, inline) = partition(vec![c], true);
401 assert!(inline.is_empty());
402 }
403}