a73x

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");
}