Skip to main content

bb_cli/commands/
pr_comments.rs

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
39/// `file:line`, or the bare file when the comment sits on no single line.
40/// `None` when the comment is not inline, leaving the fallback to the caller.
41fn 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
49/// Splits comments into general and inline buckets, oldest first. The
50/// `unresolved` filter applies only to inline threads — general comments are
51/// not resolvable in the Bitbucket API and are always kept.
52pub 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
185/// Builds the request body, rejecting flag combinations the API cannot honour.
186pub 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
300/// Marks a comment thread as resolved. Bitbucket resolves the whole thread, so
301/// `comment` is the id of its root — the entry `bb pr view` reports without a
302/// `parent`. The response body carries only the resolution, which adds nothing
303/// the caller does not already know, so it is discarded.
304///
305/// Resolving hides a reviewer's point from the pull request, and nothing in the
306/// api asks whether that point was actually addressed. So a human does: the
307/// command confirms first, and `yes` is the only way past it.
308pub 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
322/// Puts the thread in front of a human and waits for a yes. The prompt renders
323/// on stderr, so `--json` stdout stays pure.
324///
325/// With no terminal there is nobody to ask, so this names the flag rather than
326/// blocking on input that will not arrive. That also means an agent or a CI job
327/// cannot resolve anything unless whoever wrote the command line said `--yes`.
328async 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
337/// Shows the thread, then turns the answer into a verdict. `ask` is a parameter
338/// because the real prompt needs a terminal no test has: this way the parts that
339/// carry the decision are exercised, and `ask_human` is left holding nothing but
340/// the rendering.
341async fn gate<A>(ctx: &Ctx, id: u64, comment: u64, ask: A) -> Result<()>
342where
343    A: FnOnce(&str) -> Result<bool>,
344{
345    // Fetched only on this path: `--yes` must cost no extra request.
346    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        // Declining is an error, not a quiet success: a script reading exit 0 as
356        // "resolved" must never see one.
357        Err(BbError::Config(format!("comment {comment} left open")))
358    }
359}
360
361/// Left uncovered on purpose: it needs a terminal, and it holds no decision that
362/// a test could get wrong — the same shape as the `inquire::Editor` call in
363/// `read_body`.
364fn 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
371/// Rejects the ids the endpoint cannot act on, and yields where the thread sits.
372///
373/// Bitbucket answers 403 both for a reply and for a comment that is not on the
374/// diff, and the generic 403 text blames the token's scopes — a wrong diagnosis
375/// that costs the reader real time. So these two cases are named here instead,
376/// before anything is sent or any human is asked to approve a doomed request.
377fn 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
397/// One line naming what the approval covers: where the thread sits, who raised
398/// it, and what it says.
399fn describe(thread: &Comment, where_: &str) -> String {
400    format!(
401        "the thread at {where_} by {} — \"{}\"",
402        thread.author(),
403        summarize(&thread.body())
404    )
405}
406
407/// First line of a comment, short enough to sit inside a prompt.
408fn 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
417/// Reopens a resolved thread. This is not gated: it restores a reviewer's point
418/// rather than hiding one, so the worst case is noise a human can see.
419pub 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/// Everything the gate decides, with the prompt answered by the test instead of
512/// by a human. The one thing left uncovered is `ask_human`, which needs a
513/// terminal and holds no decision of its own.
514#[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    /// A reply id is refused before the prompt: the request it would send is the
603    /// one bitbucket answers 403 for.
604    #[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    /// A bad id fails at the lookup, so nobody is asked to approve a thread that
629    /// does not exist.
630    #[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/// The confirmation prompt cannot be driven from a piped stdin — that is what
650/// makes it a gate — so what a human is asked is asserted here instead.
651#[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    /// Bitbucket answers 403 for a reply, so the id is refused with the root to
680    /// use instead — a prompt claiming to resolve that root would be a lie.
681    #[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    /// "Not on the diff" is the other documented 403: a general comment has no
692    /// thread to resolve.
693    #[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    /// An inline root with a file but no line is still resolvable.
707    #[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}