205 lines
7.9 KiB
Rust
205 lines
7.9 KiB
Rust
//! `comments <number> [--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<usize>,
|
||
}
|
||
|
||
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<Value> = 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<Vec<Value>> {
|
||
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/<n>/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<Vec<Value>> {
|
||
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<Value> = 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));
|
||
}
|
||
}
|