a6375981
Add archive for closed issues/patches and relates-to for issues
a73x 2026-03-21 17:12
Auto-archive closed/merged items by moving refs from refs/collab/{issues,patches}/
to refs/collab/archive/{issues,patches}/. List commands show only active items by
default; --archived flag includes archived. Resolve (show/comment/etc) searches
both active and archive namespaces. Sync fetches and pushes archive refs.
Also adds --relates-to on issue open for linking issues, and removes the CLI
reopen subcommand (IssueReopen event kept for backward compat).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
diff --git a/benches/core_ops.rs b/benches/core_ops.rs index 29f5f6d..ef69a7f 100644 --- a/benches/core_ops.rs +++ b/benches/core_ops.rs @@ -47,6 +47,7 @@ fn setup_issues(n: usize) -> (Repository, TempDir) { action: Action::IssueOpen { title: format!("Issue {}", i), body: format!("Body for issue {}", i), relates_to: None, }, clock: 0, }; @@ -99,6 +100,7 @@ fn setup_issue_with_comments(n: usize) -> (Repository, String, String, TempDir) action: Action::IssueOpen { title: "Big issue".to_string(), body: "An issue with many comments".to_string(), relates_to: None, }, clock: 0, }; diff --git a/src/cli.rs b/src/cli.rs index bdcb2a2..5c9acff 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -91,12 +91,18 @@ pub enum IssueCmd { /// Issue body #[arg(short, long, default_value = "")] body: String, /// Related issue ID #[arg(long)] relates_to: Option<String>, }, /// List issues List { /// Show closed issues too #[arg(short = 'a', long)] all: bool, /// Include archived issues #[arg(long)] archived: bool, /// Maximum number of issues to display #[arg(short = 'n', long)] limit: Option<usize>, @@ -173,11 +179,6 @@ pub enum IssueCmd { #[arg(short, long)] reason: Option<String>, }, /// Reopen a closed issue Reopen { /// Issue ID (prefix match) id: String, }, /// Delete an issue (removes the local collab ref) Delete { /// Issue ID (prefix match) @@ -242,6 +243,9 @@ pub enum PatchCmd { /// Show closed/merged patches too #[arg(short = 'a', long)] all: bool, /// Include archived patches #[arg(long)] archived: bool, /// Maximum number of patches to display #[arg(short = 'n', long)] limit: Option<usize>, diff --git a/src/event.rs b/src/event.rs index d3e5a49..fbcd2d6 100644 --- a/src/event.rs +++ b/src/event.rs @@ -22,6 +22,8 @@ pub enum Action { IssueOpen { title: String, body: String, #[serde(default, skip_serializing_if = "Option::is_none")] relates_to: Option<String>, }, #[serde(rename = "issue.comment")] IssueComment { diff --git a/src/issue.rs b/src/issue.rs index 9b440cb..b732f7f 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -7,7 +7,12 @@ use crate::identity::get_author; use crate::signing; use crate::state::{self, IssueState, IssueStatus}; pub fn open(repo: &Repository, title: &str, body: &str) -> Result<String, crate::error::Error> { pub fn open( repo: &Repository, title: &str, body: &str, relates_to: Option<&str>, ) -> Result<String, crate::error::Error> { let sk = signing::load_signing_key(&signing::signing_key_dir()?)?; let author = get_author(repo)?; let event = Event { @@ -16,6 +21,7 @@ pub fn open(repo: &Repository, title: &str, body: &str) -> Result<String, crate: action: Action::IssueOpen { title: title.to_string(), body: body.to_string(), relates_to: relates_to.map(|s| s.to_string()), }, clock: 0, }; @@ -34,11 +40,16 @@ pub struct ListEntry { pub fn list( repo: &Repository, show_closed: bool, show_archived: bool, limit: Option<usize>, offset: Option<usize>, sort: SortMode, ) -> Result<Vec<ListEntry>, crate::error::Error> { let issues = state::list_issues(repo)?; let issues = if show_archived { state::list_issues_with_archived(repo)? } else { state::list_issues(repo)? }; let mut entries: Vec<_> = issues .into_iter() .filter(|i| show_closed || i.status == IssueStatus::Open) @@ -63,12 +74,13 @@ pub fn list( pub fn list_to_writer( repo: &Repository, show_closed: bool, show_archived: 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, sort)?; let entries = list(repo, show_closed, show_archived, limit, offset, sort)?; if entries.is_empty() { writeln!(writer, "No issues found.").ok(); return Ok(()); @@ -118,8 +130,12 @@ fn count_unread(repo: &git2::Repository, id: &str) -> Option<usize> { Some(revwalk.count()) } pub fn list_json(repo: &Repository, show_closed: bool, sort: SortMode) -> Result<String, crate::error::Error> { let issues = state::list_issues(repo)?; pub fn list_json(repo: &Repository, show_closed: bool, show_archived: bool, sort: SortMode) -> Result<String, crate::error::Error> { let issues = if show_archived { state::list_issues_with_archived(repo)? } else { state::list_issues(repo)? }; let mut filtered: Vec<_> = issues .into_iter() .filter(|i| show_closed || i.status == IssueStatus::Open) @@ -269,7 +285,7 @@ pub fn close( reason: Option<&str>, ) -> Result<(), crate::error::Error> { let sk = signing::load_signing_key(&signing::signing_key_dir()?)?; let (ref_name, _id) = state::resolve_issue_ref(repo, id_prefix)?; let (ref_name, id) = state::resolve_issue_ref(repo, id_prefix)?; let author = get_author(repo)?; let event = Event { timestamp: chrono::Utc::now().to_rfc3339(), @@ -280,6 +296,10 @@ pub fn close( clock: 0, }; dag::append_event(repo, &ref_name, &event, &sk)?; // Archive the ref (move to refs/collab/archive/issues/) if ref_name.starts_with("refs/collab/issues/") { state::archive_issue_ref(repo, &id)?; } Ok(()) } diff --git a/src/lib.rs b/src/lib.rs index db934ed..822c0ac 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -26,18 +26,18 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { match cli.command { Commands::Init => sync::init(repo), Commands::Issue(cmd) => match cmd { IssueCmd::Open { title, body } => { let id = issue::open(repo, &title, &body)?; IssueCmd::Open { title, body, relates_to } => { let id = issue::open(repo, &title, &body, relates_to.as_deref())?; println!("Opened issue {:.8}", id); Ok(()) } IssueCmd::List { all, limit, offset, json, sort } => { IssueCmd::List { all, archived, limit, offset, json, sort } => { if json { let output = issue::list_json(repo, all, sort)?; let output = issue::list_json(repo, all, archived, sort)?; println!("{}", output); return Ok(()); } let entries = issue::list(repo, all, limit, offset, sort)?; let entries = issue::list(repo, all, archived, limit, offset, sort)?; if entries.is_empty() { println!("No issues found."); } else { @@ -85,6 +85,9 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { if !i.assignees.is_empty() { println!("Assignees: {}", i.assignees.join(", ")); } if let Some(ref relates_to) = i.relates_to { println!("Relates-to: {:.8}", relates_to); } if let Some(ref reason) = i.close_reason { println!("Closed: {}", reason); } @@ -134,11 +137,6 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { println!("Issue closed."); Ok(()) } IssueCmd::Reopen { id } => { issue::reopen(repo, &id)?; println!("Issue reopened."); Ok(()) } IssueCmd::Delete { id } => { let full_id = issue::delete(repo, &id)?; println!("Deleted issue {:.8}", full_id); @@ -187,13 +185,13 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { println!("Created patch {:.8}", id); Ok(()) } PatchCmd::List { all, limit, offset, json, sort } => { PatchCmd::List { all, archived, limit, offset, json, sort } => { if json { let output = patch::list_json(repo, all, sort)?; let output = patch::list_json(repo, all, archived, sort)?; println!("{}", output); return Ok(()); } let patches = patch::list(repo, all, limit, offset, sort)?; let patches = patch::list(repo, all, archived, limit, offset, sort)?; if patches.is_empty() { println!("No patches found."); } else { diff --git a/src/patch.rs b/src/patch.rs index 1dd2e43..7b70e49 100644 --- a/src/patch.rs +++ b/src/patch.rs @@ -63,11 +63,16 @@ pub fn create( pub fn list( repo: &Repository, show_closed: bool, show_archived: bool, limit: Option<usize>, offset: Option<usize>, sort: SortMode, ) -> Result<Vec<PatchState>, crate::error::Error> { let patches = state::list_patches(repo)?; let patches = if show_archived { state::list_patches_with_archived(repo)? } else { state::list_patches(repo)? }; let mut filtered: Vec<_> = patches .into_iter() .filter(|p| show_closed || p.status == PatchStatus::Open) @@ -88,12 +93,13 @@ pub fn list( pub fn list_to_writer( repo: &Repository, show_closed: bool, show_archived: 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, sort)?; let patches = list(repo, show_closed, show_archived, limit, offset, sort)?; if patches.is_empty() { writeln!(writer, "No patches found.").ok(); return Ok(()); @@ -114,8 +120,12 @@ pub fn list_to_writer( Ok(()) } pub fn list_json(repo: &Repository, show_closed: bool, sort: SortMode) -> Result<String, crate::error::Error> { let patches = state::list_patches(repo)?; pub fn list_json(repo: &Repository, show_closed: bool, show_archived: bool, sort: SortMode) -> Result<String, crate::error::Error> { let patches = if show_archived { state::list_patches_with_archived(repo)? } else { state::list_patches(repo)? }; let mut filtered: Vec<_> = patches .into_iter() .filter(|p| show_closed || p.status == PatchStatus::Open) @@ -281,9 +291,14 @@ pub fn merge(repo: &Repository, id_prefix: &str) -> Result<PatchState, crate::er }; dag::append_event(repo, &ref_name, &event, &sk)?; // Archive the patch ref if ref_name.starts_with("refs/collab/patches/") { state::archive_patch_ref(repo, &id)?; } // Auto-close linked issue if present if let Some(ref fixes_id) = p.fixes { if let Ok((issue_ref, _)) = state::resolve_issue_ref(repo, fixes_id) { if let Ok((issue_ref, issue_id)) = state::resolve_issue_ref(repo, fixes_id) { let close_event = Event { timestamp: chrono::Utc::now().to_rfc3339(), author, @@ -293,6 +308,10 @@ pub fn merge(repo: &Repository, id_prefix: &str) -> Result<PatchState, crate::er clock: 0, }; dag::append_event(repo, &issue_ref, &close_event, &sk)?; // Archive the issue ref if issue_ref.starts_with("refs/collab/issues/") { state::archive_issue_ref(repo, &issue_id)?; } } } @@ -367,7 +386,7 @@ pub fn close( reason: Option<&str>, ) -> Result<(), crate::error::Error> { let sk = signing::load_signing_key(&signing::signing_key_dir()?)?; let (ref_name, _id) = state::resolve_patch_ref(repo, id_prefix)?; let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?; let author = get_author(repo)?; let event = Event { timestamp: chrono::Utc::now().to_rfc3339(), @@ -378,6 +397,10 @@ pub fn close( clock: 0, }; dag::append_event(repo, &ref_name, &event, &sk)?; // Archive the ref (move to refs/collab/archive/patches/) if ref_name.starts_with("refs/collab/patches/") { state::archive_patch_ref(repo, &id)?; } Ok(()) } diff --git a/src/signing.rs b/src/signing.rs index 76a2790..883f1ff 100644 --- a/src/signing.rs +++ b/src/signing.rs @@ -321,6 +321,7 @@ mod tests { action: Action::IssueOpen { title: "Test".to_string(), body: "Body".to_string(), relates_to: None, }, clock: 0, }; diff --git a/src/state.rs b/src/state.rs index 6d3b907..0378c93 100644 --- a/src/state.rs +++ b/src/state.rs @@ -87,6 +87,8 @@ pub struct IssueState { #[serde(default)] pub last_updated: String, pub author: Author, #[serde(default, skip_serializing_if = "Option::is_none")] pub relates_to: Option<String>, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -174,7 +176,7 @@ impl IssueState { max_timestamp = event.timestamp.clone(); } match event.action { Action::IssueOpen { title, body } => { Action::IssueOpen { title, body, relates_to } => { state = Some(IssueState { id: id.to_string(), title, @@ -188,6 +190,7 @@ impl IssueState { created_at: event.timestamp.clone(), last_updated: String::new(), author: event.author.clone(), relates_to, }); } Action::IssueComment { body } => { @@ -447,17 +450,46 @@ fn collab_refs( Ok(result) } /// Resolve a short ID prefix to a full ref. Returns (ref_name, id). /// Enumerate all collab refs of a given kind under the archive namespace. fn collab_archive_refs( repo: &Repository, kind: &str, ) -> Result<Vec<(String, String)>, crate::error::Error> { let prefix = format!("refs/collab/archive/{}/", kind); let glob = format!("{}*", prefix); let refs = repo.references_glob(&glob)?; let mut result = Vec::new(); for r in refs { let r = r?; let ref_name = r.name().unwrap_or_default().to_string(); let id = ref_name .strip_prefix(&prefix) .unwrap_or_default() .to_string(); result.push((ref_name, id)); } Ok(result) } /// Resolve a short ID prefix to a full ref. Searches both active and archive namespaces. /// Returns (ref_name, id). fn resolve_ref( repo: &Repository, kind: &str, singular: &str, prefix: &str, ) -> Result<(String, String), crate::error::Error> { let matches: Vec<_> = collab_refs(repo, kind)? let mut matches: Vec<_> = collab_refs(repo, kind)? .into_iter() .filter(|(_, id)| id.starts_with(prefix)) .collect(); // Also search archive namespace let archive_matches: Vec<_> = collab_archive_refs(repo, kind)? .into_iter() .filter(|(_, id)| id.starts_with(prefix)) .collect(); matches.extend(archive_matches); match matches.len() { 0 => Err( git2::Error::from_str(&format!("no {} found matching '{}'", singular, prefix)).into(), @@ -491,6 +523,54 @@ pub fn list_patches(repo: &Repository) -> Result<Vec<PatchState>, crate::error:: Ok(items) } /// List all issue refs (active + archived) and return their materialized state. pub fn list_issues_with_archived(repo: &Repository) -> Result<Vec<IssueState>, crate::error::Error> { let mut items: Vec<_> = collab_refs(repo, "issues")? .into_iter() .filter_map(|(ref_name, id)| IssueState::from_ref(repo, &ref_name, &id).ok()) .collect(); let archived: Vec<_> = collab_archive_refs(repo, "issues")? .into_iter() .filter_map(|(ref_name, id)| IssueState::from_ref(repo, &ref_name, &id).ok()) .collect(); items.extend(archived); Ok(items) } /// List all patch refs (active + archived) and return their materialized state. pub fn list_patches_with_archived(repo: &Repository) -> Result<Vec<PatchState>, crate::error::Error> { let mut items: Vec<_> = collab_refs(repo, "patches")? .into_iter() .filter_map(|(ref_name, id)| PatchState::from_ref(repo, &ref_name, &id).ok()) .collect(); let archived: Vec<_> = collab_archive_refs(repo, "patches")? .into_iter() .filter_map(|(ref_name, id)| PatchState::from_ref(repo, &ref_name, &id).ok()) .collect(); items.extend(archived); Ok(items) } /// Move an issue ref from active to archive namespace. pub fn archive_issue_ref(repo: &Repository, id: &str) -> Result<(), crate::error::Error> { let old_ref = format!("refs/collab/issues/{}", id); let oid = repo.refname_to_id(&old_ref)?; let new_ref = format!("refs/collab/archive/issues/{}", id); repo.reference(&new_ref, oid, false, "archive issue")?; repo.find_reference(&old_ref)?.delete()?; Ok(()) } /// Move a patch ref from active to archive namespace. pub fn archive_patch_ref(repo: &Repository, id: &str) -> Result<(), crate::error::Error> { let old_ref = format!("refs/collab/patches/{}", id); let oid = repo.refname_to_id(&old_ref)?; let new_ref = format!("refs/collab/archive/patches/{}", id); repo.reference(&new_ref, oid, false, "archive patch")?; repo.find_reference(&old_ref)?.delete()?; Ok(()) } /// Resolve a short ID prefix to the full issue ref name. Returns (ref_name, id). pub fn resolve_issue_ref( repo: &Repository, diff --git a/src/sync.rs b/src/sync.rs index 98a8d1d..da0c0ed 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -182,7 +182,12 @@ fn push_ref(workdir: &Path, remote_name: &str, ref_name: &str) -> RefPushResult /// Collect all collab ref names that should be pushed. fn collect_push_refs(repo: &Repository) -> Result<Vec<String>, Error> { let mut refs = Vec::new(); for pattern in &["refs/collab/issues/*", "refs/collab/patches/*"] { for pattern in &[ "refs/collab/issues/*", "refs/collab/patches/*", "refs/collab/archive/issues/*", "refs/collab/archive/patches/*", ] { let iter = repo.references_glob(pattern)?; for r in iter { let r = r?; @@ -294,6 +299,8 @@ pub fn sync(repo: &Repository, remote_name: &str) -> Result<(), Error> { remote_name, "+refs/collab/issues/*:refs/collab/sync/issues/*", "+refs/collab/patches/*:refs/collab/sync/patches/*", "+refs/collab/archive/issues/*:refs/collab/sync/archive/issues/*", "+refs/collab/archive/patches/*:refs/collab/sync/archive/patches/*", ]) .current_dir(&workdir) .status() diff --git a/src/tui/events.rs b/src/tui/events.rs index ff02163..3171aaf 100644 --- a/src/tui/events.rs +++ b/src/tui/events.rs @@ -85,7 +85,7 @@ pub(crate) fn run_loop( KeyCode::Esc => { // Submit with title only, no body let title = app.create_title.clone(); match issue_mod::open(repo, &title, "") { match issue_mod::open(repo, &title, "", None) { Ok(id) => { app.reload(repo); app.status_msg = @@ -103,7 +103,7 @@ pub(crate) fn run_loop( KeyCode::Enter => { let title = app.create_title.clone(); let body = app.input_buf.clone(); match issue_mod::open(repo, &title, &body) { match issue_mod::open(repo, &title, &body, None) { Ok(id) => { app.reload(repo); app.status_msg = diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 1fdf39a..5ea36ad 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -66,6 +66,7 @@ mod tests { created_at: String::new(), last_updated: String::new(), author: make_author(), relates_to: None, } } @@ -253,6 +254,7 @@ mod tests { created_at: "2026-01-01T00:00:00Z".to_string(), last_updated: "2026-01-01T00:00:00Z".to_string(), author: test_author(), relates_to: None, }) .collect() } @@ -330,6 +332,7 @@ mod tests { action: Action::IssueOpen { title: "Test Issue".to_string(), body: "This is the body".to_string(), relates_to: None, }, clock: 0, }, @@ -369,6 +372,7 @@ mod tests { let action = Action::IssueOpen { title: "t".to_string(), body: "b".to_string(), relates_to: None, }; assert_eq!(action_type_label(&action), "Issue Open"); } @@ -439,6 +443,7 @@ mod tests { action: Action::IssueOpen { title: "My Issue".to_string(), body: "Description here".to_string(), relates_to: None, }, clock: 0, }; diff --git a/src/tui/widgets.rs b/src/tui/widgets.rs index e786584..f7d32ef 100644 --- a/src/tui/widgets.rs +++ b/src/tui/widgets.rs @@ -40,7 +40,7 @@ pub(crate) fn format_event_detail(oid: &Oid, event: &crate::event::Event) -> Str // Action-specific payload match &event.action { Action::IssueOpen { title, body } => { Action::IssueOpen { title, body, .. } => { detail.push_str(&format!("\nTitle: {}\n", title)); if !body.is_empty() { detail.push_str(&format!("\n{}\n", body)); diff --git a/tests/adversarial_test.rs b/tests/adversarial_test.rs index 91b0638..c56105d 100644 --- a/tests/adversarial_test.rs +++ b/tests/adversarial_test.rs @@ -80,6 +80,7 @@ fn valid_event_json() -> String { action: Action::IssueOpen { title: "Test".to_string(), body: "Body".to_string(), relates_to: None, }, clock: 1, }) @@ -274,6 +275,7 @@ fn dag_with_one_corrupted_commit_in_middle() { action: Action::IssueOpen { title: "Test".to_string(), body: "Body".to_string(), relates_to: None, }, clock: 0, }; @@ -353,6 +355,7 @@ fn blob_at_limit_succeeds() { action: Action::IssueOpen { title: "Test".to_string(), body: body_padding, relates_to: None, }, clock: 1, }; @@ -464,6 +467,7 @@ fn malformed_remote_event_no_local_state_change() { action: Action::IssueOpen { title: "Original".to_string(), body: "Body".to_string(), relates_to: None, }, clock: 0, }; @@ -527,7 +531,7 @@ fn arb_author() -> impl Strategy<Value = Author> { /// Generate an arbitrary Action variant fn arb_action() -> impl Strategy<Value = Action> { prop_oneof![ (".*", ".*").prop_map(|(title, body)| Action::IssueOpen { title, body }), (".*", ".*").prop_map(|(title, body)| Action::IssueOpen { title, body, relates_to: None }), ".*".prop_map(|body| Action::IssueComment { body }), proptest::option::of(".*").prop_map(|reason| Action::IssueClose { reason }), Just(Action::IssueReopen), diff --git a/tests/archive_test.rs b/tests/archive_test.rs new file mode 100644 index 0000000..c70962f --- /dev/null +++ b/tests/archive_test.rs @@ -0,0 +1,230 @@ mod common; use tempfile::TempDir; use git_collab::dag; use git_collab::event::{Action, Event}; use git_collab::state::{self, IssueState, IssueStatus, PatchState, PatchStatus}; use common::{alice, close_issue, create_patch, init_repo, now, open_issue, test_signing_key}; // --------------------------------------------------------------------------- // Archive on close: issues // --------------------------------------------------------------------------- #[test] fn test_close_issue_moves_ref_to_archive() { let tmp = TempDir::new().unwrap(); let repo = init_repo(tmp.path(), &alice()); let (ref_name, id) = open_issue(&repo, &alice(), "To be archived"); // Close + archive close_issue(&repo, &ref_name, &alice()); state::archive_issue_ref(&repo, &id).unwrap(); // Old ref should be gone assert!(repo.refname_to_id(&ref_name).is_err()); // New archive ref should exist let archive_ref = format!("refs/collab/archive/issues/{}", id); assert!(repo.refname_to_id(&archive_ref).is_ok()); // Should still be able to materialize state from archive ref let issue = IssueState::from_ref(&repo, &archive_ref, &id).unwrap(); assert_eq!(issue.status, IssueStatus::Closed); assert_eq!(issue.title, "To be archived"); } #[test] fn test_list_issues_excludes_archived() { let tmp = TempDir::new().unwrap(); let repo = init_repo(tmp.path(), &alice()); let (_ref1, _id1) = open_issue(&repo, &alice(), "Active issue"); let (ref2, id2) = open_issue(&repo, &alice(), "Archived issue"); close_issue(&repo, &ref2, &alice()); state::archive_issue_ref(&repo, &id2).unwrap(); // Default list should only show active let issues = state::list_issues(&repo).unwrap(); assert_eq!(issues.len(), 1); assert_eq!(issues[0].title, "Active issue"); } #[test] fn test_list_issues_with_archived_shows_both() { let tmp = TempDir::new().unwrap(); let repo = init_repo(tmp.path(), &alice()); let (_ref1, _id1) = open_issue(&repo, &alice(), "Active issue"); let (ref2, id2) = open_issue(&repo, &alice(), "Archived issue"); close_issue(&repo, &ref2, &alice()); state::archive_issue_ref(&repo, &id2).unwrap(); // With archived flag should show both let issues = state::list_issues_with_archived(&repo).unwrap(); assert_eq!(issues.len(), 2); } #[test] fn test_resolve_issue_ref_finds_archived() { let tmp = TempDir::new().unwrap(); let repo = init_repo(tmp.path(), &alice()); let (ref_name, id) = open_issue(&repo, &alice(), "Will be archived"); close_issue(&repo, &ref_name, &alice()); state::archive_issue_ref(&repo, &id).unwrap(); // resolve_issue_ref should find it in archive let (resolved_ref, resolved_id) = state::resolve_issue_ref(&repo, &id[..8]).unwrap(); assert_eq!(resolved_id, id); assert!(resolved_ref.contains("archive")); } // --------------------------------------------------------------------------- // Archive on close: patches // --------------------------------------------------------------------------- #[test] fn test_close_patch_moves_ref_to_archive() { let tmp = TempDir::new().unwrap(); let repo = init_repo(tmp.path(), &alice()); let (ref_name, id) = create_patch(&repo, &alice(), "To be archived"); // Close + archive let sk = test_signing_key(); let event = Event { timestamp: now(), author: alice(), action: Action::PatchClose { reason: None }, clock: 0, }; dag::append_event(&repo, &ref_name, &event, &sk).unwrap(); state::archive_patch_ref(&repo, &id).unwrap(); // Old ref should be gone assert!(repo.refname_to_id(&ref_name).is_err()); // New archive ref should exist let archive_ref = format!("refs/collab/archive/patches/{}", id); assert!(repo.refname_to_id(&archive_ref).is_ok()); // Should still be able to materialize state from archive ref let patch = PatchState::from_ref(&repo, &archive_ref, &id).unwrap(); assert_eq!(patch.status, PatchStatus::Closed); } #[test] fn test_list_patches_excludes_archived() { let tmp = TempDir::new().unwrap(); let repo = init_repo(tmp.path(), &alice()); let (_ref1, _id1) = create_patch(&repo, &alice(), "Active patch"); let (ref2, id2) = create_patch(&repo, &alice(), "Archived patch"); let sk = test_signing_key(); let event = Event { timestamp: now(), author: alice(), action: Action::PatchClose { reason: None }, clock: 0, }; dag::append_event(&repo, &ref2, &event, &sk).unwrap(); state::archive_patch_ref(&repo, &id2).unwrap(); let patches = state::list_patches(&repo).unwrap(); assert_eq!(patches.len(), 1); assert_eq!(patches[0].title, "Active patch"); } #[test] fn test_list_patches_with_archived_shows_both() { let tmp = TempDir::new().unwrap(); let repo = init_repo(tmp.path(), &alice()); let (_ref1, _id1) = create_patch(&repo, &alice(), "Active patch"); let (ref2, id2) = create_patch(&repo, &alice(), "Archived patch"); let sk = test_signing_key(); let event = Event { timestamp: now(), author: alice(), action: Action::PatchClose { reason: None }, clock: 0, }; dag::append_event(&repo, &ref2, &event, &sk).unwrap(); state::archive_patch_ref(&repo, &id2).unwrap(); let patches = state::list_patches_with_archived(&repo).unwrap(); assert_eq!(patches.len(), 2); } #[test] fn test_resolve_patch_ref_finds_archived() { let tmp = TempDir::new().unwrap(); let repo = init_repo(tmp.path(), &alice()); let (ref_name, id) = create_patch(&repo, &alice(), "Will archive"); let sk = test_signing_key(); let event = Event { timestamp: now(), author: alice(), action: Action::PatchClose { reason: None }, clock: 0, }; dag::append_event(&repo, &ref_name, &event, &sk).unwrap(); state::archive_patch_ref(&repo, &id).unwrap(); let (resolved_ref, resolved_id) = state::resolve_patch_ref(&repo, &id[..8]).unwrap(); assert_eq!(resolved_id, id); assert!(resolved_ref.contains("archive")); } // --------------------------------------------------------------------------- // --relates-to on issue open // --------------------------------------------------------------------------- #[test] fn test_issue_open_with_relates_to() { let tmp = TempDir::new().unwrap(); let repo = init_repo(tmp.path(), &alice()); // First create an issue to relate to let (_ref1, id1) = open_issue(&repo, &alice(), "Parent issue"); // Open an issue with relates_to let sk = test_signing_key(); let event = Event { timestamp: now(), author: alice(), action: Action::IssueOpen { title: "Child issue".to_string(), body: "".to_string(), relates_to: Some(id1.clone()), }, 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 with relates_to") .unwrap(); let issue = IssueState::from_ref(&repo, &ref_name, &id).unwrap(); assert_eq!(issue.title, "Child issue"); assert_eq!(issue.relates_to.as_deref(), Some(id1.as_str())); } #[test] fn test_issue_open_without_relates_to() { let tmp = TempDir::new().unwrap(); let repo = init_repo(tmp.path(), &alice()); let (ref_name, id) = open_issue(&repo, &alice(), "Solo issue"); let issue = IssueState::from_ref(&repo, &ref_name, &id).unwrap(); assert_eq!(issue.relates_to, None); } diff --git a/tests/cli_test.rs b/tests/cli_test.rs index 2ea1c65..4495397 100644 --- a/tests/cli_test.rs +++ b/tests/cli_test.rs @@ -44,7 +44,8 @@ fn test_issue_list_filters_closed() { assert!(out.contains("Open bug")); assert!(!out.contains("Closed bug")); let out = repo.run_ok(&["issue", "list", "--all"]); // Closed issues are archived, so --all alone won't show them; need --archived let out = repo.run_ok(&["issue", "list", "--all", "--archived"]); assert!(out.contains("Open bug")); assert!(out.contains("Closed bug")); assert!(out.contains("closed")); @@ -88,19 +89,6 @@ fn test_issue_close_without_reason() { } #[test] fn test_issue_reopen() { let repo = TestRepo::new("Alice", "alice@example.com"); let id = repo.issue_open("Reopen me"); repo.run_ok(&["issue", "close", &id]); let out = repo.run_ok(&["issue", "reopen", &id]); assert!(out.contains("Issue reopened")); let out = repo.run_ok(&["issue", "show", &id]); assert!(out.contains("[open]")); } #[test] fn test_issue_prefix_resolution() { let repo = TestRepo::new("Alice", "alice@example.com"); let id = repo.issue_open("Prefix test"); @@ -327,7 +315,8 @@ fn test_patch_list_filters_by_status() { assert!(out.contains("Open patch")); assert!(!out.contains("Closed patch")); let out = repo.run_ok(&["patch", "list", "--all"]); // Closed patches are archived, so --all alone won't show them; need --archived let out = repo.run_ok(&["patch", "list", "--all", "--archived"]); assert!(out.contains("Open patch")); assert!(out.contains("Closed patch")); } @@ -692,21 +681,19 @@ fn test_full_issue_lifecycle() { assert!(out.contains("Stack trace attached")); assert!(out.contains("Reproduced on Chrome")); // Close with reason // Close with reason (this archives the issue) repo.run_ok(&["issue", "close", &id, "-r", "Fixed in commit abc123"]); let out = repo.run_ok(&["issue", "show", &id]); assert!(out.contains("[closed]")); assert!(out.contains("Fixed in commit abc123")); // Reopen repo.run_ok(&["issue", "reopen", &id]); let out = repo.run_ok(&["issue", "show", &id]); assert!(out.contains("[open]")); // Close again (no reason) repo.run_ok(&["issue", "close", &id]); // Issue should be archived and not in default list let out = repo.run_ok(&["issue", "list"]); assert!(!out.contains("Login page crashes") || out.contains("No issues")); // But visible with --archived let out = repo.run_ok(&["issue", "list", "--all", "--archived"]); assert!(out.contains("Login page crashes")); } #[test] diff --git a/tests/collab_test.rs b/tests/collab_test.rs index 4e47759..9233a7d 100644 --- a/tests/collab_test.rs +++ b/tests/collab_test.rs @@ -510,6 +510,7 @@ fn test_signed_event_in_dag() { action: Action::IssueOpen { title: "Signed issue".to_string(), body: "".to_string(), relates_to: None, }, clock: 0, }; @@ -934,9 +935,9 @@ fn test_merge_branch_based_patch() { // Merge the patch patch::merge(&repo, &id).unwrap(); // Check that the patch is now merged let patch_ref = format!("refs/collab/patches/{}", id); let state = PatchState::from_ref(&repo, &patch_ref, &id).unwrap(); // Check that the patch is now merged (archived after merge) let archive_ref = format!("refs/collab/archive/patches/{}", id); let state = PatchState::from_ref(&repo, &archive_ref, &id).unwrap(); assert_eq!(state.status, PatchStatus::Merged); // Check that the base branch now has the feature commit @@ -1106,7 +1107,7 @@ fn capture_issue_list( offset: Option<usize>, ) -> String { let mut buf = Vec::new(); issue::list_to_writer(repo, show_closed, limit, offset, git_collab::cli::SortMode::Recent, &mut buf).unwrap(); issue::list_to_writer(repo, show_closed, false, limit, offset, git_collab::cli::SortMode::Recent, &mut buf).unwrap(); String::from_utf8(buf).unwrap() } @@ -1118,7 +1119,7 @@ fn capture_patch_list( offset: Option<usize>, ) -> String { let mut buf = Vec::new(); patch::list_to_writer(repo, show_closed, limit, offset, git_collab::cli::SortMode::Recent, &mut buf).unwrap(); patch::list_to_writer(repo, show_closed, false, limit, offset, git_collab::cli::SortMode::Recent, &mut buf).unwrap(); String::from_utf8(buf).unwrap() } @@ -1331,7 +1332,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, git_collab::cli::SortMode::Recent).unwrap(); let json_str = git_collab::issue::list_json(&repo, false, 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 +1352,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, git_collab::cli::SortMode::Recent).unwrap(); let json_str = git_collab::issue::list_json(&repo, false, 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, git_collab::cli::SortMode::Recent).unwrap(); let json_str = git_collab::issue::list_json(&repo, true, 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(), 2); } @@ -1383,7 +1384,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, git_collab::cli::SortMode::Recent).unwrap(); let json_str = git_collab::patch::list_json(&repo, false, 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/common/mod.rs b/tests/common/mod.rs index cdfcf0c..3d87e7e 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -65,6 +65,7 @@ pub fn open_issue(repo: &Repository, author: &Author, title: &str) -> (String, S action: Action::IssueOpen { title: title.to_string(), body: "".to_string(), relates_to: None, }, clock: 0, }; diff --git a/tests/crdt_test.rs b/tests/crdt_test.rs index 4c64b91..426ef11 100644 --- a/tests/crdt_test.rs +++ b/tests/crdt_test.rs @@ -60,6 +60,7 @@ fn create_root_event_sets_clock_to_1() { action: Action::IssueOpen { title: "test".to_string(), body: "body".to_string(), relates_to: None, }, clock: 0, // caller passes 0, DAG should override to 1 }; @@ -80,6 +81,7 @@ fn append_event_increments_clock() { action: Action::IssueOpen { title: "test".to_string(), body: "body".to_string(), relates_to: None, }, clock: 0, }; @@ -125,6 +127,7 @@ fn max_clock_returns_highest_clock_in_dag() { action: Action::IssueOpen { title: "test".to_string(), body: "body".to_string(), relates_to: None, }, clock: 0, }; @@ -162,6 +165,7 @@ fn reconcile_merge_clock_is_max_of_both_branches_plus_one() { action: Action::IssueOpen { title: "test".to_string(), body: "body".to_string(), relates_to: None, }, clock: 0, }; @@ -227,6 +231,7 @@ fn concurrent_issue_close_reopen_higher_clock_wins() { action: Action::IssueOpen { title: "test".to_string(), body: "body".to_string(), relates_to: None, }, clock: 0, }; @@ -285,6 +290,7 @@ fn concurrent_issue_same_clock_oid_breaks_tie() { action: Action::IssueOpen { title: "test".to_string(), body: "body".to_string(), relates_to: None, }, clock: 0, }; @@ -416,6 +422,7 @@ fn migrate_clocks_assigns_sequential_clocks() { action: Action::IssueOpen { title: "test".to_string(), body: "body".to_string(), relates_to: None, }, clock: 0, }; @@ -462,6 +469,7 @@ fn migrate_clocks_on_zero_clock_events() { action: Action::IssueOpen { title: "test".to_string(), body: "body".to_string(), relates_to: None, }, clock: 0, }; @@ -507,6 +515,7 @@ fn clock_field_survives_serialization_round_trip() { action: Action::IssueOpen { title: "test".to_string(), body: "body".to_string(), relates_to: None, }, clock: 42, }; @@ -530,6 +539,7 @@ fn clock_field_in_dag_round_trip() { action: Action::IssueOpen { title: "test".to_string(), body: "body".to_string(), relates_to: None, }, clock: 0, // Caller passes 0 }; diff --git a/tests/signing_test.rs b/tests/signing_test.rs index 89eb86e..6e440b9 100644 --- a/tests/signing_test.rs +++ b/tests/signing_test.rs @@ -15,6 +15,7 @@ fn make_event() -> Event { action: Action::IssueOpen { title: "Test issue".to_string(), body: "This is a test".to_string(), relates_to: None, }, clock: 0, } diff --git a/tests/sort_test.rs b/tests/sort_test.rs index 160b56d..29f148b 100644 --- a/tests/sort_test.rs +++ b/tests/sort_test.rs @@ -39,6 +39,7 @@ fn open_issue_at( action: Action::IssueOpen { title: title.to_string(), body: "".to_string(), relates_to: None, }, clock: 0, }; @@ -159,7 +160,7 @@ fn test_issue_default_sort_by_recency() { 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(); let entries = git_collab::issue::list(&repo, true, false, 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"); @@ -184,7 +185,7 @@ fn test_issue_sort_by_created() { 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(); let entries = git_collab::issue::list(&repo, true, false, 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"); @@ -199,7 +200,7 @@ fn test_issue_sort_alpha() { 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(); let entries = git_collab::issue::list(&repo, true, false, 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"); @@ -246,7 +247,7 @@ fn test_patch_default_sort_by_recency() { // 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(); let patches = git_collab::patch::list(&repo, true, false, None, None, SortMode::Recent).unwrap(); assert_eq!(patches.len(), 2); assert_eq!(patches[0].title, "Alpha patch"); assert_eq!(patches[1].title, "Beta patch"); @@ -268,7 +269,7 @@ fn test_patch_sort_by_created() { 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(); let patches = git_collab::patch::list(&repo, true, false, None, None, SortMode::Created).unwrap(); assert_eq!(patches.len(), 2); assert_eq!(patches[0].title, "Beta patch"); assert_eq!(patches[1].title, "Alpha patch"); @@ -283,7 +284,7 @@ fn test_patch_sort_alpha() { 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(); let patches = git_collab::patch::list(&repo, true, false, None, None, SortMode::Alpha).unwrap(); assert_eq!(patches.len(), 3); assert_eq!(patches[0].title, "Apple patch"); assert_eq!(patches[1].title, "Mango patch"); diff --git a/tests/sync_test.rs b/tests/sync_test.rs index cbcd567..ba2715c 100644 --- a/tests/sync_test.rs +++ b/tests/sync_test.rs @@ -474,6 +474,7 @@ fn test_unsigned_event_sync_rejected() { action: Action::IssueOpen { title: "Unsigned issue".to_string(), body: "No signature".to_string(), relates_to: None, }, clock: 0, }; @@ -522,6 +523,7 @@ fn test_tampered_event_sync_rejected() { action: Action::IssueOpen { title: "Tampered issue".to_string(), body: "Will be tampered".to_string(), relates_to: None, }, clock: 0, };