//! `timeline [--limit N | --tail N]` — list timeline events //! (comments + activity: labels, assignees, close/reopen, pushes, …) on //! an issue or PR. Fills the gap where agents kept falling back to curl //! for "who closed this?" / "when was this labelled?" archaeology. //! Composes naturally with `view ` / `comments ` — separate verb //! keeps the existing shapes stable. //! //! Same three windows as `comments`, same reasoning: `--limit N` returns //! the first N events (oldest-first) — an explicit opt-in now, since a //! plain `timeline` call reading as "I've read this thread" when it's //! really "the oldest N of it" was the same silent trap `comments` had. //! `--tail N` returns the last N (chronological) — and with neither flag //! given, THIS is the default (N = 10): most-recent activity first, the //! useful default for "what just happened here". `--since ` //! filters to events 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. Mutually exclusive with `--tail`; `--limit` clamps like //! before (see [`crate::verbs::MAX_LIMIT`]) since a since-filtered query //! still has no total to page against precisely. //! //! **The count of events outside whatever window is shown is always //! reported** — `--tail`/default read the real total off this endpoint's //! own `X-Total-Count` header (previously discarded; the "no total //! exists" premise this module's docs used to state was false against //! what `forgejo-api` actually returns), so unlike the old `--limit`-only //! shape this is now an exact count, not a boolean "more" flag. use anyhow::{Context, Result}; use clap::Args as ClapArgs; use forgejo_api::structs::{IssueGetCommentsAndTimelineQuery, TimelineComment}; use serde_json::{Value, json}; use time::format_description::well_known::Rfc3339; use crate::client::{Client, index}; use crate::verbs::{MAX_LIMIT, NullableVec, PAGE_SIZE, clamp_limit, parse_rfc3339, print_json}; #[derive(ClapArgs)] pub struct Args { /// Issue or PR number. pub(crate) number: u64, /// Return the first `N` events (oldest-first), or (with `--since`) /// the most this call returns — capped at /// [`crate::verbs::MAX_LIMIT`] in the latter case. An explicit /// opt-in: with neither this nor `--tail` given, the default is the /// newest 10 (see `--tail`). Mutually exclusive with `--tail`. #[arg(long, conflicts_with = "tail")] limit: Option, /// Return the last `N` events (chronological) — the most recent /// activity. Defaults to `N` = 10 when you pass neither `--limit` /// nor `--tail`. Mutually exclusive with `--limit`/`--since`. #[arg(long, conflicts_with = "since")] tail: Option, /// Only show events at or after this RFC3339 timestamp (same format /// this verb's own output prints) — pass back the last-seen row's /// `created_at` to fetch only what's new. #[arg(long)] since: Option, } pub fn run(client: &Client, args: Args) -> Result<()> { // Truncation info, same shape as `comments`: a `--tail`/default // window has nothing after it; a `--limit` window has nothing // before it; a `--since` window only knows a boolean "there's more" // (no total for a since-filtered query). let (events, 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.unwrap_or(10)); let (events, more) = fetch_since(client, args.number, since, limit)?; (events, 0, 0, more, clamped) } else if let Some(limit) = args.limit { let (events, total) = fetch_head(client, args.number, limit)?; let more_after = total.saturating_sub(events.len()); (events, 0, more_after, false, false) } else { // Default (no `--limit`/`--tail`/`--since`): same window as // an explicit `--tail 10` — the newest activity, not the // oldest. `args.tail` still wins if the agent passed a // different count explicitly. let n = args.tail.unwrap_or(10); let total = fetch_total(client, args.number)?; let events = fetch_tail(client, args.number, n, total)?; let more_before = total.saturating_sub(events.len()); (events, more_before, 0, false, false) }; // Serialize back to the API's JSON shape so the per-type render // arms (and their tests) keep working on plain `Value`s. let v = serde_json::to_value(&events)?; if client.json_mode() { return print_json(&json!({ "events": v, "more_before": more_before, "more_after": more_after, "since_more": since_more, "since_limit_clamped": since_clamped, })); } let Some(events) = v.as_array() else { return print_json(&v); }; for ev in events { print_event(ev); } if since_clamped { println!( "(--limit {} is above the {MAX_LIMIT} cap --since can reliably detect truncation at — clamped)", args.limit.unwrap_or(10) ); } if since_more { println!( "(more activity 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}"); } Ok(()) } /// A one-line truncation note when the shown window doesn't cover the /// whole timeline — never a silent gap. `None` when both are zero (the /// whole timeline rode the wire). Mirrors `comments.rs`'s helper of the /// same name/shape. fn truncation_note(more_before: usize, more_after: usize) -> Option { match (more_before > 0, more_after > 0) { (false, false) => None, (true, false) => Some(format!( "({more_before} earlier event(s) not shown — use --limit to see the start of the thread)" )), (false, true) => Some(format!( "({more_after} more event(s) not shown — use --tail N to see the latest instead)" )), (true, true) => Some(format!( "({more_before} earlier + {more_after} later event(s) not shown — use --tail N)" )), } } /// Total timeline-event count for an issue/PR, read off this endpoint's /// own `X-Total-Count` header via a cheap `page_size=1` request. No /// cheaper alternate endpoint carries this count — this /// request-then-discard-the-one-item IS the cheap path. fn fetch_total(client: &Client, number: u64) -> Result { let (owner, name) = client.owner_repo()?; let (headers, _) = client .api() .issue_get_comments_and_timeline( owner, name, index(number)?, IssueGetCommentsAndTimelineQuery { since: None, before: None, }, ) .page_size(1) .send()?; Ok(headers .x_total_count .and_then(|t| usize::try_from(t).ok()) .unwrap_or(0)) } /// Fetch the first page's worth of events (oldest-first) plus the /// thread's real total, read straight off this same request's /// `X-Total-Count` header — one request, no separate count query, and /// (unlike the old over-fetch-by-one trick) an exact total rather than /// a boolean "more". fn fetch_head(client: &Client, number: u64, limit: u64) -> Result<(Vec, usize)> { let (owner, name) = client.owner_repo()?; let (limit, _clamped) = clamp_limit(limit); let (headers, events) = client .api() .issue_get_comments_and_timeline( owner, name, index(number)?, IssueGetCommentsAndTimelineQuery { since: None, before: None, }, ) .page_size(u32::try_from(limit).unwrap_or(u32::MAX)) .send()?; let total = headers .x_total_count .and_then(|t| usize::try_from(t).ok()) .unwrap_or(events.len()); Ok((events, total)) } /// Fetch the last `n` timeline events (chronological). `total` (from /// [`fetch_total`]) drives the pagination plan and is the caller's, not /// fetched again here. Forgejo paginates oldest-first with no `direction=desc` knob, so this /// starts fetching from the page that contains item `total - n` rather /// than paging the whole thread. fn fetch_tail( client: &Client, number: u64, n: usize, total: usize, ) -> Result> { if n == 0 || total == 0 { return Ok(Vec::new()); } let (owner, name) = client.owner_repo()?; let idx = index(number)?; let n = n.min(total); 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; let mut merged: Vec = Vec::with_capacity(n + page_size); for page in start_page..=last_page { let (_, arr) = client .api() .issue_get_comments_and_timeline( owner, name, idx, IssueGetCommentsAndTimelineQuery { since: None, before: None, }, ) .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 (events added/ // removed between the count request and now) or upstream's // playing tricks. Stop rather than spin. break; } merged.extend(arr); } let overshoot = merged.len().saturating_sub(n); Ok(merged.into_iter().skip(overshoot).collect()) } /// Fetch events 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, so this uses the same over-fetch-by-one trick /// `comments.rs`'s `fetch_since` does: ask for `limit + 1`, and a full /// extra row means there's more. Returns `(events, more)`, `more` a /// boolean rather than an exact count. /// /// Deliberately bypasses the typed `forgejo-api` client /// (`issue_get_comments_and_timeline`) rather than calling it directly: /// this endpoint answers an empty `--since` window with a JSON `null` /// body, and the typed client's generated response type is /// `Vec`, which fails to deserialize `null` at all — the /// single most common answer to "anything new since my last check?" /// turns into a hard error instead of an empty list. The sibling /// `issue_get_comments` endpoint `comments.rs`'s own `fetch_since` calls /// returns `[]` for the same case and never hits this. [`NullableVec`] is /// this codebase's existing, tested newtype for exactly this shape of /// problem — used the same way for reactions and blocking-issue lists — /// so this reuses it via [`Client::get_api_json`] rather than inventing a /// second null-tolerant path. fn fetch_since( client: &Client, number: u64, since: time::OffsetDateTime, limit: u64, ) -> Result<(Vec, bool)> { let (owner, name) = client.owner_repo()?; let idx = index(number)?; let fetch_limit = limit.saturating_add(1); let since_str = since .format(&Rfc3339) .context("format --since timestamp as RFC 3339")?; let fetch_limit_str = fetch_limit.to_string(); let path = format!("/repos/{owner}/{name}/issues/{idx}/timeline"); let events: NullableVec = client.get_api_json( &path, &[("since", since_str.as_str()), ("limit", &fetch_limit_str)], )?; let events = events.0; let limit = usize::try_from(limit).unwrap_or(usize::MAX); let more = events.len() > limit; let mut events = events; events.truncate(limit); Ok((events, more)) } /// Render one timeline event as a single `**actor @ ts**: summary` /// line. Comment rows inline their full body; structured event types /// (label, assignees, close, etc.) get a one-line human summary /// derived from the per-type fields the API populates. Unknown / /// future types fall through to a `[]` placeholder so a forge /// schema bump doesn't panic the verb — operator still sees that the /// event existed, with timestamp + actor. /// /// Pure function (no I/O) so the tests below can pin the formatted /// output for every supported event type without re-implementing the /// per-arm dispatch. `print_event` is the only caller that adds the /// terminating newline. #[allow( clippy::too_many_lines, reason = "flat per-event-type dispatch: one arm per timeline event kind; \ splitting it would scatter the formatting without shrinking it" )] fn format_event(ev: &Value) -> String { let event_type = ev.get("type").and_then(Value::as_str).unwrap_or("?"); let user = ev .get("user") .and_then(|u| u.get("login")) .and_then(Value::as_str) .unwrap_or("?"); let ts = ev.get("created_at").and_then(Value::as_str).unwrap_or("?"); let summary = match event_type { "comment" => { // Comments get the full body inlined — matches `comments` // verb shape so the operator sees the same line they'd // get from the head-of-thread listing. ev.get("body") .and_then(Value::as_str) .unwrap_or("") .to_owned() } "label" => { // Forgejo encodes label add/remove via `body = "1"` (added) // or `body = "0"` (removed). Quirky but stable. let action = match ev.get("body").and_then(Value::as_str).unwrap_or("") { "1" => "added", "0" => "removed", _ => "changed", }; let label = ev .get("label") .and_then(|l| l.get("name")) .and_then(Value::as_str) .unwrap_or("?"); format!("{action} label `{label}`") } "assignees" => { let assignee = ev .get("assignee") .and_then(|a| a.get("login")) .and_then(Value::as_str) .unwrap_or("?"); let removed = ev .get("removed_assignee") .and_then(Value::as_bool) .unwrap_or(false); if removed { format!("unassigned @{assignee}") } else { format!("assigned @{assignee}") } } "review_request" => { let reviewer = ev .get("assignee") .and_then(|a| a.get("login")) .and_then(Value::as_str) .unwrap_or("?"); let removed = ev .get("removed_assignee") .and_then(Value::as_bool) .unwrap_or(false); if removed { format!("removed review request from @{reviewer}") } else { format!("requested review from @{reviewer}") } } "close" => "closed".to_owned(), "reopen" => "reopened".to_owned(), "merge" => "merged".to_owned(), "milestone" => { let title = ev .get("milestone") .and_then(|x| x.get("title")) .and_then(Value::as_str) .unwrap_or("?"); format!("added to milestone `{title}`") } "demilestone" => { let title = ev .get("old_milestone") .and_then(|x| x.get("title")) .and_then(Value::as_str) .unwrap_or("?"); format!("removed from milestone `{title}`") } "pull_push" => { // Body is JSON: `{"is_force_push":bool,"commit_ids":[...]}`. // Defensive parse — fall through to a no-detail summary if // the shape ever drifts. let body_str = ev.get("body").and_then(Value::as_str).unwrap_or(""); let parsed: Option = serde_json::from_str(body_str).ok(); let n = parsed .as_ref() .and_then(|v| v.get("commit_ids")) .and_then(Value::as_array) .map_or(0, Vec::len); let force = parsed .as_ref() .and_then(|v| v.get("is_force_push")) .and_then(Value::as_bool) .unwrap_or(false); if force { format!("force-pushed {n} commit(s)") } else { format!("pushed {n} commit(s)") } } "commit_ref" => { let sha = ev .get("ref_commit_sha") .and_then(Value::as_str) .unwrap_or(""); let short: String = sha.chars().take(7).collect(); if short.is_empty() { "referenced from a commit".to_owned() } else { format!("referenced from commit {short}") } } "comment_ref" | "issue_ref" => "referenced from another issue/PR".to_owned(), "add_dependency" | "remove_dependency" => { let verb = if event_type == "add_dependency" { "added dependency on" } else { "removed dependency on" }; let dep = ev.get("dependent_issue"); let number = dep .and_then(|d| d.get("number")) .and_then(Value::as_u64) .map_or_else(|| "?".to_owned(), |n| n.to_string()); let title = dep .and_then(|d| d.get("title")) .and_then(Value::as_str) .unwrap_or("?"); format!("{verb} #{number} ({title})") } "changed_target_branch" => "changed target branch".to_owned(), "review" => "submitted a review".to_owned(), "lock" => "locked the conversation".to_owned(), "unlock" => "unlocked the conversation".to_owned(), // Future / unknown types: surface the raw label so we don't // pretend nothing happened. Operator sees `[deploy_status]` or // whatever new event a forge bump invents. other => format!("[{other}]"), }; format!("**{user} @ {ts}**: {summary}") } /// Print one event followed by a blank line spacer. Thin wrapper /// around `format_event` so the tests can pin per-arm output without /// duplicating the dispatch. fn print_event(ev: &Value) { println!("{}", format_event(ev)); println!(); } #[cfg(test)] mod tests { //! Tests call `format_event` directly so any new event-type arm //! added in `print_event`'s dispatch is automatically covered by //! the rendering path (no parallel test-side dispatch to keep in //! sync). A review flagged: "extract a `format_event(ev) -> //! String` helper and test that function directly instead of //! duplicating the logic" — addressed. use super::*; /// Pure helper mirroring the page-arithmetic in `fetch_tail`: given a /// total event count + requested tail size, return the (`start_page`, /// `last_page`) pair the network loop would walk — lets us pin the /// pagination plan without touching the network. fn tail_plan(total: usize, n: usize) -> (usize, usize) { let n = n.min(total); 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; (start_page, last_page) } #[test] fn tail_plan_small_thread() { assert_eq!(tail_plan(5, 2), (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 → ONE page fetch, not the whole thread. assert_eq!(tail_plan(5000, 3), (100, 100)); } #[test] fn tail_plan_n_exceeds_total() { assert_eq!(tail_plan(5, 100), (1, 1)); } #[test] fn truncation_note_silent_when_nothing_hidden() { assert_eq!(truncation_note(0, 0), None); } #[test] fn truncation_note_after_only_points_at_tail() { let note = truncation_note(0, 40).unwrap(); assert!(note.contains("40"), "{note}"); assert!(note.contains("--tail"), "{note}"); } #[test] fn truncation_note_before_only_points_at_limit() { let note = truncation_note(12, 0).unwrap(); assert!(note.contains("12"), "{note}"); assert!(note.contains("--limit"), "{note}"); } #[test] fn truncation_note_both_sides_mentions_both_counts() { let note = truncation_note(3, 7).unwrap(); assert!(note.contains('3') && note.contains('7'), "{note}"); } #[test] fn comment_renders_body_inline() { let ev = serde_json::json!({ "type": "comment", "user": { "login": "iris" }, "created_at": "2026-05-31T12:00:00Z", "body": "looks good to me", }); assert_eq!( format_event(&ev), "**iris @ 2026-05-31T12:00:00Z**: looks good to me" ); } #[test] fn label_added_renders_action_and_name() { let ev = serde_json::json!({ "type": "label", "user": { "login": "triage" }, "created_at": "2026-05-31T12:00:00Z", "body": "1", "label": { "name": "area:harness" }, }); assert_eq!( format_event(&ev), "**triage @ 2026-05-31T12:00:00Z**: added label `area:harness`" ); } #[test] fn label_removed_renders_removed_action() { let ev = serde_json::json!({ "type": "label", "user": { "login": "mara" }, "created_at": "2026-05-31T12:00:00Z", "body": "0", "label": { "name": "needs-review" }, }); assert_eq!( format_event(&ev), "**mara @ 2026-05-31T12:00:00Z**: removed label `needs-review`" ); } #[test] fn close_event_renders_one_word_summary() { let ev = serde_json::json!({ "type": "close", "user": { "login": "mara" }, "created_at": "2026-05-31T12:00:00Z", }); assert_eq!(format_event(&ev), "**mara @ 2026-05-31T12:00:00Z**: closed"); } #[test] fn assignees_added_and_removed() { let added = serde_json::json!({ "type": "assignees", "user": { "login": "triage" }, "created_at": "2026-05-31T12:00:00Z", "assignee": { "login": "damocles" }, "removed_assignee": false, }); assert_eq!( format_event(&added), "**triage @ 2026-05-31T12:00:00Z**: assigned @damocles" ); let removed = serde_json::json!({ "type": "assignees", "user": { "login": "triage" }, "created_at": "2026-05-31T12:00:00Z", "assignee": { "login": "damocles" }, "removed_assignee": true, }); assert_eq!( format_event(&removed), "**triage @ 2026-05-31T12:00:00Z**: unassigned @damocles" ); } #[test] fn pull_push_counts_commits_and_marks_force() { let normal = serde_json::json!({ "type": "pull_push", "user": { "login": "damocles" }, "created_at": "2026-05-31T12:00:00Z", "body": r#"{"is_force_push":false,"commit_ids":["a","b","c"]}"#, }); assert_eq!( format_event(&normal), "**damocles @ 2026-05-31T12:00:00Z**: pushed 3 commit(s)" ); let forced = serde_json::json!({ "type": "pull_push", "user": { "login": "damocles" }, "created_at": "2026-05-31T12:00:00Z", "body": r#"{"is_force_push":true,"commit_ids":["a"]}"#, }); assert_eq!( format_event(&forced), "**damocles @ 2026-05-31T12:00:00Z**: force-pushed 1 commit(s)" ); } #[test] fn commit_ref_truncates_sha_to_seven() { let ev = serde_json::json!({ "type": "commit_ref", "user": { "login": "damocles" }, "created_at": "2026-05-31T12:00:00Z", "ref_commit_sha": "abcdef0123456789abcdef0123456789abcdef01", }); assert_eq!( format_event(&ev), "**damocles @ 2026-05-31T12:00:00Z**: referenced from commit abcdef0" ); } #[test] fn add_dependency_renders_target_issue() { let ev = serde_json::json!({ "type": "add_dependency", "user": { "login": "mara" }, "created_at": "2026-05-31T12:00:00Z", "dependent_issue": { "number": 2850, "title": "extract swarm controller" }, }); assert_eq!( format_event(&ev), "**mara @ 2026-05-31T12:00:00Z**: added dependency on #2850 (extract swarm controller)" // lint:allow: sample issue number in a test fixture string, not a tracker tag ); } #[test] fn remove_dependency_renders_target_issue() { let ev = serde_json::json!({ "type": "remove_dependency", "user": { "login": "mara" }, "created_at": "2026-05-31T12:00:00Z", "dependent_issue": { "number": 2850, "title": "extract swarm controller" }, }); assert_eq!( format_event(&ev), "**mara @ 2026-05-31T12:00:00Z**: removed dependency on #2850 (extract swarm controller)" // lint:allow: sample issue number in a test fixture string, not a tracker tag ); } #[test] fn unknown_event_type_renders_bracketed_placeholder() { // Future-proofing: a forge schema bump that adds a new event // type shouldn't silently swallow the row. let ev = serde_json::json!({ "type": "deploy_status", "user": { "login": "ci-bot" }, "created_at": "2026-05-31T12:00:00Z", }); assert_eq!( format_event(&ev), "**ci-bot @ 2026-05-31T12:00:00Z**: [deploy_status]" ); } #[test] fn missing_user_falls_back_to_placeholder() { // Defensive: forge has been known to omit `user` on bot events. let ev = serde_json::json!({ "type": "close", "created_at": "2026-05-31T12:00:00Z", }); assert_eq!(format_event(&ev), "**? @ 2026-05-31T12:00:00Z**: closed"); } }