//! `comments [--limit N | --tail N]` — list comments on an //! issue or PR. Replaces the curl fallback. //! //! - `--limit N` returns the first N comments (oldest-first) — an //! explicit opt-in: a plain `comments` call used to read as "I've read //! this thread" when it was really "the oldest N of it", so the //! default moved to `--tail`'s window instead (see below). //! - `--tail N` returns the *last* N comments, chronological — and with //! neither flag given, THIS is the default (N = 10): most-recent //! activity. Both windows are cut from one whole-thread request (see //! `fetch_thread`). //! - **The count of comments outside whatever window is shown is //! always reported** — never a silent truncation. That total is the //! length of this endpoint's list, never `issue_get_issue`'s `comments` //! field, which also counts non-prose events this endpoint never returns. //! - `--since ` filters to comments at or after that //! timestamp instead of a head/tail window — a cursor: feed the //! last-seen row's own `created_at` back in next time. Mutually //! exclusive with `--tail`; `--limit` windows the filtered list as //! given, uncapped. //! //! Review *bodies* on PRs are always merged in too (`pulls//reviews` //! isn't the issues/comments thread), tagged `[review: STATE]`, with a //! `(N line comment(s) — see 'pr reviews')` pointer for inline comments. //! //! `--json` output is an object (`{"comments": [...], "more_before": N, //! "more_after": N, "since_more": bool, "since_limit_clamped": false}`), //! not a bare array, so a script can read the truncation info too. //! `--show-reactions` adds a per-comment reaction summary — see that //! flag's own doc for the cost. use anyhow::Result; use clap::Args as ClapArgs; use forgejo_api::structs::IssueGetCommentsQuery; use serde_json::{Value, json}; use time::OffsetDateTime; use crate::client::{Client, index}; use crate::notify; use crate::verbs::{ all_pages, comment_reactions, parse_rfc3339, print_json, reaction_summary, rfc3339, }; #[derive(ClapArgs)] pub struct Args { /// Issue or PR number. pub(crate) number: u64, /// Number of comments from the start of the thread (oldest-first), /// or (with `--since`) the most this call returns. An explicit /// opt-in: with neither this nor `--tail` given, the default is the /// newest 10 (see `--tail`). Mutually exclusive with `--tail`. #[arg(long, conflicts_with = "tail")] limit: Option, /// Return the last `N` comments (chronological) — the most recent /// activity. Defaults to `N` = 10 when you pass neither `--limit` /// nor `--tail`. Mutually exclusive with `--limit`/`--since`. #[arg(long, conflicts_with = "since")] tail: Option, /// Only show comments at or after this RFC3339 timestamp (same /// format this verb's own output prints). Mutually exclusive with /// `--tail`. #[arg(long)] since: Option, /// Fetch + display each shown comment's reaction summary. Costs one /// extra request per comment shown — opt-in, not the default. #[arg(long)] show_reactions: bool, } pub fn run(client: &Client, args: Args) -> Result<()> { let repo = client.repo()?; // Truncation info: what sits outside the window we're about to show. // A `--tail`/default window has nothing after it (it ends at the // thread's current end); a `--limit` window has nothing before it (it // starts at the thread's beginning); a `--since` window is a boolean // "there's more" (no total exists for a since-filtered query) rather // than an exact count. let (thread, more_before, more_after, since_more) = if let Some(since_str) = &args.since { let since = parse_rfc3339(since_str)?; let (thread, more) = fetch_since(client, args.number, since, args.limit.unwrap_or(10))?; (thread, 0, 0, more) } else if let Some(limit) = args.limit { let (thread, more_after) = head(fetch_thread(client, args.number)?, limit); (thread, 0, more_after, false) } else { // Default (no `--limit`/`--tail`/`--since`): same window as // an explicit `--tail 10` — the newest activity, not the // oldest. `args.tail` still wins if the agent passed a // different count explicitly. let n = args.tail.unwrap_or(10); let (thread, more_before) = tail(fetch_thread(client, args.number)?, n); (thread, more_before, 0, false) }; // Merge in PR review bodies (empty for issues — degrades to a // no-op) so review feedback isn't silently dropped. let mut comments = merge_chronological(thread, fetch_review_bodies(client, args.number)); // Reading the thread clears its unread notification so the // read-before-comment guard (in `comment`) lets a reply through. notify::mark_read_best_effort(client, repo, args.number); let (owner, name) = client.owner_repo()?; attach_reactions(client, owner, name, &mut comments, args.show_reactions); if client.json_mode() { let trimmed: Vec = comments .iter() .map(|c| { json!({ "id": c.get("id"), "user": c.get("user").and_then(|u| u.get("login")), "created_at": c.get("created_at"), "updated_at": c.get("updated_at"), "body": c.get("body"), "url": c.get("html_url"), "kind": c.get("kind").and_then(Value::as_str).unwrap_or("comment"), "state": c.get("state"), "comments_count": c.get("comments_count"), "attachments": attachment_json_from_value(c), "reactions": c.get("reactions").cloned().unwrap_or_else(|| json!([])), }) }) .collect(); print_json(&json!({ "comments": trimmed, "more_before": more_before, "more_after": more_after, "since_more": since_more, "since_limit_clamped": false, })) } else { for c in &comments { let user = c .get("user") .and_then(|u| u.get("login")) .and_then(Value::as_str) .unwrap_or("?"); let ts = c.get("created_at").and_then(Value::as_str).unwrap_or("?"); let body = c.get("body").and_then(Value::as_str).unwrap_or(""); if c.get("kind").and_then(Value::as_str) == Some("review") { let state = c.get("state").and_then(Value::as_str).unwrap_or("?"); let n = c.get("comments_count").and_then(Value::as_i64).unwrap_or(0); if n > 0 { println!( "**{user} @ {ts}** [review: {state}] ({n} line comment(s) — see `hive-forge pr reviews {}`): {body}", args.number ); } else { println!("**{user} @ {ts}** [review: {state}]: {body}"); } } else { println!("**{user} @ {ts}**: {body}"); } for line in attachment_lines_from_value(c) { println!("{line}"); } if let Some(summary) = c .get("reactions") .and_then(Value::as_array) .and_then(|r| reaction_summary(r)) { println!("[reactions: {summary}]"); } println!(); } if since_more { println!( "(more comments since this timestamp not shown — raise --limit or bump --since; exact count not available)" ); } if let Some(note) = truncation_note(more_before, more_after) { println!("{note}"); } Ok(()) } } /// A one-line truncation note when the shown window doesn't cover the /// whole thread — never a silent gap. `None` when both are zero (the /// whole thread rode the wire). Returns a `String` rather than printing /// directly so the three cases are unit-testable. fn truncation_note(more_before: usize, more_after: usize) -> Option { match (more_before > 0, more_after > 0) { (false, false) => None, (true, false) => Some(format!( "({more_before} earlier comment(s) not shown — use --limit to see the start of the thread)" )), (false, true) => Some(format!( "({more_after} more comment(s) not shown — use --tail N to see the latest instead)" )), (true, true) => Some(format!( "({more_before} earlier + {more_after} later comment(s) not shown — use --tail N)" )), } } /// Fetch + attach each shown comment's reaction list under a /// `"reactions"` key, mutating in place so both the `--json` and /// pretty-print branches see the same fetch. No-op when `show_reactions` /// is false (the default) — the whole point of gating this behind a /// flag is that a plain `comments` call costs nothing extra. Review /// entries are skipped (synthesized, never have real reactions); a /// per-comment fetch failure is swallowed rather than failing the whole /// listing over one comment. fn attach_reactions( client: &Client, owner: &str, name: &str, comments: &mut [Value], show_reactions: bool, ) { if !show_reactions { return; } for c in comments.iter_mut() { if c.get("kind").and_then(Value::as_str) == Some("review") { continue; } let Some(id) = c.get("id").and_then(Value::as_u64) else { continue; }; if let Ok(reactions) = comment_reactions(client, owner, name, id) && let Some(obj) = c.as_object_mut() { obj.insert("reactions".to_owned(), json!(reactions)); } } } /// Attachment display lines for a single comment's already-serialized /// `assets` field — the shape this module's merge/render pipeline works /// on (comments arrive as `Value`s, not the typed `Comment` struct, once /// merged with synthesized review entries). Review entries carry no /// `assets` field at all, so they naturally yield nothing here. Mirrors /// [`crate::verbs::attachment_line`]'s `[file: ] ` format. fn attachment_lines_from_value(c: &Value) -> Vec { c.get("assets") .and_then(Value::as_array) .into_iter() .flatten() .filter_map(|a| { let name = a.get("name").and_then(Value::as_str).unwrap_or("?"); let url = a.get("browser_download_url").and_then(Value::as_str)?; Some(format!("[file: {name}] {url}")) }) .collect() } /// JSON form of [`attachment_lines_from_value`]'s data, for `--json` /// output — same `{"name", "url"}` shape [`crate::verbs::attachment_json`] /// produces from the typed struct. fn attachment_json_from_value(c: &Value) -> Vec { c.get("assets") .and_then(Value::as_array) .into_iter() .flatten() .map(|a| { json!({ "name": a.get("name"), "url": a.get("browser_download_url"), }) }) .collect() } /// Serialize a typed comment page back to the JSON `Value` shape the /// merge + render pipeline works on (the structs serialize to the API /// wire shape, so downstream field access is unchanged). fn to_values(items: Vec) -> Result> { items .into_iter() .map(|c| serde_json::to_value(&c).map_err(Into::into)) .collect() } /// Fetch a PR's review *bodies* and normalise them to the comment /// shape so they merge alongside issue-thread comments. /// /// Review summaries live in the `pulls//reviews` object, not the /// issues/comments thread, so the plain comment listing misses them /// — the review-body gap this fixes. Best-effort: returns empty on any /// error — notably when `number` is an issue (no reviews endpoint) — /// so callers degrade gracefully. Skips PENDING reviews (not yet /// visible to others) and reviews with neither a body nor line /// comments (a bare approval adds nothing to the thread). Deliberately /// does NOT inline the per-line comments themselves: merging them into /// this glance-first thread would spam anyone who just wants status. /// `comments_count` carries a pointer to `pr reviews ` instead, /// which prints the full per-line detail — see that verb /// (`pr_reviews.rs`) for the exhaustive form. `created_at` /// is synthesised from the review's `submitted_at` so the chronological /// merge sorts uniformly; `kind:"review"` + the review `state` tag the /// entry for display. fn fetch_review_bodies(client: &Client, number: u64) -> Vec { let Ok((owner, name)) = client.owner_repo() else { return Vec::new(); }; let Ok(idx) = index(number) else { return Vec::new(); }; let reviews = all_pages(|page, size| { let (_, batch) = client .api() .repo_list_pull_reviews(owner, name, idx) .page(page) .page_size(size) .send()?; Ok(batch) }) .unwrap_or_default(); reviews .into_iter() .filter_map(|r| { let state = r.state.as_deref().unwrap_or("").to_owned(); if state == "PENDING" { return None; } let comments_count = r.comments_count.unwrap_or(0); if r.body.as_deref().unwrap_or("").trim().is_empty() && comments_count == 0 { return None; } let submitted = rfc3339(r.submitted_at); Some(json!({ "id": r.id, "user": r.user, "created_at": submitted, "updated_at": submitted, "body": r.body, "html_url": r.html_url, "kind": "review", "state": state, "comments_count": comments_count, })) }) .collect() } /// Merge issue-thread comments with review bodies and sort the /// combined list chronologically by `created_at`. ISO-8601 timestamps /// sort lexicographically in time order, so a plain string compare is /// correct; the sort is stable, so same-timestamp entries keep fetch /// order. fn merge_chronological(mut items: Vec, reviews: Vec) -> Vec { items.extend(reviews); items.sort_by(|a, b| { let ka = a.get("created_at").and_then(Value::as_str).unwrap_or(""); let kb = b.get("created_at").and_then(Value::as_str).unwrap_or(""); ka.cmp(kb) }); items } /// Every comment on an issue/PR, oldest-first, in one request. The /// endpoint takes no page or limit, so the response is always the whole /// thread and a window has to be cut client-side. fn fetch_thread(client: &Client, number: u64) -> Result> { let (owner, name) = client.owner_repo()?; let (_, comments) = client .api() .issue_get_comments( owner, name, index(number)?, IssueGetCommentsQuery::default(), ) .send()?; to_values(comments) } /// The first `limit` comments of `thread`, and how many follow them. fn head(mut thread: Vec, limit: u64) -> (Vec, usize) { let total = thread.len(); thread.truncate(usize::try_from(limit).unwrap_or(usize::MAX)); let more_after = total - thread.len(); (thread, more_after) } /// The last `n` comments of `thread`, and how many precede them. fn tail(mut thread: Vec, n: usize) -> (Vec, usize) { let more_before = thread.len().saturating_sub(n); thread.drain(..more_before); (thread, more_before) } /// Comments at or after `since`, the first `limit` of them. The endpoint /// takes no page or limit, so the filtered list arrives whole and `more` /// (whether any follow the window) is read off its length. A boolean /// rather than a count: a since-filtered window has no total to report. fn fetch_since( client: &Client, number: u64, since: OffsetDateTime, limit: u64, ) -> Result<(Vec, bool)> { let (owner, name) = client.owner_repo()?; let query = IssueGetCommentsQuery { since: Some(since), ..Default::default() }; let (_, comments) = client .api() .issue_get_comments(owner, name, index(number)?, query) .send()?; let (values, more_after) = head(to_values(comments)?, limit); Ok((values, more_after > 0)) } #[cfg(test)] mod tests { use super::*; fn thread(len: usize) -> Vec { (0..len).map(|i| json!({ "id": i })).collect() } fn ids(thread: &[Value]) -> Vec { thread.iter().filter_map(|c| c["id"].as_u64()).collect() } #[test] fn head_keeps_the_first_limit_and_counts_the_rest() { let (shown, more_after) = head(thread(60), 5); assert_eq!(ids(&shown), vec![0, 1, 2, 3, 4]); assert_eq!(more_after, 55); } #[test] fn head_past_the_end_shows_everything() { let (shown, more_after) = head(thread(3), 10); assert_eq!(shown.len(), 3); assert_eq!(more_after, 0); } #[test] fn tail_keeps_the_last_n_and_counts_the_rest() { let (shown, more_before) = tail(thread(60), 3); assert_eq!(ids(&shown), vec![57, 58, 59]); assert_eq!(more_before, 57); } #[test] fn tail_past_the_start_shows_everything() { let (shown, more_before) = tail(thread(3), 10); assert_eq!(shown.len(), 3); assert_eq!(more_before, 0); } #[test] fn merge_interleaves_reviews_by_timestamp() { // A review submitted between two comments must land between // them, not appended at the end — that's the whole fix. let comments = vec![ json!({"created_at": "2026-06-29T01:00:00Z", "body": "c1"}), json!({"created_at": "2026-06-29T01:20:00Z", "body": "c2"}), ]; let reviews = vec![json!({"created_at": "2026-06-29T01:10:00Z", "body": "r1", "kind": "review"})]; let merged = merge_chronological(comments, reviews); let bodies: Vec<&str> = merged .iter() .map(|v| v.get("body").and_then(Value::as_str).unwrap()) .collect(); assert_eq!(bodies, vec!["c1", "r1", "c2"]); } #[test] fn truncation_note_silent_when_nothing_hidden() { assert_eq!(truncation_note(0, 0), None); } #[test] fn truncation_note_after_only_points_at_tail() { let note = truncation_note(0, 40).unwrap(); assert!(note.contains("40"), "{note}"); assert!(note.contains("--tail"), "{note}"); } #[test] fn truncation_note_before_only_points_at_limit() { // Regression: this used to (wrongly) suggest `--tail` — the flag // that produced the very window you're already looking at when // more_before is nonzero. `--limit` is what actually reaches the // start of the thread. let note = truncation_note(12, 0).unwrap(); assert!(note.contains("12"), "{note}"); assert!(note.contains("earlier"), "{note}"); assert!(note.contains("--limit"), "{note}"); } #[test] fn truncation_note_both_sides_mentions_both_counts() { let note = truncation_note(3, 7).unwrap(); assert!(note.contains('3') && note.contains('7'), "{note}"); } #[test] fn merge_with_no_reviews_is_identity() { // Issues have no reviews → fetch_review_bodies returns empty → // the merge must leave the comment thread untouched. let comments = vec![ json!({"created_at": "2026-06-29T01:00:00Z", "body": "c1"}), json!({"created_at": "2026-06-29T01:20:00Z", "body": "c2"}), ]; let merged = merge_chronological(comments, vec![]); let bodies: Vec<&str> = merged .iter() .map(|v| v.get("body").and_then(Value::as_str).unwrap()) .collect(); assert_eq!(bodies, vec!["c1", "c2"]); } }