hyperhive/hive-forge/src/verbs/comments.rs
atlas ef9935e3d0 docs(#2671): trim per-verb arg help (pr-create, comments, ci-log, ci-rerun)
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.
2026-07-23 21:55:04 +02:00

348 lines
13 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. 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"]);
}
}