Skip to main content

bb_cli/commands/
pr_comments.rs

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
35/// Splits comments into general and inline buckets, oldest first. The
36/// `unresolved` filter applies only to inline threads — general comments are
37/// not resolvable in the Bitbucket API and are always kept.
38pub 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
162/// Builds the request body, rejecting flag combinations the API cannot honour.
163pub 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}