//! `comments [--limit N | --tail N]` — list comments on an //! issue or PR. Replaces the curl fallback; `--tail` //! handles the paging-for-long-threads //! awkwardness). //! //! - `--limit N` (default 50, Forgejo's cap) returns the first N //! comments — same shape this verb has always had. //! - `--tail N` returns the *last* N comments in chronological //! order. Reads the issue's `comments` count first to compute //! which page contains the tail, then fetches only `ceil(N/50) + //! 1` pages. No upstream cap — the work is bounded by `N`, not //! by the thread's length, so it stays cheap even on threads with //! thousands of comments. Use this for "what was the conclusion //! on this long thread?" without scrolling through the whole //! history. //! //! For PRs, review *bodies* (the summary text submitted with an //! approve / request-changes / comment review) are merged in too: //! they live in the `pulls//reviews` object, NOT the //! issues/comments thread, so plain comment listings used to miss //! them entirely and reviewers/authors silently lost feedback — //! the gap this fix closes. They're always included regardless of //! `--limit`/`--tail` //! (reviews are few + high-signal) and tagged `[review: STATE]` so //! they're distinguishable from issue-thread comments. //! //! Use the global `--json` flag for JSON output. use anyhow::Result; use clap::Args as ClapArgs; use forgejo_api::structs::IssueGetCommentsQuery; use serde_json::{Value, json}; use crate::client::{Client, index}; use crate::notify; use crate::verbs::{print_json, rfc3339}; /// Forgejo's per-page comment cap. The API caps `limit` at 50 even /// if a higher value is requested; pin it explicitly so the math /// downstream doesn't depend on a hidden default. const PAGE_SIZE: usize = 50; #[derive(ClapArgs)] pub struct Args { /// Issue or PR number. pub(crate) number: u64, /// Number of comments from the start of the thread (max 50). /// Mutually exclusive with `--tail`. #[arg(long, default_value_t = 50, conflicts_with = "tail")] limit: u64, /// Return the last `N` comments (chronological). Mutually exclusive /// with `--limit`. #[arg(long)] tail: Option, } pub fn run(client: &Client, args: Args) -> Result<()> { let repo = client.repo(); let thread = match args.tail { Some(n) => fetch_tail(client, args.number, n)?, None => fetch_head(client, args.number, args.limit)?, }; // Merge in PR review bodies (empty for issues — degrades to a // no-op) so review feedback isn't silently dropped. let 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); 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"), }) }) .collect(); print_json(&Value::Array(trimmed)) } 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("?"); println!("**{user} @ {ts}** [review: {state}]: {body}"); } else { println!("**{user} @ {ts}**: {body}"); } println!(); } Ok(()) } } /// 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 /// empty-body reviews (a bare approval adds nothing to the thread). /// `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 = client .api() .repo_list_pull_reviews(owner, name, idx) .send() .map(|(_, reviews)| reviews) .unwrap_or_default(); reviews .into_iter() .filter_map(|r| { let state = r.state.as_deref().unwrap_or("").to_owned(); if state == "PENDING" { return None; } if r.body.as_deref().unwrap_or("").trim().is_empty() { 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, })) }) .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 } /// Fetch the first page's worth of comments (existing behaviour). fn fetch_head(client: &Client, number: u64, limit: u64) -> Result> { let (owner, name) = client.owner_repo()?; let (_, comments) = client .api() .issue_get_comments( owner, name, index(number)?, IssueGetCommentsQuery::default(), ) .page_size(u32::try_from(limit).unwrap_or(u32::MAX)) .send()?; to_values(comments) } /// Fetch the last `n` comments on an issue/PR in chronological order. /// /// Forgejo orders `/issues//comments` oldest-first and has no /// `direction=desc` knob, so naive "page everything and slice" pages /// from the WRONG end on long threads — the first 1000 comments /// instead of the last `n`. Fix: read the issue's `comments` count /// first to know how many exist, then start paginating from the /// page that contains item `total - n`. Work is bounded by /// `ceil(n/50) + 1` page fetches, regardless of thread length. fn fetch_tail(client: &Client, number: u64, n: usize) -> Result> { if n == 0 { return Ok(Vec::new()); } let (owner, name) = client.owner_repo()?; let idx = index(number)?; let issue = client.api().issue_get_issue(owner, name, idx).send()?; let total = issue .comments .and_then(|c| usize::try_from(c).ok()) .unwrap_or(0); if total == 0 { return Ok(Vec::new()); } // Cap `n` at the actual total so the math below stays in range // when the caller asks for more comments than exist. let n = n.min(total); let page_size = PAGE_SIZE; // 0-based index of the first comment we want; integer-divide to // get the 1-based page that contains it. let start_idx = total - n; let start_page = (start_idx / page_size) + 1; let last_page = (total - 1) / page_size + 1; let mut merged: Vec = Vec::with_capacity(n + page_size); for page in start_page..=last_page { let (_, arr) = client .api() .issue_get_comments(owner, name, idx, IssueGetCommentsQuery::default()) .page(u32::try_from(page).unwrap_or(u32::MAX)) .page_size(u32::try_from(PAGE_SIZE).unwrap_or(u32::MAX)) .send()?; if arr.is_empty() { // Page came back empty — either we miscounted (comments // deleted between the issue GET and now) or upstream's // playing tricks. Stop rather than spin. break; } merged.extend(to_values(arr)?); } // The first fetched page contains items from `start_page` × 50 // back; we overshoot by `start_idx % 50` items. Slice the tail // to exactly `n` (or fewer if the count shrank under us). let overshoot = merged.len().saturating_sub(n); Ok(merged.into_iter().skip(overshoot).collect()) } #[cfg(test)] mod tests { use super::*; /// Pure helper mirroring the page-arithmetic in `fetch_tail`: /// given a total comment count + requested tail size, return /// the (`start_page`, `last_page`) pair the network loop would /// walk. Lets us pin the pagination plan — the part that's /// easy to off-by-one — without touching the network. fn tail_plan(total: usize, n: usize) -> (usize, usize) { let n = n.min(total); let page_size = PAGE_SIZE; let start_idx = total - n; let start_page = (start_idx / page_size) + 1; let last_page = (total - 1) / page_size + 1; (start_page, last_page) } #[test] fn tail_plan_small_thread() { // 5 total, tail 2 → page 1 covers everything; trim happens // via the merged.len() - n overshoot calculation, not pages. assert_eq!(tail_plan(5, 2), (1, 1)); } #[test] fn tail_plan_exact_page_boundary() { // 50 total, tail 3 → all on page 1 (items 1..50). assert_eq!(tail_plan(50, 3), (1, 1)); } #[test] fn tail_plan_crosses_page_boundary() { // 51 total, tail 3 → start_idx=48 lives on page 1, item 51 // lives on page 2; fetch both. assert_eq!(tail_plan(51, 3), (1, 2)); } #[test] fn tail_plan_large_thread_bounded_pages() { // 5000 total, tail 3 → start_idx=4997 lives on page 100, // last_page=100. ONE page fetch on a 5000-comment thread — // the whole point of swapping to count-then-page (vs the // old "page everything, then slice the wrong end"). assert_eq!(tail_plan(5000, 3), (100, 100)); } #[test] fn tail_plan_large_thread_spans_two_pages() { // 5000 total, tail 60 → start_idx=4940 on page 99, item 5000 // on page 100. Two fetches even for n > PAGE_SIZE. assert_eq!(tail_plan(5000, 60), (99, 100)); } #[test] fn tail_plan_n_exceeds_total() { // 5 total, tail 100 → cap n at total; same plan as the // small-thread case above. assert_eq!(tail_plan(5, 100), (1, 1)); } #[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 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"]); } }