From 7d9284bf91141f8e834f03ed60f6d577ca32305f Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Mon, 7 Jul 2025 22:27:24 -0600 Subject: [PATCH 01/10] `ent list` now accepts a filter, default "state=New,Backlog,Blocked,InProgress" --- src/bin/ent/main.rs | 13 ++++++++++--- src/issue.rs | 7 ++++--- src/lib.rs | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index 1c9ddaa..1982feb 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -23,7 +23,11 @@ struct Args { #[derive(clap::Subcommand, Debug)] enum Commands { /// List issues. - List, + List { + /// Filter string, describes issues to include in the list. + #[arg(default_value_t = String::from("state=New,Backlog,Blocked,InProgress"))] + filter: String, + }, /// Create a new issue. New { description: Option }, @@ -49,11 +53,14 @@ enum Commands { fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<()> { match &args.command { - Commands::List => { + Commands::List { filter } => { let issues = entomologist::issues::Issues::new_from_dir(std::path::Path::new(issues_dir))?; + let filter = entomologist::parse_filter(filter)?; for (uuid, issue) in issues.issues.iter() { - println!("{} {} ({:?})", uuid, issue.title(), issue.state); + if filter.include_states.contains(&issue.state) { + println!("{} {} ({:?})", uuid, issue.title(), issue.state); + } } } diff --git a/src/issue.rs b/src/issue.rs index c762c82..6ce8e46 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -5,7 +5,7 @@ use std::str::FromStr; #[cfg(feature = "log")] use log::debug; -#[derive(Clone, Debug, PartialEq, serde::Deserialize)] +#[derive(Clone, Debug, Eq, Hash, PartialEq, serde::Deserialize)] /// These are the states an issue can be in. pub enum State { New, @@ -38,6 +38,8 @@ pub enum IssueError { CommentError(#[from] crate::comment::CommentError), #[error("Failed to parse issue")] IssueParseError, + #[error("Failed to parse state")] + StateParseError, #[error("Failed to run git")] GitError(#[from] crate::git::GitError), #[error("Failed to run editor")] @@ -61,7 +63,7 @@ impl FromStr for State { } else if s == "wontdo" { Ok(State::WontDo) } else { - Err(IssueError::IssueParseError) + Err(IssueError::StateParseError) } } } @@ -75,7 +77,6 @@ impl fmt::Display for State { State::InProgress => "inprogress", State::Done => "done", State::WontDo => "wontdo", - }; write!(f, "{fmt_str}") } diff --git a/src/lib.rs b/src/lib.rs index e129eff..77a00d9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,40 @@ +use std::str::FromStr; + pub mod comment; pub mod git; pub mod issue; pub mod issues; + +#[derive(Debug, thiserror::Error)] +pub enum ParseFilterError { + #[error("Failed to parse filter")] + ParseError, + #[error(transparent)] + IssueParseError(#[from] crate::issue::IssueError), +} + +// FIXME: It's easy to imagine a full dsl for filtering issues, for now +// i'm starting with obvious easy things. Chumsky looks appealing but +// more research is needed. +#[derive(Debug)] +pub struct Filter { + pub include_states: std::collections::HashSet, +} + +// Parses a filter description matching "state=STATE[,STATE*]" +pub fn parse_filter(filter_str: &str) -> Result { + let tokens: Vec<&str> = filter_str.split("=").collect(); + if tokens.len() != 2 { + return Err(ParseFilterError::ParseError); + } + if tokens[0] != "state" { + return Err(ParseFilterError::ParseError); + } + + let mut include_states = std::collections::HashSet::::new(); + for s in tokens[1].split(",") { + include_states.insert(crate::issue::State::from_str(s)?); + } + + Ok(Filter { include_states }) +} From be362517fb8c7185df7229dffe7058c717e908a1 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Mon, 7 Jul 2025 23:45:03 -0600 Subject: [PATCH 02/10] give Comment a timestamp, display in chronological order This commit makes a couple of changes: - `ent show ISSUE` now displays the Issue's Comments in chronological order - the Comment struct now includes a timestamp, which is the Author Time of the oldest commit that touches the comment's directory - the Issue struct now stores its Comments in a sorted Vec, not in a HashMap - The Comment's uuid moved into the Comment struct itself, instead of being the key in the Issue's HashMap of Comments --- Cargo.toml | 1 + src/bin/ent/main.rs | 5 +++-- src/comment.rs | 13 +++++++++++-- src/git.rs | 28 ++++++++++++++++++++++++++++ src/issue.rs | 21 ++++++++++++--------- src/issues.rs | 20 ++++++++++---------- 6 files changed, 65 insertions(+), 23 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index fccb9c9..af271d7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ log = ["dep:log", "dep:simple_logger"] [dependencies] anyhow = "1.0.95" +chrono = "0.4.41" clap = { version = "4.5.26", features = ["derive"] } log = { version = "0.4.27", optional = true } rand = "0.9.1" diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index 4ecc596..2d0f56a 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -112,9 +112,10 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<( } println!(""); println!("{}", issue.description); - for (uuid, comment) in issue.comments.iter() { + for comment in &issue.comments { println!(""); - println!("comment: {}", uuid); + println!("comment: {}", comment.uuid); + println!("timestamp: {}", comment.timestamp); println!("{}", comment.description); } } diff --git a/src/comment.rs b/src/comment.rs index ab2ced9..8cd00a0 100644 --- a/src/comment.rs +++ b/src/comment.rs @@ -2,6 +2,8 @@ use std::io::Write; #[derive(Debug, PartialEq)] pub struct Comment { + pub uuid: String, + pub timestamp: chrono::DateTime, pub description: String, /// This is the directory that the comment lives in. Only used @@ -39,12 +41,16 @@ impl Comment { } } } - if description == None { return Err(CommentError::CommentParseError); } + let timestamp = crate::git::git_log_oldest_timestamp(comment_dir)?; + let dir = std::path::PathBuf::from(comment_dir); + Ok(Self { + uuid: String::from(dir.file_name().unwrap().to_string_lossy()), + timestamp, description: description.unwrap(), dir: std::path::PathBuf::from(comment_dir), }) @@ -95,8 +101,11 @@ mod tests { std::path::Path::new("test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/comments/9055dac36045fe36545bed7ae7b49347"); let comment = Comment::new_from_dir(comment_dir).unwrap(); let expected = Comment { + uuid: String::from("9055dac36045fe36545bed7ae7b49347"), + timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-07T15:26:26-06:00") + .unwrap() + .with_timezone(&chrono::Local), description: String::from("This is a comment on issue dd79c8cfb8beeacd0460429944b4ecbe95a31561\n\nIt has multiple lines\n"), - dir: std::path::PathBuf::from(comment_dir), }; assert_eq!(comment, expected); diff --git a/src/git.rs b/src/git.rs index 30f703e..d26293a 100644 --- a/src/git.rs +++ b/src/git.rs @@ -4,6 +4,8 @@ use std::io::Write; pub enum GitError { #[error(transparent)] StdIoError(#[from] std::io::Error), + #[error(transparent)] + ParseIntError(#[from] std::num::ParseIntError), #[error("Oops, something went wrong")] Oops, } @@ -180,6 +182,32 @@ pub fn sync(dir: &std::path::Path, remote: &str, branch: &str) -> Result<(), Git Ok(()) } +pub fn git_log_oldest_timestamp( + path: &std::path::Path, +) -> Result, GitError> { + let mut git_dir = std::path::PathBuf::from(path); + git_dir.pop(); + let result = std::process::Command::new("git") + .args([ + "log", + "--pretty=format:%at", + &path.file_name().unwrap().to_string_lossy(), + ]) + .current_dir(&git_dir) + .output()?; + if !result.status.success() { + println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + return Err(GitError::Oops); + } + let timestamp_str = std::str::from_utf8(&result.stdout).unwrap(); + let timestamp_i64 = timestamp_str.parse::()?; + let timestamp = chrono::DateTime::from_timestamp(timestamp_i64, 0) + .unwrap() + .with_timezone(&chrono::Local); + Ok(timestamp) +} + pub fn create_orphan_branch(branch: &str) -> Result<(), GitError> { { let tmp_worktree = tempfile::tempdir().unwrap(); diff --git a/src/issue.rs b/src/issue.rs index 5f9664f..cdbac1f 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -23,7 +23,7 @@ pub struct Issue { pub description: String, pub state: State, pub dependencies: Option>, - pub comments: std::collections::HashMap, + pub comments: Vec, /// This is the directory that the issue lives in. Only used /// internally by the entomologist library. @@ -87,7 +87,7 @@ impl Issue { let mut description: Option = None; let mut state = State::New; // default state, if not specified in the issue let mut dependencies: Option> = None; - let mut comments = std::collections::HashMap::::new(); + let mut comments = Vec::::new(); for direntry in dir.read_dir()? { if let Ok(direntry) = direntry { @@ -129,16 +129,16 @@ impl Issue { } fn read_comments( - comments: &mut std::collections::HashMap, + comments: &mut Vec, dir: &std::path::Path, ) -> Result<(), IssueError> { for direntry in dir.read_dir()? { if let Ok(direntry) = direntry { - let uuid = direntry.file_name(); let comment = crate::comment::Comment::new_from_dir(&direntry.path())?; - comments.insert(String::from(uuid.to_string_lossy()), comment); + comments.push(comment); } } + comments.sort_by(|a, b| a.timestamp.cmp(&b.timestamp)); Ok(()) } @@ -150,10 +150,13 @@ impl Issue { } let rnd: u128 = rand::random(); - dir.push(&format!("{:032x}", rnd)); + let uuid = format!("{:032x}", rnd); + dir.push(&uuid); std::fs::create_dir(&dir)?; Ok(crate::comment::Comment { + uuid, + timestamp: chrono::Local::now(), description: String::from(""), // FIXME dir, }) @@ -168,7 +171,7 @@ impl Issue { description: String::from(""), // FIXME: kind of bogus to use the empty string as None state: State::New, dependencies: None, - comments: std::collections::HashMap::::new(), + comments: Vec::::new(), dir: issue_dir, }) } @@ -244,7 +247,7 @@ mod tests { description: String::from("this is the title of my issue\n\nThis is the description of my issue.\nIt is multiple lines.\n* Arbitrary contents\n* But let's use markdown by convention\n"), state: State::New, dependencies: None, - comments: std::collections::HashMap::::new(), + comments: Vec::::new(), dir: std::path::PathBuf::from(issue_dir), }; assert_eq!(issue, expected); @@ -258,7 +261,7 @@ mod tests { description: String::from("minimal"), state: State::InProgress, dependencies: None, - comments: std::collections::HashMap::::new(), + comments: Vec::::new(), dir: std::path::PathBuf::from(issue_dir), }; assert_eq!(issue, expected); diff --git a/src/issues.rs b/src/issues.rs index 28df2e9..06af867 100644 --- a/src/issues.rs +++ b/src/issues.rs @@ -99,7 +99,7 @@ mod tests { description: String::from("minimal"), state: crate::issue::State::InProgress, dependencies: None, - comments: std::collections::HashMap::::new(), + comments: Vec::::new(), dir, }, ); @@ -113,7 +113,7 @@ mod tests { description: String::from("this is the title of my issue\n\nThis is the description of my issue.\nIt is multiple lines.\n* Arbitrary contents\n* But let's use markdown by convention\n"), state: crate::issue::State::New, dependencies: None, - comments: std::collections::HashMap::::new(), + comments: Vec::::new(), dir, } ); @@ -136,7 +136,7 @@ mod tests { description: String::from("oh yeah we got titles"), state: crate::issue::State::Done, dependencies: None, - comments: std::collections::HashMap::::new(), + comments: Vec::::new(), dir, }, ); @@ -148,12 +148,12 @@ mod tests { let comment_uuid = String::from("9055dac36045fe36545bed7ae7b49347"); comment_dir.push("comments"); comment_dir.push(&comment_uuid); - let mut expected_comments = - std::collections::HashMap::::new(); - expected_comments.insert( - String::from(&comment_uuid), + let mut expected_comments = Vec::::new(); + expected_comments.push( crate::comment::Comment { + uuid: comment_uuid, description: String::from("This is a comment on issue dd79c8cfb8beeacd0460429944b4ecbe95a31561\n\nIt has multiple lines\n"), + timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-07T15:26:26-06:00").unwrap().with_timezone(&chrono::Local), dir: std::path::PathBuf::from(comment_dir), } ); @@ -186,7 +186,7 @@ mod tests { description: String::from("oh yeah we got titles\n"), state: crate::issue::State::Done, dependencies: None, - comments: std::collections::HashMap::::new(), + comments: Vec::::new(), dir, }, ); @@ -200,7 +200,7 @@ mod tests { description: String::from("issues out the wazoo\n\nLots of words\nthat don't say much\nbecause this is just\na test\n"), state: crate::issue::State::WontDo, dependencies: None, - comments: std::collections::HashMap::::new(), + comments: Vec::::new(), dir, }, ); @@ -217,7 +217,7 @@ mod tests { crate::issue::IssueHandle::from("3fa5bfd93317ad25772680071d5ac3259cd2384f"), crate::issue::IssueHandle::from("dd79c8cfb8beeacd0460429944b4ecbe95a31561"), ]), - comments: std::collections::HashMap::::new(), + comments: Vec::::new(), dir, }, ); From a2c7ce34a363e15b3dac2b0160fb3a449a750900 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Tue, 8 Jul 2025 14:45:17 -0600 Subject: [PATCH 03/10] fix git::git_log_oldest_timestamp() when there are multiple log entries --- src/git.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/git.rs b/src/git.rs index d26293a..28e8400 100644 --- a/src/git.rs +++ b/src/git.rs @@ -191,6 +191,7 @@ pub fn git_log_oldest_timestamp( .args([ "log", "--pretty=format:%at", + "--", &path.file_name().unwrap().to_string_lossy(), ]) .current_dir(&git_dir) @@ -201,7 +202,8 @@ pub fn git_log_oldest_timestamp( return Err(GitError::Oops); } let timestamp_str = std::str::from_utf8(&result.stdout).unwrap(); - let timestamp_i64 = timestamp_str.parse::()?; + let timestamp_last = timestamp_str.split("\n").last().unwrap(); + let timestamp_i64 = timestamp_last.parse::()?; let timestamp = chrono::DateTime::from_timestamp(timestamp_i64, 0) .unwrap() .with_timezone(&chrono::Local); From 37b7eb341f4b222cc29f8bfb298602754f01ee4a Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Tue, 8 Jul 2025 14:08:21 -0600 Subject: [PATCH 04/10] add author to Comment --- src/bin/ent/main.rs | 1 + src/comment.rs | 4 ++++ src/git.rs | 22 ++++++++++++++++++++++ src/issue.rs | 1 + src/issues.rs | 3 ++- 5 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index 2d0f56a..ac15eb4 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -115,6 +115,7 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<( for comment in &issue.comments { println!(""); println!("comment: {}", comment.uuid); + println!("author: {}", comment.author); println!("timestamp: {}", comment.timestamp); println!("{}", comment.description); } diff --git a/src/comment.rs b/src/comment.rs index 8cd00a0..d6ed66e 100644 --- a/src/comment.rs +++ b/src/comment.rs @@ -3,6 +3,7 @@ use std::io::Write; #[derive(Debug, PartialEq)] pub struct Comment { pub uuid: String, + pub author: String, pub timestamp: chrono::DateTime, pub description: String, @@ -45,11 +46,13 @@ impl Comment { return Err(CommentError::CommentParseError); } + let author = crate::git::git_log_oldest_author(comment_dir)?; let timestamp = crate::git::git_log_oldest_timestamp(comment_dir)?; let dir = std::path::PathBuf::from(comment_dir); Ok(Self { uuid: String::from(dir.file_name().unwrap().to_string_lossy()), + author, timestamp, description: description.unwrap(), dir: std::path::PathBuf::from(comment_dir), @@ -102,6 +105,7 @@ mod tests { let comment = Comment::new_from_dir(comment_dir).unwrap(); let expected = Comment { uuid: String::from("9055dac36045fe36545bed7ae7b49347"), + author: String::from("Sebastian Kuzminsky "), timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-07T15:26:26-06:00") .unwrap() .with_timezone(&chrono::Local), diff --git a/src/git.rs b/src/git.rs index 28e8400..6018392 100644 --- a/src/git.rs +++ b/src/git.rs @@ -210,6 +210,28 @@ pub fn git_log_oldest_timestamp( Ok(timestamp) } +pub fn git_log_oldest_author(path: &std::path::Path) -> Result { + let mut git_dir = std::path::PathBuf::from(path); + git_dir.pop(); + let result = std::process::Command::new("git") + .args([ + "log", + "--pretty=format:%an <%ae>", + "--", + &path.file_name().unwrap().to_string_lossy(), + ]) + .current_dir(&git_dir) + .output()?; + if !result.status.success() { + println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + return Err(GitError::Oops); + } + let author_str = std::str::from_utf8(&result.stdout).unwrap(); + let author_last = author_str.split("\n").last().unwrap(); + Ok(String::from(author_last)) +} + pub fn create_orphan_branch(branch: &str) -> Result<(), GitError> { { let tmp_worktree = tempfile::tempdir().unwrap(); diff --git a/src/issue.rs b/src/issue.rs index cdbac1f..0e83297 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -156,6 +156,7 @@ impl Issue { Ok(crate::comment::Comment { uuid, + author: String::from("Sebastian Kuzminsky "), timestamp: chrono::Local::now(), description: String::from(""), // FIXME dir, diff --git a/src/issues.rs b/src/issues.rs index 06af867..3be405d 100644 --- a/src/issues.rs +++ b/src/issues.rs @@ -152,8 +152,9 @@ mod tests { expected_comments.push( crate::comment::Comment { uuid: comment_uuid, - description: String::from("This is a comment on issue dd79c8cfb8beeacd0460429944b4ecbe95a31561\n\nIt has multiple lines\n"), + author: String::from("Sebastian Kuzminsky "), timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-07T15:26:26-06:00").unwrap().with_timezone(&chrono::Local), + description: String::from("This is a comment on issue dd79c8cfb8beeacd0460429944b4ecbe95a31561\n\nIt has multiple lines\n"), dir: std::path::PathBuf::from(comment_dir), } ); From 316ca3a9013ba48b449cfe6fd0167893041baa80 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Tue, 8 Jul 2025 14:18:31 -0600 Subject: [PATCH 05/10] add author and timestamp to Issue --- src/bin/ent/main.rs | 2 ++ src/issue.rs | 27 ++++++++++++++++++++++----- src/issues.rs | 42 +++++++++++++++++++++++++++++++++++------- 3 files changed, 59 insertions(+), 12 deletions(-) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index ac15eb4..81870e3 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -106,6 +106,8 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<( match issues.get_issue(issue_id) { Some(issue) => { println!("issue {}", issue_id); + println!("author: {}", issue.author); + println!("timestamp: {}", issue.timestamp); println!("state: {:?}", issue.state); if let Some(dependencies) = &issue.dependencies { println!("dependencies: {:?}", dependencies); diff --git a/src/issue.rs b/src/issue.rs index 0e83297..97aa8f1 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -20,9 +20,11 @@ pub type IssueHandle = String; #[derive(Debug, PartialEq)] pub struct Issue { - pub description: String, + pub author: String, + pub timestamp: chrono::DateTime, pub state: State, pub dependencies: Option>, + pub description: String, pub comments: Vec, /// This is the directory that the issue lives in. Only used @@ -119,10 +121,15 @@ impl Issue { return Err(IssueError::IssueParseError); } + let author = crate::git::git_log_oldest_author(dir)?; + let timestamp = crate::git::git_log_oldest_timestamp(dir)?; + Ok(Self { - description: description.unwrap(), + author, + timestamp, state: state, dependencies, + description: description.unwrap(), comments, dir: std::path::PathBuf::from(dir), }) @@ -169,9 +176,11 @@ impl Issue { issue_dir.push(&format!("{:032x}", rnd)); std::fs::create_dir(&issue_dir)?; Ok(Self { - description: String::from(""), // FIXME: kind of bogus to use the empty string as None + author: String::from(""), + timestamp: chrono::Local::now(), state: State::New, dependencies: None, + description: String::from(""), // FIXME: kind of bogus to use the empty string as None comments: Vec::::new(), dir: issue_dir, }) @@ -245,9 +254,13 @@ mod tests { let issue_dir = std::path::Path::new("test/0000/3943fc5c173fdf41c0a22251593cd476d96e6c9f/"); let issue = Issue::new_from_dir(issue_dir).unwrap(); let expected = Issue { - description: String::from("this is the title of my issue\n\nThis is the description of my issue.\nIt is multiple lines.\n* Arbitrary contents\n* But let's use markdown by convention\n"), + author: String::from("Sebastian Kuzminsky "), + timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-03T12:14:26-06:00") + .unwrap() + .with_timezone(&chrono::Local), state: State::New, dependencies: None, + description: String::from("this is the title of my issue\n\nThis is the description of my issue.\nIt is multiple lines.\n* Arbitrary contents\n* But let's use markdown by convention\n"), comments: Vec::::new(), dir: std::path::PathBuf::from(issue_dir), }; @@ -259,9 +272,13 @@ mod tests { let issue_dir = std::path::Path::new("test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/"); let issue = Issue::new_from_dir(issue_dir).unwrap(); let expected = Issue { - description: String::from("minimal"), + author: String::from("Sebastian Kuzminsky "), + timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-03T12:14:26-06:00") + .unwrap() + .with_timezone(&chrono::Local), state: State::InProgress, dependencies: None, + description: String::from("minimal"), comments: Vec::::new(), dir: std::path::PathBuf::from(issue_dir), }; diff --git a/src/issues.rs b/src/issues.rs index 3be405d..ff6accb 100644 --- a/src/issues.rs +++ b/src/issues.rs @@ -96,9 +96,13 @@ mod tests { expected.add_issue( uuid, crate::issue::Issue { - description: String::from("minimal"), + author: String::from("Sebastian Kuzminsky "), + timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-03T12:14:26-06:00") + .unwrap() + .with_timezone(&chrono::Local), state: crate::issue::State::InProgress, dependencies: None, + description: String::from("minimal"), comments: Vec::::new(), dir, }, @@ -110,9 +114,13 @@ mod tests { expected.add_issue( uuid, crate::issue::Issue { - description: String::from("this is the title of my issue\n\nThis is the description of my issue.\nIt is multiple lines.\n* Arbitrary contents\n* But let's use markdown by convention\n"), + author: String::from("Sebastian Kuzminsky "), + timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-03T12:14:26-06:00") + .unwrap() + .with_timezone(&chrono::Local), state: crate::issue::State::New, dependencies: None, + description: String::from("this is the title of my issue\n\nThis is the description of my issue.\nIt is multiple lines.\n* Arbitrary contents\n* But let's use markdown by convention\n"), comments: Vec::::new(), dir, } @@ -133,9 +141,13 @@ mod tests { expected.add_issue( uuid, crate::issue::Issue { - description: String::from("oh yeah we got titles"), + author: String::from("Sebastian Kuzminsky "), + timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-03T11:59:44-06:00") + .unwrap() + .with_timezone(&chrono::Local), state: crate::issue::State::Done, dependencies: None, + description: String::from("oh yeah we got titles"), comments: Vec::::new(), dir, }, @@ -161,9 +173,13 @@ mod tests { expected.add_issue( uuid, crate::issue::Issue { - description: String::from("issues out the wazoo\n\nLots of words\nthat don't say much\nbecause this is just\na test\n"), + author: String::from("Sebastian Kuzminsky "), + timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-03T11:59:44-06:00") + .unwrap() + .with_timezone(&chrono::Local), state: crate::issue::State::WontDo, dependencies: None, + description: String::from("issues out the wazoo\n\nLots of words\nthat don't say much\nbecause this is just\na test\n"), comments: expected_comments, dir, }, @@ -184,9 +200,13 @@ mod tests { expected.add_issue( uuid, crate::issue::Issue { - description: String::from("oh yeah we got titles\n"), + author: String::from("sigil-03 "), + timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-05T13:55:49-06:00") + .unwrap() + .with_timezone(&chrono::Local), state: crate::issue::State::Done, dependencies: None, + description: String::from("oh yeah we got titles\n"), comments: Vec::::new(), dir, }, @@ -198,9 +218,13 @@ mod tests { expected.add_issue( uuid, crate::issue::Issue { - description: String::from("issues out the wazoo\n\nLots of words\nthat don't say much\nbecause this is just\na test\n"), + author: String::from("sigil-03 "), + timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-05T13:55:49-06:00") + .unwrap() + .with_timezone(&chrono::Local), state: crate::issue::State::WontDo, dependencies: None, + description: String::from("issues out the wazoo\n\nLots of words\nthat don't say much\nbecause this is just\na test\n"), comments: Vec::::new(), dir, }, @@ -212,12 +236,16 @@ mod tests { expected.add_issue( uuid, crate::issue::Issue { - description: String::from("issue with dependencies\n\na test has begun\nfor dependencies we seek\nintertwining life"), + author: String::from("sigil-03 "), + timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-05T13:55:49-06:00") + .unwrap() + .with_timezone(&chrono::Local), state: crate::issue::State::WontDo, dependencies: Some(vec![ crate::issue::IssueHandle::from("3fa5bfd93317ad25772680071d5ac3259cd2384f"), crate::issue::IssueHandle::from("dd79c8cfb8beeacd0460429944b4ecbe95a31561"), ]), + description: String::from("issue with dependencies\n\na test has begun\nfor dependencies we seek\nintertwining life"), comments: Vec::::new(), dir, }, From 80b842baaf285eb91be2272e6d9a49aa2e435911 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Tue, 8 Jul 2025 16:09:45 -0600 Subject: [PATCH 06/10] make `ent list` sort issues first by state, then by ctime --- src/bin/ent/main.rs | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index 81870e3..aa15752 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -66,11 +66,44 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<( let issues = entomologist::issues::Issues::new_from_dir(std::path::Path::new(issues_dir))?; let filter = entomologist::parse_filter(filter)?; + let mut uuids_by_state = std::collections::HashMap::< + entomologist::issue::State, + Vec<&entomologist::issue::IssueHandle>, + >::new(); for (uuid, issue) in issues.issues.iter() { if filter.include_states.contains(&issue.state) { - println!("{} {} ({:?})", uuid, issue.title(), issue.state); + uuids_by_state + .entry(issue.state.clone()) + .or_default() + .push(uuid); } } + + use entomologist::issue::State; + for state in [ + State::InProgress, + State::Blocked, + State::Backlog, + State::New, + State::Done, + State::WontDo, + ] { + let these_uuids = uuids_by_state.entry(state.clone()).or_default(); + if these_uuids.len() == 0 { + continue; + } + these_uuids.sort_by(|a_id, b_id| { + let a = issues.issues.get(*a_id).unwrap(); + let b = issues.issues.get(*b_id).unwrap(); + a.timestamp.cmp(&b.timestamp) + }); + println!("{:?}:", state); + for uuid in these_uuids { + let issue = issues.issues.get(*uuid).unwrap(); + println!("{} {}", uuid, issue.title()); + } + println!(""); + } } Commands::New { From 9aa0567d13e45a24fa23798023d2cb6f83b9ebe2 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Tue, 8 Jul 2025 16:20:44 -0600 Subject: [PATCH 07/10] ent list: show comment count for each issue --- src/bin/ent/main.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index aa15752..952d178 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -100,7 +100,12 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<( println!("{:?}:", state); for uuid in these_uuids { let issue = issues.issues.get(*uuid).unwrap(); - println!("{} {}", uuid, issue.title()); + let num_comments = issue.comments.len(); + if num_comments == 0 { + println!("{} {}", uuid, issue.title()); + } else { + println!("{} 🗩 {} {}", uuid, num_comments, issue.title()); + } } println!(""); } From 6c791ef3c239e11f5636de2ac82c985f7bba0f5b Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Tue, 8 Jul 2025 16:29:00 -0600 Subject: [PATCH 08/10] add optional 'assignee' to Issue --- src/bin/ent/main.rs | 3 +++ src/issue.rs | 10 ++++++++++ src/issues.rs | 7 +++++++ .../7792b063eef6d33e7da5dc1856750c149ba678c6/assignee | 1 + 4 files changed, 21 insertions(+) create mode 100644 test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/assignee diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index 952d178..cb8eb9d 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -150,6 +150,9 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<( if let Some(dependencies) = &issue.dependencies { println!("dependencies: {:?}", dependencies); } + if let Some(assignee) = &issue.assignee { + println!("assignee: {}", assignee); + } println!(""); println!("{}", issue.description); for comment in &issue.comments { diff --git a/src/issue.rs b/src/issue.rs index 97aa8f1..096e497 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -24,6 +24,7 @@ pub struct Issue { pub timestamp: chrono::DateTime, pub state: State, pub dependencies: Option>, + pub assignee: Option, pub description: String, pub comments: Vec, @@ -90,6 +91,7 @@ impl Issue { let mut state = State::New; // default state, if not specified in the issue let mut dependencies: Option> = None; let mut comments = Vec::::new(); + let mut assignee: Option = None; for direntry in dir.read_dir()? { if let Ok(direntry) = direntry { @@ -99,6 +101,10 @@ impl Issue { } else if file_name == "state" { let state_string = std::fs::read_to_string(direntry.path())?; state = State::from_str(state_string.trim())?; + } else if file_name == "assignee" { + assignee = Some(String::from( + std::fs::read_to_string(direntry.path())?.trim(), + )); } else if file_name == "dependencies" { let dep_strings = std::fs::read_to_string(direntry.path())?; let deps: Vec = dep_strings @@ -129,6 +135,7 @@ impl Issue { timestamp, state: state, dependencies, + assignee, description: description.unwrap(), comments, dir: std::path::PathBuf::from(dir), @@ -180,6 +187,7 @@ impl Issue { timestamp: chrono::Local::now(), state: State::New, dependencies: None, + assignee: None, description: String::from(""), // FIXME: kind of bogus to use the empty string as None comments: Vec::::new(), dir: issue_dir, @@ -260,6 +268,7 @@ mod tests { .with_timezone(&chrono::Local), state: State::New, dependencies: None, + assignee: None, description: String::from("this is the title of my issue\n\nThis is the description of my issue.\nIt is multiple lines.\n* Arbitrary contents\n* But let's use markdown by convention\n"), comments: Vec::::new(), dir: std::path::PathBuf::from(issue_dir), @@ -278,6 +287,7 @@ mod tests { .with_timezone(&chrono::Local), state: State::InProgress, dependencies: None, + assignee: Some(String::from("beep boop")), description: String::from("minimal"), comments: Vec::::new(), dir: std::path::PathBuf::from(issue_dir), diff --git a/src/issues.rs b/src/issues.rs index ff6accb..16dab55 100644 --- a/src/issues.rs +++ b/src/issues.rs @@ -102,6 +102,7 @@ mod tests { .with_timezone(&chrono::Local), state: crate::issue::State::InProgress, dependencies: None, + assignee: Some(String::from("beep boop")), description: String::from("minimal"), comments: Vec::::new(), dir, @@ -120,6 +121,7 @@ mod tests { .with_timezone(&chrono::Local), state: crate::issue::State::New, dependencies: None, + assignee: None, description: String::from("this is the title of my issue\n\nThis is the description of my issue.\nIt is multiple lines.\n* Arbitrary contents\n* But let's use markdown by convention\n"), comments: Vec::::new(), dir, @@ -147,6 +149,7 @@ mod tests { .with_timezone(&chrono::Local), state: crate::issue::State::Done, dependencies: None, + assignee: None, description: String::from("oh yeah we got titles"), comments: Vec::::new(), dir, @@ -179,6 +182,7 @@ mod tests { .with_timezone(&chrono::Local), state: crate::issue::State::WontDo, dependencies: None, + assignee: None, description: String::from("issues out the wazoo\n\nLots of words\nthat don't say much\nbecause this is just\na test\n"), comments: expected_comments, dir, @@ -206,6 +210,7 @@ mod tests { .with_timezone(&chrono::Local), state: crate::issue::State::Done, dependencies: None, + assignee: None, description: String::from("oh yeah we got titles\n"), comments: Vec::::new(), dir, @@ -224,6 +229,7 @@ mod tests { .with_timezone(&chrono::Local), state: crate::issue::State::WontDo, dependencies: None, + assignee: None, description: String::from("issues out the wazoo\n\nLots of words\nthat don't say much\nbecause this is just\na test\n"), comments: Vec::::new(), dir, @@ -245,6 +251,7 @@ mod tests { crate::issue::IssueHandle::from("3fa5bfd93317ad25772680071d5ac3259cd2384f"), crate::issue::IssueHandle::from("dd79c8cfb8beeacd0460429944b4ecbe95a31561"), ]), + assignee: None, description: String::from("issue with dependencies\n\na test has begun\nfor dependencies we seek\nintertwining life"), comments: Vec::::new(), dir, diff --git a/test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/assignee b/test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/assignee new file mode 100644 index 0000000..fae06e3 --- /dev/null +++ b/test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/assignee @@ -0,0 +1 @@ +beep boop From 30bac14577e0cf11936b1226a8ec50162ab6c34d Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Tue, 8 Jul 2025 17:16:17 -0600 Subject: [PATCH 09/10] add `ent assign ISSUE PERSON` --- src/bin/ent/main.rs | 37 +++++++++++++++++++++++++++++++++++++ src/issue.rs | 9 +++++++++ 2 files changed, 46 insertions(+) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index cb8eb9d..5237281 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -58,6 +58,12 @@ enum Commands { #[arg(default_value_t = String::from("origin"))] remote: String, }, + + /// Get or set the Assignee field of an Issue. + Assign { + issue_id: String, + new_assignee: Option, + }, } fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<()> { @@ -234,6 +240,37 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<( entomologist::git::sync(issues_dir, remote, branch)?; println!("synced {:?} with {:?}", branch, remote); } + + Commands::Assign { + issue_id, + new_assignee, + } => { + let mut issues = + entomologist::issues::Issues::new_from_dir(std::path::Path::new(issues_dir))?; + let Some(issue) = issues.issues.get_mut(issue_id) else { + return Err(anyhow::anyhow!("issue {} not found", issue_id)); + }; + match (&issue.assignee, new_assignee) { + (Some(old_assignee), Some(new_assignee)) => { + println!("issue: {}", issue_id); + println!("assignee: {} -> {}", old_assignee, new_assignee); + issue.set_assignee(new_assignee)?; + } + (Some(old_assignee), None) => { + println!("issue: {}", issue_id); + println!("assignee: {}", old_assignee); + } + (None, Some(new_assignee)) => { + println!("issue: {}", issue_id); + println!("assignee: None -> {}", new_assignee); + issue.set_assignee(new_assignee)?; + } + (None, None) => { + println!("issue: {}", issue_id); + println!("assignee: None"); + } + } + } } Ok(()) diff --git a/src/issue.rs b/src/issue.rs index 096e497..68cb8f9 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -251,6 +251,15 @@ impl Issue { self.state = State::from_str(state_string.trim())?; Ok(()) } + + pub fn set_assignee(&mut self, new_assignee: &str) -> Result<(), IssueError> { + let mut assignee_filename = std::path::PathBuf::from(&self.dir); + assignee_filename.push("assignee"); + let mut assignee_file = std::fs::File::create(&assignee_filename)?; + write!(assignee_file, "{}", new_assignee)?; + crate::git::git_commit_file(&assignee_filename)?; + Ok(()) + } } #[cfg(test)] From 1b87de72d6c7ff726d0873fcb79939c418a722d7 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Tue, 8 Jul 2025 17:31:10 -0600 Subject: [PATCH 10/10] ent list: show assignee, if any --- src/bin/ent/main.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index 5237281..2a8f1f4 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -106,12 +106,15 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<( println!("{:?}:", state); for uuid in these_uuids { let issue = issues.issues.get(*uuid).unwrap(); - let num_comments = issue.comments.len(); - if num_comments == 0 { - println!("{} {}", uuid, issue.title()); - } else { - println!("{} 🗩 {} {}", uuid, num_comments, issue.title()); - } + let comments = match issue.comments.len() { + 0 => String::from(" "), + n => format!("🗩 {}", n), + }; + let assignee = match &issue.assignee { + Some(assignee) => format!(" (👉 {})", assignee), + None => String::from(""), + }; + println!("{} {} {}{}", uuid, comments, issue.title(), assignee); } println!(""); }