hive-forge: add --since cursor paging to comments and timeline

This commit is contained in:
damocles 2026-08-03 20:39:39 +02:00 committed by mara
commit 4cf647aa70
3 changed files with 159 additions and 70 deletions

View file

@ -12,62 +12,81 @@
//! comments.
//! - **The count of comments outside whatever window is shown is
//! always reported** — never a silent truncation.
//! - `--since <RFC3339>` filters to comments at or after that
//! timestamp instead of a head/tail window — a cursor: feed the
//! last-seen row's own `created_at` back in next time to fetch only
//! what's new. Mutually exclusive with `--tail`. No total exists for
//! a since-filtered query, so this uses the same over-fetch-by-one
//! trick `timeline`'s `--limit` does, and `--limit` is clamped the
//! same way (see `crate::verbs::MAX_LIMIT`).
//!
//! Review *bodies* on PRs are always merged in too (`pulls/<n>/reviews`
//! isn't the issues/comments thread, so a plain listing used to miss
//! them), tagged `[review: STATE]`.
//!
//! `--json` output is an object (`{"comments": [...], "more_before": N,
//! "more_after": N}`), not a bare array, so a script can read the
//! truncation counts too.
//! "more_after": N, "since_more": bool}`), not a bare array, so a
//! script can read the truncation info too.
use anyhow::Result;
use clap::Args as ClapArgs;
use forgejo_api::structs::IssueGetCommentsQuery;
use serde_json::{Value, json};
use time::OffsetDateTime;
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;
use crate::verbs::{MAX_LIMIT, PAGE_SIZE, clamp_limit, parse_rfc3339, print_json, rfc3339};
#[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`.
/// Number of comments from the start of the thread, or (with
/// `--since`) the most this call returns — capped at
/// [`crate::verbs::MAX_LIMIT`] in the latter case. Mutually
/// exclusive with `--tail`.
#[arg(long, default_value_t = 10, conflicts_with = "tail")]
limit: u64,
/// Return the last `N` comments (chronological). Mutually exclusive
/// with `--limit`.
#[arg(long)]
/// with `--limit`/`--since`.
#[arg(long, conflicts_with = "since")]
tail: Option<usize>,
/// Only show comments at or after this RFC3339 timestamp (same
/// format this verb's own output prints). Mutually exclusive with
/// `--tail`.
#[arg(long)]
since: Option<String>,
}
pub fn run(client: &Client, args: Args) -> Result<()> {
let repo = client.repo();
let total = fetch_total(client, args.number)?;
// Truncation counts: how many comments sit outside the window we're
// about to show, on each side. A `--tail` window has nothing after it
// (it ends at the thread's current end); a `--limit`/default window
// has nothing before it (it starts at the thread's beginning). Derived
// from `thread.len()` rather than the requested `n`/`limit` so a
// Truncation info: what sits outside the window we're about to show.
// A `--tail` window has nothing after it (it ends at the thread's
// current end); a `--limit`/default window has nothing before it (it
// starts at the thread's beginning); a `--since` window is a boolean
// "there's more" (no total exists for a since-filtered query) rather
// than an exact count. `more_before`/`more_after` are derived from
// `thread.len()` rather than the requested `n`/`limit` so a
// shrunk-under-us thread (comments deleted mid-fetch) still reports
// accurately instead of the number we merely asked for.
let (thread, more_before, more_after) = if let Some(n) = args.tail {
let thread = fetch_tail(client, args.number, n, total)?;
let more_before = total.saturating_sub(thread.len());
(thread, more_before, 0)
} else {
let thread = fetch_head(client, args.number, args.limit)?;
let more_after = total.saturating_sub(thread.len());
(thread, 0, more_after)
};
let (thread, more_before, more_after, since_more, since_clamped) =
if let Some(since_str) = &args.since {
let since = parse_rfc3339(since_str)?;
let (limit, clamped) = clamp_limit(args.limit);
let (thread, more) = fetch_since(client, args.number, since, limit)?;
(thread, 0, 0, more, clamped)
} else if let Some(n) = args.tail {
let total = fetch_total(client, args.number)?;
let thread = fetch_tail(client, args.number, n, total)?;
let more_before = total.saturating_sub(thread.len());
(thread, more_before, 0, false, false)
} else {
let total = fetch_total(client, args.number)?;
let thread = fetch_head(client, args.number, args.limit)?;
let more_after = total.saturating_sub(thread.len());
(thread, 0, more_after, false, false)
};
// 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));
@ -94,6 +113,8 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
"comments": trimmed,
"more_before": more_before,
"more_after": more_after,
"since_more": since_more,
"since_limit_clamped": since_clamped,
}))
} else {
for c in &comments {
@ -112,6 +133,17 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
}
println!();
}
if since_clamped {
println!(
"(--limit {} is above the {MAX_LIMIT} cap --since can reliably detect truncation at — clamped)",
args.limit
);
}
if since_more {
println!(
"(more comments since this timestamp not shown — raise --limit or bump --since; exact count not available)"
);
}
if let Some(note) = truncation_note(more_before, more_after) {
println!("{note}");
}
@ -265,7 +297,7 @@ fn fetch_tail(client: &Client, number: u64, n: usize, total: usize) -> Result<Ve
// 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;
let page_size = usize::try_from(PAGE_SIZE).unwrap_or(usize::MAX);
// 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;
@ -294,6 +326,36 @@ fn fetch_tail(client: &Client, number: u64, n: usize, total: usize) -> Result<Ve
Ok(merged.into_iter().skip(overshoot).collect())
}
/// Fetch comments at or after `since`, capped at `limit` (already
/// clamped to [`crate::verbs::MAX_LIMIT`] by the caller). No total exists
/// for a since-filtered query — Forgejo's API reports none — so this
/// uses the same over-fetch-by-one trick `timeline`'s `--limit` does:
/// ask for `limit + 1`, and a full extra row means there's more. Returns
/// `(comments, more)`, `more` a boolean rather than an exact count.
fn fetch_since(
client: &Client,
number: u64,
since: OffsetDateTime,
limit: u64,
) -> Result<(Vec<Value>, bool)> {
let (owner, name) = client.owner_repo()?;
let query = IssueGetCommentsQuery {
since: Some(since),
..Default::default()
};
let fetch_limit = limit.saturating_add(1);
let (_, comments) = client
.api()
.issue_get_comments(owner, name, index(number)?, query)
.page_size(u32::try_from(fetch_limit).unwrap_or(u32::MAX))
.send()?;
let mut values = to_values(comments)?;
let limit = usize::try_from(limit).unwrap_or(usize::MAX);
let more = values.len() > limit;
values.truncate(limit);
Ok((values, more))
}
#[cfg(test)]
mod tests {
use super::*;
@ -305,7 +367,7 @@ mod tests {
/// 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 page_size = usize::try_from(PAGE_SIZE).unwrap_or(usize::MAX);
let start_idx = total - n;
let start_page = (start_idx / page_size) + 1;
let last_page = (total - 1) / page_size + 1;