Drop implementation detail from the clap arg help (the `<verb> --help` surface) — which API/endpoint, page-count math, persisted-vs-streamer log-source internals, refspec shapes — keeping only what/when-to-use for each flag. Module `//!` docs (dev-facing, not shown by `--help`) left intact.
348 lines
13 KiB
Rust
348 lines
13 KiB
Rust
//! `comments <number> [--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/<n>/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<usize>,
|
||
}
|
||
|
||
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<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"),
|
||
"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<T: serde::Serialize>(items: Vec<T>) -> Result<Vec<Value>> {
|
||
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/<n>/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<Value> {
|
||
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<Value>, reviews: Vec<Value>) -> Vec<Value> {
|
||
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<Vec<Value>> {
|
||
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/<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.
|
||
fn fetch_tail(client: &Client, number: u64, n: usize) -> Result<Vec<Value>> {
|
||
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<Value> = 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"]);
|
||
}
|
||
}
|