a73x

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();