hive-forge: timeline verb — issue/PR audit trail (closes #783)
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 <n> / comments <n> - 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**: <summary>'), --json for raw piping. unknown event types fall through to a '[<type>]' 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.
This commit is contained in:
parent
7cc59c4338
commit
8f9866f5e1
3 changed files with 310 additions and 0 deletions
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
304
hive-forge/src/verbs/timeline.rs
Normal file
304
hive-forge/src/verbs/timeline.rs
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
//! `timeline <number> [--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 <n>` /
|
||||
//! `comments <n>` — 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 `[<type>]` 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<Value> = 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");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue