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