1use crate::api::models::{Comment, PullRequest};
2use crate::commands::pr::{self, 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 pub parent: Option<u64>,
17}
18
19fn to_view(comment: &Comment) -> CommentView {
20 let inline = comment.inline.as_ref();
21 CommentView {
22 id: comment.id,
23 author: comment.author().to_string(),
24 timestamp: comment
25 .created_on
26 .as_deref()
27 .map(output::relative_time)
28 .unwrap_or_else(|| "-".into()),
29 body: comment.body(),
30 file: inline.and_then(|i| i.path.clone()),
31 line: inline.and_then(|i| i.to.or(i.from)),
32 resolved: comment.is_resolved(),
33 parent: comment.parent_id(),
34 }
35}
36
37fn location(file: Option<&str>, line: Option<u64>) -> Option<String> {
40 match (file, line) {
41 (Some(file), Some(line)) => Some(format!("{file}:{line}")),
42 (Some(file), None) => Some(file.to_string()),
43 _ => None,
44 }
45}
46
47pub fn partition(
51 mut comments: Vec<Comment>,
52 unresolved: bool,
53) -> (Vec<CommentView>, Vec<CommentView>) {
54 comments.sort_by(|a, b| a.created_on.cmp(&b.created_on));
55
56 let mut general = Vec::new();
57 let mut inline = Vec::new();
58 for comment in &comments {
59 if comment.is_inline() {
60 if unresolved && comment.is_resolved() {
61 continue;
62 }
63 inline.push(to_view(comment));
64 } else {
65 general.push(to_view(comment));
66 }
67 }
68 (general, inline)
69}
70
71pub async fn view(ctx: &Ctx, id: u64, unresolved: bool, comments_only: bool) -> Result<()> {
72 let pr: Option<PullRequest> = if comments_only {
73 None
74 } else {
75 Some(
76 ctx.client
77 .get_json(&ctx.path(&format!("/pullrequests/{id}")))
78 .await?,
79 )
80 };
81
82 let spinner = output::spinner("fetching comments");
83 let comments: Vec<Comment> = ctx
84 .client
85 .paginate(&ctx.path(&format!("/pullrequests/{id}/comments?pagelen=100")))
86 .await?;
87 spinner.finish_and_clear();
88
89 let (general, inline) = partition(comments, unresolved);
90
91 match ctx.format {
92 Format::Json => output::print_json(&serde_json::json!({
93 "pull_request": pr.as_ref().map(|pr| serde_json::json!({
94 "id": pr.id,
95 "title": pr.title,
96 "state": pr.state,
97 "author": pr.author_name(),
98 "source": pr.source_branch(),
99 "destination": pr.destination_branch(),
100 "url": pr.html_url(),
101 })),
102 "general": general,
103 "inline": inline,
104 }))?,
105 Format::Human => {
106 if let Some(pr) = &pr {
107 output::heading(&format!(
108 "#{} {}",
109 pr.id,
110 pr.title.clone().unwrap_or_default()
111 ));
112 output::info(&format!(
113 "{} → {} · {} · by {}",
114 pr.source_branch(),
115 pr.destination_branch(),
116 pr.state.clone().unwrap_or_else(|| "-".into()),
117 pr.author_name()
118 ));
119 output::info(pr.html_url());
120 println!();
121 }
122
123 output::heading("general comments");
124 if general.is_empty() {
125 output::info("none");
126 }
127 for c in &general {
128 println!(" {} ({}):", c.author, c.timestamp);
129 for line in c.body.lines() {
130 println!(" {line}");
131 }
132 println!();
133 }
134
135 output::heading(if unresolved {
136 "inline comments (unresolved)"
137 } else {
138 "inline comments"
139 });
140 if inline.is_empty() {
141 output::info("none");
142 }
143 for c in &inline {
144 let location =
145 location(c.file.as_deref(), c.line).unwrap_or_else(|| "-".to_string());
146 let marker = if c.resolved { " [resolved]" } else { "" };
147 match c.parent {
148 Some(parent) => println!(
149 " {location}{marker} (comment {} · reply to {parent})",
150 c.id
151 ),
152 None => println!(" {location}{marker} (comment {})", c.id),
153 }
154 println!(" {} ({}):", c.author, c.timestamp);
155 for line in c.body.lines() {
156 println!(" {line}");
157 }
158 println!();
159 }
160 }
161 }
162
163 Ok(())
164}
165
166#[derive(Debug, Default)]
167pub struct CommentArgs {
168 pub id: u64,
169 pub body: Option<String>,
170 pub body_stdin: bool,
171 pub file: Option<String>,
172 pub line: Option<u64>,
173 pub reply_to: Option<u64>,
174 pub web: bool,
175}
176
177pub fn build_payload(args: &CommentArgs, body: &str) -> Result<serde_json::Value> {
179 if body.trim().is_empty() {
180 return Err(BbError::Config("comment body is empty".into()));
181 }
182 if args.line.is_some() && args.file.is_none() {
183 return Err(BbError::Config("--line requires --file".into()));
184 }
185 if args.reply_to.is_some() && (args.file.is_some() || args.line.is_some()) {
186 return Err(BbError::Config(
187 "--reply-to cannot be combined with --file or --line — a reply inherits its parent's location".into(),
188 ));
189 }
190
191 let mut payload = serde_json::json!({ "content": { "raw": body } });
192
193 if let Some(file) = &args.file {
194 let mut inline = serde_json::json!({ "path": file });
195 if let Some(line) = args.line {
196 inline["to"] = serde_json::Value::from(line);
197 }
198 payload["inline"] = inline;
199 }
200
201 if let Some(parent) = args.reply_to {
202 payload["parent"] = serde_json::json!({ "id": parent });
203 }
204
205 Ok(payload)
206}
207
208fn read_body(args: &CommentArgs) -> Result<String> {
209 if args.body_stdin {
210 let mut buf = String::new();
211 std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf)?;
212 return Ok(buf.trim_end_matches('\n').to_string());
213 }
214 if let Some(body) = &args.body {
215 return Ok(body.clone());
216 }
217 if !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
218 return Err(BbError::Config(
219 "no comment body — pass --body or --body-stdin".into(),
220 ));
221 }
222 inquire::Editor::new("comment:")
223 .prompt()
224 .map_err(|e| BbError::Config(format!("cancelled: {e}")))
225}
226
227pub async fn comment(ctx: &Ctx, args: CommentArgs) -> Result<()> {
228 let body = read_body(&args)?;
229 let payload = build_payload(&args, &body)?;
230
231 let spinner = if ctx.format.is_json() {
232 None
233 } else {
234 Some(output::spinner("posting comment"))
235 };
236 let created: Comment = ctx
237 .client
238 .post_json(
239 &ctx.path(&format!("/pullrequests/{}/comments", args.id)),
240 &payload,
241 )
242 .await?;
243 if let Some(spinner) = spinner {
244 spinner.finish_and_clear();
245 }
246
247 let url = format!(
248 "{}/pull-requests/{}#comment-{}",
249 ctx.slug.browse_url(),
250 args.id,
251 created.id
252 );
253
254 if args.web {
255 let _ = open::that_detached(&url);
256 }
257
258 match ctx.format {
259 Format::Json => output::print_json(&serde_json::json!({
260 "id": created.id,
261 "pull_request": args.id,
262 "url": url,
263 }))?,
264 Format::Human => {
265 output::success(&format!("comment {} added to #{}", created.id, args.id));
266 output::info(&url);
267 }
268 }
269
270 Ok(())
271}
272
273pub async fn resolve(ctx: &Ctx, id: u64, comment: u64, yes: bool) -> Result<()> {
282 if !yes {
283 approve(ctx, id, comment).await?;
284 }
285 ctx.client
286 .post_empty(&resolve_path(ctx, id, comment))
287 .await?;
288 pr::report(
289 ctx,
290 &format!("comment {comment} resolved on #{id}"),
291 serde_json::json!({ "resolved": comment, "pull_request": id }),
292 )
293}
294
295async fn approve(ctx: &Ctx, id: u64, comment: u64) -> Result<()> {
302 if !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
303 return Err(BbError::Config(
304 "resolving needs approval — answer the prompt in a terminal, or pass --yes to approve up front".into(),
305 ));
306 }
307 gate(ctx, id, comment, ask_human).await
308}
309
310async fn gate<A>(ctx: &Ctx, id: u64, comment: u64, ask: A) -> Result<()>
315where
316 A: FnOnce(&str) -> Result<bool>,
317{
318 let thread: Comment = ctx
320 .client
321 .get_json(&ctx.path(&format!("/pullrequests/{id}/comments/{comment}")))
322 .await?;
323 let where_ = resolvable(&thread)?;
324
325 if ask(&format!("resolve {}?", describe(&thread, &where_)))? {
326 Ok(())
327 } else {
328 Err(BbError::Config(format!("comment {comment} left open")))
331 }
332}
333
334fn ask_human(question: &str) -> Result<bool> {
338 inquire::Confirm::new(question)
339 .with_default(false)
340 .prompt()
341 .map_err(|e| BbError::Config(format!("cancelled: {e}")))
342}
343
344fn resolvable(thread: &Comment) -> Result<String> {
351 if let Some(root) = thread.parent_id() {
352 return Err(BbError::Config(format!(
353 "comment {} is a reply — resolve the thread's first comment, {root}",
354 thread.id
355 )));
356 }
357 let inline = thread.inline.as_ref();
358 location(
359 inline.and_then(|i| i.path.as_deref()),
360 inline.and_then(|i| i.to.or(i.from)),
361 )
362 .ok_or_else(|| {
363 BbError::Config(format!(
364 "comment {} is not on the diff, and bitbucket resolves only inline threads",
365 thread.id
366 ))
367 })
368}
369
370fn describe(thread: &Comment, where_: &str) -> String {
373 format!(
374 "the thread at {where_} by {} — \"{}\"",
375 thread.author(),
376 summarize(&thread.body())
377 )
378}
379
380fn summarize(body: &str) -> String {
382 const MAX: usize = 72;
383 let first = body.lines().next().unwrap_or_default().trim();
384 if first.chars().count() <= MAX {
385 return first.to_string();
386 }
387 format!("{}…", first.chars().take(MAX).collect::<String>())
388}
389
390pub async fn unresolve(ctx: &Ctx, id: u64, comment: u64) -> Result<()> {
393 ctx.client.delete(&resolve_path(ctx, id, comment)).await?;
394 pr::report(
395 ctx,
396 &format!("comment {comment} reopened on #{id}"),
397 serde_json::json!({ "unresolved": comment, "pull_request": id }),
398 )
399}
400
401fn resolve_path(ctx: &Ctx, id: u64, comment: u64) -> String {
402 ctx.path(&format!("/pullrequests/{id}/comments/{comment}/resolve"))
403}
404
405#[cfg(test)]
406#[allow(clippy::unwrap_used)]
407mod build_payload_tests {
408 use super::*;
409
410 fn args() -> CommentArgs {
411 CommentArgs {
412 id: 7,
413 ..Default::default()
414 }
415 }
416
417 #[test]
418 fn general_comment_payload_has_only_content() {
419 let payload = build_payload(&args(), "hi").unwrap();
420 assert_eq!(payload["content"]["raw"], "hi");
421 assert!(payload.get("inline").is_none());
422 assert!(payload.get("parent").is_none());
423 }
424
425 #[test]
426 fn inline_payload_carries_path_and_line() {
427 let a = CommentArgs {
428 file: Some("src/main.rs".into()),
429 line: Some(9),
430 ..args()
431 };
432 let payload = build_payload(&a, "hi").unwrap();
433 assert_eq!(payload["inline"]["path"], "src/main.rs");
434 assert_eq!(payload["inline"]["to"], 9);
435 }
436
437 #[test]
438 fn file_without_line_comments_on_the_file() {
439 let a = CommentArgs {
440 file: Some("src/main.rs".into()),
441 ..args()
442 };
443 let payload = build_payload(&a, "hi").unwrap();
444 assert_eq!(payload["inline"]["path"], "src/main.rs");
445 assert!(payload["inline"].get("to").is_none());
446 }
447
448 #[test]
449 fn empty_body_rejected() {
450 assert!(build_payload(&args(), " ").is_err());
451 }
452
453 #[test]
454 fn line_without_file_rejected() {
455 let a = CommentArgs {
456 line: Some(9),
457 ..args()
458 };
459 assert!(build_payload(&a, "hi").is_err());
460 }
461
462 #[test]
463 fn reply_with_inline_location_rejected() {
464 let a = CommentArgs {
465 reply_to: Some(1),
466 file: Some("x".into()),
467 ..args()
468 };
469 assert!(build_payload(&a, "hi").is_err());
470 }
471}
472
473#[cfg(test)]
477#[allow(clippy::unwrap_used)]
478mod gate_tests {
479 use super::*;
480 use crate::api::Client;
481 use crate::credentials::Credentials;
482 use crate::repo::RepoSlug;
483 use crate::secret::SecretString;
484 use wiremock::matchers::{method, path};
485 use wiremock::{Mock, MockServer, ResponseTemplate};
486
487 fn ctx(server: &MockServer) -> Ctx {
488 Ctx {
489 client: Client::new(
490 Credentials {
491 email: "dev@example.com".into(),
492 token: SecretString::from("t0ken-value"),
493 },
494 server.uri(),
495 )
496 .unwrap(),
497 slug: RepoSlug::parse("acme/widgets").unwrap(),
498 format: Format::Human,
499 }
500 }
501
502 async fn mount_thread(server: &MockServer) {
503 Mock::given(method("GET"))
504 .and(path(
505 "/repositories/acme/widgets/pullrequests/7/comments/900",
506 ))
507 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
508 "id": 900,
509 "content": { "raw": "this drops the error" },
510 "user": { "display_name": "Reviewer" },
511 "inline": { "path": "src/auth.rs", "to": 88 },
512 })))
513 .expect(1)
514 .mount(server)
515 .await;
516 }
517
518 #[tokio::test]
519 async fn a_yes_passes_the_gate_and_the_question_names_the_thread() {
520 let server = MockServer::start().await;
521 mount_thread(&server).await;
522
523 let mut asked = String::new();
524 let result = gate(&ctx(&server), 7, 900, |question| {
525 asked = question.to_string();
526 Ok(true)
527 })
528 .await;
529
530 assert!(result.is_ok(), "{result:?}");
531 assert!(asked.starts_with("resolve "), "{asked}");
532 assert!(asked.contains("src/auth.rs:88"), "{asked}");
533 assert!(asked.contains("this drops the error"), "{asked}");
534 }
535
536 #[tokio::test]
537 async fn a_no_fails_and_names_the_comment_left_open() {
538 let server = MockServer::start().await;
539 mount_thread(&server).await;
540
541 let err = gate(&ctx(&server), 7, 900, |_| Ok(false))
542 .await
543 .unwrap_err();
544
545 let shown = err.to_string();
546 assert!(shown.contains("900"), "{shown}");
547 assert!(shown.contains("left open"), "{shown}");
548 assert_eq!(err.exit_code(), 1);
549 }
550
551 #[tokio::test]
552 async fn a_cancelled_prompt_stops_the_gate() {
553 let server = MockServer::start().await;
554 mount_thread(&server).await;
555
556 let result = gate(&ctx(&server), 7, 900, |_| {
557 Err(BbError::Config("cancelled: interrupted".into()))
558 })
559 .await;
560
561 assert!(result.is_err(), "an unanswered prompt must not resolve");
562 }
563
564 #[tokio::test]
567 async fn a_reply_never_reaches_the_prompt() {
568 let server = MockServer::start().await;
569 Mock::given(method("GET"))
570 .and(path(
571 "/repositories/acme/widgets/pullrequests/7/comments/901",
572 ))
573 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
574 "id": 901,
575 "content": { "raw": "fixed" },
576 "user": { "display_name": "Me" },
577 "inline": { "path": "src/auth.rs", "to": 88 },
578 "parent": { "id": 900 },
579 })))
580 .mount(&server)
581 .await;
582
583 let err = gate(&ctx(&server), 7, 901, |_| unreachable!("asked anyway"))
584 .await
585 .unwrap_err();
586
587 assert!(err.to_string().contains("900"), "{err}");
588 }
589
590 #[tokio::test]
593 async fn an_unknown_comment_never_reaches_the_prompt() {
594 let server = MockServer::start().await;
595 Mock::given(method("GET"))
596 .and(path(
597 "/repositories/acme/widgets/pullrequests/7/comments/404",
598 ))
599 .respond_with(ResponseTemplate::new(404))
600 .mount(&server)
601 .await;
602
603 let err = gate(&ctx(&server), 7, 404, |_| unreachable!("asked anyway"))
604 .await
605 .unwrap_err();
606
607 assert_eq!(err.exit_code(), 3);
608 }
609}
610
611#[cfg(test)]
614#[allow(clippy::unwrap_used)]
615mod describe_tests {
616 use super::*;
617
618 fn comment(json: serde_json::Value) -> Comment {
619 serde_json::from_value(json).unwrap()
620 }
621
622 fn inline_thread() -> serde_json::Value {
623 serde_json::json!({
624 "id": 600,
625 "content": { "raw": "this drops the error\nsecond line" },
626 "user": { "display_name": "Reviewer" },
627 "inline": { "path": "src/auth.rs", "to": 88 },
628 })
629 }
630
631 #[test]
632 fn names_the_place_the_author_and_the_point() {
633 let thread = comment(inline_thread());
634 let shown = describe(&thread, &resolvable(&thread).unwrap());
635 assert!(shown.contains("src/auth.rs:88"), "{shown}");
636 assert!(shown.contains("Reviewer"), "{shown}");
637 assert!(shown.contains("this drops the error"), "{shown}");
638 assert!(!shown.contains("second line"), "one line only: {shown}");
639 }
640
641 #[test]
644 fn a_reply_is_refused_and_names_the_root() {
645 let mut json = inline_thread();
646 json["id"] = serde_json::Value::from(601);
647 json["parent"] = serde_json::json!({ "id": 600 });
648 let err = resolvable(&comment(json)).unwrap_err().to_string();
649 assert!(err.contains("reply"), "{err}");
650 assert!(err.contains("600"), "must name the root: {err}");
651 }
652
653 #[test]
656 fn a_general_comment_is_refused() {
657 let err = resolvable(&comment(serde_json::json!({
658 "id": 42,
659 "content": { "raw": "a general remark" },
660 "user": { "display_name": "Reviewer" },
661 })))
662 .unwrap_err()
663 .to_string();
664 assert!(err.contains("42"), "{err}");
665 assert!(err.contains("inline"), "{err}");
666 }
667
668 #[test]
670 fn a_whole_file_thread_is_resolvable() {
671 let where_ = resolvable(&comment(serde_json::json!({
672 "id": 500,
673 "content": { "raw": "whole-file note" },
674 "user": { "display_name": "Reviewer" },
675 "inline": { "path": "src/lib.rs" },
676 })))
677 .unwrap();
678 assert_eq!(where_, "src/lib.rs");
679 }
680
681 #[test]
682 fn a_long_point_is_truncated_on_a_char_boundary() {
683 let body = "ü".repeat(200);
684 let shown = summarize(&body);
685 assert!(shown.ends_with('…'), "{shown}");
686 assert_eq!(shown.chars().count(), 73);
687 }
688
689 #[test]
690 fn location_is_none_when_the_comment_is_not_inline() {
691 assert_eq!(location(None, Some(9)), None);
692 assert_eq!(location(Some("a.rs"), None).unwrap(), "a.rs");
693 assert_eq!(location(Some("a.rs"), Some(9)).unwrap(), "a.rs:9");
694 }
695}
696
697#[cfg(test)]
698#[allow(clippy::unwrap_used)]
699mod partition_tests {
700 use super::*;
701
702 fn comment(json: serde_json::Value) -> Comment {
703 serde_json::from_value(json).unwrap()
704 }
705
706 #[test]
707 fn general_comment_survives_unresolved_filter() {
708 let c = comment(serde_json::json!({
709 "id": 1,
710 "content": { "raw": "hi" },
711 "user": { "display_name": "Me" },
712 "created_on": "2026-08-04T10:00:00+00:00",
713 }));
714 let (general, inline) = partition(vec![c], true);
715 assert_eq!(general.len(), 1);
716 assert!(inline.is_empty());
717 }
718
719 fn resolved_inline() -> serde_json::Value {
720 serde_json::json!({
721 "id": 2,
722 "content": { "raw": "fix this" },
723 "user": { "display_name": "Me" },
724 "created_on": "2026-08-04T10:00:00+00:00",
725 "inline": { "path": "src/main.rs", "to": 1 },
726 "resolution": { "user": { "display_name": "Me" } },
727 })
728 }
729
730 #[test]
731 fn resolved_inline_comment_dropped_when_unresolved_true() {
732 let (_, inline) = partition(vec![comment(resolved_inline())], true);
733 assert!(inline.is_empty());
734 }
735
736 #[test]
737 fn resolved_inline_comment_kept_when_unresolved_false() {
738 let (_, inline) = partition(vec![comment(resolved_inline())], false);
739 assert_eq!(inline.len(), 1);
740 }
741
742 #[test]
743 fn buckets_are_oldest_first_by_created_on() {
744 let newer = comment(serde_json::json!({
745 "id": 1,
746 "content": { "raw": "newer" },
747 "user": { "display_name": "Me" },
748 "created_on": "2026-08-04T12:00:00+00:00",
749 }));
750 let older = comment(serde_json::json!({
751 "id": 2,
752 "content": { "raw": "older" },
753 "user": { "display_name": "Me" },
754 "created_on": "2026-08-04T09:00:00+00:00",
755 }));
756 let (general, _) = partition(vec![newer, older], false);
757 assert_eq!(general[0].body, "older");
758 assert_eq!(general[1].body, "newer");
759 }
760
761 #[test]
762 fn empty_resolution_object_counts_as_resolved() {
763 let c = comment(serde_json::json!({
764 "id": 3,
765 "content": { "raw": "done" },
766 "user": { "display_name": "Me" },
767 "created_on": "2026-08-04T10:00:00+00:00",
768 "inline": { "path": "src/main.rs", "to": 1 },
769 "resolution": {},
770 }));
771 let (_, inline) = partition(vec![c], true);
772 assert!(inline.is_empty());
773 }
774}