a73x

2b6f69e8

Simplify codebase: extract helpers, deduplicate across modules

a73x   2026-03-22 09:58

dag.rs:
- Extract sign_and_build_tree() — eliminates 4x blob-creation boilerplate
- Extract load_event_from_commit() — eliminates 2x event-reading boilerplate
- max_clock is now a one-liner

state.rs:
- Unify collab_refs/collab_archive_refs into shared refs_under() helper

patch.rs:
- Extract resolve_base_tree() — dedup between generate_diff and generate_diff_at_revision
- list_json now reuses list() instead of duplicating archived conditional

issue.rs:
- Extract load_issues() helper — dedup archived conditional in list/list_json

tests:
- crdt_test.rs: use common alice/bob/init_repo/test_signing_key instead of local redefs
- sort_test.rs: use common init_repo/test_signing_key instead of local redefs

Also fix remaining {:?} -> {} for ReviewVerdict Display in log.rs and tui/widgets.rs.

Fixes: da854a5b, 75254fb0, 33943aca, f6fd326d, 2da1931e

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

diff --git a/src/dag.rs b/src/dag.rs
index 57f2df7..063d825 100644
--- a/src/dag.rs
+++ b/src/dag.rs
@@ -29,11 +29,26 @@ fn build_event_tree(
    Ok(tree_oid)
}

/// Read the clock value from the tip commit of a DAG.
/// Since clocks are monotonically increasing, the tip always has the max clock.
/// Returns 0 if the event has clock 0 (pre-migration).
pub fn max_clock(repo: &Repository, tip: Oid) -> Result<u64, Error> {
    let commit = repo.find_commit(tip)?;
/// Sign an event, serialize it, and build the event tree. Returns the tree OID.
fn sign_and_build_tree(
    repo: &Repository,
    event: &Event,
    signing_key: &ed25519_dalek::SigningKey,
) -> Result<Oid, Error> {
    let detached = sign_event(event, signing_key)?;
    let event_json = serde_json::to_vec_pretty(event)?;

    let event_blob = repo.blob(&event_json)?;
    let sig_blob = repo.blob(detached.signature.as_bytes())?;
    let pubkey_blob = repo.blob(detached.pubkey.as_bytes())?;
    let manifest_blob = repo.blob(MANIFEST_JSON)?;

    build_event_tree(repo, event_blob, sig_blob, pubkey_blob, manifest_blob)
}

/// Load and deserialize the event from a commit's tree.
fn load_event_from_commit(repo: &Repository, oid: Oid) -> Result<Event, Error> {
    let commit = repo.find_commit(oid)?;
    let tree = commit.tree()?;
    let entry = tree
        .get_name("event.json")
@@ -54,8 +69,14 @@ pub fn max_clock(repo: &Repository, tip: Oid) -> Result<u64, Error> {
        });
    }

    let event: Event = serde_json::from_slice(content)?;
    Ok(event.clock)
    Ok(serde_json::from_slice(content)?)
}

/// Read the clock value from the tip commit of a DAG.
/// Since clocks are monotonically increasing, the tip always has the max clock.
/// Returns 0 if the event has clock 0 (pre-migration).
pub fn max_clock(repo: &Repository, tip: Oid) -> Result<u64, Error> {
    Ok(load_event_from_commit(repo, tip)?.clock)
}

/// Create an orphan commit (no parents) with the given event.
@@ -69,15 +90,7 @@ pub fn create_root_event(
    let mut event = event.clone();
    event.clock = 1;

    let detached = sign_event(&event, signing_key)?;
    let event_json = serde_json::to_vec_pretty(&event)?;

    let event_blob = repo.blob(&event_json)?;
    let sig_blob = repo.blob(detached.signature.as_bytes())?;
    let pubkey_blob = repo.blob(detached.pubkey.as_bytes())?;
    let manifest_blob = repo.blob(MANIFEST_JSON)?;

    let tree_oid = build_event_tree(repo, event_blob, sig_blob, pubkey_blob, manifest_blob)?;
    let tree_oid = sign_and_build_tree(repo, &event, signing_key)?;
    let tree = repo.find_tree(tree_oid)?;

    let sig = author_signature(&event.author)?;
@@ -101,15 +114,7 @@ pub fn append_event(
    let mut event = event.clone();
    event.clock = current_max + 1;

    let detached = sign_event(&event, signing_key)?;
    let event_json = serde_json::to_vec_pretty(&event)?;

    let event_blob = repo.blob(&event_json)?;
    let sig_blob = repo.blob(detached.signature.as_bytes())?;
    let pubkey_blob = repo.blob(detached.pubkey.as_bytes())?;
    let manifest_blob = repo.blob(MANIFEST_JSON)?;

    let tree_oid = build_event_tree(repo, event_blob, sig_blob, pubkey_blob, manifest_blob)?;
    let tree_oid = sign_and_build_tree(repo, &event, signing_key)?;
    let tree = repo.find_tree(tree_oid)?;

    let sig = author_signature(&event.author)?;
@@ -168,31 +173,7 @@ pub fn walk_events(repo: &Repository, ref_name: &str) -> Result<Vec<(Oid, Event)
    let mut events = Vec::new();
    for oid_result in revwalk {
        let oid = oid_result?;
        let commit = repo.find_commit(oid)?;
        let tree = commit.tree()?;
        let entry = tree
            .get_name("event.json")
            .ok_or_else(|| git2::Error::from_str("missing event.json in commit tree"))?;

        // Verify the entry points to a blob, not a tree or commit
        if entry.kind() != Some(ObjectType::Blob) {
            return Err(Error::Git(git2::Error::from_str(
                "event.json entry is not a blob",
            )));
        }

        let blob = repo.find_blob(entry.id())?;

        // Check blob size before attempting deserialization
        let content = blob.content();
        if content.len() > MAX_EVENT_BLOB_SIZE {
            return Err(Error::PayloadTooLarge {
                actual: content.len(),
                limit: MAX_EVENT_BLOB_SIZE,
            });
        }

        let event: Event = serde_json::from_slice(content)?;
        let event = load_event_from_commit(repo, oid)?;
        events.push((oid, event));
    }
    Ok(events)
@@ -256,14 +237,7 @@ pub fn reconcile(
        clock: merge_clock,
    };

    let detached = sign_event(&merge_event, signing_key)?;
    let event_json = serde_json::to_vec_pretty(&merge_event)?;
    let event_blob = repo.blob(&event_json)?;
    let sig_blob = repo.blob(detached.signature.as_bytes())?;
    let pubkey_blob = repo.blob(detached.pubkey.as_bytes())?;
    let manifest_blob = repo.blob(MANIFEST_JSON)?;

    let tree_oid = build_event_tree(repo, event_blob, sig_blob, pubkey_blob, manifest_blob)?;
    let tree_oid = sign_and_build_tree(repo, &merge_event, signing_key)?;
    let tree = repo.find_tree(tree_oid)?;

    let sig = author_signature(merge_author)?;
@@ -311,14 +285,7 @@ pub fn migrate_clocks(
            clock = migrated.clock; // respect existing clocks
        }

        let detached = sign_event(&migrated, signing_key)?;
        let event_json = serde_json::to_vec_pretty(&migrated)?;
        let event_blob = repo.blob(&event_json)?;
        let sig_blob = repo.blob(detached.signature.as_bytes())?;
        let pubkey_blob = repo.blob(detached.pubkey.as_bytes())?;
        let manifest_blob = repo.blob(MANIFEST_JSON)?;

        let tree_oid = build_event_tree(repo, event_blob, sig_blob, pubkey_blob, manifest_blob)?;
        let tree_oid = sign_and_build_tree(repo, &migrated, signing_key)?;
        let tree = repo.find_tree(tree_oid)?;

        let sig = author_signature(&migrated.author)?;
diff --git a/src/issue.rs b/src/issue.rs
index 0385825..39e3d2b 100644
--- a/src/issue.rs
+++ b/src/issue.rs
@@ -30,6 +30,14 @@ pub struct ListEntry {
    pub unread: Option<usize>,
}

fn load_issues(repo: &Repository, show_archived: bool) -> Result<Vec<state::IssueState>, crate::error::Error> {
    if show_archived {
        state::list_issues_with_archived(repo)
    } else {
        state::list_issues(repo)
    }
}

pub fn list(
    repo: &Repository,
    show_closed: bool,
@@ -38,11 +46,7 @@ pub fn list(
    offset: Option<usize>,
    sort: SortMode,
) -> Result<Vec<ListEntry>, crate::error::Error> {
    let issues = if show_archived {
        state::list_issues_with_archived(repo)?
    } else {
        state::list_issues(repo)?
    };
    let issues = load_issues(repo, show_archived)?;
    let filtered = cli::filter_sort_paginate(issues, show_closed, sort, offset, limit);
    let entries = filtered
        .into_iter()
@@ -111,11 +115,7 @@ fn count_unread(repo: &git2::Repository, id: &str) -> Option<usize> {
}

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 issues = load_issues(repo, show_archived)?;
    let filtered = cli::filter_sort_paginate(issues, show_closed, sort, None, None);
    Ok(serde_json::to_string_pretty(&filtered)?)
}
diff --git a/src/log.rs b/src/log.rs
index b9ad607..d92f0cf 100644
--- a/src/log.rs
+++ b/src/log.rs
@@ -154,7 +154,7 @@ fn action_summary(action: &Action) -> String {
            Some(b) => format!("revision: {}", truncate(b, 50)),
            None => "revision".to_string(),
        },
        Action::PatchReview { verdict, .. } => format!("review: {:?}", verdict),
        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 {
diff --git a/src/patch.rs b/src/patch.rs
index a75752a..e260649 100644
--- a/src/patch.rs
+++ b/src/patch.rs
@@ -167,13 +167,8 @@ pub fn list_to_writer(
}

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 filtered = cli::filter_sort_paginate(patches, show_closed, sort, None, None);
    Ok(serde_json::to_string_pretty(&filtered)?)
    let patches = list(repo, show_closed, show_archived, None, None, sort)?;
    Ok(serde_json::to_string_pretty(&patches)?)
}

pub fn show_json(repo: &Repository, id_prefix: &str) -> Result<String, crate::error::Error> {
@@ -369,25 +364,29 @@ pub fn diff(
    }
}

/// Resolve the base tree for diffing: find the merge-base between the base branch
/// and the given head OID, falling back to the base branch tip if no merge-base exists.
fn resolve_base_tree<'a>(
    repo: &'a Repository,
    base_branch: &str,
    head_oid: Oid,
) -> Result<Option<git2::Tree<'a>>, Error> {
    let base_ref = format!("refs/heads/{}", base_branch);
    let base_oid = match repo.refname_to_id(&base_ref) {
        Ok(oid) => oid,
        Err(_) => return Ok(None),
    };
    let tree_source = repo.merge_base(base_oid, head_oid).unwrap_or(base_oid);
    Ok(Some(repo.find_commit(tree_source)?.tree()?))
}

/// Generate a diff string from a patch's base and head using three-dot (merge-base) diff.
pub fn generate_diff(repo: &Repository, patch: &state::PatchState) -> Result<String, Error> {
    let head_oid = patch.resolve_head(repo)?;
    let head_commit = repo.find_commit(head_oid)
        .map_err(|e| Error::Cmd(format!("bad head ref: {}", e)))?;
    let head_tree = head_commit.tree()?;

    let base_ref = format!("refs/heads/{}", patch.base_ref);
    let base_tree = if let Ok(base_oid) = repo.refname_to_id(&base_ref) {
        if let Ok(merge_base_oid) = repo.merge_base(base_oid, head_oid) {
            let merge_base_commit = repo.find_commit(merge_base_oid)?;
            Some(merge_base_commit.tree()?)
        } else {
            let base_commit = repo.find_commit(base_oid)?;
            Some(base_commit.tree()?)
        }
    } else {
        None
    };
    let base_tree = resolve_base_tree(repo, &patch.base_ref, head_oid)?;

    let git_diff = repo.diff_tree_to_tree(base_tree.as_ref(), Some(&head_tree), None)?;
    format_diff(&git_diff)
@@ -402,22 +401,9 @@ fn generate_diff_at_revision(
    let revision = patch.revisions.iter().find(|r| r.number == rev_number)
        .ok_or_else(|| Error::Cmd(format!("revision {} not found", rev_number)))?;

    let tree_oid = Oid::from_str(&revision.tree)?;
    let head_tree = repo.find_tree(tree_oid)?;

    let base_ref = format!("refs/heads/{}", patch.base_ref);
    let head_tree = repo.find_tree(Oid::from_str(&revision.tree)?)?;
    let commit_oid = Oid::from_str(&revision.commit)?;
    let base_tree = if let Ok(base_oid) = repo.refname_to_id(&base_ref) {
        if let Ok(merge_base_oid) = repo.merge_base(base_oid, commit_oid) {
            let merge_base_commit = repo.find_commit(merge_base_oid)?;
            Some(merge_base_commit.tree()?)
        } else {
            let base_commit = repo.find_commit(base_oid)?;
            Some(base_commit.tree()?)
        }
    } else {
        None
    };
    let base_tree = resolve_base_tree(repo, &patch.base_ref, commit_oid)?;

    let git_diff = repo.diff_tree_to_tree(base_tree.as_ref(), Some(&head_tree), None)?;
    format_diff(&git_diff)
diff --git a/src/state.rs b/src/state.rs
index a08289a..9b0132e 100644
--- a/src/state.rs
+++ b/src/state.rs
@@ -541,12 +541,11 @@ impl PatchState {
    }
}

/// Enumerate all collab refs of a given kind, returning (ref_name, id) pairs.
fn collab_refs(
/// Enumerate collab refs under a given prefix, returning (ref_name, id) pairs.
fn refs_under(
    repo: &Repository,
    kind: &str,
    prefix: &str,
) -> Result<Vec<(String, String)>, crate::error::Error> {
    let prefix = format!("refs/collab/{}/", kind);
    let glob = format!("{}*", prefix);
    let refs = repo.references_glob(&glob)?;
    let mut result = Vec::new();
@@ -554,7 +553,7 @@ fn collab_refs(
        let r = r?;
        let ref_name = r.name().unwrap_or_default().to_string();
        let id = ref_name
            .strip_prefix(&prefix)
            .strip_prefix(prefix)
            .unwrap_or_default()
            .to_string();
        result.push((ref_name, id));
@@ -562,25 +561,18 @@ fn collab_refs(
    Ok(result)
}

/// Enumerate all collab refs of a given kind under the archive namespace.
fn collab_refs(
    repo: &Repository,
    kind: &str,
) -> Result<Vec<(String, String)>, crate::error::Error> {
    refs_under(repo, &format!("refs/collab/{}/", kind))
}

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)
    refs_under(repo, &format!("refs/collab/archive/{}/", kind))
}

/// Resolve a short ID prefix to a full ref. Searches both active and archive namespaces.
diff --git a/src/tui/mod.rs b/src/tui/mod.rs
index 2d2300a..ccb92a6 100644
--- a/src/tui/mod.rs
+++ b/src/tui/mod.rs
@@ -496,7 +496,7 @@ mod tests {
        };
        let detail = format_event_detail(&oid, &event);
        assert!(detail.contains("Patch Review"));
        assert!(detail.contains("Approve"));
        assert!(detail.contains("approve"));
        assert!(detail.contains("Looks good!"));
    }

diff --git a/src/tui/widgets.rs b/src/tui/widgets.rs
index fcfdc69..170ebff 100644
--- a/src/tui/widgets.rs
+++ b/src/tui/widgets.rs
@@ -78,7 +78,7 @@ pub(crate) fn format_event_detail(oid: &Oid, event: &crate::event::Event) -> Str
            }
        }
        Action::PatchReview { verdict, body, .. } => {
            detail.push_str(&format!("\nVerdict: {:?}\n", verdict));
            detail.push_str(&format!("\nVerdict: {}\n", verdict));
            if !body.is_empty() {
                detail.push_str(&format!("\n{}\n", body));
            }
diff --git a/tests/crdt_test.rs b/tests/crdt_test.rs
index 4853366..d618350 100644
--- a/tests/crdt_test.rs
+++ b/tests/crdt_test.rs
@@ -1,42 +1,14 @@
mod common;

use common::{alice, bob, init_repo, test_signing_key};
use ed25519_dalek::SigningKey;
use git2::{Oid, Repository};
use rand_core::OsRng;
use tempfile::TempDir;

use git_collab::dag;
use git_collab::event::{Action, Author, Event};
use git_collab::event::{Action, Event};
use git_collab::state::{IssueState, IssueStatus, PatchState, PatchStatus};

fn alice() -> Author {
    Author {
        name: "Alice".to_string(),
        email: "alice@example.com".to_string(),
    }
}

fn bob() -> Author {
    Author {
        name: "Bob".to_string(),
        email: "bob@example.com".to_string(),
    }
}

fn test_sk() -> SigningKey {
    SigningKey::generate(&mut OsRng)
}

fn init_repo(dir: &std::path::Path) -> Repository {
    let repo = Repository::init(dir).unwrap();
    {
        let mut config = repo.config().unwrap();
        config.set_str("user.name", "Test").unwrap();
        config.set_str("user.email", "test@test.com").unwrap();
    }
    repo
}

fn read_event_clock(repo: &Repository, oid: Oid) -> u64 {
    let commit = repo.find_commit(oid).unwrap();
    let tree = commit.tree().unwrap();
@@ -51,8 +23,8 @@ fn read_event_clock(repo: &Repository, oid: Oid) -> u64 {
#[test]
fn create_root_event_sets_clock_to_1() {
    let dir = TempDir::new().unwrap();
    let repo = init_repo(dir.path());
    let sk = test_sk();
    let repo = init_repo(dir.path(), &alice());
    let sk = test_signing_key();

    let event = Event {
        timestamp: "2026-01-01T00:00:00Z".to_string(),
@@ -72,8 +44,8 @@ fn create_root_event_sets_clock_to_1() {
#[test]
fn append_event_increments_clock() {
    let dir = TempDir::new().unwrap();
    let repo = init_repo(dir.path());
    let sk = test_sk();
    let repo = init_repo(dir.path(), &alice());
    let sk = test_signing_key();

    let open_event = Event {
        timestamp: "2026-01-01T00:00:00Z".to_string(),
@@ -118,8 +90,8 @@ fn append_event_increments_clock() {
#[test]
fn max_clock_returns_highest_clock_in_dag() {
    let dir = TempDir::new().unwrap();
    let repo = init_repo(dir.path());
    let sk = test_sk();
    let repo = init_repo(dir.path(), &alice());
    let sk = test_signing_key();

    let event = Event {
        timestamp: "2026-01-01T00:00:00Z".to_string(),
@@ -155,8 +127,8 @@ fn max_clock_returns_highest_clock_in_dag() {
#[test]
fn reconcile_merge_clock_is_max_of_both_branches_plus_one() {
    let dir = TempDir::new().unwrap();
    let repo = init_repo(dir.path());
    let sk = test_sk();
    let repo = init_repo(dir.path(), &alice());
    let sk = test_signing_key();

    // Create root event (clock=1)
    let open = Event {
@@ -221,8 +193,8 @@ fn concurrent_issue_close_reopen_higher_clock_wins() {
    // the OID tiebreaker should produce a deterministic result.
    // But when clocks differ, the higher clock always wins.
    let dir = TempDir::new().unwrap();
    let repo = init_repo(dir.path());
    let sk = test_sk();
    let repo = init_repo(dir.path(), &alice());
    let sk = test_signing_key();

    // Create root issue (clock=1)
    let open = Event {
@@ -280,8 +252,8 @@ fn concurrent_issue_close_reopen_higher_clock_wins() {
fn concurrent_issue_same_clock_oid_breaks_tie() {
    // When two status changes have the same clock, higher OID (lexicographic) wins.
    let dir = TempDir::new().unwrap();
    let repo = init_repo(dir.path());
    let sk = test_sk();
    let repo = init_repo(dir.path(), &alice());
    let sk = test_signing_key();

    // Create root issue (clock=1)
    let open = Event {
@@ -338,17 +310,12 @@ fn concurrent_issue_same_clock_oid_breaks_tie() {
#[test]
fn concurrent_patch_close_merge_higher_clock_wins() {
    let dir = TempDir::new().unwrap();
    let repo = init_repo(dir.path());
    let sk = test_sk();
    let repo = init_repo(dir.path(), &alice());
    let sk = test_signing_key();

    // Need a branch for the patch
    let sig = git2::Signature::now("Test", "test@test.com").unwrap();
    let tree_oid = repo.treebuilder(None).unwrap().write().unwrap();
    let tree = repo.find_tree(tree_oid).unwrap();
    let initial = repo
        .commit(Some("refs/heads/main"), &sig, &sig, "initial", &tree, &[])
        .unwrap();
    let initial_commit = repo.find_commit(initial).unwrap();
    let main_oid = repo.refname_to_id("refs/heads/main").unwrap();
    let initial_commit = repo.find_commit(main_oid).unwrap();
    repo.branch("test-branch", &initial_commit, false).unwrap();

    // Create root patch (clock=1)
@@ -414,8 +381,8 @@ fn concurrent_patch_close_merge_higher_clock_wins() {
#[test]
fn migrate_clocks_assigns_sequential_clocks() {
    let dir = TempDir::new().unwrap();
    let repo = init_repo(dir.path());
    let sk = test_sk();
    let repo = init_repo(dir.path(), &alice());
    let sk = test_signing_key();

    // Create a DAG with events that have clock=0 (simulating pre-migration data)
    // We need to create commits directly to simulate old format
@@ -463,8 +430,8 @@ fn migrate_clocks_assigns_sequential_clocks() {
fn migrate_clocks_on_zero_clock_events() {
    // Build a DAG with clock=0 events by writing directly (bypassing DAG functions)
    let dir = TempDir::new().unwrap();
    let repo = init_repo(dir.path());
    let sk = test_sk();
    let repo = init_repo(dir.path(), &alice());
    let sk = test_signing_key();

    let event1 = Event {
        timestamp: "2026-01-01T00:00:00Z".to_string(),
@@ -533,8 +500,8 @@ fn clock_field_survives_serialization_round_trip() {
#[test]
fn clock_field_in_dag_round_trip() {
    let dir = TempDir::new().unwrap();
    let repo = init_repo(dir.path());
    let sk = test_sk();
    let repo = init_repo(dir.path(), &alice());
    let sk = test_signing_key();

    let event = Event {
        timestamp: "2026-01-01T00:00:00Z".to_string(),
diff --git a/tests/sort_test.rs b/tests/sort_test.rs
index 81c0c93..04c979c 100644
--- a/tests/sort_test.rs
+++ b/tests/sort_test.rs
@@ -1,9 +1,7 @@
mod common;

use common::{alice, bob, TestRepo};
use ed25519_dalek::SigningKey;
use common::{alice, bob, init_repo, test_signing_key, TestRepo};
use git2::Repository;
use rand_core::OsRng;
use tempfile::TempDir;

use git_collab::cli::SortMode;
@@ -11,20 +9,6 @@ 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,