c9642747
Surface linked commits on IssueState with first-seen dedup
alex emery 2026-04-12 06:34
Adds LinkedCommit + IssueState.linked_commits, populated from the new event variant in IssueState::from_ref_uncached. Walking topo-oldest- first means the first-seen event for a given SHA wins, which matches the spec's render-time dedup rule. serde(default) keeps older cached state compatible.
diff --git a/src/state.rs b/src/state.rs index c642eed..ff007e1 100644 --- a/src/state.rs +++ b/src/state.rs @@ -82,6 +82,16 @@ pub struct Comment { } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LinkedCommit { /// Full 40-char commit SHA from the trailer. pub commit: String, /// Author of the `IssueCommitLink` event (who ran sync). pub event_author: Author, /// Timestamp of the `IssueCommitLink` event. pub event_timestamp: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct IssueState { pub id: String, pub title: String, @@ -96,6 +106,8 @@ pub struct IssueState { pub labels: Vec<String>, pub assignees: Vec<String>, pub comments: Vec<Comment>, #[serde(default)] pub linked_commits: Vec<LinkedCommit>, pub created_at: String, #[serde(default)] pub last_updated: String, @@ -264,6 +276,7 @@ impl IssueState { labels: Vec::new(), assignees: Vec::new(), comments: Vec::new(), linked_commits: Vec::new(), created_at: event.timestamp.clone(), last_updated: String::new(), author: event.author.clone(), @@ -336,6 +349,21 @@ impl IssueState { } } } Action::IssueCommitLink { commit } => { if let Some(ref mut s) = state { // Render-time dedup by commit SHA. The revwalk is // topo-oldest-first (Sort::TOPOLOGICAL | Sort::REVERSE), // so the first event we see for a given SHA is the // earliest emission — exactly what the spec requires. if !s.linked_commits.iter().any(|lc| lc.commit == commit) { s.linked_commits.push(LinkedCommit { commit, event_author: event.author.clone(), event_timestamp: event.timestamp.clone(), }); } } } _ => {} } } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index ccb92a6..6eea9d9 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -63,6 +63,7 @@ mod tests { labels: vec![], assignees: vec![], comments: vec![], linked_commits: vec![], created_at: String::new(), last_updated: String::new(), author: make_author(), @@ -253,6 +254,7 @@ mod tests { labels: vec![], assignees: vec![], comments: Vec::new(), linked_commits: Vec::new(), created_at: "2026-01-01T00:00:00Z".to_string(), last_updated: "2026-01-01T00:00:00Z".to_string(), author: test_author(), diff --git a/tests/commit_link_test.rs b/tests/commit_link_test.rs index 9cb6cf3..d8506a3 100644 --- a/tests/commit_link_test.rs +++ b/tests/commit_link_test.rs @@ -1,6 +1,11 @@ //! Unit tests for the Action::IssueCommitLink event variant. use git_collab::event::{Action, Author, Event}; use git_collab::state::IssueState; use tempfile::TempDir; mod common; use common::{add_commit_link, alice, init_repo, open_issue, ScopedTestConfig}; fn test_author() -> Author { Author { @@ -9,6 +14,10 @@ fn test_author() -> Author { } } fn sha(byte: u8) -> String { format!("{:02x}{}", byte, "00".repeat(19)) } #[test] fn issue_commit_link_variant_round_trips() { let event = Event { @@ -40,3 +49,40 @@ fn issue_commit_link_variant_round_trips() { other => panic!("expected IssueCommitLink, got {:?}", other), } } #[test] fn issue_state_surfaces_commit_links_in_order() { let _config = ScopedTestConfig::new(); let dir = TempDir::new().unwrap(); let repo = init_repo(dir.path(), &alice()); let (ref_name, id) = open_issue(&repo, &alice(), "bug"); add_commit_link(&repo, &ref_name, &alice(), &sha(0xaa)); add_commit_link(&repo, &ref_name, &alice(), &sha(0xbb)); let issue = IssueState::from_ref_uncached(&repo, &ref_name, &id).unwrap(); let commits: Vec<String> = issue .linked_commits .iter() .map(|lc| lc.commit.clone()) .collect(); assert_eq!(commits, vec![sha(0xaa), sha(0xbb)]); } #[test] fn issue_state_dedups_commit_links_by_sha_keeping_earliest() { let _config = ScopedTestConfig::new(); let dir = TempDir::new().unwrap(); let repo = init_repo(dir.path(), &alice()); let (ref_name, id) = open_issue(&repo, &alice(), "bug"); // Two different emitters link the same commit. First-seen should win. add_commit_link(&repo, &ref_name, &alice(), &sha(0xcc)); let bob_link = common::bob(); add_commit_link(&repo, &ref_name, &bob_link, &sha(0xcc)); let issue = IssueState::from_ref_uncached(&repo, &ref_name, &id).unwrap(); assert_eq!(issue.linked_commits.len(), 1); assert_eq!(issue.linked_commits[0].commit, sha(0xcc)); assert_eq!(issue.linked_commits[0].event_author.name, "Alice"); } diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 2b9756c..7b9d529 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -228,6 +228,25 @@ pub fn add_comment(repo: &Repository, ref_name: &str, author: &Author, body: &st dag::append_event(repo, ref_name, &event, &sk).unwrap(); } /// Append an IssueCommitLink event to an issue ref. Returns the new DAG tip OID. pub fn add_commit_link( repo: &Repository, ref_name: &str, author: &Author, commit_sha: &str, ) -> git2::Oid { let sk = test_signing_key(); let event = Event { timestamp: now(), author: author.clone(), action: Action::IssueCommitLink { commit: commit_sha.to_string(), }, clock: 0, }; dag::append_event(repo, ref_name, &event, &sk).unwrap() } /// Append a close event to an issue ref. pub fn close_issue(repo: &Repository, ref_name: &str, author: &Author) { let sk = test_signing_key();