3227a9c9
Add collab log command for chronological event stream
a73x 2026-03-21 16:02
Adds `git-collab log [-n LIMIT]` to display all collab events (issues and patches) in chronological order. Each entry shows timestamp, action type, entity kind, short ID, author, and a human-readable summary. Handles all Action variants including IssueEdit, IssueLabel, IssueUnlabel, IssueAssign, IssueUnassign. Also includes concurrent changes: status command, shell completions, JSON output flags, and pagination for list commands. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
diff --git a/Cargo.lock b/Cargo.lock index 950f366..a518e92 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -115,7 +115,16 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" dependencies = [ "bit-vec", "bit-vec 0.6.3", ] [[package]] name = "bit-set" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ "bit-vec 0.8.0", ] [[package]] @@ -125,6 +134,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" [[package]] name = "bit-vec" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" [[package]] name = "bitflags" version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -227,6 +242,15 @@ dependencies = [ ] [[package]] name = "clap_complete" version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19c9f1dde76b736e3681f28cec9d5a61299cbaae0fce80a68e43724ad56031eb" dependencies = [ "clap", ] [[package]] name = "clap_derive" version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -528,7 +552,7 @@ checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ "curve25519-dalek", "ed25519", "rand_core", "rand_core 0.6.4", "serde", "sha2", "subtle", @@ -572,7 +596,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" dependencies = [ "bit-set", "bit-set 0.5.3", "regex", ] @@ -697,12 +721,14 @@ dependencies = [ "base64", "chrono", "clap", "clap_complete", "clap_mangen", "crossterm", "dirs", "ed25519-dalek", "git2", "rand_core", "proptest", "rand_core 0.6.4", "ratatui", "serde", "serde_json", @@ -1356,7 +1382,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", "rand", "rand 0.8.5", ] [[package]] @@ -1419,6 +1445,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] name = "ppv-lite86" version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ "zerocopy", ] [[package]] name = "prettyplease" version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1438,6 +1473,31 @@ dependencies = [ ] [[package]] name = "proptest" version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532" dependencies = [ "bit-set 0.8.0", "bit-vec 0.8.0", "bitflags 2.11.0", "num-traits", "rand 0.9.2", "rand_chacha", "rand_xorshift", "regex-syntax", "rusty-fork", "tempfile", "unarray", ] [[package]] name = "quick-error" version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" [[package]] name = "quote" version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1464,7 +1524,27 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "rand_core", "rand_core 0.6.4", ] [[package]] name = "rand" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha", "rand_core 0.9.5", ] [[package]] name = "rand_chacha" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", "rand_core 0.9.5", ] [[package]] @@ -1477,6 +1557,24 @@ dependencies = [ ] [[package]] name = "rand_core" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ "getrandom 0.3.4", ] [[package]] name = "rand_xorshift" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" dependencies = [ "rand_core 0.9.5", ] [[package]] name = "ratatui" version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1645,6 +1743,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "rusty-fork" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" dependencies = [ "fnv", "quick-error", "tempfile", "wait-timeout", ] [[package]] name = "ryu" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1759,7 +1869,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "rand_core", "rand_core 0.6.4", ] [[package]] @@ -2022,6 +2132,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" [[package]] name = "unarray" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2114,6 +2230,15 @@ dependencies = [ ] [[package]] name = "wait-timeout" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" dependencies = [ "libc", ] [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2562,6 +2687,26 @@ dependencies = [ ] [[package]] name = "zerocopy" version = "0.8.47" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" version = "0.8.47" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", ] [[package]] name = "zerofrom" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/Cargo.toml b/Cargo.toml index 8764c48..89855b3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" [dependencies] git2 = "0.19" clap = { version = "4", features = ["derive"] } clap_complete = "4" serde = { version = "1", features = ["derive"] } serde_json = "1" chrono = { version = "0.4", features = ["serde"] } @@ -19,6 +20,7 @@ dirs = "5" [build-dependencies] clap = { version = "4", features = ["derive"] } clap_complete = "4" clap_mangen = "0.2" [dev-dependencies] diff --git a/src/cli.rs b/src/cli.rs index 202a6fd..89ab8a3 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,4 +1,5 @@ use clap::{Parser, Subcommand}; use clap_complete::Shell; #[derive(Parser)] #[command( @@ -23,9 +24,25 @@ pub enum Commands { #[command(subcommand)] Patch(PatchCmd), /// Show project status overview Status, /// Interactive patch review dashboard Dashboard, /// Show raw collab event stream in chronological order Log { /// Maximum number of events to show #[arg(short = 'n', long)] limit: Option<usize>, }, /// Generate shell completions Completions { /// Shell to generate completions for shell: Shell, }, /// Sync with a remote (fetch, reconcile, push) Sync { /// Remote name (default: origin) @@ -62,11 +79,23 @@ pub enum IssueCmd { /// Show closed issues too #[arg(short = 'a', long)] all: bool, /// Maximum number of issues to display #[arg(short = 'n', long)] limit: Option<usize>, /// Number of issues to skip before displaying #[arg(long)] offset: Option<usize>, /// Output as JSON #[arg(long)] json: bool, }, /// Show issue details Show { /// Issue ID (prefix match) id: String, /// Output as JSON #[arg(long)] json: bool, }, /// Comment on an issue Comment { @@ -177,11 +206,23 @@ pub enum PatchCmd { /// Show closed/merged patches too #[arg(short = 'a', long)] all: bool, /// Maximum number of patches to display #[arg(short = 'n', long)] limit: Option<usize>, /// Number of patches to skip before displaying #[arg(long)] offset: Option<usize>, /// Output as JSON #[arg(long)] json: bool, }, /// Show patch details Show { /// Patch ID (prefix match) id: String, /// Output as JSON #[arg(long)] json: bool, }, /// Show diff between base and head Diff { diff --git a/src/issue.rs b/src/issue.rs index c4e0cf8..cee4408 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -30,19 +30,63 @@ pub struct ListEntry { pub unread: Option<usize>, } pub fn list(repo: &Repository, show_closed: bool) -> Result<Vec<ListEntry>, crate::error::Error> { pub fn list( repo: &Repository, show_closed: bool, limit: Option<usize>, offset: Option<usize>, ) -> Result<Vec<ListEntry>, crate::error::Error> { let issues = state::list_issues(repo)?; let entries = issues let entries: Vec<_> = issues .into_iter() .filter(|i| show_closed || i.status == IssueStatus::Open) .map(|issue| { let unread = count_unread(repo, &issue.id); ListEntry { issue, unread } }) .skip(offset.unwrap_or(0)) .take(limit.unwrap_or(usize::MAX)) .collect(); Ok(entries) } pub fn list_to_writer( repo: &Repository, show_closed: bool, limit: Option<usize>, offset: Option<usize>, writer: &mut dyn std::io::Write, ) -> Result<(), crate::error::Error> { let entries = list(repo, show_closed, limit, offset)?; if entries.is_empty() { writeln!(writer, "No issues found.").ok(); return Ok(()); } for e in &entries { let i = &e.issue; let status = match i.status { IssueStatus::Open => "open", IssueStatus::Closed => "closed", }; let labels = if i.labels.is_empty() { String::new() } else { format!(" [{}]", i.labels.join(", ")) }; let unread = match e.unread { Some(n) if n > 0 => format!(" ({} new)", n), _ => String::new(), }; writeln!( writer, "{:.8} {:6} {}{} (by {}){}", i.id, status, i.title, labels, i.author.name, unread ) .ok(); } Ok(()) } /// Count events after the last-seen mark. Returns None if never viewed. fn count_unread(repo: &git2::Repository, id: &str) -> Option<usize> { let seen_ref = format!("refs/collab/local/seen/issues/{}", id); @@ -63,6 +107,21 @@ fn count_unread(repo: &git2::Repository, id: &str) -> Option<usize> { Some(revwalk.count()) } pub fn list_json(repo: &Repository, show_closed: bool) -> Result<String, crate::error::Error> { let issues = state::list_issues(repo)?; let filtered: Vec<_> = issues .into_iter() .filter(|i| show_closed || i.status == IssueStatus::Open) .collect(); Ok(serde_json::to_string_pretty(&filtered)?) } pub fn show_json(repo: &Repository, id_prefix: &str) -> Result<String, crate::error::Error> { let (ref_name, id) = state::resolve_issue_ref(repo, id_prefix)?; let issue = IssueState::from_ref(repo, &ref_name, &id)?; Ok(serde_json::to_string_pretty(&issue)?) } pub fn show(repo: &Repository, id_prefix: &str) -> Result<IssueState, crate::error::Error> { let (ref_name, id) = state::resolve_issue_ref(repo, id_prefix)?; let issue = IssueState::from_ref(repo, &ref_name, &id)?; diff --git a/src/lib.rs b/src/lib.rs index 08b1c3e..ce146e9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,9 +5,11 @@ pub mod error; pub mod event; pub mod identity; pub mod issue; pub mod log; pub mod patch; pub mod state; pub mod signing; pub mod status; pub mod sync; pub mod sync_lock; pub mod trust; @@ -28,8 +30,13 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { println!("Opened issue {:.8}", id); Ok(()) } IssueCmd::List { all } => { let entries = issue::list(repo, all)?; IssueCmd::List { all, limit, offset, json } => { if json { let output = issue::list_json(repo, all)?; println!("{}", output); return Ok(()); } let entries = issue::list(repo, all, limit, offset)?; if entries.is_empty() { println!("No issues found."); } else { @@ -56,7 +63,12 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { } Ok(()) } IssueCmd::Show { id } => { IssueCmd::Show { id, json } => { if json { let output = issue::show_json(repo, &id)?; println!("{}", output); return Ok(()); } let i = issue::show(repo, &id)?; let status = match i.status { IssueStatus::Open => "open", @@ -169,8 +181,13 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { println!("Created patch {:.8}", id); Ok(()) } PatchCmd::List { all } => { let patches = patch::list(repo, all)?; PatchCmd::List { all, limit, offset, json } => { if json { let output = patch::list_json(repo, all)?; println!("{}", output); return Ok(()); } let patches = patch::list(repo, all, limit, offset)?; if patches.is_empty() { println!("No patches found."); } else { @@ -188,7 +205,12 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { } Ok(()) } PatchCmd::Show { id } => { PatchCmd::Show { id, json } => { if json { let output = patch::show_json(repo, &id)?; println!("{}", output); return Ok(()); } let p = patch::show(repo, &id)?; let status = match p.status { PatchStatus::Open => "open", @@ -294,7 +316,14 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { Ok(()) } }, Commands::Status => { let project_status = status::compute(repo)?; print!("{}", project_status); Ok(()) } Commands::Log { limit } => log::print_log(repo, limit), Commands::Dashboard => tui::run(repo), Commands::Completions { .. } => unreachable!("handled before repo open"), Commands::Sync { remote } => sync::sync(repo, &remote), Commands::InitKey { force } => { let config_dir = signing::signing_key_dir()?; diff --git a/src/log.rs b/src/log.rs new file mode 100644 index 0000000..143962e --- /dev/null +++ b/src/log.rs @@ -0,0 +1,175 @@ use git2::Repository; use crate::dag; use crate::error::Error; use crate::event::{Action, Author}; /// A single entry in the collab log output. #[derive(Debug, Clone)] pub struct LogEntry { pub timestamp: String, pub event_type: String, pub entity_kind: String, pub entity_id: String, pub author: Author, pub summary: String, } /// Collect all events from every collab ref, sorted by timestamp. /// If `limit` is provided, return at most that many entries (most recent last). pub fn collect_events(repo: &Repository, limit: Option<usize>) -> Result<Vec<LogEntry>, Error> { let mut entries = Vec::new(); // Walk issues let issue_refs = repo.references_glob("refs/collab/issues/*")?; for r in issue_refs { let r = r?; let ref_name = r.name().unwrap_or_default().to_string(); let id = ref_name .strip_prefix("refs/collab/issues/") .unwrap_or_default() .to_string(); if let Ok(events) = dag::walk_events(repo, &ref_name) { for (_oid, event) in events { entries.push(LogEntry { timestamp: event.timestamp.clone(), event_type: action_type_name(&event.action), entity_kind: "issue".to_string(), entity_id: id.clone(), author: event.author, summary: action_summary(&event.action), }); } } } // Walk patches let patch_refs = repo.references_glob("refs/collab/patches/*")?; for r in patch_refs { let r = r?; let ref_name = r.name().unwrap_or_default().to_string(); let id = ref_name .strip_prefix("refs/collab/patches/") .unwrap_or_default() .to_string(); if let Ok(events) = dag::walk_events(repo, &ref_name) { for (_oid, event) in events { entries.push(LogEntry { timestamp: event.timestamp.clone(), event_type: action_type_name(&event.action), entity_kind: "patch".to_string(), entity_id: id.clone(), author: event.author, summary: action_summary(&event.action), }); } } } // Sort by timestamp (chronological) entries.sort_by(|a, b| a.timestamp.cmp(&b.timestamp)); if let Some(n) = limit { entries.truncate(n); } Ok(entries) } /// Format log entries into a human-readable string. pub fn format_log(entries: &[LogEntry]) -> String { let mut out = String::new(); for entry in entries { out.push_str(&format!( "{} {} {:.8} {} <{}> {}\n", entry.timestamp, entry.event_type, entry.entity_kind, entry.entity_id.get(..8).unwrap_or(&entry.entity_id), entry.author.name, entry.summary, )); } out } /// Print the log to stdout. pub fn print_log(repo: &Repository, limit: Option<usize>) -> Result<(), Error> { let entries = collect_events(repo, limit)?; if entries.is_empty() { println!("No collab events found."); return Ok(()); } print!("{}", format_log(&entries)); Ok(()) } fn action_type_name(action: &Action) -> String { match action { Action::IssueOpen { .. } => "IssueOpen".to_string(), Action::IssueComment { .. } => "IssueComment".to_string(), Action::IssueClose { .. } => "IssueClose".to_string(), Action::IssueEdit { .. } => "IssueEdit".to_string(), Action::IssueLabel { .. } => "IssueLabel".to_string(), Action::IssueUnlabel { .. } => "IssueUnlabel".to_string(), Action::IssueAssign { .. } => "IssueAssign".to_string(), Action::IssueUnassign { .. } => "IssueUnassign".to_string(), Action::IssueReopen => "IssueReopen".to_string(), Action::PatchCreate { .. } => "PatchCreate".to_string(), Action::PatchRevise { .. } => "PatchRevise".to_string(), Action::PatchReview { .. } => "PatchReview".to_string(), Action::PatchComment { .. } => "PatchComment".to_string(), Action::PatchInlineComment { .. } => "PatchInlineComment".to_string(), Action::PatchClose { .. } => "PatchClose".to_string(), Action::PatchMerge => "PatchMerge".to_string(), Action::Merge => "Merge".to_string(), } } fn action_summary(action: &Action) -> String { match action { Action::IssueOpen { title, .. } => format!("open \"{}\"", title), Action::IssueComment { body } => truncate(body, 60), Action::IssueClose { reason } => match reason { Some(r) => format!("close: {}", r), None => "close".to_string(), }, Action::IssueEdit { title, body } => { let parts: Vec<&str> = [ title.as_ref().map(|_| "title"), body.as_ref().map(|_| "body"), ] .into_iter() .flatten() .collect(); format!("edit {}", parts.join(", ")) } Action::IssueLabel { label } => format!("label \"{}\"", label), Action::IssueUnlabel { label } => format!("unlabel \"{}\"", label), Action::IssueAssign { assignee } => format!("assign \"{}\"", assignee), Action::IssueUnassign { assignee } => format!("unassign \"{}\"", assignee), Action::IssueReopen => "reopen".to_string(), Action::PatchCreate { title, .. } => format!("create \"{}\"", title), Action::PatchRevise { body, .. } => match body { Some(b) => format!("revise: {}", truncate(b, 50)), None => "revise".to_string(), }, Action::PatchReview { verdict, .. } => format!("review: {:?}", verdict), Action::PatchComment { body } => truncate(body, 60), Action::PatchInlineComment { file, line, .. } => format!("comment on {}:{}", file, line), Action::PatchClose { reason } => match reason { Some(r) => format!("close: {}", r), None => "close".to_string(), }, Action::PatchMerge => "merge".to_string(), Action::Merge => "dag merge".to_string(), } } fn truncate(s: &str, max: usize) -> String { if s.len() <= max { s.to_string() } else { format!("{}...", &s[..max]) } } diff --git a/src/main.rs b/src/main.rs index 464e96a..12329b5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,22 @@ use clap::Parser; use clap::{CommandFactory, Parser}; use git2::Repository; use git_collab::cli::Cli; use git_collab::cli::{Cli, Commands}; fn main() { let cli = Cli::parse(); // Handle completions before opening the repo (no git repo needed) if let Commands::Completions { shell } = &cli.command { clap_complete::generate( *shell, &mut Cli::command(), "git-collab", &mut std::io::stdout(), ); return; } let repo = match Repository::open_from_env() { Ok(r) => r, Err(e) => { diff --git a/src/patch.rs b/src/patch.rs index 43bb9ae..f09a353 100644 --- a/src/patch.rs +++ b/src/patch.rs @@ -59,15 +59,65 @@ pub fn create( Ok(id) } pub fn list(repo: &Repository, show_closed: bool) -> Result<Vec<PatchState>, crate::error::Error> { pub fn list( repo: &Repository, show_closed: bool, limit: Option<usize>, offset: Option<usize>, ) -> Result<Vec<PatchState>, crate::error::Error> { let patches = state::list_patches(repo)?; let filtered = patches .into_iter() .filter(|p| show_closed || p.status == PatchStatus::Open) .skip(offset.unwrap_or(0)) .take(limit.unwrap_or(usize::MAX)) .collect(); Ok(filtered) } pub fn list_to_writer( repo: &Repository, show_closed: bool, limit: Option<usize>, offset: Option<usize>, writer: &mut dyn std::io::Write, ) -> Result<(), crate::error::Error> { let patches = list(repo, show_closed, limit, offset)?; if patches.is_empty() { writeln!(writer, "No patches found.").ok(); return Ok(()); } for p in &patches { let status = match p.status { PatchStatus::Open => "open", PatchStatus::Closed => "closed", PatchStatus::Merged => "merged", }; writeln!( writer, "{:.8} {:6} {} (by {})", p.id, status, p.title, p.author.name ) .ok(); } Ok(()) } pub fn list_json(repo: &Repository, show_closed: bool) -> Result<String, crate::error::Error> { let patches = state::list_patches(repo)?; let filtered: Vec<_> = patches .into_iter() .filter(|p| show_closed || p.status == PatchStatus::Open) .collect(); Ok(serde_json::to_string_pretty(&filtered)?) } pub fn show_json(repo: &Repository, id_prefix: &str) -> Result<String, crate::error::Error> { let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?; let p = PatchState::from_ref(repo, &ref_name, &id)?; Ok(serde_json::to_string_pretty(&p)?) } pub fn show(repo: &Repository, id_prefix: &str) -> Result<PatchState, crate::error::Error> { let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?; PatchState::from_ref(repo, &ref_name, &id) diff --git a/src/state.rs b/src/state.rs index 05ae1a8..a0dba56 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,30 +1,54 @@ use git2::{Oid, Repository}; use serde::Serialize; use crate::dag; use crate::event::{Action, Author, ReviewVerdict}; #[derive(Debug, Clone, PartialEq)] fn serialize_oid<S: serde::Serializer>(oid: &Oid, s: S) -> Result<S::Ok, S::Error> { s.serialize_str(&oid.to_string()) } fn serialize_oid_option<S: serde::Serializer>(oid: &Option<Oid>, s: S) -> Result<S::Ok, S::Error> { match oid { Some(o) => s.serialize_some(&o.to_string()), None => s.serialize_none(), } } fn serialize_verdict<S: serde::Serializer>(v: &ReviewVerdict, s: S) -> Result<S::Ok, S::Error> { let str_val = match v { ReviewVerdict::Approve => "approve", ReviewVerdict::RequestChanges => "request-changes", ReviewVerdict::Comment => "comment", }; s.serialize_str(str_val) } #[derive(Debug, Clone, PartialEq, Serialize)] #[serde(rename_all = "lowercase")] pub enum IssueStatus { Open, Closed, } #[derive(Debug, Clone)] #[derive(Debug, Clone, Serialize)] #[allow(dead_code)] pub struct Comment { pub author: Author, pub body: String, pub timestamp: String, #[serde(serialize_with = "serialize_oid")] pub commit_id: Oid, } #[derive(Debug, Clone)] #[derive(Debug, Clone, Serialize)] pub struct IssueState { pub id: String, pub title: String, pub body: String, pub status: IssueStatus, pub close_reason: Option<String>, #[serde(serialize_with = "serialize_oid_option")] pub closed_by: Option<Oid>, pub labels: Vec<String>, pub assignees: Vec<String>, @@ -33,15 +57,17 @@ pub struct IssueState { pub author: Author, } #[derive(Debug, Clone)] #[derive(Debug, Clone, Serialize)] pub struct Review { pub author: Author, #[serde(serialize_with = "serialize_verdict")] pub verdict: ReviewVerdict, pub body: String, pub timestamp: String, } #[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq, Serialize)] #[serde(rename_all = "lowercase")] #[allow(dead_code)] pub enum PatchStatus { Open, @@ -49,7 +75,7 @@ pub enum PatchStatus { Merged, } #[derive(Debug, Clone)] #[derive(Debug, Clone, Serialize)] pub struct InlineComment { pub author: Author, pub file: String, @@ -58,7 +84,7 @@ pub struct InlineComment { pub timestamp: String, } #[derive(Debug, Clone)] #[derive(Debug, Clone, Serialize)] pub struct PatchState { pub id: String, pub title: String, diff --git a/src/status.rs b/src/status.rs new file mode 100644 index 0000000..62b3598 --- /dev/null +++ b/src/status.rs @@ -0,0 +1,153 @@ use std::fmt; use git2::Repository; use crate::error::Error; use crate::event::ReviewVerdict; use crate::state::{self, IssueStatus, PatchStatus}; /// A single recently-updated item (issue or patch) for the status summary. #[derive(Debug, Clone)] pub struct RecentItem { pub kind: &'static str, pub id: String, pub title: String, pub status: String, pub created_at: String, } /// Aggregated project status computed from all collab refs. #[derive(Debug, Clone)] pub struct ProjectStatus { pub issues_open: usize, pub issues_closed: usize, pub patches_open: usize, pub patches_closed: usize, pub patches_merged: usize, /// Number of open patches whose latest review verdict is Approve. pub patches_approved: usize, /// Number of open patches whose latest review verdict is RequestChanges. pub patches_changes_requested: usize, /// Most recently created items (up to 10), sorted newest first. pub recent_items: Vec<RecentItem>, } /// Compute an aggregate status for the project. pub fn compute(repo: &Repository) -> Result<ProjectStatus, Error> { let issues = state::list_issues(repo)?; let patches = state::list_patches(repo)?; let issues_open = issues .iter() .filter(|i| i.status == IssueStatus::Open) .count(); let issues_closed = issues .iter() .filter(|i| i.status == IssueStatus::Closed) .count(); let patches_open = patches .iter() .filter(|p| p.status == PatchStatus::Open) .count(); let patches_closed = patches .iter() .filter(|p| p.status == PatchStatus::Closed) .count(); let patches_merged = patches .iter() .filter(|p| p.status == PatchStatus::Merged) .count(); // Review summary: for each open patch, look at the latest review verdict. let mut patches_approved = 0usize; let mut patches_changes_requested = 0usize; for p in patches.iter().filter(|p| p.status == PatchStatus::Open) { if let Some(last_review) = p.reviews.last() { match last_review.verdict { ReviewVerdict::Approve => patches_approved += 1, ReviewVerdict::RequestChanges => patches_changes_requested += 1, ReviewVerdict::Comment => {} } } } // Collect recent items from both issues and patches, sort by created_at descending, cap at 10. let mut recent_items: Vec<RecentItem> = Vec::new(); for issue in &issues { recent_items.push(RecentItem { kind: "issue", id: issue.id[..8.min(issue.id.len())].to_string(), title: issue.title.clone(), status: match issue.status { IssueStatus::Open => "open".to_string(), IssueStatus::Closed => "closed".to_string(), }, created_at: issue.created_at.clone(), }); } for patch in &patches { recent_items.push(RecentItem { kind: "patch", id: patch.id[..8.min(patch.id.len())].to_string(), title: patch.title.clone(), status: match patch.status { PatchStatus::Open => "open".to_string(), PatchStatus::Closed => "closed".to_string(), PatchStatus::Merged => "merged".to_string(), }, created_at: patch.created_at.clone(), }); } recent_items.sort_by(|a, b| b.created_at.cmp(&a.created_at)); recent_items.truncate(10); Ok(ProjectStatus { issues_open, issues_closed, patches_open, patches_closed, patches_merged, patches_approved, patches_changes_requested, recent_items, }) } impl fmt::Display for ProjectStatus { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { writeln!(f, "Issues: {} open, {} closed", self.issues_open, self.issues_closed)?; writeln!( f, "Patches: {} open, {} merged, {} closed", self.patches_open, self.patches_merged, self.patches_closed )?; if self.patches_open > 0 { writeln!( f, " Review: {} approved, {} changes requested, {} pending", self.patches_approved, self.patches_changes_requested, self.patches_open - self.patches_approved - self.patches_changes_requested )?; } if !self.recent_items.is_empty() { writeln!(f)?; writeln!(f, "Recently updated:")?; for item in &self.recent_items { writeln!( f, " [{kind}] {id} {title} ({status})", kind = item.kind, id = item.id, title = item.title, status = item.status, )?; } } Ok(()) } } diff --git a/tests/collab_test.rs b/tests/collab_test.rs index 3b54981..af75d8c 100644 --- a/tests/collab_test.rs +++ b/tests/collab_test.rs @@ -994,3 +994,271 @@ fn test_branch_push_auto_reflects_in_patch() { assert_eq!(head2, new_tip, "head should be the new tip"); } // --------------------------------------------------------------------------- // Log command tests // --------------------------------------------------------------------------- use git_collab::issue; use git_collab::log; #[test] fn test_log_collects_events_from_all_refs() { let tmp = TempDir::new().unwrap(); let repo = init_repo(tmp.path(), &alice()); // Create an issue and a patch let (ref1, _) = open_issue(&repo, &alice(), "Log test issue"); add_comment(&repo, &ref1, &bob(), "A comment"); let (ref2, _) = create_patch(&repo, &alice(), "Log test patch"); add_review(&repo, &ref2, &bob(), ReviewVerdict::Approve); let entries = log::collect_events(&repo, None).unwrap(); // 2 issue events + 2 patch events = 4 assert_eq!(entries.len(), 4); } #[test] fn test_log_entries_sorted_by_timestamp() { let tmp = TempDir::new().unwrap(); let repo = init_repo(tmp.path(), &alice()); let (ref1, _) = open_issue(&repo, &alice(), "First"); add_comment(&repo, &ref1, &bob(), "Second"); let entries = log::collect_events(&repo, None).unwrap(); assert!(entries.len() >= 2); // Timestamps should be non-decreasing for w in entries.windows(2) { assert!(w[0].timestamp <= w[1].timestamp); } } #[test] fn test_log_limit() { let tmp = TempDir::new().unwrap(); let repo = init_repo(tmp.path(), &alice()); let (ref1, _) = open_issue(&repo, &alice(), "Limit test"); add_comment(&repo, &ref1, &alice(), "c1"); add_comment(&repo, &ref1, &alice(), "c2"); add_comment(&repo, &ref1, &alice(), "c3"); let entries = log::collect_events(&repo, Some(2)).unwrap(); assert_eq!(entries.len(), 2); } #[test] fn test_log_entry_fields() { let tmp = TempDir::new().unwrap(); let repo = init_repo(tmp.path(), &alice()); let (_, _) = open_issue(&repo, &alice(), "Field test"); let entries = log::collect_events(&repo, None).unwrap(); assert_eq!(entries.len(), 1); let entry = &entries[0]; assert_eq!(entry.entity_kind, "issue"); assert_eq!(entry.event_type, "IssueOpen"); assert_eq!(entry.author.name, "Alice"); assert_eq!(entry.summary, "open \"Field test\""); assert!(!entry.entity_id.is_empty()); assert!(!entry.timestamp.is_empty()); } #[test] fn test_log_empty_repo() { let tmp = TempDir::new().unwrap(); let repo = init_repo(tmp.path(), &alice()); let entries = log::collect_events(&repo, None).unwrap(); assert!(entries.is_empty()); } #[test] fn test_log_format_output() { let tmp = TempDir::new().unwrap(); let repo = init_repo(tmp.path(), &alice()); let (_, _) = open_issue(&repo, &alice(), "Format test"); let entries = log::collect_events(&repo, None).unwrap(); let output = log::format_log(&entries); assert!(output.contains("issue")); assert!(output.contains("Alice")); assert!(output.contains("IssueOpen")); } // --------------------------------------------------------------------------- // Pagination tests // --------------------------------------------------------------------------- /// Helper: capture output from issue::list_to_writer fn capture_issue_list( repo: &git2::Repository, show_closed: bool, limit: Option<usize>, offset: Option<usize>, ) -> String { let mut buf = Vec::new(); issue::list_to_writer(repo, show_closed, limit, offset, &mut buf).unwrap(); String::from_utf8(buf).unwrap() } /// Helper: capture output from patch::list_to_writer fn capture_patch_list( repo: &git2::Repository, show_closed: bool, limit: Option<usize>, offset: Option<usize>, ) -> String { let mut buf = Vec::new(); patch::list_to_writer(repo, show_closed, limit, offset, &mut buf).unwrap(); String::from_utf8(buf).unwrap() } #[test] fn test_issue_list_limit() { let tmp = TempDir::new().unwrap(); let repo = init_repo(tmp.path(), &alice()); open_issue(&repo, &alice(), "Issue A"); open_issue(&repo, &alice(), "Issue B"); open_issue(&repo, &alice(), "Issue C"); let all = state::list_issues(&repo).unwrap(); assert_eq!(all.len(), 3); let output = capture_issue_list(&repo, false, Some(2), None); let lines: Vec<_> = output.lines().filter(|l| !l.is_empty()).collect(); assert_eq!(lines.len(), 2, "limit=2 should show exactly 2 issues"); } #[test] fn test_issue_list_offset() { let tmp = TempDir::new().unwrap(); let repo = init_repo(tmp.path(), &alice()); open_issue(&repo, &alice(), "Issue A"); open_issue(&repo, &alice(), "Issue B"); open_issue(&repo, &alice(), "Issue C"); let output_all = capture_issue_list(&repo, false, None, None); let all_lines: Vec<_> = output_all.lines().filter(|l| !l.is_empty()).collect(); let output_offset = capture_issue_list(&repo, false, None, Some(1)); let offset_lines: Vec<_> = output_offset.lines().filter(|l| !l.is_empty()).collect(); assert_eq!( offset_lines.len(), all_lines.len() - 1, "offset=1 should skip one issue" ); assert_eq!(offset_lines, &all_lines[1..]); } #[test] fn test_issue_list_limit_and_offset() { let tmp = TempDir::new().unwrap(); let repo = init_repo(tmp.path(), &alice()); open_issue(&repo, &alice(), "Issue A"); open_issue(&repo, &alice(), "Issue B"); open_issue(&repo, &alice(), "Issue C"); open_issue(&repo, &alice(), "Issue D"); let output_all = capture_issue_list(&repo, false, None, None); let all_lines: Vec<_> = output_all.lines().filter(|l| !l.is_empty()).collect(); assert_eq!(all_lines.len(), 4); let output = capture_issue_list(&repo, false, Some(2), Some(1)); let lines: Vec<_> = output.lines().filter(|l| !l.is_empty()).collect(); assert_eq!( lines.len(), 2, "limit=2,offset=1 should show exactly 2 issues" ); assert_eq!(lines, &all_lines[1..3]); } #[test] fn test_issue_list_offset_beyond_end() { let tmp = TempDir::new().unwrap(); let repo = init_repo(tmp.path(), &alice()); open_issue(&repo, &alice(), "Issue A"); let output = capture_issue_list(&repo, false, None, Some(100)); assert!( output.contains("No issues found."), "offset beyond end should show 'No issues found.'" ); } #[test] fn test_patch_list_limit() { let tmp = TempDir::new().unwrap(); let repo = init_repo(tmp.path(), &alice()); create_patch(&repo, &alice(), "Patch A"); create_patch(&repo, &alice(), "Patch B"); create_patch(&repo, &alice(), "Patch C"); let output = capture_patch_list(&repo, false, Some(2), None); let lines: Vec<_> = output.lines().filter(|l| !l.is_empty()).collect(); assert_eq!(lines.len(), 2, "limit=2 should show exactly 2 patches"); } #[test] fn test_patch_list_offset() { let tmp = TempDir::new().unwrap(); let repo = init_repo(tmp.path(), &alice()); create_patch(&repo, &alice(), "Patch A"); create_patch(&repo, &alice(), "Patch B"); create_patch(&repo, &alice(), "Patch C"); let output_all = capture_patch_list(&repo, false, None, None); let all_lines: Vec<_> = output_all.lines().filter(|l| !l.is_empty()).collect(); let output_offset = capture_patch_list(&repo, false, None, Some(1)); let offset_lines: Vec<_> = output_offset.lines().filter(|l| !l.is_empty()).collect(); assert_eq!(offset_lines.len(), all_lines.len() - 1); assert_eq!(offset_lines, &all_lines[1..]); } #[test] fn test_patch_list_limit_and_offset() { let tmp = TempDir::new().unwrap(); let repo = init_repo(tmp.path(), &alice()); create_patch(&repo, &alice(), "Patch A"); create_patch(&repo, &alice(), "Patch B"); create_patch(&repo, &alice(), "Patch C"); create_patch(&repo, &alice(), "Patch D"); let output_all = capture_patch_list(&repo, false, None, None); let all_lines: Vec<_> = output_all.lines().filter(|l| !l.is_empty()).collect(); let output = capture_patch_list(&repo, false, Some(2), Some(1)); let lines: Vec<_> = output.lines().filter(|l| !l.is_empty()).collect(); assert_eq!(lines.len(), 2); assert_eq!(lines, &all_lines[1..3]); } #[test] fn test_patch_list_offset_beyond_end() { let tmp = TempDir::new().unwrap(); let repo = init_repo(tmp.path(), &alice()); create_patch(&repo, &alice(), "Patch A"); let output = capture_patch_list(&repo, false, None, Some(100)); assert!( output.contains("No patches found."), "offset beyond end should show 'No patches found.'" ); } diff --git a/tests/completions_test.rs b/tests/completions_test.rs new file mode 100644 index 0000000..389939d --- /dev/null +++ b/tests/completions_test.rs @@ -0,0 +1,48 @@ use std::process::Command; /// Verify `completions bash` parses successfully and produces output #[test] fn completions_bash_produces_output() { let output = Command::new(env!("CARGO_BIN_EXE_git-collab")) .args(["completions", "bash"]) .output() .expect("failed to run binary"); assert!(output.status.success(), "stderr: {}", String::from_utf8_lossy(&output.stderr)); let stdout = String::from_utf8_lossy(&output.stdout); assert!(stdout.contains("complete"), "bash completions should contain 'complete': {}", stdout); } /// Verify `completions zsh` parses successfully and produces output #[test] fn completions_zsh_produces_output() { let output = Command::new(env!("CARGO_BIN_EXE_git-collab")) .args(["completions", "zsh"]) .output() .expect("failed to run binary"); assert!(output.status.success(), "stderr: {}", String::from_utf8_lossy(&output.stderr)); let stdout = String::from_utf8_lossy(&output.stdout); assert!(!stdout.is_empty(), "zsh completions should not be empty"); } /// Verify `completions fish` parses successfully and produces output #[test] fn completions_fish_produces_output() { let output = Command::new(env!("CARGO_BIN_EXE_git-collab")) .args(["completions", "fish"]) .output() .expect("failed to run binary"); assert!(output.status.success(), "stderr: {}", String::from_utf8_lossy(&output.stderr)); let stdout = String::from_utf8_lossy(&output.stdout); assert!(stdout.contains("complete"), "fish completions should contain 'complete': {}", stdout); } /// Verify completions work without a git repo (run from /tmp) #[test] fn completions_work_without_git_repo() { let output = Command::new(env!("CARGO_BIN_EXE_git-collab")) .args(["completions", "bash"]) .current_dir(std::env::temp_dir()) .output() .expect("failed to run binary"); assert!(output.status.success(), "completions should work outside a git repo: {}", String::from_utf8_lossy(&output.stderr)); } diff --git a/tests/status_test.rs b/tests/status_test.rs new file mode 100644 index 0000000..b6291ce --- /dev/null +++ b/tests/status_test.rs @@ -0,0 +1,153 @@ mod common; use tempfile::TempDir; use git_collab::event::ReviewVerdict; use git_collab::status; use common::{alice, bob, create_patch, init_repo, open_issue, close_issue, add_review}; // Additional helpers not in common/mod.rs fn close_patch(repo: &git2::Repository, ref_name: &str, author: &git_collab::event::Author) { let sk = common::test_signing_key(); let event = git_collab::event::Event { timestamp: common::now(), author: author.clone(), action: git_collab::event::Action::PatchClose { reason: None }, clock: 0, }; git_collab::dag::append_event(repo, ref_name, &event, &sk).unwrap(); } fn merge_patch(repo: &git2::Repository, ref_name: &str, author: &git_collab::event::Author) { let sk = common::test_signing_key(); let event = git_collab::event::Event { timestamp: common::now(), author: author.clone(), action: git_collab::event::Action::PatchMerge, clock: 0, }; git_collab::dag::append_event(repo, ref_name, &event, &sk).unwrap(); } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[test] fn test_status_empty_repo() { let tmp = TempDir::new().unwrap(); let repo = init_repo(tmp.path(), &alice()); let status = status::compute(&repo).unwrap(); assert_eq!(status.issues_open, 0); assert_eq!(status.issues_closed, 0); assert_eq!(status.patches_open, 0); assert_eq!(status.patches_closed, 0); assert_eq!(status.patches_merged, 0); assert_eq!(status.patches_approved, 0); assert_eq!(status.patches_changes_requested, 0); assert!(status.recent_items.is_empty()); } #[test] fn test_status_counts_issues() { let tmp = TempDir::new().unwrap(); let repo = init_repo(tmp.path(), &alice()); // 2 open, 1 closed open_issue(&repo, &alice(), "Open one"); open_issue(&repo, &bob(), "Open two"); let (ref_name, _) = open_issue(&repo, &alice(), "Will close"); close_issue(&repo, &ref_name, &alice()); let status = status::compute(&repo).unwrap(); assert_eq!(status.issues_open, 2); assert_eq!(status.issues_closed, 1); } #[test] fn test_status_counts_patches_by_state() { let tmp = TempDir::new().unwrap(); let repo = init_repo(tmp.path(), &alice()); // 1 open, 1 closed, 1 merged create_patch(&repo, &alice(), "Open patch"); let (ref_name, _) = create_patch(&repo, &alice(), "Closed patch"); close_patch(&repo, &ref_name, &alice()); let (ref_name, _) = create_patch(&repo, &alice(), "Merged patch"); merge_patch(&repo, &ref_name, &alice()); let status = status::compute(&repo).unwrap(); assert_eq!(status.patches_open, 1); assert_eq!(status.patches_closed, 1); assert_eq!(status.patches_merged, 1); } #[test] fn test_status_review_summary_for_open_patches() { let tmp = TempDir::new().unwrap(); let repo = init_repo(tmp.path(), &alice()); // Patch with approval let (ref_name, _) = create_patch(&repo, &alice(), "Approved patch"); add_review(&repo, &ref_name, &bob(), ReviewVerdict::Approve); // Patch with changes requested let (ref_name, _) = create_patch(&repo, &alice(), "Needs work"); add_review(&repo, &ref_name, &bob(), ReviewVerdict::RequestChanges); // Patch with no reviews (just open) create_patch(&repo, &alice(), "No reviews yet"); let status = status::compute(&repo).unwrap(); assert_eq!(status.patches_open, 3); assert_eq!(status.patches_approved, 1); assert_eq!(status.patches_changes_requested, 1); } #[test] fn test_status_recent_items() { let tmp = TempDir::new().unwrap(); let repo = init_repo(tmp.path(), &alice()); open_issue(&repo, &alice(), "Recent issue"); create_patch(&repo, &alice(), "Recent patch"); let status = status::compute(&repo).unwrap(); assert_eq!(status.recent_items.len(), 2); let titles: Vec<&str> = status.recent_items.iter().map(|i| i.title.as_str()).collect(); assert!(titles.contains(&"Recent issue")); assert!(titles.contains(&"Recent patch")); } #[test] fn test_status_recent_items_capped_at_10() { let tmp = TempDir::new().unwrap(); let repo = init_repo(tmp.path(), &alice()); for i in 0..15 { open_issue(&repo, &alice(), &format!("Issue {}", i)); } let status = status::compute(&repo).unwrap(); assert_eq!(status.recent_items.len(), 10); } #[test] fn test_status_display_format() { let tmp = TempDir::new().unwrap(); let repo = init_repo(tmp.path(), &alice()); open_issue(&repo, &alice(), "Test issue"); create_patch(&repo, &alice(), "Test patch"); let status = status::compute(&repo).unwrap(); let output = status.to_string(); // Should contain section headers and counts assert!(output.contains("Issues")); assert!(output.contains("Patches")); assert!(output.contains("1 open")); }