//! `timeline [--limit N]` — list timeline events on an //! issue or PR. Closes #783 (last piece of the #694 epic: 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. //! //! Forgejo's `/issues/{n}/timeline` endpoint returns BOTH the actual //! comments AND the event entries (label, assignee, close, reopen, //! `pull_push`, etc.) in chronological order. We render each row in //! a human-readable form by default; pass the global `--json` flag //! for the raw API shape. //! //! `--tail N` is a follow-up (the timeline endpoint doesn't expose a //! total-count field so we can't use the count-then-page trick that //! `comments --tail` lands in #770; future shape probably mirrors //! `comments --tail` once Forgejo grows a `count` query or we accept //! the trailing-slice cost). use anyhow::Result; use clap::Args as ClapArgs; use serde_json::Value; use crate::client::Client; use crate::verbs::print_json; #[derive(ClapArgs)] pub struct Args { /// Issue or PR number. number: u64, /// Page size (Forgejo caps at 50). Returns the first `N` events. #[arg(long, default_value_t = 50)] limit: u64, } pub fn run(client: &Client, args: Args) -> Result<()> { let repo = client.repo(); let v = client.get_json(&format!( "/repos/{repo}/issues/{}/timeline?limit={}", args.number, args.limit ))?; if client.json_mode() { return print_json(&v); } let Some(events) = v.as_array() else { return print_json(&v); }; for ev in events { print_event(ev); } Ok(()) } /// 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)] 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(), "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). Argus on PR #798 🟡: "extract a `format_event(ev) -> //! String` helper and test that function directly instead of //! duplicating the logic" — addressed. use super::*; #[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 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"); } }