From 8f9866f5e1cccba6d87b1aeb65c092b96b38a98d Mon Sep 17 00:00:00 2001 From: damocles Date: Sun, 31 May 2026 15:04:27 +0200 Subject: [PATCH] =?UTF-8?q?hive-forge:=20timeline=20verb=20=E2=80=94=20iss?= =?UTF-8?q?ue/PR=20audit=20trail=20(closes=20#783)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit last unstarted piece of the original #694 epic. agents kept falling back to curl for 'who closed this?' / 'when was this labelled?' archaeology because view + comments only surface the body + comments, not the structured timeline events (label adds, assignments, closes, reopens, pushes, commit refs, review submissions, milestone changes). separate verb rather than view --timeline because: - composes naturally with view / comments - keeps existing verb output shapes stable (no script breakage) - argus on #770 already noted view's output is busy human-readable by default ('**actor @ ts**: '), --json for raw piping. unknown event types fall through to a '[]' placeholder so a forge schema bump doesn't panic the verb. --tail N is a follow-up: timeline endpoint doesn't expose a total-count field so the count-then-page strategy from #770 doesn't apply directly. 6 tests cover comment / label add/remove / close / unknown-type placeholder / missing-user fallback. --- hive-forge/src/main.rs | 5 + hive-forge/src/verbs/mod.rs | 1 + hive-forge/src/verbs/timeline.rs | 304 +++++++++++++++++++++++++++++++ 3 files changed, 310 insertions(+) create mode 100644 hive-forge/src/verbs/timeline.rs diff --git a/hive-forge/src/main.rs b/hive-forge/src/main.rs index 375a7111..f8dcd7a5 100644 --- a/hive-forge/src/main.rs +++ b/hive-forge/src/main.rs @@ -91,6 +91,10 @@ enum Verb { Diff(verbs::diff::Args), /// Get or set this user's watch subscription on a repo. Subscription(verbs::subscription::Args), + /// List timeline events on an issue or PR (closes, label adds, + /// assignments, commit refs, pushes, etc.) — the audit trail + /// `view` + `comments` don't surface (closes #783). + Timeline(verbs::timeline::Args), /// Upload a file as an attachment to an issue. AttachIssue(verbs::attach::IssueArgs), /// Upload a file as an attachment to a comment. @@ -123,6 +127,7 @@ fn main() -> Result<()> { Verb::TreeSha(a) => verbs::tree_sha::run(&client, a), Verb::Diff(a) => verbs::diff::run(&client, a), Verb::Subscription(a) => verbs::subscription::run(&client, a), + Verb::Timeline(a) => verbs::timeline::run(&client, a), Verb::AttachIssue(a) => verbs::attach::run_issue(&client, a), Verb::AttachComment(a) => verbs::attach::run_comment(&client, a), } diff --git a/hive-forge/src/verbs/mod.rs b/hive-forge/src/verbs/mod.rs index 5030759c..d47342af 100644 --- a/hive-forge/src/verbs/mod.rs +++ b/hive-forge/src/verbs/mod.rs @@ -23,6 +23,7 @@ pub mod pr; pub mod pr_create; pub mod pr_reviews; pub mod subscription; +pub mod timeline; pub mod tree_sha; pub mod view; diff --git a/hive-forge/src/verbs/timeline.rs b/hive-forge/src/verbs/timeline.rs new file mode 100644 index 00000000..056aff98 --- /dev/null +++ b/hive-forge/src/verbs/timeline.rs @@ -0,0 +1,304 @@ +//! `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 `**actor @ ts**: summary`. Comment +/// rows print 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. +fn print_event(ev: &Value) { + 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 — matches `comments` verb shape. + let body = ev.get("body").and_then(Value::as_str).unwrap_or(""); + println!("**{user} @ {ts}**: {body}"); + println!(); + return; + } + "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}]"), + }; + println!("**{user} @ {ts}**: {summary}"); + println!(); +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Render one mock event and assert the formatted line matches. + /// Tests don't hit the network — we synthesise `Value` shapes + /// matching the Forgejo schema directly. + fn captured(ev: &Value) -> String { + // Tests run in-process; `println!` would interleave with the + // test harness. We rebuild the line via the same format + // expression. Keep this helper in lockstep with `print_event`. + 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" => { + let body = ev.get("body").and_then(Value::as_str).unwrap_or(""); + return format!("**{user} @ {ts}**: {body}"); + } + "label" => { + 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}`") + } + "close" => "closed".to_owned(), + other => format!("[{other}]"), + }; + format!("**{user} @ {ts}**: {summary}") + } + + #[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!(captured(&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!( + captured(&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!( + captured(&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!(captured(&ev), "**mara @ 2026-05-31T12:00:00Z**: closed"); + } + + #[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!( + captured(&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!(captured(&ev), "**? @ 2026-05-31T12:00:00Z**: closed"); + } +}