aa59d483
Default sort by recency for issue and patch list
a73x 2026-03-21 16:56
- Add last_updated field to IssueState and PatchState, populated from max event timestamp during DAG walk - Add SortMode enum (recent/created/alpha) with clap ValueEnum derive - Add --sort flag to issue list and patch list commands (default: recent) - Apply sorting in list, list_to_writer, and list_json functions - Add serde(default) on last_updated for backward compat with cached entries Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
diff --git a/src/cli.rs b/src/cli.rs index 72b4794..bdcb2a2 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,6 +1,17 @@ use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand, ValueEnum}; use clap_complete::Shell; #[derive(Debug, Clone, Copy, Default, ValueEnum)] pub enum SortMode { /// Sort by last updated (most recent first) #[default] Recent, /// Sort by creation time (newest first) Created, /// Sort alphabetically by title Alpha, } #[derive(Parser)] #[command( name = "git-collab", @@ -95,6 +106,9 @@ pub enum IssueCmd { /// Output as JSON #[arg(long)] json: bool, /// Sort order: recent (default), created, alpha #[arg(long, default_value = "recent")] sort: SortMode, }, /// Show issue details Show { @@ -237,6 +251,9 @@ pub enum PatchCmd { /// Output as JSON #[arg(long)] json: bool, /// Sort order: recent (default), created, alpha #[arg(long, default_value = "recent")] sort: SortMode, }, /// Show patch details Show { diff --git a/src/issue.rs b/src/issue.rs index 3636152..9b440cb 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -1,5 +1,6 @@ use git2::Repository; use crate::cli::SortMode; use crate::dag; use crate::event::{Action, Event}; use crate::identity::get_author; @@ -35,15 +36,24 @@ pub fn list( show_closed: bool, limit: Option<usize>, offset: Option<usize>, sort: SortMode, ) -> Result<Vec<ListEntry>, crate::error::Error> { let issues = state::list_issues(repo)?; let entries: Vec<_> = issues let mut 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 } }) .collect(); match sort { SortMode::Recent => entries.sort_by(|a, b| b.issue.last_updated.cmp(&a.issue.last_updated)), SortMode::Created => entries.sort_by(|a, b| b.issue.created_at.cmp(&a.issue.created_at)), SortMode::Alpha => entries.sort_by(|a, b| a.issue.title.cmp(&b.issue.title)), } let entries = entries .into_iter() .skip(offset.unwrap_or(0)) .take(limit.unwrap_or(usize::MAX)) .collect(); @@ -55,9 +65,10 @@ pub fn list_to_writer( show_closed: bool, limit: Option<usize>, offset: Option<usize>, sort: SortMode, writer: &mut dyn std::io::Write, ) -> Result<(), crate::error::Error> { let entries = list(repo, show_closed, limit, offset)?; let entries = list(repo, show_closed, limit, offset, sort)?; if entries.is_empty() { writeln!(writer, "No issues found.").ok(); return Ok(()); @@ -107,12 +118,17 @@ 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> { pub fn list_json(repo: &Repository, show_closed: bool, sort: SortMode) -> Result<String, crate::error::Error> { let issues = state::list_issues(repo)?; let filtered: Vec<_> = issues let mut filtered: Vec<_> = issues .into_iter() .filter(|i| show_closed || i.status == IssueStatus::Open) .collect(); match sort { SortMode::Recent => filtered.sort_by(|a, b| b.last_updated.cmp(&a.last_updated)), SortMode::Created => filtered.sort_by(|a, b| b.created_at.cmp(&a.created_at)), SortMode::Alpha => filtered.sort_by(|a, b| a.title.cmp(&b.title)), } Ok(serde_json::to_string_pretty(&filtered)?) } diff --git a/src/lib.rs b/src/lib.rs index 9f37a92..db934ed 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -31,13 +31,13 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { println!("Opened issue {:.8}", id); Ok(()) } IssueCmd::List { all, limit, offset, json } => { IssueCmd::List { all, limit, offset, json, sort } => { if json { let output = issue::list_json(repo, all)?; let output = issue::list_json(repo, all, sort)?; println!("{}", output); return Ok(()); } let entries = issue::list(repo, all, limit, offset)?; let entries = issue::list(repo, all, limit, offset, sort)?; if entries.is_empty() { println!("No issues found."); } else { @@ -187,13 +187,13 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { println!("Created patch {:.8}", id); Ok(()) } PatchCmd::List { all, limit, offset, json } => { PatchCmd::List { all, limit, offset, json, sort } => { if json { let output = patch::list_json(repo, all)?; let output = patch::list_json(repo, all, sort)?; println!("{}", output); return Ok(()); } let patches = patch::list(repo, all, limit, offset)?; let patches = patch::list(repo, all, limit, offset, sort)?; if patches.is_empty() { println!("No patches found."); } else { diff --git a/src/patch.rs b/src/patch.rs index e756238..1dd2e43 100644 --- a/src/patch.rs +++ b/src/patch.rs @@ -1,5 +1,6 @@ use git2::{DiffFormat, Repository}; use crate::cli::SortMode; use crate::dag; use crate::error::Error; use crate::event::{Action, Event, ReviewVerdict}; @@ -64,11 +65,20 @@ pub fn list( show_closed: bool, limit: Option<usize>, offset: Option<usize>, sort: SortMode, ) -> Result<Vec<PatchState>, crate::error::Error> { let patches = state::list_patches(repo)?; let filtered = patches let mut filtered: Vec<_> = patches .into_iter() .filter(|p| show_closed || p.status == PatchStatus::Open) .collect(); match sort { SortMode::Recent => filtered.sort_by(|a, b| b.last_updated.cmp(&a.last_updated)), SortMode::Created => filtered.sort_by(|a, b| b.created_at.cmp(&a.created_at)), SortMode::Alpha => filtered.sort_by(|a, b| a.title.cmp(&b.title)), } let filtered = filtered .into_iter() .skip(offset.unwrap_or(0)) .take(limit.unwrap_or(usize::MAX)) .collect(); @@ -80,9 +90,10 @@ pub fn list_to_writer( show_closed: bool, limit: Option<usize>, offset: Option<usize>, sort: SortMode, writer: &mut dyn std::io::Write, ) -> Result<(), crate::error::Error> { let patches = list(repo, show_closed, limit, offset)?; let patches = list(repo, show_closed, limit, offset, sort)?; if patches.is_empty() { writeln!(writer, "No patches found.").ok(); return Ok(()); @@ -103,12 +114,17 @@ pub fn list_to_writer( Ok(()) } pub fn list_json(repo: &Repository, show_closed: bool) -> Result<String, crate::error::Error> { pub fn list_json(repo: &Repository, show_closed: bool, sort: SortMode) -> Result<String, crate::error::Error> { let patches = state::list_patches(repo)?; let filtered: Vec<_> = patches let mut filtered: Vec<_> = patches .into_iter() .filter(|p| show_closed || p.status == PatchStatus::Open) .collect(); match sort { SortMode::Recent => filtered.sort_by(|a, b| b.last_updated.cmp(&a.last_updated)), SortMode::Created => filtered.sort_by(|a, b| b.created_at.cmp(&a.created_at)), SortMode::Alpha => filtered.sort_by(|a, b| a.title.cmp(&b.title)), } Ok(serde_json::to_string_pretty(&filtered)?) } diff --git a/src/state.rs b/src/state.rs index 9a6a4a8..6d3b907 100644 --- a/src/state.rs +++ b/src/state.rs @@ -84,6 +84,8 @@ pub struct IssueState { pub assignees: Vec<String>, pub comments: Vec<Comment>, pub created_at: String, #[serde(default)] pub last_updated: String, pub author: Author, } @@ -127,6 +129,8 @@ pub struct PatchState { pub inline_comments: Vec<InlineComment>, pub reviews: Vec<Review>, pub created_at: String, #[serde(default)] pub last_updated: String, pub author: Author, } @@ -159,12 +163,16 @@ impl IssueState { ) -> Result<Self, crate::error::Error> { let events = dag::walk_events(repo, ref_name)?; let mut state: Option<IssueState> = None; let mut max_timestamp = String::new(); // Track the (clock, commit_oid_hex) of the latest status-changing event. // Higher clock wins; on tie, lexicographically higher OID wins. let mut status_key: Option<(u64, String)> = None; for (oid, event) in events { if event.timestamp > max_timestamp { max_timestamp = event.timestamp.clone(); } match event.action { Action::IssueOpen { title, body } => { state = Some(IssueState { @@ -178,6 +186,7 @@ impl IssueState { assignees: Vec::new(), comments: Vec::new(), created_at: event.timestamp.clone(), last_updated: String::new(), author: event.author.clone(), }); } @@ -252,6 +261,9 @@ impl IssueState { } } if let Some(ref mut s) = state { s.last_updated = max_timestamp; } state.ok_or_else(|| git2::Error::from_str("no IssueOpen event found in DAG").into()) } } @@ -314,10 +326,14 @@ impl PatchState { ) -> Result<Self, crate::error::Error> { let events = dag::walk_events(repo, ref_name)?; let mut state: Option<PatchState> = None; let mut max_timestamp = String::new(); let mut status_key: Option<(u64, String)> = None; for (oid, event) in events { if event.timestamp > max_timestamp { max_timestamp = event.timestamp.clone(); } match event.action { Action::PatchCreate { title, @@ -338,6 +354,7 @@ impl PatchState { inline_comments: Vec::new(), reviews: Vec::new(), created_at: event.timestamp.clone(), last_updated: String::new(), author: event.author.clone(), }); } @@ -402,6 +419,9 @@ impl PatchState { } } if let Some(ref mut s) = state { s.last_updated = max_timestamp; } state.ok_or_else(|| git2::Error::from_str("no PatchCreate event found in DAG").into()) } } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index ee95f8b..1fdf39a 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -64,6 +64,7 @@ mod tests { assignees: vec![], comments: vec![], created_at: String::new(), last_updated: String::new(), author: make_author(), } } @@ -81,6 +82,7 @@ mod tests { inline_comments: vec![], reviews: vec![], created_at: String::new(), last_updated: String::new(), author: make_author(), } } @@ -249,6 +251,7 @@ mod tests { assignees: vec![], comments: Vec::new(), created_at: "2026-01-01T00:00:00Z".to_string(), last_updated: "2026-01-01T00:00:00Z".to_string(), author: test_author(), }) .collect() @@ -272,6 +275,7 @@ mod tests { inline_comments: Vec::new(), reviews: Vec::new(), created_at: "2026-01-01T00:00:00Z".to_string(), last_updated: "2026-01-01T00:00:00Z".to_string(), author: test_author(), }) .collect() diff --git a/tests/collab_test.rs b/tests/collab_test.rs index f67aa19..4e47759 100644 --- a/tests/collab_test.rs +++ b/tests/collab_test.rs @@ -1106,7 +1106,7 @@ fn capture_issue_list( offset: Option<usize>, ) -> String { let mut buf = Vec::new(); issue::list_to_writer(repo, show_closed, limit, offset, &mut buf).unwrap(); issue::list_to_writer(repo, show_closed, limit, offset, git_collab::cli::SortMode::Recent, &mut buf).unwrap(); String::from_utf8(buf).unwrap() } @@ -1118,7 +1118,7 @@ fn capture_patch_list( offset: Option<usize>, ) -> String { let mut buf = Vec::new(); patch::list_to_writer(repo, show_closed, limit, offset, &mut buf).unwrap(); patch::list_to_writer(repo, show_closed, limit, offset, git_collab::cli::SortMode::Recent, &mut buf).unwrap(); String::from_utf8(buf).unwrap() } @@ -1331,7 +1331,7 @@ fn test_issue_list_json_output() { open_issue(&repo, &alice(), "Issue one"); open_issue(&repo, &bob(), "Issue two"); let json_str = git_collab::issue::list_json(&repo, false).unwrap(); let json_str = git_collab::issue::list_json(&repo, false, git_collab::cli::SortMode::Recent).unwrap(); let value: serde_json::Value = serde_json::from_str(&json_str).unwrap(); let arr = value.as_array().unwrap(); assert_eq!(arr.len(), 2); @@ -1351,12 +1351,12 @@ fn test_issue_list_json_filters_closed() { close_issue(&repo, &ref2, &alice()); // Without --all, only open issues let json_str = git_collab::issue::list_json(&repo, false).unwrap(); let json_str = git_collab::issue::list_json(&repo, false, git_collab::cli::SortMode::Recent).unwrap(); let value: serde_json::Value = serde_json::from_str(&json_str).unwrap(); assert_eq!(value.as_array().unwrap().len(), 1); // With --all, both let json_str = git_collab::issue::list_json(&repo, true).unwrap(); let json_str = git_collab::issue::list_json(&repo, true, git_collab::cli::SortMode::Recent).unwrap(); let value: serde_json::Value = serde_json::from_str(&json_str).unwrap(); assert_eq!(value.as_array().unwrap().len(), 2); } @@ -1383,7 +1383,7 @@ fn test_patch_list_json_output() { create_patch(&repo, &alice(), "Patch one"); create_patch(&repo, &bob(), "Patch two"); let json_str = git_collab::patch::list_json(&repo, false).unwrap(); let json_str = git_collab::patch::list_json(&repo, false, git_collab::cli::SortMode::Recent).unwrap(); let value: serde_json::Value = serde_json::from_str(&json_str).unwrap(); let arr = value.as_array().unwrap(); assert_eq!(arr.len(), 2); diff --git a/tests/sort_test.rs b/tests/sort_test.rs new file mode 100644 index 0000000..160b56d --- /dev/null +++ b/tests/sort_test.rs @@ -0,0 +1,357 @@ mod common; use common::{alice, bob, TestRepo}; use ed25519_dalek::SigningKey; use git2::Repository; use rand_core::OsRng; use tempfile::TempDir; use git_collab::cli::SortMode; use git_collab::dag; use git_collab::event::{Action, Author, Event}; use git_collab::state::{self, IssueState, PatchState}; fn test_signing_key() -> SigningKey { SigningKey::generate(&mut OsRng) } fn init_repo(dir: &std::path::Path, author: &Author) -> Repository { let repo = Repository::init(dir).expect("init repo"); { let mut config = repo.config().unwrap(); config.set_str("user.name", &author.name).unwrap(); config.set_str("user.email", &author.email).unwrap(); } repo } /// Create an issue with a specific timestamp. Returns (ref_name, id). fn open_issue_at( repo: &Repository, author: &Author, title: &str, timestamp: &str, ) -> (String, String) { let sk = test_signing_key(); let event = Event { timestamp: timestamp.to_string(), author: author.clone(), action: Action::IssueOpen { title: title.to_string(), body: "".to_string(), }, clock: 0, }; let oid = dag::create_root_event(repo, &event, &sk).unwrap(); let id = oid.to_string(); let ref_name = format!("refs/collab/issues/{}", id); repo.reference(&ref_name, oid, false, "test open").unwrap(); (ref_name, id) } /// Add a comment with a specific timestamp to an issue. fn add_comment_at( repo: &Repository, ref_name: &str, author: &Author, body: &str, timestamp: &str, ) { let sk = test_signing_key(); let event = Event { timestamp: timestamp.to_string(), author: author.clone(), action: Action::IssueComment { body: body.to_string(), }, clock: 0, }; dag::append_event(repo, ref_name, &event, &sk).unwrap(); } /// Create a patch with a specific timestamp. Returns (ref_name, id). fn create_patch_at( repo: &Repository, author: &Author, title: &str, timestamp: &str, ) -> (String, String) { let sk = test_signing_key(); let event = Event { timestamp: timestamp.to_string(), author: author.clone(), action: Action::PatchCreate { title: title.to_string(), body: "".to_string(), base_ref: "main".to_string(), branch: format!("branch-{}", title.replace(' ', "-")), fixes: None, }, clock: 0, }; let oid = dag::create_root_event(repo, &event, &sk).unwrap(); let id = oid.to_string(); let ref_name = format!("refs/collab/patches/{}", id); repo.reference(&ref_name, oid, false, "test patch").unwrap(); (ref_name, id) } /// Add a comment to a patch with a specific timestamp. fn add_patch_comment_at( repo: &Repository, ref_name: &str, author: &Author, body: &str, timestamp: &str, ) { let sk = test_signing_key(); let event = Event { timestamp: timestamp.to_string(), author: author.clone(), action: Action::PatchComment { body: body.to_string(), }, clock: 0, }; dag::append_event(repo, ref_name, &event, &sk).unwrap(); } // ---- Issue sorting tests ---- #[test] fn test_issue_last_updated_populated() { let dir = TempDir::new().unwrap(); let repo = init_repo(dir.path(), &alice()); let (ref_name, id) = open_issue_at(&repo, &alice(), "Test issue", "2025-01-01T00:00:00Z"); add_comment_at( &repo, &ref_name, &bob(), "later comment", "2025-06-15T12:00:00Z", ); let state = IssueState::from_ref(&repo, &ref_name, &id).unwrap(); assert_eq!(state.last_updated, "2025-06-15T12:00:00Z"); assert_eq!(state.created_at, "2025-01-01T00:00:00Z"); } #[test] fn test_issue_default_sort_by_recency() { let dir = TempDir::new().unwrap(); let repo = init_repo(dir.path(), &alice()); // Issue A: created early, updated recently let (ref_a, _) = open_issue_at(&repo, &alice(), "Alpha issue", "2025-01-01T00:00:00Z"); add_comment_at( &repo, &ref_a, &bob(), "recent comment", "2025-12-01T00:00:00Z", ); // Issue B: created later, never updated after creation let (_, _) = open_issue_at(&repo, &alice(), "Beta issue", "2025-06-01T00:00:00Z"); let issues = state::list_issues(&repo).unwrap(); assert_eq!(issues.len(), 2); // Default sort = recent: issue A (last_updated=2025-12) should come first let entries = git_collab::issue::list(&repo, true, None, None, SortMode::Recent).unwrap(); assert_eq!(entries.len(), 2); assert_eq!(entries[0].issue.title, "Alpha issue"); assert_eq!(entries[1].issue.title, "Beta issue"); } #[test] fn test_issue_sort_by_created() { let dir = TempDir::new().unwrap(); let repo = init_repo(dir.path(), &alice()); // Issue A: created early, updated recently let (ref_a, _) = open_issue_at(&repo, &alice(), "Alpha issue", "2025-01-01T00:00:00Z"); add_comment_at( &repo, &ref_a, &bob(), "recent comment", "2025-12-01T00:00:00Z", ); // Issue B: created later let (_, _) = open_issue_at(&repo, &alice(), "Beta issue", "2025-06-01T00:00:00Z"); // Sort by created: B (2025-06) comes first (descending) let entries = git_collab::issue::list(&repo, true, None, None, SortMode::Created).unwrap(); assert_eq!(entries.len(), 2); assert_eq!(entries[0].issue.title, "Beta issue"); assert_eq!(entries[1].issue.title, "Alpha issue"); } #[test] fn test_issue_sort_alpha() { let dir = TempDir::new().unwrap(); let repo = init_repo(dir.path(), &alice()); open_issue_at(&repo, &alice(), "Zebra issue", "2025-01-01T00:00:00Z"); open_issue_at(&repo, &alice(), "Apple issue", "2025-06-01T00:00:00Z"); open_issue_at(&repo, &alice(), "Mango issue", "2025-03-01T00:00:00Z"); let entries = git_collab::issue::list(&repo, true, None, None, SortMode::Alpha).unwrap(); assert_eq!(entries.len(), 3); assert_eq!(entries[0].issue.title, "Apple issue"); assert_eq!(entries[1].issue.title, "Mango issue"); assert_eq!(entries[2].issue.title, "Zebra issue"); } // ---- Patch sorting tests ---- #[test] fn test_patch_last_updated_populated() { let dir = TempDir::new().unwrap(); let repo = init_repo(dir.path(), &alice()); let (ref_name, id) = create_patch_at(&repo, &alice(), "Test patch", "2025-01-01T00:00:00Z"); add_patch_comment_at( &repo, &ref_name, &bob(), "later comment", "2025-06-15T12:00:00Z", ); let state = PatchState::from_ref(&repo, &ref_name, &id).unwrap(); assert_eq!(state.last_updated, "2025-06-15T12:00:00Z"); assert_eq!(state.created_at, "2025-01-01T00:00:00Z"); } #[test] fn test_patch_default_sort_by_recency() { let dir = TempDir::new().unwrap(); let repo = init_repo(dir.path(), &alice()); // Patch A: created early, updated recently let (ref_a, _) = create_patch_at(&repo, &alice(), "Alpha patch", "2025-01-01T00:00:00Z"); add_patch_comment_at( &repo, &ref_a, &bob(), "recent comment", "2025-12-01T00:00:00Z", ); // Patch B: created later, never updated let (_, _) = create_patch_at(&repo, &alice(), "Beta patch", "2025-06-01T00:00:00Z"); let patches = git_collab::patch::list(&repo, true, None, None, SortMode::Recent).unwrap(); assert_eq!(patches.len(), 2); assert_eq!(patches[0].title, "Alpha patch"); assert_eq!(patches[1].title, "Beta patch"); } #[test] fn test_patch_sort_by_created() { let dir = TempDir::new().unwrap(); let repo = init_repo(dir.path(), &alice()); let (ref_a, _) = create_patch_at(&repo, &alice(), "Alpha patch", "2025-01-01T00:00:00Z"); add_patch_comment_at( &repo, &ref_a, &bob(), "recent comment", "2025-12-01T00:00:00Z", ); let (_, _) = create_patch_at(&repo, &alice(), "Beta patch", "2025-06-01T00:00:00Z"); let patches = git_collab::patch::list(&repo, true, None, None, SortMode::Created).unwrap(); assert_eq!(patches.len(), 2); assert_eq!(patches[0].title, "Beta patch"); assert_eq!(patches[1].title, "Alpha patch"); } #[test] fn test_patch_sort_alpha() { let dir = TempDir::new().unwrap(); let repo = init_repo(dir.path(), &alice()); create_patch_at(&repo, &alice(), "Zebra patch", "2025-01-01T00:00:00Z"); create_patch_at(&repo, &alice(), "Apple patch", "2025-06-01T00:00:00Z"); create_patch_at(&repo, &alice(), "Mango patch", "2025-03-01T00:00:00Z"); let patches = git_collab::patch::list(&repo, true, None, None, SortMode::Alpha).unwrap(); assert_eq!(patches.len(), 3); assert_eq!(patches[0].title, "Apple patch"); assert_eq!(patches[1].title, "Mango patch"); assert_eq!(patches[2].title, "Zebra patch"); } // ---- CLI integration test ---- #[test] fn test_cli_issue_list_sort_flag() { let repo = TestRepo::new("Alice", "alice@example.com"); repo.issue_open("Zebra"); // Small delay to ensure different timestamps std::thread::sleep(std::time::Duration::from_millis(50)); repo.issue_open("Apple"); // Default sort (recent) should show Apple first (created more recently) let out = repo.run_ok(&["issue", "list"]); let lines: Vec<&str> = out.lines().collect(); assert_eq!(lines.len(), 2); assert!(lines[0].contains("Apple"), "expected Apple first, got: {}", out); // Alpha sort should show Apple first (alphabetical) let out = repo.run_ok(&["issue", "list", "--sort", "alpha"]); let lines: Vec<&str> = out.lines().collect(); assert!(lines[0].contains("Apple"), "expected Apple first in alpha sort, got: {}", out); assert!(lines[1].contains("Zebra"), "expected Zebra second in alpha sort, got: {}", out); // Created sort should show Apple first (most recently created) let out = repo.run_ok(&["issue", "list", "--sort", "created"]); let lines: Vec<&str> = out.lines().collect(); assert!(lines[0].contains("Apple"), "expected Apple first in created sort, got: {}", out); } #[test] fn test_cli_patch_list_sort_flag() { let repo = TestRepo::new("Alice", "alice@example.com"); repo.patch_create("Zebra"); std::thread::sleep(std::time::Duration::from_millis(50)); repo.patch_create("Apple"); // Alpha sort let out = repo.run_ok(&["patch", "list", "--sort", "alpha"]); let lines: Vec<&str> = out.lines().collect(); assert_eq!(lines.len(), 2); assert!(lines[0].contains("Apple"), "expected Apple first in alpha sort, got: {}", out); assert!(lines[1].contains("Zebra"), "expected Zebra second in alpha sort, got: {}", out); } #[test] fn test_last_updated_serde_default() { // Verify that deserializing an IssueState without last_updated works (backward compat) let json = r#"{ "id": "abc123", "title": "Test", "body": "", "status": "open", "close_reason": null, "closed_by": null, "labels": [], "assignees": [], "comments": [], "created_at": "2025-01-01T00:00:00Z", "author": {"name": "Alice", "email": "alice@example.com"} }"#; let state: IssueState = serde_json::from_str(json).unwrap(); assert_eq!(state.last_updated, ""); assert_eq!(state.title, "Test"); }