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 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
37/// `file:line`, or the bare file when the comment sits on no single line.
38/// `None` when the comment is not inline, leaving the fallback to the caller.
39fn 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
47/// Splits comments into general and inline buckets, oldest first. The
48/// `unresolved` filter applies only to inline threads — general comments are
49/// not resolvable in the Bitbucket API and are always kept.
50pub 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
177/// Builds the request body, rejecting flag combinations the API cannot honour.
178pub 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
273/// Marks a comment thread as resolved. Bitbucket resolves the whole thread, so
274/// `comment` is the id of its root — the entry `bb pr view` reports without a
275/// `parent`. The response body carries only the resolution, which adds nothing
276/// the caller does not already know, so it is discarded.
277///
278/// Resolving hides a reviewer's point from the pull request, and nothing in the
279/// api asks whether that point was actually addressed. So a human does: the
280/// command confirms first, and `yes` is the only way past it.
281pub 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
295/// Puts the thread in front of a human and waits for a yes. The prompt renders
296/// on stderr, so `--json` stdout stays pure.
297///
298/// With no terminal there is nobody to ask, so this names the flag rather than
299/// blocking on input that will not arrive. That also means an agent or a CI job
300/// cannot resolve anything unless whoever wrote the command line said `--yes`.
301async 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
310/// Shows the thread, then turns the answer into a verdict. `ask` is a parameter
311/// because the real prompt needs a terminal no test has: this way the parts that
312/// carry the decision are exercised, and `ask_human` is left holding nothing but
313/// the rendering.
314async fn gate<A>(ctx: &Ctx, id: u64, comment: u64, ask: A) -> Result<()>
315where
316    A: FnOnce(&str) -> Result<bool>,
317{
318    // Fetched only on this path: `--yes` must cost no extra request.
319    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        // Declining is an error, not a quiet success: a script reading exit 0 as
329        // "resolved" must never see one.
330        Err(BbError::Config(format!("comment {comment} left open")))
331    }
332}
333
334/// Left uncovered on purpose: it needs a terminal, and it holds no decision that
335/// a test could get wrong — the same shape as the `inquire::Editor` call in
336/// `read_body`.
337fn 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
344/// Rejects the ids the endpoint cannot act on, and yields where the thread sits.
345///
346/// Bitbucket answers 403 both for a reply and for a comment that is not on the
347/// diff, and the generic 403 text blames the token's scopes — a wrong diagnosis
348/// that costs the reader real time. So these two cases are named here instead,
349/// before anything is sent or any human is asked to approve a doomed request.
350fn 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
370/// One line naming what the approval covers: where the thread sits, who raised
371/// it, and what it says.
372fn describe(thread: &Comment, where_: &str) -> String {
373    format!(
374        "the thread at {where_} by {} — \"{}\"",
375        thread.author(),
376        summarize(&thread.body())
377    )
378}
379
380/// First line of a comment, short enough to sit inside a prompt.
381fn 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
390/// Reopens a resolved thread. This is not gated: it restores a reviewer's point
391/// rather than hiding one, so the worst case is noise a human can see.
392pub 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/// Everything the gate decides, with the prompt answered by the test instead of
474/// by a human. The one thing left uncovered is `ask_human`, which needs a
475/// terminal and holds no decision of its own.
476#[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    /// A reply id is refused before the prompt: the request it would send is the
565    /// one bitbucket answers 403 for.
566    #[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    /// A bad id fails at the lookup, so nobody is asked to approve a thread that
591    /// does not exist.
592    #[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/// The confirmation prompt cannot be driven from a piped stdin — that is what
612/// makes it a gate — so what a human is asked is asserted here instead.
613#[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    /// Bitbucket answers 403 for a reply, so the id is refused with the root to
642    /// use instead — a prompt claiming to resolve that root would be a lie.
643    #[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    /// "Not on the diff" is the other documented 403: a general comment has no
654    /// thread to resolve.
655    #[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    /// An inline root with a file but no line is still resolvable.
669    #[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}