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 10d0637..2a8f1f4 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 }, @@ -54,15 +58,65 @@ 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<()> { 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)?; + let mut uuids_by_state = std::collections::HashMap::< + entomologist::issue::State, + Vec<&entomologist::issue::IssueHandle>, + >::new(); for (uuid, issue) in issues.issues.iter() { - println!("{} {} ({:?})", uuid, issue.title(), issue.state); + if filter.include_states.contains(&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(); + 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!(""); } } @@ -99,15 +153,22 @@ 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); } + if let Some(assignee) = &issue.assignee { + println!("assignee: {}", assignee); + } 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!("author: {}", comment.author); + println!("timestamp: {}", comment.timestamp); println!("{}", comment.description); } } @@ -182,6 +243,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/comment.rs b/src/comment.rs index ab2ced9..d6ed66e 100644 --- a/src/comment.rs +++ b/src/comment.rs @@ -2,6 +2,9 @@ use std::io::Write; #[derive(Debug, PartialEq)] pub struct Comment { + pub uuid: String, + pub author: String, + pub timestamp: chrono::DateTime, pub description: String, /// This is the directory that the comment lives in. Only used @@ -39,12 +42,18 @@ impl Comment { } } } - if description == None { 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), }) @@ -95,8 +104,12 @@ 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"), + 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), }; assert_eq!(comment, expected); diff --git a/src/git.rs b/src/git.rs index 30f703e..6018392 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,56 @@ 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_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); + 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 e099616..68cb8f9 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, @@ -20,10 +20,13 @@ 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 comments: std::collections::HashMap, + pub assignee: Option, + pub description: String, + pub comments: Vec, /// This is the directory that the issue lives in. Only used /// internally by the entomologist library. @@ -38,6 +41,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 +66,7 @@ impl FromStr for State { } else if s == "wontdo" { Ok(State::WontDo) } else { - Err(IssueError::IssueParseError) + Err(IssueError::StateParseError) } } } @@ -85,7 +90,8 @@ 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(); + let mut assignee: Option = None; for direntry in dir.read_dir()? { if let Ok(direntry) = direntry { @@ -95,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 @@ -117,26 +127,32 @@ 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, + assignee, + description: description.unwrap(), comments, dir: std::path::PathBuf::from(dir), }) } 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(()) } @@ -148,10 +164,14 @@ 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, + author: String::from("Sebastian Kuzminsky "), + timestamp: chrono::Local::now(), description: String::from(""), // FIXME dir, }) @@ -163,10 +183,13 @@ 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, - comments: std::collections::HashMap::::new(), + assignee: None, + description: String::from(""), // FIXME: kind of bogus to use the empty string as None + comments: Vec::::new(), dir: issue_dir, }) } @@ -228,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)] @@ -239,10 +271,15 @@ 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, - comments: std::collections::HashMap::::new(), + 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), }; assert_eq!(issue, expected); @@ -253,10 +290,15 @@ 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, - comments: std::collections::HashMap::::new(), + assignee: Some(String::from("beep boop")), + description: String::from("minimal"), + 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..16dab55 100644 --- a/src/issues.rs +++ b/src/issues.rs @@ -96,10 +96,15 @@ 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, - comments: std::collections::HashMap::::new(), + assignee: Some(String::from("beep boop")), + description: String::from("minimal"), + comments: Vec::::new(), dir, }, ); @@ -110,10 +115,15 @@ 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, - comments: std::collections::HashMap::::new(), + 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, } ); @@ -133,10 +143,15 @@ 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, - comments: std::collections::HashMap::::new(), + assignee: None, + description: String::from("oh yeah we got titles"), + comments: Vec::::new(), dir, }, ); @@ -148,11 +163,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, + 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), } @@ -160,9 +176,14 @@ 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, + 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, }, @@ -183,10 +204,15 @@ 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, - comments: std::collections::HashMap::::new(), + assignee: None, + description: String::from("oh yeah we got titles\n"), + comments: Vec::::new(), dir, }, ); @@ -197,10 +223,15 @@ 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, - comments: std::collections::HashMap::::new(), + 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, }, ); @@ -211,13 +242,18 @@ 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"), ]), - comments: std::collections::HashMap::::new(), + 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/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 }) +} 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