//! `comments [--limit N | --tail N]` — list comments on an //! issue or PR. Closes the curl-fallback gap (#418); `--tail` //! closes the third of the four #694 gaps (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. //! //! Use the global `--json` flag for JSON output (#421). use anyhow::Result; use clap::Args as ClapArgs; use serde_json::{Value, json}; use crate::client::Client; use crate::verbs::print_json; /// 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. number: u64, /// Page size for the head-of-thread shape (Forgejo caps at 50). /// Mutually exclusive with `--tail`. #[arg(long, default_value_t = 50, conflicts_with = "tail")] limit: u64, /// Return the last `N` comments in chronological order. Reads /// the issue's `comments` count first, then fetches only the /// `ceil(N/50) + 1` pages that contain the tail — work is /// bounded by N, not by thread length. Mutually exclusive with /// `--limit`. #[arg(long)] tail: Option, } pub fn run(client: &Client, args: Args) -> Result<()> { let repo = client.repo(); let comments = match args.tail { Some(n) => fetch_tail(client, repo, args.number, n)?, None => fetch_head(client, repo, args.number, args.limit)?, }; 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"), }) }) .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(""); println!("**{user} @ {ts}**: {body}"); println!(); } Ok(()) } } /// Fetch the first page's worth of comments (existing behaviour). fn fetch_head(client: &Client, repo: &str, number: u64, limit: u64) -> Result> { let v = client.get_json(&format!( "/repos/{repo}/issues/{number}/comments?limit={limit}" ))?; Ok(v.as_array().cloned().unwrap_or_default()) } /// 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. #[allow( clippy::cast_possible_truncation, reason = "the forge `comments` count cast to usize is a small issue-thread length, never anywhere near usize::MAX even on a 32-bit target, so it cannot truncate in practice" )] fn fetch_tail(client: &Client, repo: &str, number: u64, n: usize) -> Result> { if n == 0 { return Ok(Vec::new()); } let issue = client.get_json(&format!("/repos/{repo}/issues/{number}"))?; let total = issue.get("comments").and_then(Value::as_u64).unwrap_or(0) as usize; 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 v = client.get_json(&format!( "/repos/{repo}/issues/{number}/comments?limit={PAGE_SIZE}&page={page}" ))?; let arr = v.as_array().cloned().unwrap_or_default(); 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(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)); } }