hyperhive/hive-forge/src/verbs/comments.rs

205 lines
7.9 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! `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));
}
}